forked from home-assistant/core
Compare commits
40 Commits
2022.2.0b2
...
2022.2.0b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef143b5eb2 | ||
|
|
5d7aefa0b4 | ||
|
|
6b6bd381fd | ||
|
|
252f5f6b35 | ||
|
|
8bdee9cb1c | ||
|
|
7e350b8347 | ||
|
|
ac8a1248f9 | ||
|
|
ffe262abce | ||
|
|
5174e68b16 | ||
|
|
6e4c281e15 | ||
|
|
8e71e2e8ee | ||
|
|
26905115c8 | ||
|
|
eca3514f9e | ||
|
|
305ffc4ab6 | ||
|
|
508fd0cb2a | ||
|
|
5368fb6d54 | ||
|
|
d6527953c3 | ||
|
|
14c969ef6d | ||
|
|
f6f25fa4ff | ||
|
|
dcf6e61d4f | ||
|
|
2041d4c118 | ||
|
|
b40bcecac0 | ||
|
|
2ed20df906 | ||
|
|
1a6964448c | ||
|
|
3dde12f887 | ||
|
|
cd6c182c07 | ||
|
|
f8e0c41e91 | ||
|
|
5f56107116 | ||
|
|
fb3c99a891 | ||
|
|
ca505b79b5 | ||
|
|
c74a8bf65a | ||
|
|
406801ef73 | ||
|
|
2bfedcbdc5 | ||
|
|
84f817eb25 | ||
|
|
4ead2f2f7e | ||
|
|
421f9716a7 | ||
|
|
25e6d8858c | ||
|
|
3829a81d15 | ||
|
|
9318843867 | ||
|
|
4eb787b619 |
@@ -560,12 +560,7 @@ omit =
|
||||
homeassistant/components/knx/__init__.py
|
||||
homeassistant/components/knx/climate.py
|
||||
homeassistant/components/knx/cover.py
|
||||
homeassistant/components/knx/diagnostics.py
|
||||
homeassistant/components/knx/expose.py
|
||||
homeassistant/components/knx/knx_entity.py
|
||||
homeassistant/components/knx/light.py
|
||||
homeassistant/components/knx/notify.py
|
||||
homeassistant/components/knx/schema.py
|
||||
homeassistant/components/kodi/__init__.py
|
||||
homeassistant/components/kodi/browse_media.py
|
||||
homeassistant/components/kodi/const.py
|
||||
|
||||
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -76,8 +76,10 @@ jobs:
|
||||
- name: Build package
|
||||
shell: bash
|
||||
run: |
|
||||
pip install twine wheel
|
||||
python setup.py sdist bdist_wheel
|
||||
# Remove dist, build, and homeassistant.egg-info
|
||||
# when build locally for testing!
|
||||
pip install twine build
|
||||
python -m build
|
||||
|
||||
- name: Upload package
|
||||
shell: bash
|
||||
|
||||
@@ -107,7 +107,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/manifest\.json|setup\.py|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$
|
||||
files: ^(homeassistant/.+/manifest\.json|setup\.cfg|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$
|
||||
- id: hassfest
|
||||
name: hassfest
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest
|
||||
@@ -115,3 +115,10 @@ repos:
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(manifest|strings)\.json|\.coveragerc|\.strict-typing|homeassistant/.+/services\.yaml|script/hassfest/.+\.py)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(script/hassfest/.+\.py|homeassistant/const\.py$|setup\.cfg)$
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
include README.rst
|
||||
include LICENSE.md
|
||||
graft homeassistant
|
||||
recursive-exclude * *.py[co]
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
{
|
||||
"hostname": "blink*",
|
||||
"macaddress": "B85F98*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"hostname": "blink*",
|
||||
"macaddress": "00037F*"
|
||||
}
|
||||
],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
import functools as ft
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
@@ -461,33 +460,10 @@ class CastDevice(MediaPlayerEntity):
|
||||
media_controller = self._media_controller()
|
||||
media_controller.seek(position)
|
||||
|
||||
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||
"""Implement the websocket media browsing helper."""
|
||||
kwargs = {}
|
||||
async def _async_root_payload(self, content_filter):
|
||||
"""Generate root node."""
|
||||
children = []
|
||||
|
||||
if self._chromecast.cast_type == pychromecast.const.CAST_TYPE_AUDIO:
|
||||
kwargs["content_filter"] = lambda item: item.media_content_type.startswith(
|
||||
"audio/"
|
||||
)
|
||||
|
||||
if media_content_id is not None:
|
||||
if plex.is_plex_media_id(media_content_id):
|
||||
return await plex.async_browse_media(
|
||||
self.hass,
|
||||
media_content_type,
|
||||
media_content_id,
|
||||
platform=CAST_DOMAIN,
|
||||
)
|
||||
return await media_source.async_browse_media(
|
||||
self.hass, media_content_id, **kwargs
|
||||
)
|
||||
|
||||
if media_content_type == "plex":
|
||||
return await plex.async_browse_media(
|
||||
self.hass, None, None, platform=CAST_DOMAIN
|
||||
)
|
||||
|
||||
# Add external sources
|
||||
if "plex" in self.hass.config.components:
|
||||
children.append(
|
||||
BrowseMedia(
|
||||
@@ -501,15 +477,17 @@ class CastDevice(MediaPlayerEntity):
|
||||
)
|
||||
)
|
||||
|
||||
# Add local media source
|
||||
try:
|
||||
result = await media_source.async_browse_media(
|
||||
self.hass, media_content_id, **kwargs
|
||||
self.hass, None, content_filter=content_filter
|
||||
)
|
||||
children.append(result)
|
||||
except BrowseError:
|
||||
if not children:
|
||||
raise
|
||||
|
||||
# If there's only one media source, resolve it
|
||||
if len(children) == 1:
|
||||
return await self.async_browse_media(
|
||||
children[0].media_content_type,
|
||||
@@ -526,6 +504,34 @@ class CastDevice(MediaPlayerEntity):
|
||||
children=children,
|
||||
)
|
||||
|
||||
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||
"""Implement the websocket media browsing helper."""
|
||||
content_filter = None
|
||||
|
||||
if self._chromecast.cast_type == pychromecast.const.CAST_TYPE_AUDIO:
|
||||
|
||||
def audio_content_filter(item):
|
||||
"""Filter non audio content."""
|
||||
return item.media_content_type.startswith("audio/")
|
||||
|
||||
content_filter = audio_content_filter
|
||||
|
||||
if media_content_id is None:
|
||||
return await self._async_root_payload(content_filter)
|
||||
|
||||
if plex.is_plex_media_id(media_content_id):
|
||||
return await plex.async_browse_media(
|
||||
self.hass, media_content_type, media_content_id, platform=CAST_DOMAIN
|
||||
)
|
||||
if media_content_type == "plex":
|
||||
return await plex.async_browse_media(
|
||||
self.hass, None, None, platform=CAST_DOMAIN
|
||||
)
|
||||
|
||||
return await media_source.async_browse_media(
|
||||
self.hass, media_content_id, content_filter=content_filter
|
||||
)
|
||||
|
||||
async def async_play_media(self, media_type, media_id, **kwargs):
|
||||
"""Play a piece of media."""
|
||||
# Handle media_source
|
||||
@@ -547,12 +553,6 @@ class CastDevice(MediaPlayerEntity):
|
||||
hass_url = get_url(self.hass, prefer_external=True)
|
||||
media_id = f"{hass_url}{media_id}"
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
ft.partial(self.play_media, media_type, media_id, **kwargs)
|
||||
)
|
||||
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Play media from a URL."""
|
||||
extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
|
||||
metadata = extra.get("metadata")
|
||||
|
||||
@@ -571,7 +571,9 @@ class CastDevice(MediaPlayerEntity):
|
||||
if "app_id" in app_data:
|
||||
app_id = app_data.pop("app_id")
|
||||
_LOGGER.info("Starting Cast app by ID %s", app_id)
|
||||
self._chromecast.start_app(app_id)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._chromecast.start_app, app_id
|
||||
)
|
||||
if app_data:
|
||||
_LOGGER.warning(
|
||||
"Extra keys %s were ignored. Please use app_name to cast media",
|
||||
@@ -581,21 +583,28 @@ class CastDevice(MediaPlayerEntity):
|
||||
|
||||
app_name = app_data.pop("app_name")
|
||||
try:
|
||||
quick_play(self._chromecast, app_name, app_data)
|
||||
await self.hass.async_add_executor_job(
|
||||
quick_play, self._chromecast, app_name, app_data
|
||||
)
|
||||
except NotImplementedError:
|
||||
_LOGGER.error("App %s not supported", app_name)
|
||||
|
||||
# Handle plex
|
||||
elif media_id and media_id.startswith(PLEX_URI_SCHEME):
|
||||
media_id = media_id[len(PLEX_URI_SCHEME) :]
|
||||
media = lookup_plex_media(self.hass, media_type, media_id)
|
||||
media = await self.hass.async_add_executor_job(
|
||||
lookup_plex_media, self.hass, media_type, media_id
|
||||
)
|
||||
if media is None:
|
||||
return
|
||||
controller = PlexController()
|
||||
self._chromecast.register_handler(controller)
|
||||
controller.play_media(media)
|
||||
await self.hass.async_add_executor_job(controller.play_media, media)
|
||||
else:
|
||||
app_data = {"media_id": media_id, "media_type": media_type, **extra}
|
||||
quick_play(self._chromecast, "default_media_receiver", app_data)
|
||||
await self.hass.async_add_executor_job(
|
||||
quick_play, self._chromecast, "default_media_receiver", app_data
|
||||
)
|
||||
|
||||
def _media_status(self):
|
||||
"""
|
||||
|
||||
@@ -46,7 +46,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Enable asyncio debugging and start the debugger."""
|
||||
get_running_loop().set_debug(True)
|
||||
|
||||
debugpy.listen((conf[CONF_HOST], conf[CONF_PORT]))
|
||||
await hass.async_add_executor_job(
|
||||
debugpy.listen, (conf[CONF_HOST], conf[CONF_PORT])
|
||||
)
|
||||
|
||||
if conf[CONF_WAIT]:
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from pydeconz.group import DeconzScene as PydeconzScene
|
||||
|
||||
from homeassistant.components.scene import Scene
|
||||
from homeassistant.components.scene import DOMAIN, Scene
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -23,6 +23,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up scenes for deCONZ component."""
|
||||
gateway = get_gateway_from_config_entry(hass, config_entry)
|
||||
gateway.entities[DOMAIN] = set()
|
||||
|
||||
@callback
|
||||
def async_add_scene(
|
||||
@@ -30,7 +31,11 @@ async def async_setup_entry(
|
||||
| ValuesView[PydeconzScene] = gateway.api.scenes.values(),
|
||||
) -> None:
|
||||
"""Add scene from deCONZ."""
|
||||
entities = [DeconzScene(scene, gateway) for scene in scenes]
|
||||
entities = [
|
||||
DeconzScene(scene, gateway)
|
||||
for scene in scenes
|
||||
if scene.deconz_id not in gateway.entities[DOMAIN]
|
||||
]
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
@@ -59,10 +64,12 @@ class DeconzScene(Scene):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to sensors events."""
|
||||
self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id
|
||||
self.gateway.entities[DOMAIN].add(self._scene.deconz_id)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect scene object when removed."""
|
||||
del self.gateway.deconz_ids[self.entity_id]
|
||||
self.gateway.entities[DOMAIN].remove(self._scene.deconz_id)
|
||||
self._scene = None
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -170,7 +170,7 @@ async def _async_get_json_file_response(
|
||||
return web.Response(
|
||||
body=json_data,
|
||||
content_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}.json"'},
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}.json.txt"'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,19 +2,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import REDACTED
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@callback
|
||||
def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict[str, Any]:
|
||||
def async_redact_data(data: T, to_redact: Iterable[Any]) -> T:
|
||||
"""Redact sensitive data in a dict."""
|
||||
if not isinstance(data, (Mapping, list)):
|
||||
return data
|
||||
|
||||
if isinstance(data, list):
|
||||
return cast(T, [async_redact_data(val, to_redact) for val in data])
|
||||
|
||||
redacted = {**data}
|
||||
|
||||
for key, value in redacted.items():
|
||||
@@ -25,4 +30,4 @@ def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict[str, Any]
|
||||
elif isinstance(value, list):
|
||||
redacted[key] = [async_redact_data(item, to_redact) for item in value]
|
||||
|
||||
return redacted
|
||||
return cast(T, redacted)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""The Flick Electric integration."""
|
||||
|
||||
from datetime import datetime as dt
|
||||
import logging
|
||||
|
||||
import jwt
|
||||
from pyflick import FlickAPI
|
||||
from pyflick.authentication import AbstractFlickAuth
|
||||
from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET
|
||||
@@ -18,7 +20,9 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import CONF_TOKEN_EXPIRES_IN, CONF_TOKEN_EXPIRY, DOMAIN
|
||||
from .const import CONF_TOKEN_EXPIRY, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ID_TOKEN = "id_token"
|
||||
|
||||
@@ -69,6 +73,8 @@ class HassFlickAuth(AbstractFlickAuth):
|
||||
return self._entry.data[CONF_ACCESS_TOKEN]
|
||||
|
||||
async def _update_token(self):
|
||||
_LOGGER.debug("Fetching new access token")
|
||||
|
||||
token = await self.get_new_token(
|
||||
username=self._entry.data[CONF_USERNAME],
|
||||
password=self._entry.data[CONF_PASSWORD],
|
||||
@@ -78,15 +84,19 @@ class HassFlickAuth(AbstractFlickAuth):
|
||||
),
|
||||
)
|
||||
|
||||
# Reduce expiry by an hour to avoid API being called after expiry
|
||||
expiry = dt.now().timestamp() + int(token[CONF_TOKEN_EXPIRES_IN] - 3600)
|
||||
_LOGGER.debug("New token: %s", token)
|
||||
|
||||
# Flick will send the same token, but expiry is relative - so grab it from the token
|
||||
token_decoded = jwt.decode(
|
||||
token[CONF_ID_TOKEN], options={"verify_signature": False}
|
||||
)
|
||||
|
||||
self._hass.config_entries.async_update_entry(
|
||||
self._entry,
|
||||
data={
|
||||
**self._entry.data,
|
||||
CONF_ACCESS_TOKEN: token,
|
||||
CONF_TOKEN_EXPIRY: expiry,
|
||||
CONF_TOKEN_EXPIRY: token_decoded["exp"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
DOMAIN = "flick_electric"
|
||||
|
||||
CONF_TOKEN_EXPIRES_IN = "expires_in"
|
||||
CONF_TOKEN_EXPIRY = "expires"
|
||||
|
||||
ATTR_START_AT = "start_at"
|
||||
|
||||
@@ -15,8 +15,6 @@ from homeassistant.util.dt import utcnow
|
||||
from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_AUTH_URL = "https://api.flick.energy/identity/oauth/token"
|
||||
_RESOURCE = "https://api.flick.energy/customer/mobile_provider/price"
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
@@ -71,6 +69,8 @@ class FlickPricingSensor(SensorEntity):
|
||||
async with async_timeout.timeout(60):
|
||||
self._price = await self._api.getPricing()
|
||||
|
||||
_LOGGER.debug("Pricing data: %s", self._price)
|
||||
|
||||
self._attributes[ATTR_START_AT] = self._price.start_at
|
||||
self._attributes[ATTR_END_AT] = self._price.end_at
|
||||
for component in self._price.components:
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_time_change,
|
||||
@@ -88,6 +88,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Migrate entities when the mac address gets discovered."""
|
||||
unique_id = entry.unique_id
|
||||
if not unique_id:
|
||||
return
|
||||
entry_id = entry.entry_id
|
||||
|
||||
@callback
|
||||
def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
|
||||
# Old format {entry_id}.....
|
||||
# New format {unique_id}....
|
||||
entity_unique_id = entity_entry.unique_id
|
||||
if not entity_unique_id.startswith(entry_id):
|
||||
return None
|
||||
new_unique_id = f"{unique_id}{entity_unique_id[len(entry_id):]}"
|
||||
_LOGGER.info(
|
||||
"Migrating unique_id from [%s] to [%s]",
|
||||
entity_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
return {"new_unique_id": new_unique_id}
|
||||
|
||||
await er.async_migrate_entries(hass, entry.entry_id, _async_migrator)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Flux LED/MagicLight from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
@@ -135,6 +160,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# is either missing or we have verified it matches
|
||||
async_update_entry_from_discovery(hass, entry, discovery, device.model_num)
|
||||
|
||||
await _async_migrate_unique_ids(hass, entry)
|
||||
|
||||
coordinator = FluxLedUpdateCoordinator(hass, device, entry)
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
platforms = PLATFORMS_BY_TYPE[device.device_type]
|
||||
|
||||
@@ -64,8 +64,8 @@ class FluxButton(FluxBaseEntity, ButtonEntity):
|
||||
self.entity_description = description
|
||||
super().__init__(device, entry)
|
||||
self._attr_name = f"{entry.data[CONF_NAME]} {description.name}"
|
||||
if entry.unique_id:
|
||||
self._attr_unique_id = f"{entry.unique_id}_{description.key}"
|
||||
base_unique_id = entry.unique_id or entry.entry_id
|
||||
self._attr_unique_id = f"{base_unique_id}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Send out a command."""
|
||||
|
||||
@@ -51,6 +51,7 @@ FLUX_LED_EXCEPTIONS: Final = (
|
||||
|
||||
STARTUP_SCAN_TIMEOUT: Final = 5
|
||||
DISCOVER_SCAN_TIMEOUT: Final = 10
|
||||
DIRECTED_DISCOVERY_TIMEOUT: Final = 15
|
||||
|
||||
CONF_MODEL: Final = "model"
|
||||
CONF_MODEL_NUM: Final = "model_num"
|
||||
|
||||
@@ -38,7 +38,7 @@ from .const import (
|
||||
CONF_REMOTE_ACCESS_ENABLED,
|
||||
CONF_REMOTE_ACCESS_HOST,
|
||||
CONF_REMOTE_ACCESS_PORT,
|
||||
DISCOVER_SCAN_TIMEOUT,
|
||||
DIRECTED_DISCOVERY_TIMEOUT,
|
||||
DOMAIN,
|
||||
FLUX_LED_DISCOVERY,
|
||||
)
|
||||
@@ -194,7 +194,7 @@ async def async_discover_device(
|
||||
"""Direct discovery at a single ip instead of broadcast."""
|
||||
# If we are missing the unique_id we should be able to fetch it
|
||||
# from the device by doing a directed discovery at the host only
|
||||
for device in await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT, host):
|
||||
for device in await async_discover_devices(hass, DIRECTED_DISCOVERY_TIMEOUT, host):
|
||||
if device[ATTR_IPADDR] == host:
|
||||
return device
|
||||
return None
|
||||
|
||||
@@ -7,19 +7,28 @@ from typing import Any
|
||||
from flux_led.aiodevice import AIOWifiLedBulb
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
ATTR_HW_VERSION,
|
||||
ATTR_IDENTIFIERS,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_NAME,
|
||||
ATTR_SW_VERSION,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_MINOR_VERSION, CONF_MODEL, SIGNAL_STATE_UPDATED
|
||||
from .const import CONF_MINOR_VERSION, CONF_MODEL, DOMAIN, SIGNAL_STATE_UPDATED
|
||||
from .coordinator import FluxLedUpdateCoordinator
|
||||
|
||||
|
||||
def _async_device_info(
|
||||
unique_id: str, device: AIOWifiLedBulb, entry: config_entries.ConfigEntry
|
||||
device: AIOWifiLedBulb, entry: config_entries.ConfigEntry
|
||||
) -> DeviceInfo:
|
||||
version_num = device.version_num
|
||||
if minor_version := entry.data.get(CONF_MINOR_VERSION):
|
||||
@@ -27,14 +36,18 @@ def _async_device_info(
|
||||
sw_version_str = f"{sw_version:0.2f}"
|
||||
else:
|
||||
sw_version_str = str(device.version_num)
|
||||
return DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, unique_id)},
|
||||
manufacturer="Zengge",
|
||||
model=device.model,
|
||||
name=entry.data[CONF_NAME],
|
||||
sw_version=sw_version_str,
|
||||
hw_version=entry.data.get(CONF_MODEL),
|
||||
)
|
||||
device_info: DeviceInfo = {
|
||||
ATTR_IDENTIFIERS: {(DOMAIN, entry.entry_id)},
|
||||
ATTR_MANUFACTURER: "Zengge",
|
||||
ATTR_MODEL: device.model,
|
||||
ATTR_NAME: entry.data[CONF_NAME],
|
||||
ATTR_SW_VERSION: sw_version_str,
|
||||
}
|
||||
if hw_model := entry.data.get(CONF_MODEL):
|
||||
device_info[ATTR_HW_VERSION] = hw_model
|
||||
if entry.unique_id:
|
||||
device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
|
||||
return device_info
|
||||
|
||||
|
||||
class FluxBaseEntity(Entity):
|
||||
@@ -50,10 +63,7 @@ class FluxBaseEntity(Entity):
|
||||
"""Initialize the light."""
|
||||
self._device: AIOWifiLedBulb = device
|
||||
self.entry = entry
|
||||
if entry.unique_id:
|
||||
self._attr_device_info = _async_device_info(
|
||||
entry.unique_id, self._device, entry
|
||||
)
|
||||
self._attr_device_info = _async_device_info(self._device, entry)
|
||||
|
||||
|
||||
class FluxEntity(CoordinatorEntity):
|
||||
@@ -64,7 +74,7 @@ class FluxEntity(CoordinatorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FluxLedUpdateCoordinator,
|
||||
unique_id: str | None,
|
||||
base_unique_id: str,
|
||||
name: str,
|
||||
key: str | None,
|
||||
) -> None:
|
||||
@@ -74,13 +84,10 @@ class FluxEntity(CoordinatorEntity):
|
||||
self._responding = True
|
||||
self._attr_name = name
|
||||
if key:
|
||||
self._attr_unique_id = f"{unique_id}_{key}"
|
||||
self._attr_unique_id = f"{base_unique_id}_{key}"
|
||||
else:
|
||||
self._attr_unique_id = unique_id
|
||||
if unique_id:
|
||||
self._attr_device_info = _async_device_info(
|
||||
unique_id, self._device, coordinator.entry
|
||||
)
|
||||
self._attr_unique_id = base_unique_id
|
||||
self._attr_device_info = _async_device_info(self._device, coordinator.entry)
|
||||
|
||||
async def _async_ensure_device_on(self) -> None:
|
||||
"""Turn the device on if it needs to be turned on before a command."""
|
||||
|
||||
@@ -177,7 +177,7 @@ async def async_setup_entry(
|
||||
[
|
||||
FluxLight(
|
||||
coordinator,
|
||||
entry.unique_id,
|
||||
entry.unique_id or entry.entry_id,
|
||||
entry.data[CONF_NAME],
|
||||
list(custom_effect_colors),
|
||||
options.get(CONF_CUSTOM_EFFECT_SPEED_PCT, DEFAULT_EFFECT_SPEED),
|
||||
@@ -195,14 +195,14 @@ class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FluxLedUpdateCoordinator,
|
||||
unique_id: str | None,
|
||||
base_unique_id: str,
|
||||
name: str,
|
||||
custom_effect_colors: list[tuple[int, int, int]],
|
||||
custom_effect_speed_pct: int,
|
||||
custom_effect_transition: str,
|
||||
) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(coordinator, unique_id, name, None)
|
||||
super().__init__(coordinator, base_unique_id, name, None)
|
||||
self._attr_min_mireds = color_temperature_kelvin_to_mired(self._device.max_temp)
|
||||
self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp)
|
||||
self._attr_supported_color_modes = _hass_color_modes(self._device)
|
||||
|
||||
@@ -51,26 +51,28 @@ async def async_setup_entry(
|
||||
| FluxMusicSegmentsNumber
|
||||
] = []
|
||||
name = entry.data[CONF_NAME]
|
||||
unique_id = entry.unique_id
|
||||
base_unique_id = entry.unique_id or entry.entry_id
|
||||
|
||||
if device.pixels_per_segment is not None:
|
||||
entities.append(
|
||||
FluxPixelsPerSegmentNumber(
|
||||
coordinator,
|
||||
unique_id,
|
||||
base_unique_id,
|
||||
f"{name} Pixels Per Segment",
|
||||
"pixels_per_segment",
|
||||
)
|
||||
)
|
||||
if device.segments is not None:
|
||||
entities.append(
|
||||
FluxSegmentsNumber(coordinator, unique_id, f"{name} Segments", "segments")
|
||||
FluxSegmentsNumber(
|
||||
coordinator, base_unique_id, f"{name} Segments", "segments"
|
||||
)
|
||||
)
|
||||
if device.music_pixels_per_segment is not None:
|
||||
entities.append(
|
||||
FluxMusicPixelsPerSegmentNumber(
|
||||
coordinator,
|
||||
unique_id,
|
||||
base_unique_id,
|
||||
f"{name} Music Pixels Per Segment",
|
||||
"music_pixels_per_segment",
|
||||
)
|
||||
@@ -78,12 +80,12 @@ async def async_setup_entry(
|
||||
if device.music_segments is not None:
|
||||
entities.append(
|
||||
FluxMusicSegmentsNumber(
|
||||
coordinator, unique_id, f"{name} Music Segments", "music_segments"
|
||||
coordinator, base_unique_id, f"{name} Music Segments", "music_segments"
|
||||
)
|
||||
)
|
||||
if device.effect_list and device.effect_list != [EFFECT_RANDOM]:
|
||||
entities.append(
|
||||
FluxSpeedNumber(coordinator, unique_id, f"{name} Effect Speed", None)
|
||||
FluxSpeedNumber(coordinator, base_unique_id, f"{name} Effect Speed", None)
|
||||
)
|
||||
|
||||
if entities:
|
||||
@@ -131,12 +133,12 @@ class FluxConfigNumber(FluxEntity, CoordinatorEntity, NumberEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FluxLedUpdateCoordinator,
|
||||
unique_id: str | None,
|
||||
base_unique_id: str,
|
||||
name: str,
|
||||
key: str | None,
|
||||
) -> None:
|
||||
"""Initialize the flux number."""
|
||||
super().__init__(coordinator, unique_id, name, key)
|
||||
super().__init__(coordinator, base_unique_id, name, key)
|
||||
self._debouncer: Debouncer | None = None
|
||||
self._pending_value: int | None = None
|
||||
|
||||
|
||||
@@ -54,28 +54,28 @@ async def async_setup_entry(
|
||||
| FluxWhiteChannelSelect
|
||||
] = []
|
||||
name = entry.data[CONF_NAME]
|
||||
unique_id = entry.unique_id
|
||||
base_unique_id = entry.unique_id or entry.entry_id
|
||||
|
||||
if device.device_type == DeviceType.Switch:
|
||||
entities.append(FluxPowerStateSelect(coordinator.device, entry))
|
||||
if device.operating_modes:
|
||||
entities.append(
|
||||
FluxOperatingModesSelect(
|
||||
coordinator, unique_id, f"{name} Operating Mode", "operating_mode"
|
||||
coordinator, base_unique_id, f"{name} Operating Mode", "operating_mode"
|
||||
)
|
||||
)
|
||||
if device.wirings:
|
||||
entities.append(
|
||||
FluxWiringsSelect(coordinator, unique_id, f"{name} Wiring", "wiring")
|
||||
FluxWiringsSelect(coordinator, base_unique_id, f"{name} Wiring", "wiring")
|
||||
)
|
||||
if device.ic_types:
|
||||
entities.append(
|
||||
FluxICTypeSelect(coordinator, unique_id, f"{name} IC Type", "ic_type")
|
||||
FluxICTypeSelect(coordinator, base_unique_id, f"{name} IC Type", "ic_type")
|
||||
)
|
||||
if device.remote_config:
|
||||
entities.append(
|
||||
FluxRemoteConfigSelect(
|
||||
coordinator, unique_id, f"{name} Remote Config", "remote_config"
|
||||
coordinator, base_unique_id, f"{name} Remote Config", "remote_config"
|
||||
)
|
||||
)
|
||||
if FLUX_COLOR_MODE_RGBW in device.color_modes:
|
||||
@@ -111,8 +111,8 @@ class FluxPowerStateSelect(FluxConfigAtStartSelect, SelectEntity):
|
||||
"""Initialize the power state select."""
|
||||
super().__init__(device, entry)
|
||||
self._attr_name = f"{entry.data[CONF_NAME]} Power Restored"
|
||||
if entry.unique_id:
|
||||
self._attr_unique_id = f"{entry.unique_id}_power_restored"
|
||||
base_unique_id = entry.unique_id or entry.entry_id
|
||||
self._attr_unique_id = f"{base_unique_id}_power_restored"
|
||||
self._async_set_current_option_from_device()
|
||||
|
||||
@callback
|
||||
@@ -201,12 +201,12 @@ class FluxRemoteConfigSelect(FluxConfigSelect):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FluxLedUpdateCoordinator,
|
||||
unique_id: str | None,
|
||||
base_unique_id: str,
|
||||
name: str,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the remote config type select."""
|
||||
super().__init__(coordinator, unique_id, name, key)
|
||||
super().__init__(coordinator, base_unique_id, name, key)
|
||||
assert self._device.remote_config is not None
|
||||
self._name_to_state = {
|
||||
_human_readable_option(option.name): option for option in RemoteConfig
|
||||
@@ -238,8 +238,8 @@ class FluxWhiteChannelSelect(FluxConfigAtStartSelect):
|
||||
"""Initialize the white channel select."""
|
||||
super().__init__(device, entry)
|
||||
self._attr_name = f"{entry.data[CONF_NAME]} White Channel"
|
||||
if entry.unique_id:
|
||||
self._attr_unique_id = f"{entry.unique_id}_white_channel"
|
||||
base_unique_id = entry.unique_id or entry.entry_id
|
||||
self._attr_unique_id = f"{base_unique_id}_white_channel"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
|
||||
@@ -25,7 +25,7 @@ async def async_setup_entry(
|
||||
[
|
||||
FluxPairedRemotes(
|
||||
coordinator,
|
||||
entry.unique_id,
|
||||
entry.unique_id or entry.entry_id,
|
||||
f"{entry.data[CONF_NAME]} Paired Remotes",
|
||||
"paired_remotes",
|
||||
)
|
||||
|
||||
@@ -34,18 +34,18 @@ async def async_setup_entry(
|
||||
"""Set up the Flux lights."""
|
||||
coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities: list[FluxSwitch | FluxRemoteAccessSwitch | FluxMusicSwitch] = []
|
||||
unique_id = entry.unique_id
|
||||
base_unique_id = entry.unique_id or entry.entry_id
|
||||
name = entry.data[CONF_NAME]
|
||||
|
||||
if coordinator.device.device_type == DeviceType.Switch:
|
||||
entities.append(FluxSwitch(coordinator, unique_id, name, None))
|
||||
entities.append(FluxSwitch(coordinator, base_unique_id, name, None))
|
||||
|
||||
if entry.data.get(CONF_REMOTE_ACCESS_HOST):
|
||||
entities.append(FluxRemoteAccessSwitch(coordinator.device, entry))
|
||||
|
||||
if coordinator.device.microphone:
|
||||
entities.append(
|
||||
FluxMusicSwitch(coordinator, unique_id, f"{name} Music", "music")
|
||||
FluxMusicSwitch(coordinator, base_unique_id, f"{name} Music", "music")
|
||||
)
|
||||
|
||||
if entities:
|
||||
@@ -74,8 +74,8 @@ class FluxRemoteAccessSwitch(FluxBaseEntity, SwitchEntity):
|
||||
"""Initialize the light."""
|
||||
super().__init__(device, entry)
|
||||
self._attr_name = f"{entry.data[CONF_NAME]} Remote Access"
|
||||
if entry.unique_id:
|
||||
self._attr_unique_id = f"{entry.unique_id}_remote_access"
|
||||
base_unique_id = entry.unique_id or entry.entry_id
|
||||
self._attr_unique_id = f"{base_unique_id}_remote_access"
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote access on."""
|
||||
|
||||
@@ -107,7 +107,7 @@ class Device:
|
||||
ip_address: str
|
||||
name: str
|
||||
ssid: str | None
|
||||
wan_access: bool = True
|
||||
wan_access: bool | None = None
|
||||
|
||||
|
||||
class Interface(TypedDict):
|
||||
@@ -277,6 +277,14 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator):
|
||||
)
|
||||
return bool(version), version
|
||||
|
||||
def _get_wan_access(self, ip_address: str) -> bool | None:
|
||||
"""Get WAN access rule for given IP address."""
|
||||
return not self.connection.call_action(
|
||||
"X_AVM-DE_HostFilter:1",
|
||||
"GetWANAccessByIP",
|
||||
NewIPv4Address=ip_address,
|
||||
).get("NewDisallow")
|
||||
|
||||
async def async_scan_devices(self, now: datetime | None = None) -> None:
|
||||
"""Wrap up FritzboxTools class scan."""
|
||||
await self.hass.async_add_executor_job(self.scan_devices, now)
|
||||
@@ -315,7 +323,7 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator):
|
||||
connection_type="",
|
||||
ip_address=host["ip"],
|
||||
ssid=None,
|
||||
wan_access=False,
|
||||
wan_access=None,
|
||||
)
|
||||
|
||||
mesh_intf = {}
|
||||
@@ -343,32 +351,32 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator):
|
||||
|
||||
for interf in node["node_interfaces"]:
|
||||
dev_mac = interf["mac_address"]
|
||||
|
||||
if dev_mac not in hosts:
|
||||
continue
|
||||
|
||||
dev_info: Device = hosts[dev_mac]
|
||||
|
||||
for link in interf["node_links"]:
|
||||
intf = mesh_intf.get(link["node_interface_1_uid"])
|
||||
if (
|
||||
intf is not None
|
||||
and link["state"] == "CONNECTED"
|
||||
and dev_mac in hosts
|
||||
):
|
||||
dev_info: Device = hosts[dev_mac]
|
||||
if intf["op_mode"] != "AP_GUEST":
|
||||
dev_info.wan_access = not self.connection.call_action(
|
||||
"X_AVM-DE_HostFilter:1",
|
||||
"GetWANAccessByIP",
|
||||
NewIPv4Address=dev_info.ip_address,
|
||||
).get("NewDisallow")
|
||||
if intf is not None:
|
||||
if intf["op_mode"] != "AP_GUEST" and dev_info.ip_address:
|
||||
dev_info.wan_access = self._get_wan_access(
|
||||
dev_info.ip_address
|
||||
)
|
||||
|
||||
dev_info.connected_to = intf["device"]
|
||||
dev_info.connection_type = intf["type"]
|
||||
dev_info.ssid = intf.get("ssid")
|
||||
_LOGGER.debug("Client dev_info: %s", dev_info)
|
||||
|
||||
if dev_mac in self._devices:
|
||||
self._devices[dev_mac].update(dev_info, consider_home)
|
||||
else:
|
||||
device = FritzDevice(dev_mac, dev_info.name)
|
||||
device.update(dev_info, consider_home)
|
||||
self._devices[dev_mac] = device
|
||||
new_device = True
|
||||
if dev_mac in self._devices:
|
||||
self._devices[dev_mac].update(dev_info, consider_home)
|
||||
else:
|
||||
device = FritzDevice(dev_mac, dev_info.name)
|
||||
device.update(dev_info, consider_home)
|
||||
self._devices[dev_mac] = device
|
||||
new_device = True
|
||||
|
||||
dispatcher_send(self.hass, self.signal_device_update)
|
||||
if new_device:
|
||||
@@ -760,7 +768,7 @@ class FritzDevice:
|
||||
self._mac = mac
|
||||
self._name = name
|
||||
self._ssid: str | None = None
|
||||
self._wan_access = False
|
||||
self._wan_access: bool | None = False
|
||||
|
||||
def update(self, dev_info: Device, consider_home: float) -> None:
|
||||
"""Update device info."""
|
||||
@@ -828,7 +836,7 @@ class FritzDevice:
|
||||
return self._ssid
|
||||
|
||||
@property
|
||||
def wan_access(self) -> bool:
|
||||
def wan_access(self) -> bool | None:
|
||||
"""Return device wan access."""
|
||||
return self._wan_access
|
||||
|
||||
|
||||
@@ -477,10 +477,17 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Switch status."""
|
||||
return self._avm_wrapper.devices[self._mac].wan_access
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return availability of the switch."""
|
||||
if self._avm_wrapper.devices[self._mac].wan_access is None:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device information."""
|
||||
|
||||
@@ -158,6 +158,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
existing_entry = await self.async_set_unique_id(isy_mac)
|
||||
if not existing_entry:
|
||||
return
|
||||
if existing_entry.source == config_entries.SOURCE_IGNORE:
|
||||
raise data_entry_flow.AbortFlow("already_configured")
|
||||
parsed_url = urlparse(existing_entry.data[CONF_HOST])
|
||||
if parsed_url.hostname != ip_address:
|
||||
new_netloc = ip_address
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import DateTime, ExposeSensor
|
||||
from xknx.dpt import DPTNumeric
|
||||
from xknx.dpt import DPTNumeric, DPTString
|
||||
from xknx.exceptions import ConversionError
|
||||
from xknx.remote_value import RemoteValueSensor
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -22,6 +24,8 @@ from homeassistant.helpers.typing import ConfigType, StateType
|
||||
from .const import KNX_ADDRESS
|
||||
from .schema import ExposeSchema
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def create_knx_exposure(
|
||||
@@ -101,7 +105,10 @@ class KNXExposeSensor:
|
||||
"""Initialize state of the exposure."""
|
||||
init_state = self.hass.states.get(self.entity_id)
|
||||
state_value = self._get_expose_value(init_state)
|
||||
self.device.sensor_value.value = state_value
|
||||
try:
|
||||
self.device.sensor_value.value = state_value
|
||||
except ConversionError:
|
||||
_LOGGER.exception("Error during sending of expose sensor value")
|
||||
|
||||
@callback
|
||||
def shutdown(self) -> None:
|
||||
@@ -132,6 +139,13 @@ class KNXExposeSensor:
|
||||
and issubclass(self.device.sensor_value.dpt_class, DPTNumeric)
|
||||
):
|
||||
return float(value)
|
||||
if (
|
||||
value is not None
|
||||
and isinstance(self.device.sensor_value, RemoteValueSensor)
|
||||
and issubclass(self.device.sensor_value.dpt_class, DPTString)
|
||||
):
|
||||
# DPT 16.000 only allows up to 14 Bytes
|
||||
return str(value)[:14]
|
||||
return value
|
||||
|
||||
async def _async_entity_changed(self, event: Event) -> None:
|
||||
@@ -148,9 +162,10 @@ class KNXExposeSensor:
|
||||
|
||||
async def _async_set_knx_value(self, value: StateType) -> None:
|
||||
"""Set new value on xknx ExposeSensor."""
|
||||
if value is None:
|
||||
return
|
||||
await self.device.set(value)
|
||||
try:
|
||||
await self.device.set(value)
|
||||
except ConversionError:
|
||||
_LOGGER.exception("Error during sending of expose sensor value")
|
||||
|
||||
|
||||
class KNXExposeTime:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/knx",
|
||||
"requirements": [
|
||||
"xknx==0.19.0"
|
||||
"xknx==0.19.1"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Julius2342",
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from google_nest_sdm import diagnostics
|
||||
from google_nest_sdm.device import Device
|
||||
from google_nest_sdm.device_traits import InfoTrait
|
||||
from google_nest_sdm.exceptions import ApiException
|
||||
@@ -30,22 +31,14 @@ async def async_get_config_entry_diagnostics(
|
||||
return {"error": str(err)}
|
||||
|
||||
return {
|
||||
**diagnostics.get_diagnostics(),
|
||||
"devices": [
|
||||
get_device_data(device) for device in device_manager.devices.values()
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_device_data(device: Device) -> dict[str, Any]:
|
||||
"""Return diagnostic information about a device."""
|
||||
# Return a simplified view of the API object, but skipping any id fields or
|
||||
# traits that include unique identifiers or personally identifiable information.
|
||||
# See https://developers.google.com/nest/device-access/traits for API details
|
||||
return {
|
||||
"type": device.type,
|
||||
"traits": {
|
||||
trait: data
|
||||
for trait, data in device.raw_data.get("traits", {}).items()
|
||||
if trait not in REDACT_DEVICE_TRAITS
|
||||
},
|
||||
}
|
||||
# Library performs its own redaction for device data
|
||||
return device.get_diagnostics()
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"domain": "oncue",
|
||||
"name": "Oncue by Kohler",
|
||||
"config_flow": true,
|
||||
"dhcp": [{
|
||||
"hostname": "kohlergen*",
|
||||
"macaddress": "00146F*"
|
||||
}],
|
||||
"documentation": "https://www.home-assistant.io/integrations/oncue",
|
||||
"requirements": ["aiooncue==0.3.2"],
|
||||
"codeowners": ["@bdraco"],
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import requests
|
||||
from tesla_powerwall import (
|
||||
AccessDeniedError,
|
||||
APIError,
|
||||
MissingAttributeError,
|
||||
Powerwall,
|
||||
PowerwallUnreachableError,
|
||||
@@ -131,7 +132,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
power_wall = Powerwall(ip_address, http_session=http_session)
|
||||
runtime_data[POWERWALL_OBJECT] = power_wall
|
||||
runtime_data[POWERWALL_HTTP_SESSION] = http_session
|
||||
power_wall.login("", password)
|
||||
power_wall.login(password)
|
||||
|
||||
async def _async_login_and_retry_update_data():
|
||||
"""Retry the update after a failed login."""
|
||||
nonlocal login_failed_count
|
||||
# If the session expired, recreate, relogin, and try again
|
||||
_LOGGER.debug("Retrying login and updating data")
|
||||
try:
|
||||
await hass.async_add_executor_job(_recreate_powerwall_login)
|
||||
data = await _async_update_powerwall_data(hass, entry, power_wall)
|
||||
except AccessDeniedError as err:
|
||||
login_failed_count += 1
|
||||
if login_failed_count == MAX_LOGIN_FAILURES:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise UpdateFailed(
|
||||
f"Login attempt {login_failed_count}/{MAX_LOGIN_FAILURES} failed, will retry: {err}"
|
||||
) from err
|
||||
except APIError as err:
|
||||
raise UpdateFailed(f"Updated failed due to {err}, will retry") from err
|
||||
else:
|
||||
login_failed_count = 0
|
||||
return data
|
||||
|
||||
async def async_update_data():
|
||||
"""Fetch data from API endpoint."""
|
||||
@@ -147,18 +169,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except AccessDeniedError as err:
|
||||
if password is None:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
||||
# If the session expired, recreate, relogin, and try again
|
||||
try:
|
||||
await hass.async_add_executor_job(_recreate_powerwall_login)
|
||||
return await _async_update_powerwall_data(hass, entry, power_wall)
|
||||
except AccessDeniedError as ex:
|
||||
login_failed_count += 1
|
||||
if login_failed_count == MAX_LOGIN_FAILURES:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
raise UpdateFailed(
|
||||
f"Login attempt {login_failed_count}/{MAX_LOGIN_FAILURES} failed, will retry"
|
||||
) from ex
|
||||
return await _async_login_and_retry_update_data()
|
||||
except APIError as err:
|
||||
raise UpdateFailed(f"Updated failed due to {err}, will retry") from err
|
||||
else:
|
||||
login_failed_count = 0
|
||||
return data
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
{
|
||||
"hostname": "roomba-*",
|
||||
"macaddress": "80A589*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"hostname": "roomba-*",
|
||||
"macaddress": "DCF505*"
|
||||
}
|
||||
],
|
||||
"iot_class": "local_push"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from abc import ABC, abstractmethod
|
||||
import contextlib
|
||||
from typing import Any
|
||||
|
||||
from requests.exceptions import Timeout as RequestsTimeout
|
||||
from samsungctl import Remote
|
||||
from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse
|
||||
from samsungtvws import SamsungTVWS
|
||||
@@ -321,7 +322,7 @@ class SamsungTVWSBridge(SamsungTVBridge):
|
||||
def device_info(self) -> dict[str, Any] | None:
|
||||
"""Try to gather infos of this TV."""
|
||||
if remote := self._get_remote(avoid_open=True):
|
||||
with contextlib.suppress(HttpApiError):
|
||||
with contextlib.suppress(HttpApiError, RequestsTimeout):
|
||||
device_info: dict[str, Any] = remote.rest_device_info()
|
||||
return device_info
|
||||
|
||||
|
||||
@@ -31,50 +31,30 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up SenseME lights."""
|
||||
device = hass.data[DOMAIN][entry.entry_id]
|
||||
if device.has_light:
|
||||
async_add_entities([HASensemeLight(device)])
|
||||
if not device.has_light:
|
||||
return
|
||||
if device.is_light:
|
||||
async_add_entities([HASensemeStandaloneLight(device)])
|
||||
else:
|
||||
async_add_entities([HASensemeFanLight(device)])
|
||||
|
||||
|
||||
class HASensemeLight(SensemeEntity, LightEntity):
|
||||
"""Representation of a Big Ass Fans SenseME light."""
|
||||
|
||||
def __init__(self, device: SensemeDevice) -> None:
|
||||
def __init__(self, device: SensemeDevice, name: str) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._device = device
|
||||
if device.is_light:
|
||||
name = device.name # The device itself is a light
|
||||
else:
|
||||
name = f"{device.name} Light" # A fan light
|
||||
super().__init__(device, name)
|
||||
if device.is_light:
|
||||
self._attr_supported_color_modes = {COLOR_MODE_COLOR_TEMP}
|
||||
self._attr_color_mode = COLOR_MODE_COLOR_TEMP
|
||||
else:
|
||||
self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS}
|
||||
self._attr_color_mode = COLOR_MODE_BRIGHTNESS
|
||||
self._attr_unique_id = f"{self._device.uuid}-LIGHT" # for legacy compat
|
||||
self._attr_min_mireds = color_temperature_kelvin_to_mired(
|
||||
self._device.light_color_temp_max
|
||||
)
|
||||
self._attr_max_mireds = color_temperature_kelvin_to_mired(
|
||||
self._device.light_color_temp_min
|
||||
)
|
||||
self._attr_unique_id = f"{device.uuid}-LIGHT" # for legacy compat
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update attrs from device."""
|
||||
self._attr_is_on = self._device.light_on
|
||||
self._attr_brightness = int(min(255, self._device.light_brightness * 16))
|
||||
self._attr_color_temp = color_temperature_kelvin_to_mired(
|
||||
self._device.light_color_temp
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None:
|
||||
self._device.light_color_temp = color_temperature_mired_to_kelvin(
|
||||
color_temp
|
||||
)
|
||||
if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None:
|
||||
# set the brightness, which will also turn on/off light
|
||||
if brightness == 255:
|
||||
@@ -86,3 +66,45 @@ class HASensemeLight(SensemeEntity, LightEntity):
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
self._device.light_on = False
|
||||
|
||||
|
||||
class HASensemeFanLight(HASensemeLight):
|
||||
"""Representation of a Big Ass Fans SenseME light on a fan."""
|
||||
|
||||
def __init__(self, device: SensemeDevice) -> None:
|
||||
"""Init a fan light."""
|
||||
super().__init__(device, device.name)
|
||||
self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS}
|
||||
self._attr_color_mode = COLOR_MODE_BRIGHTNESS
|
||||
|
||||
|
||||
class HASensemeStandaloneLight(HASensemeLight):
|
||||
"""Representation of a Big Ass Fans SenseME light."""
|
||||
|
||||
def __init__(self, device: SensemeDevice) -> None:
|
||||
"""Init a standalone light."""
|
||||
super().__init__(device, f"{device.name} Light")
|
||||
self._attr_supported_color_modes = {COLOR_MODE_COLOR_TEMP}
|
||||
self._attr_color_mode = COLOR_MODE_COLOR_TEMP
|
||||
self._attr_min_mireds = color_temperature_kelvin_to_mired(
|
||||
device.light_color_temp_max
|
||||
)
|
||||
self._attr_max_mireds = color_temperature_kelvin_to_mired(
|
||||
device.light_color_temp_min
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update attrs from device."""
|
||||
super()._async_update_attrs()
|
||||
self._attr_color_temp = color_temperature_kelvin_to_mired(
|
||||
self._device.light_color_temp
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None:
|
||||
self._device.light_color_temp = color_temperature_mired_to_kelvin(
|
||||
color_temp
|
||||
)
|
||||
await super().async_turn_on(**kwargs)
|
||||
|
||||
40
homeassistant/components/simplisafe/diagnostics.py
Normal file
40
homeassistant/components/simplisafe/diagnostics.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Diagnostics support for SimpliSafe."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import SimpliSafe
|
||||
from .const import DOMAIN
|
||||
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_SYSTEM_ID = "system_id"
|
||||
CONF_WIFI_SSID = "wifi_ssid"
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_ADDRESS,
|
||||
CONF_SERIAL,
|
||||
CONF_SYSTEM_ID,
|
||||
CONF_WIFI_SSID,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
simplisafe: SimpliSafe = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
return async_redact_data(
|
||||
{
|
||||
"entry": {
|
||||
"options": dict(entry.options),
|
||||
},
|
||||
"systems": [system.as_dict() for system in simplisafe.systems.values()],
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SimpliSafe",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
|
||||
"requirements": ["simplisafe-python==2021.12.2"],
|
||||
"requirements": ["simplisafe-python==2022.01.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
"dhcp": [
|
||||
|
||||
@@ -193,6 +193,7 @@ class SonosDiscoveryManager:
|
||||
|
||||
async def _async_stop_event_listener(self, event: Event | None = None) -> None:
|
||||
for speaker in self.data.discovered.values():
|
||||
speaker.activity_stats.log_report()
|
||||
speaker.event_stats.log_report()
|
||||
await asyncio.gather(
|
||||
*(speaker.async_offline() for speaker in self.data.discovered.values())
|
||||
|
||||
@@ -130,5 +130,6 @@ async def async_generate_speaker_info(
|
||||
if s is speaker
|
||||
}
|
||||
payload["media"] = await async_generate_media_info(hass, speaker)
|
||||
payload["activity_stats"] = speaker.activity_stats.report()
|
||||
payload["event_stats"] = speaker.event_stats.report()
|
||||
return payload
|
||||
|
||||
@@ -62,7 +62,7 @@ from .const import (
|
||||
)
|
||||
from .favorites import SonosFavorites
|
||||
from .helpers import soco_error
|
||||
from .statistics import EventStatistics
|
||||
from .statistics import ActivityStatistics, EventStatistics
|
||||
|
||||
NEVER_TIME = -1200.0
|
||||
EVENT_CHARGING = {
|
||||
@@ -177,6 +177,7 @@ class SonosSpeaker:
|
||||
self._event_dispatchers: dict[str, Callable] = {}
|
||||
self._last_activity: float = NEVER_TIME
|
||||
self._last_event_cache: dict[str, Any] = {}
|
||||
self.activity_stats: ActivityStatistics = ActivityStatistics(self.zone_name)
|
||||
self.event_stats: EventStatistics = EventStatistics(self.zone_name)
|
||||
|
||||
# Scheduled callback handles
|
||||
@@ -528,6 +529,7 @@ class SonosSpeaker:
|
||||
"""Track the last activity on this speaker, set availability and resubscribe."""
|
||||
_LOGGER.debug("Activity on %s from %s", self.zone_name, source)
|
||||
self._last_activity = time.monotonic()
|
||||
self.activity_stats.activity(source, self._last_activity)
|
||||
was_available = self.available
|
||||
self.available = True
|
||||
if not was_available:
|
||||
|
||||
@@ -9,13 +9,49 @@ from soco.events_base import Event as SonosEvent, parse_event_xml
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventStatistics:
|
||||
class SonosStatistics:
|
||||
"""Base class of Sonos statistics."""
|
||||
|
||||
def __init__(self, zone_name: str, kind: str) -> None:
|
||||
"""Initialize SonosStatistics."""
|
||||
self._stats = {}
|
||||
self._stat_type = kind
|
||||
self.zone_name = zone_name
|
||||
|
||||
def report(self) -> dict:
|
||||
"""Generate a report for use in diagnostics."""
|
||||
return self._stats.copy()
|
||||
|
||||
def log_report(self) -> None:
|
||||
"""Log statistics for this speaker."""
|
||||
_LOGGER.debug(
|
||||
"%s statistics for %s: %s",
|
||||
self._stat_type,
|
||||
self.zone_name,
|
||||
self.report(),
|
||||
)
|
||||
|
||||
|
||||
class ActivityStatistics(SonosStatistics):
|
||||
"""Representation of Sonos activity statistics."""
|
||||
|
||||
def __init__(self, zone_name: str) -> None:
|
||||
"""Initialize ActivityStatistics."""
|
||||
super().__init__(zone_name, "Activity")
|
||||
|
||||
def activity(self, source: str, timestamp: float) -> None:
|
||||
"""Track an activity occurrence."""
|
||||
activity_entry = self._stats.setdefault(source, {"count": 0})
|
||||
activity_entry["count"] += 1
|
||||
activity_entry["last_seen"] = timestamp
|
||||
|
||||
|
||||
class EventStatistics(SonosStatistics):
|
||||
"""Representation of Sonos event statistics."""
|
||||
|
||||
def __init__(self, zone_name: str) -> None:
|
||||
"""Initialize EventStatistics."""
|
||||
self._stats = {}
|
||||
self.zone_name = zone_name
|
||||
super().__init__(zone_name, "Event")
|
||||
|
||||
def receive(self, event: SonosEvent) -> None:
|
||||
"""Mark a received event by subscription type."""
|
||||
@@ -38,11 +74,3 @@ class EventStatistics:
|
||||
payload["soco:from_didl_string"] = from_didl_string.cache_info()
|
||||
payload["soco:parse_event_xml"] = parse_event_xml.cache_info()
|
||||
return payload
|
||||
|
||||
def log_report(self) -> None:
|
||||
"""Log event statistics for this speaker."""
|
||||
_LOGGER.debug(
|
||||
"Event statistics for %s: %s",
|
||||
self.zone_name,
|
||||
self.report(),
|
||||
)
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"hostname": "k[lp]*",
|
||||
"macaddress": "403F8C*"
|
||||
},
|
||||
{
|
||||
"hostname": "k[lp]*",
|
||||
"macaddress": "C0C9E3*"
|
||||
},
|
||||
{
|
||||
"hostname": "ep*",
|
||||
"macaddress": "E848B8*"
|
||||
|
||||
@@ -137,7 +137,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
[
|
||||
{
|
||||
"code": self._speed.dpcode,
|
||||
"value": self._speed.scale_value_back(percentage),
|
||||
"value": int(self._speed.remap_value_from(percentage, 0, 100)),
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
@@ -31,24 +31,42 @@ REDACT_WLANS = {"bc_filter_list", "x_passphrase"}
|
||||
|
||||
|
||||
@callback
|
||||
def async_replace_data(data: Mapping, to_replace: dict[str, str]) -> dict[str, Any]:
|
||||
"""Replace sensitive data in a dict."""
|
||||
if not isinstance(data, (Mapping, list, set, tuple)):
|
||||
return to_replace.get(data, data)
|
||||
|
||||
def async_replace_dict_data(
|
||||
data: Mapping, to_replace: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
"""Redact sensitive data in a dict."""
|
||||
redacted = {**data}
|
||||
|
||||
for key, value in redacted.items():
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
redacted[key] = async_replace_data(value, to_replace)
|
||||
redacted[key] = async_replace_dict_data(value, to_replace)
|
||||
elif isinstance(value, (list, set, tuple)):
|
||||
redacted[key] = [async_replace_data(item, to_replace) for item in value]
|
||||
redacted[key] = async_replace_list_data(value, to_replace)
|
||||
elif isinstance(value, str):
|
||||
if value in to_replace:
|
||||
redacted[key] = to_replace[value]
|
||||
elif value.count(":") == 5:
|
||||
redacted[key] = REDACTED
|
||||
return redacted
|
||||
|
||||
|
||||
@callback
|
||||
def async_replace_list_data(
|
||||
data: list | set | tuple, to_replace: dict[str, str]
|
||||
) -> list[Any]:
|
||||
"""Redact sensitive data in a list."""
|
||||
redacted = []
|
||||
for item in data:
|
||||
new_value = None
|
||||
if isinstance(item, (list, set, tuple)):
|
||||
new_value = async_replace_list_data(item, to_replace)
|
||||
elif isinstance(item, Mapping):
|
||||
new_value = async_replace_dict_data(item, to_replace)
|
||||
elif isinstance(item, str):
|
||||
if item in to_replace:
|
||||
new_value = to_replace[item]
|
||||
elif item.count(":") == 5:
|
||||
new_value = REDACTED
|
||||
redacted.append(new_value or item)
|
||||
return redacted
|
||||
|
||||
|
||||
@@ -73,26 +91,28 @@ async def async_get_config_entry_diagnostics(
|
||||
counter += 1
|
||||
|
||||
diag["config"] = async_redact_data(
|
||||
async_replace_data(config_entry.as_dict(), macs_to_redact), REDACT_CONFIG
|
||||
async_replace_dict_data(config_entry.as_dict(), macs_to_redact), REDACT_CONFIG
|
||||
)
|
||||
diag["site_role"] = controller.site_role
|
||||
diag["entities"] = async_replace_data(controller.entities, macs_to_redact)
|
||||
diag["entities"] = async_replace_dict_data(controller.entities, macs_to_redact)
|
||||
diag["clients"] = {
|
||||
macs_to_redact[k]: async_redact_data(
|
||||
async_replace_data(v.raw, macs_to_redact), REDACT_CLIENTS
|
||||
async_replace_dict_data(v.raw, macs_to_redact), REDACT_CLIENTS
|
||||
)
|
||||
for k, v in controller.api.clients.items()
|
||||
}
|
||||
diag["devices"] = {
|
||||
macs_to_redact[k]: async_redact_data(
|
||||
async_replace_data(v.raw, macs_to_redact), REDACT_DEVICES
|
||||
async_replace_dict_data(v.raw, macs_to_redact), REDACT_DEVICES
|
||||
)
|
||||
for k, v in controller.api.devices.items()
|
||||
}
|
||||
diag["dpi_apps"] = {k: v.raw for k, v in controller.api.dpi_apps.items()}
|
||||
diag["dpi_groups"] = {k: v.raw for k, v in controller.api.dpi_groups.items()}
|
||||
diag["wlans"] = {
|
||||
k: async_redact_data(async_replace_data(v.raw, macs_to_redact), REDACT_WLANS)
|
||||
k: async_redact_data(
|
||||
async_replace_dict_data(v.raw, macs_to_redact), REDACT_WLANS
|
||||
)
|
||||
for k, v in controller.api.wlans.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import async_control_connect
|
||||
from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS
|
||||
from .helpers import async_get_sources
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -178,11 +179,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
options_input = {CONF_SOURCES: user_input[CONF_SOURCES]}
|
||||
return self.async_create_entry(title="", data=options_input)
|
||||
# Get sources
|
||||
sources = self.options.get(CONF_SOURCES, "")
|
||||
sources_list = await async_get_sources(self.host, self.key)
|
||||
if not sources_list:
|
||||
errors["base"] = "cannot_retrieve"
|
||||
|
||||
sources = [s for s in self.options.get(CONF_SOURCES, []) if s in sources_list]
|
||||
if not sources:
|
||||
sources = sources_list
|
||||
|
||||
options_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
@@ -195,16 +199,3 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=options_schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
async def async_get_sources(host: str, key: str) -> list[str]:
|
||||
"""Construct sources list."""
|
||||
try:
|
||||
client = await async_control_connect(host, key)
|
||||
except WEBOSTV_EXCEPTIONS:
|
||||
return []
|
||||
|
||||
return [
|
||||
*(app["title"] for app in client.apps.values()),
|
||||
*(app["label"] for app in client.inputs.values()),
|
||||
]
|
||||
|
||||
@@ -6,8 +6,8 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from . import WebOsClientWrapper
|
||||
from .const import DATA_CONFIG_ENTRY, DOMAIN
|
||||
from . import WebOsClientWrapper, async_control_connect
|
||||
from .const import DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS
|
||||
|
||||
|
||||
@callback
|
||||
@@ -81,3 +81,29 @@ def async_get_client_wrapper_by_device_entry(
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_get_sources(host: str, key: str) -> list[str]:
|
||||
"""Construct sources list."""
|
||||
try:
|
||||
client = await async_control_connect(host, key)
|
||||
except WEBOSTV_EXCEPTIONS:
|
||||
return []
|
||||
|
||||
sources = []
|
||||
found_live_tv = False
|
||||
for app in client.apps.values():
|
||||
sources.append(app["title"])
|
||||
if app["id"] == LIVE_TV_APP_ID:
|
||||
found_live_tv = True
|
||||
|
||||
for source in client.inputs.values():
|
||||
sources.append(source["label"])
|
||||
if source["appId"] == LIVE_TV_APP_ID:
|
||||
found_live_tv = True
|
||||
|
||||
if not found_live_tv:
|
||||
sources.append("Live TV")
|
||||
|
||||
# Preserve order when filtering duplicates
|
||||
return list(dict.fromkeys(sources))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "LG webOS Smart TV",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/webostv",
|
||||
"requirements": ["aiowebostv==0.1.1", "sqlalchemy==1.4.27"],
|
||||
"requirements": ["aiowebostv==0.1.2", "sqlalchemy==1.4.27"],
|
||||
"codeowners": ["@bendavid", "@thecode"],
|
||||
"ssdp": [{"st": "urn:lge-com:service:webos-second-screen:1"}],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -87,7 +87,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = (
|
||||
icon="mdi:account-star",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda domain: domain.admin if domain.admin else None,
|
||||
value_fn=lambda domain: getattr(domain, "admin", None),
|
||||
),
|
||||
WhoisSensorEntityDescription(
|
||||
key="creation_date",
|
||||
@@ -123,7 +123,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = (
|
||||
icon="mdi:account",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda domain: domain.owner if domain.owner else None,
|
||||
value_fn=lambda domain: getattr(domain, "owner", None),
|
||||
),
|
||||
WhoisSensorEntityDescription(
|
||||
key="registrant",
|
||||
@@ -131,7 +131,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = (
|
||||
icon="mdi:account-edit",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda domain: domain.registrant if domain.registrant else None,
|
||||
value_fn=lambda domain: getattr(domain, "registrant", None),
|
||||
),
|
||||
WhoisSensorEntityDescription(
|
||||
key="registrar",
|
||||
@@ -147,7 +147,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = (
|
||||
icon="mdi:store",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda domain: domain.reseller if domain.reseller else None,
|
||||
value_fn=lambda domain: getattr(domain, "reseller", None),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -190,7 +190,6 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in SENSORS
|
||||
],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import XiaomiDevice
|
||||
from .const import DOMAIN, GATEWAYS_KEY
|
||||
@@ -181,6 +182,11 @@ class XiaomiNatgasSensor(XiaomiBinarySensor):
|
||||
attrs.update(super().extra_state_attributes)
|
||||
return attrs
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
self._state = False
|
||||
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
if DENSITY in data:
|
||||
@@ -232,6 +238,11 @@ class XiaomiMotionSensor(XiaomiBinarySensor):
|
||||
self._state = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
self._state = False
|
||||
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway.
|
||||
|
||||
@@ -293,7 +304,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor):
|
||||
return True
|
||||
|
||||
|
||||
class XiaomiDoorSensor(XiaomiBinarySensor):
|
||||
class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity):
|
||||
"""Representation of a XiaomiDoorSensor."""
|
||||
|
||||
def __init__(self, device, xiaomi_hub, config_entry):
|
||||
@@ -319,6 +330,15 @@ class XiaomiDoorSensor(XiaomiBinarySensor):
|
||||
attrs.update(super().extra_state_attributes)
|
||||
return attrs
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is None:
|
||||
return
|
||||
|
||||
self._state = state.state == "on"
|
||||
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
self._should_poll = False
|
||||
@@ -362,6 +382,11 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor):
|
||||
config_entry,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
self._state = False
|
||||
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
self._should_poll = False
|
||||
@@ -400,6 +425,11 @@ class XiaomiSmokeSensor(XiaomiBinarySensor):
|
||||
attrs.update(super().extra_state_attributes)
|
||||
return attrs
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
self._state = False
|
||||
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
if DENSITY in data:
|
||||
|
||||
@@ -7,7 +7,7 @@ from .backports.enum import StrEnum
|
||||
|
||||
MAJOR_VERSION: Final = 2022
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "0b2"
|
||||
PATCH_VERSION: Final = "0b4"
|
||||
__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)
|
||||
|
||||
@@ -46,6 +46,11 @@ DHCP = [
|
||||
"hostname": "blink*",
|
||||
"macaddress": "B85F98*"
|
||||
},
|
||||
{
|
||||
"domain": "blink",
|
||||
"hostname": "blink*",
|
||||
"macaddress": "00037F*"
|
||||
},
|
||||
{
|
||||
"domain": "broadlink",
|
||||
"macaddress": "34EA34*"
|
||||
@@ -201,6 +206,11 @@ DHCP = [
|
||||
"domain": "nuki",
|
||||
"hostname": "nuki_bridge_*"
|
||||
},
|
||||
{
|
||||
"domain": "oncue",
|
||||
"hostname": "kohlergen*",
|
||||
"macaddress": "00146F*"
|
||||
},
|
||||
{
|
||||
"domain": "overkiz",
|
||||
"hostname": "gateway*",
|
||||
@@ -250,6 +260,11 @@ DHCP = [
|
||||
"hostname": "roomba-*",
|
||||
"macaddress": "80A589*"
|
||||
},
|
||||
{
|
||||
"domain": "roomba",
|
||||
"hostname": "roomba-*",
|
||||
"macaddress": "DCF505*"
|
||||
},
|
||||
{
|
||||
"domain": "samsungtv",
|
||||
"hostname": "tizen*"
|
||||
@@ -392,6 +407,11 @@ DHCP = [
|
||||
"hostname": "k[lp]*",
|
||||
"macaddress": "403F8C*"
|
||||
},
|
||||
{
|
||||
"domain": "tplink",
|
||||
"hostname": "k[lp]*",
|
||||
"macaddress": "C0C9E3*"
|
||||
},
|
||||
{
|
||||
"domain": "tplink",
|
||||
"hostname": "ep*",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[build-system]
|
||||
requires = ["setuptools~=60.5", "wheel~=0.37.1"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.black]
|
||||
target-version = ["py38"]
|
||||
exclude = 'generated'
|
||||
|
||||
@@ -278,7 +278,7 @@ aiovlc==0.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.1.1
|
||||
aiowebostv==0.1.2
|
||||
|
||||
# homeassistant.components.yandex_transport
|
||||
aioymaps==1.2.2
|
||||
@@ -2190,7 +2190,7 @@ simplehound==0.3
|
||||
simplepush==1.1.4
|
||||
|
||||
# homeassistant.components.simplisafe
|
||||
simplisafe-python==2021.12.2
|
||||
simplisafe-python==2022.01.0
|
||||
|
||||
# homeassistant.components.sisyphus
|
||||
sisyphus-control==3.1.2
|
||||
@@ -2496,7 +2496,7 @@ xbox-webapi==2.0.11
|
||||
xboxapi==2.0.1
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==0.19.0
|
||||
xknx==0.19.1
|
||||
|
||||
# homeassistant.components.bluesound
|
||||
# homeassistant.components.fritz
|
||||
|
||||
@@ -213,7 +213,7 @@ aiovlc==0.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.1.1
|
||||
aiowebostv==0.1.2
|
||||
|
||||
# homeassistant.components.yandex_transport
|
||||
aioymaps==1.2.2
|
||||
@@ -1337,7 +1337,7 @@ sharkiqpy==0.1.8
|
||||
simplehound==0.3
|
||||
|
||||
# homeassistant.components.simplisafe
|
||||
simplisafe-python==2021.12.2
|
||||
simplisafe-python==2022.01.0
|
||||
|
||||
# homeassistant.components.slack
|
||||
slackclient==2.5.0
|
||||
@@ -1527,7 +1527,7 @@ wolf_smartset==0.1.11
|
||||
xbox-webapi==2.0.11
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==0.19.0
|
||||
xknx==0.19.1
|
||||
|
||||
# homeassistant.components.bluesound
|
||||
# homeassistant.components.fritz
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate an updated requirements_all.txt."""
|
||||
import configparser
|
||||
import difflib
|
||||
import importlib
|
||||
import os
|
||||
@@ -167,10 +168,9 @@ def explore_module(package, explore_children):
|
||||
|
||||
def core_requirements():
|
||||
"""Gather core requirements out of setup.py."""
|
||||
reqs_raw = re.search(
|
||||
r"REQUIRES = \[(.*?)\]", Path("setup.py").read_text(), re.S
|
||||
).group(1)
|
||||
return [x[1] for x in re.findall(r"(['\"])(.*?)\1", reqs_raw)]
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read("setup.cfg")
|
||||
return parser["options"]["install_requires"].strip().split("\n")
|
||||
|
||||
|
||||
def gather_recursive_requirements(domain, seen=None):
|
||||
|
||||
@@ -12,6 +12,7 @@ from . import (
|
||||
dhcp,
|
||||
json,
|
||||
manifest,
|
||||
metadata,
|
||||
mqtt,
|
||||
mypy_config,
|
||||
requirements,
|
||||
@@ -41,6 +42,7 @@ INTEGRATION_PLUGINS = [
|
||||
HASS_PLUGINS = [
|
||||
coverage,
|
||||
mypy_config,
|
||||
metadata,
|
||||
]
|
||||
|
||||
|
||||
|
||||
31
script/hassfest/metadata.py
Normal file
31
script/hassfest/metadata.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Package metadata validation."""
|
||||
import configparser
|
||||
|
||||
from homeassistant.const import REQUIRED_PYTHON_VER, __version__
|
||||
|
||||
from .model import Config, Integration
|
||||
|
||||
|
||||
def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Validate project metadata keys."""
|
||||
metadata_path = config.root / "setup.cfg"
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(metadata_path)
|
||||
|
||||
try:
|
||||
if parser["metadata"]["version"] != __version__:
|
||||
config.add_error(
|
||||
"metadata", f"'metadata.version' value does not match '{__version__}'"
|
||||
)
|
||||
except KeyError:
|
||||
config.add_error("metadata", "No 'metadata.version' key found!")
|
||||
|
||||
required_py_version = f">={'.'.join(map(str, REQUIRED_PYTHON_VER))}"
|
||||
try:
|
||||
if parser["options"]["python_requires"] != required_py_version:
|
||||
config.add_error(
|
||||
"metadata",
|
||||
f"'options.python_requires' value doesn't match '{required_py_version}",
|
||||
)
|
||||
except KeyError:
|
||||
config.add_error("metadata", "No 'options.python_requires' key found!")
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Pushes a new version to PyPi.
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
head -n 5 homeassistant/const.py | tail -n 1 | grep PATCH_VERSION > /dev/null
|
||||
|
||||
if [ $? -eq 1 ]
|
||||
then
|
||||
echo "Patch version not found on const.py line 5"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
head -n 5 homeassistant/const.py | tail -n 1 | grep dev > /dev/null
|
||||
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
echo "Release version should not contain dev tag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD`
|
||||
|
||||
if [ "$CURRENT_BRANCH" != "master" ] && [ "$CURRENT_BRANCH" != "rc" ]
|
||||
then
|
||||
echo "You have to be on the master or rc branch to release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf dist build
|
||||
python3 setup.py sdist bdist_wheel
|
||||
python3 -m twine upload dist/* --skip-existing
|
||||
@@ -117,7 +117,18 @@ def write_version(version):
|
||||
)
|
||||
|
||||
with open("homeassistant/const.py", "wt") as fil:
|
||||
content = fil.write(content)
|
||||
fil.write(content)
|
||||
|
||||
|
||||
def write_version_metadata(version: Version) -> None:
|
||||
"""Update setup.cfg file with new version."""
|
||||
with open("setup.cfg") as fp:
|
||||
content = fp.read()
|
||||
|
||||
content = re.sub(r"(version\W+=\W).+\n", f"\\g<1>{version}\n", content, count=1)
|
||||
|
||||
with open("setup.cfg", "w") as fp:
|
||||
fp.write(content)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -142,6 +153,7 @@ def main():
|
||||
assert bumped > current, "BUG! New version is not newer than old version"
|
||||
|
||||
write_version(bumped)
|
||||
write_version_metadata(bumped)
|
||||
|
||||
if not arguments.commit:
|
||||
return
|
||||
|
||||
53
setup.cfg
53
setup.cfg
@@ -1,10 +1,21 @@
|
||||
[metadata]
|
||||
name = homeassistant
|
||||
version = 2022.2.0b4
|
||||
author = The Home Assistant Authors
|
||||
author_email = hello@home-assistant.io
|
||||
license = Apache-2.0
|
||||
license_file = LICENSE.md
|
||||
platforms = any
|
||||
description = Open-source home automation platform running on Python 3.
|
||||
long_description = file: README.rst
|
||||
long_description_content_type = text/x-rst
|
||||
keywords = home, automation
|
||||
url = https://www.home-assistant.io/
|
||||
project_urls =
|
||||
Source Code = https://github.com/home-assistant/core
|
||||
Bug Reports = https://github.com/home-assistant/core/issues
|
||||
Docs: Dev = https://developers.home-assistant.io/
|
||||
Discord = https://discordapp.com/invite/c5DvZ4e
|
||||
Forum = https://community.home-assistant.io/
|
||||
classifier =
|
||||
Development Status :: 4 - Beta
|
||||
Intended Audience :: End Users/Desktop
|
||||
@@ -14,6 +25,46 @@ classifier =
|
||||
Programming Language :: Python :: 3.9
|
||||
Topic :: Home Automation
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
zip_safe = False
|
||||
include_package_data = True
|
||||
python_requires = >=3.9.0
|
||||
install_requires =
|
||||
aiohttp==3.8.1
|
||||
astral==2.2
|
||||
async_timeout==4.0.2
|
||||
attrs==21.2.0
|
||||
atomicwrites==1.4.0
|
||||
awesomeversion==22.1.0
|
||||
bcrypt==3.1.7
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.2.0
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
httpx==0.21.3
|
||||
ifaddr==0.1.7
|
||||
jinja2==3.0.3
|
||||
PyJWT==2.1.0
|
||||
# PyJWT has loose dependency. We want the latest one.
|
||||
cryptography==35.0.0
|
||||
pip>=8.0.3,<20.3
|
||||
python-slugify==4.0.1
|
||||
pyyaml==6.0
|
||||
requests==2.27.1
|
||||
typing-extensions>=3.10.0.2,<5.0
|
||||
voluptuous==0.12.2
|
||||
voluptuous-serialize==2.5.0
|
||||
yarl==1.7.2
|
||||
|
||||
[options.packages.find]
|
||||
include =
|
||||
homeassistant*
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
hass = homeassistant.__main__:main
|
||||
|
||||
[flake8]
|
||||
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
|
||||
max-complexity = 25
|
||||
|
||||
84
setup.py
Executable file → Normal file
84
setup.py
Executable file → Normal file
@@ -1,79 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Home Assistant setup script."""
|
||||
from datetime import datetime as dt
|
||||
"""
|
||||
Entry point for setuptools. Required for editable installs.
|
||||
TODO: Remove file after updating to pip 21.3
|
||||
"""
|
||||
from setuptools import setup
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
import homeassistant.const as hass_const
|
||||
|
||||
PROJECT_NAME = "Home Assistant"
|
||||
PROJECT_PACKAGE_NAME = "homeassistant"
|
||||
PROJECT_LICENSE = "Apache License 2.0"
|
||||
PROJECT_AUTHOR = "The Home Assistant Authors"
|
||||
PROJECT_COPYRIGHT = f" 2013-{dt.now().year}, {PROJECT_AUTHOR}"
|
||||
PROJECT_URL = "https://www.home-assistant.io/"
|
||||
PROJECT_EMAIL = "hello@home-assistant.io"
|
||||
|
||||
PROJECT_GITHUB_USERNAME = "home-assistant"
|
||||
PROJECT_GITHUB_REPOSITORY = "core"
|
||||
|
||||
PYPI_URL = f"https://pypi.python.org/pypi/{PROJECT_PACKAGE_NAME}"
|
||||
GITHUB_PATH = f"{PROJECT_GITHUB_USERNAME}/{PROJECT_GITHUB_REPOSITORY}"
|
||||
GITHUB_URL = f"https://github.com/{GITHUB_PATH}"
|
||||
|
||||
DOWNLOAD_URL = f"{GITHUB_URL}/archive/{hass_const.__version__}.zip"
|
||||
PROJECT_URLS = {
|
||||
"Bug Reports": f"{GITHUB_URL}/issues",
|
||||
"Dev Docs": "https://developers.home-assistant.io/",
|
||||
"Discord": "https://discordapp.com/invite/c5DvZ4e",
|
||||
"Forum": "https://community.home-assistant.io/",
|
||||
}
|
||||
|
||||
PACKAGES = find_packages(exclude=["tests", "tests.*"])
|
||||
|
||||
REQUIRES = [
|
||||
"aiohttp==3.8.1",
|
||||
"astral==2.2",
|
||||
"async_timeout==4.0.2",
|
||||
"attrs==21.2.0",
|
||||
"atomicwrites==1.4.0",
|
||||
"awesomeversion==22.1.0",
|
||||
"bcrypt==3.1.7",
|
||||
"certifi>=2021.5.30",
|
||||
"ciso8601==2.2.0",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.21.3",
|
||||
"ifaddr==0.1.7",
|
||||
"jinja2==3.0.3",
|
||||
"PyJWT==2.1.0",
|
||||
# PyJWT has loose dependency. We want the latest one.
|
||||
"cryptography==35.0.0",
|
||||
"pip>=8.0.3,<20.3",
|
||||
"python-slugify==4.0.1",
|
||||
"pyyaml==6.0",
|
||||
"requests==2.27.1",
|
||||
"typing-extensions>=3.10.0.2,<5.0",
|
||||
"voluptuous==0.12.2",
|
||||
"voluptuous-serialize==2.5.0",
|
||||
"yarl==1.7.2",
|
||||
]
|
||||
|
||||
MIN_PY_VERSION = ".".join(map(str, hass_const.REQUIRED_PYTHON_VER))
|
||||
|
||||
setup(
|
||||
name=PROJECT_PACKAGE_NAME,
|
||||
version=hass_const.__version__,
|
||||
url=PROJECT_URL,
|
||||
download_url=DOWNLOAD_URL,
|
||||
project_urls=PROJECT_URLS,
|
||||
author=PROJECT_AUTHOR,
|
||||
author_email=PROJECT_EMAIL,
|
||||
packages=PACKAGES,
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
install_requires=REQUIRES,
|
||||
python_requires=f">={MIN_PY_VERSION}",
|
||||
test_suite="tests",
|
||||
entry_points={"console_scripts": ["hass = homeassistant.__main__:main"]},
|
||||
)
|
||||
setup()
|
||||
|
||||
@@ -55,6 +55,7 @@ async def test_entry_diagnostics(
|
||||
str(Platform.LIGHT): [],
|
||||
str(Platform.LOCK): [],
|
||||
str(Platform.NUMBER): [],
|
||||
str(Platform.SCENE): [],
|
||||
str(Platform.SENSOR): [],
|
||||
str(Platform.SIREN): [],
|
||||
str(Platform.SWITCH): [],
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.deconz.gateway import get_gateway_from_config_entry
|
||||
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .test_gateway import (
|
||||
DECONZ_WEB_REQUEST,
|
||||
@@ -58,3 +60,30 @@ async def test_scenes(hass, aioclient_mock):
|
||||
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
|
||||
assert len(hass.states.async_all()) == 0
|
||||
|
||||
|
||||
async def test_only_new_scenes_are_created(hass, aioclient_mock):
|
||||
"""Test that scenes works."""
|
||||
data = {
|
||||
"groups": {
|
||||
"1": {
|
||||
"id": "Light group id",
|
||||
"name": "Light group",
|
||||
"type": "LightGroup",
|
||||
"state": {"all_on": False, "any_on": True},
|
||||
"action": {},
|
||||
"scenes": [{"id": "1", "name": "Scene"}],
|
||||
"lights": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch.dict(DECONZ_WEB_REQUEST, data):
|
||||
config_entry = await setup_deconz_integration(hass, aioclient_mock)
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
gateway = get_gateway_from_config_entry(hass, config_entry)
|
||||
async_dispatcher_send(hass, gateway.signal_new_scene)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
33
tests/components/diagnostics/test_util.py
Normal file
33
tests/components/diagnostics/test_util.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Test Diagnostics utils."""
|
||||
from homeassistant.components.diagnostics import REDACTED, async_redact_data
|
||||
|
||||
|
||||
def test_redact():
|
||||
"""Test the async_redact_data helper."""
|
||||
data = {
|
||||
"key1": "value1",
|
||||
"key2": ["value2_a", "value2_b"],
|
||||
"key3": [["value_3a", "value_3b"], ["value_3c", "value_3d"]],
|
||||
"key4": {
|
||||
"key4_1": "value4_1",
|
||||
"key4_2": ["value4_2a", "value4_2b"],
|
||||
"key4_3": [["value4_3a", "value4_3b"], ["value4_3c", "value4_3d"]],
|
||||
},
|
||||
}
|
||||
|
||||
to_redact = {
|
||||
"key1",
|
||||
"key3",
|
||||
"key4_1",
|
||||
}
|
||||
|
||||
assert async_redact_data(data, to_redact) == {
|
||||
"key1": REDACTED,
|
||||
"key2": ["value2_a", "value2_b"],
|
||||
"key3": REDACTED,
|
||||
"key4": {
|
||||
"key4_1": REDACTED,
|
||||
"key4_2": ["value4_2a", "value4_2b"],
|
||||
"key4_3": [["value4_3a", "value4_3b"], ["value4_3c", "value4_3d"]],
|
||||
},
|
||||
}
|
||||
@@ -7,10 +7,16 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import flux_led
|
||||
from homeassistant.components.flux_led.const import DOMAIN
|
||||
from homeassistant.components.flux_led.const import (
|
||||
CONF_REMOTE_ACCESS_ENABLED,
|
||||
CONF_REMOTE_ACCESS_HOST,
|
||||
CONF_REMOTE_ACCESS_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
@@ -156,3 +162,46 @@ async def test_time_sync_startup_and_next_day(hass: HomeAssistant) -> None:
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(hours=24))
|
||||
await hass.async_block_till_done()
|
||||
assert len(bulb.async_set_time.mock_calls) == 2
|
||||
|
||||
|
||||
async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> None:
|
||||
"""Test unique id migrated when mac discovered."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_REMOTE_ACCESS_HOST: "any",
|
||||
CONF_REMOTE_ACCESS_ENABLED: True,
|
||||
CONF_REMOTE_ACCESS_PORT: 1234,
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_NAME: DEFAULT_ENTRY_TITLE,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
bulb = _mocked_bulb()
|
||||
with _patch_discovery(no_device=True), _patch_wifibulb(device=bulb):
|
||||
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not config_entry.unique_id
|
||||
entity_registry = er.async_get(hass)
|
||||
assert (
|
||||
entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id
|
||||
== config_entry.entry_id
|
||||
)
|
||||
assert (
|
||||
entity_registry.async_get("switch.bulb_rgbcw_ddeeff_remote_access").unique_id
|
||||
== f"{config_entry.entry_id}_remote_access"
|
||||
)
|
||||
|
||||
with _patch_discovery(), _patch_wifibulb(device=bulb):
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id
|
||||
== config_entry.unique_id
|
||||
)
|
||||
assert (
|
||||
entity_registry.async_get("switch.bulb_rgbcw_ddeeff_remote_access").unique_id
|
||||
== f"{config_entry.unique_id}_remote_access"
|
||||
)
|
||||
|
||||
@@ -137,8 +137,8 @@ async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None:
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_light_no_unique_id(hass: HomeAssistant) -> None:
|
||||
"""Test a light without a unique id."""
|
||||
async def test_light_mac_address_not_found(hass: HomeAssistant) -> None:
|
||||
"""Test a light when we cannot discover the mac address."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}
|
||||
)
|
||||
@@ -150,7 +150,7 @@ async def test_light_no_unique_id(hass: HomeAssistant) -> None:
|
||||
|
||||
entity_id = "light.bulb_rgbcw_ddeeff"
|
||||
entity_registry = er.async_get(hass)
|
||||
assert entity_registry.async_get(entity_id) is None
|
||||
assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ from . import (
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_number_unique_id(hass: HomeAssistant) -> None:
|
||||
async def test_effects_speed_unique_id(hass: HomeAssistant) -> None:
|
||||
"""Test a number unique id."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
@@ -59,6 +59,23 @@ async def test_number_unique_id(hass: HomeAssistant) -> None:
|
||||
assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS
|
||||
|
||||
|
||||
async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None:
|
||||
"""Test a number unique id."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
bulb = _mocked_bulb()
|
||||
with _patch_discovery(no_device=True), _patch_wifibulb(device=bulb):
|
||||
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "number.bulb_rgbcw_ddeeff_effect_speed"
|
||||
entity_registry = er.async_get(hass)
|
||||
assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id
|
||||
|
||||
|
||||
async def test_rgb_light_effect_speed(hass: HomeAssistant) -> None:
|
||||
"""Test an rgb light with an effect."""
|
||||
config_entry = MockConfigEntry(
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.flux_led.const import CONF_WHITE_CHANNEL_TYPE, DOM
|
||||
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_HOST, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import (
|
||||
@@ -67,6 +68,47 @@ async def test_switch_power_restore_state(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def test_power_restored_unique_id(hass: HomeAssistant) -> None:
|
||||
"""Test a select unique id."""
|
||||
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)
|
||||
switch = _mocked_switch()
|
||||
with _patch_discovery(), _patch_wifibulb(device=switch):
|
||||
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "select.bulb_rgbcw_ddeeff_power_restored"
|
||||
entity_registry = er.async_get(hass)
|
||||
assert (
|
||||
entity_registry.async_get(entity_id).unique_id
|
||||
== f"{MAC_ADDRESS}_power_restored"
|
||||
)
|
||||
|
||||
|
||||
async def test_power_restored_unique_id_no_discovery(hass: HomeAssistant) -> None:
|
||||
"""Test a select unique id."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
switch = _mocked_switch()
|
||||
with _patch_discovery(no_device=True), _patch_wifibulb(device=switch):
|
||||
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "select.bulb_rgbcw_ddeeff_power_restored"
|
||||
entity_registry = er.async_get(hass)
|
||||
assert (
|
||||
entity_registry.async_get(entity_id).unique_id
|
||||
== f"{config_entry.entry_id}_power_restored"
|
||||
)
|
||||
|
||||
|
||||
async def test_select_addressable_strip_config(hass: HomeAssistant) -> None:
|
||||
"""Test selecting addressable strip configs."""
|
||||
config_entry = MockConfigEntry(
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
from flux_led.const import MODE_MUSIC
|
||||
|
||||
from homeassistant.components import flux_led
|
||||
from homeassistant.components.flux_led.const import CONF_REMOTE_ACCESS_ENABLED, DOMAIN
|
||||
from homeassistant.components.flux_led.const import (
|
||||
CONF_REMOTE_ACCESS_ENABLED,
|
||||
CONF_REMOTE_ACCESS_HOST,
|
||||
CONF_REMOTE_ACCESS_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
@@ -12,6 +17,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import (
|
||||
@@ -65,11 +71,69 @@ async def test_switch_on_off(hass: HomeAssistant) -> None:
|
||||
assert hass.states.get(entity_id).state == STATE_ON
|
||||
|
||||
|
||||
async def test_remote_access_unique_id(hass: HomeAssistant) -> None:
|
||||
"""Test a remote access switch unique id."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_REMOTE_ACCESS_HOST: "any",
|
||||
CONF_REMOTE_ACCESS_ENABLED: True,
|
||||
CONF_REMOTE_ACCESS_PORT: 1234,
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_NAME: DEFAULT_ENTRY_TITLE,
|
||||
},
|
||||
unique_id=MAC_ADDRESS,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
bulb = _mocked_bulb()
|
||||
with _patch_discovery(), _patch_wifibulb(device=bulb):
|
||||
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "switch.bulb_rgbcw_ddeeff_remote_access"
|
||||
entity_registry = er.async_get(hass)
|
||||
assert (
|
||||
entity_registry.async_get(entity_id).unique_id == f"{MAC_ADDRESS}_remote_access"
|
||||
)
|
||||
|
||||
|
||||
async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None:
|
||||
"""Test a remote access switch unique id when discovery fails."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_REMOTE_ACCESS_HOST: "any",
|
||||
CONF_REMOTE_ACCESS_ENABLED: True,
|
||||
CONF_REMOTE_ACCESS_PORT: 1234,
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_NAME: DEFAULT_ENTRY_TITLE,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
bulb = _mocked_bulb()
|
||||
with _patch_discovery(no_device=True), _patch_wifibulb(device=bulb):
|
||||
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "switch.bulb_rgbcw_ddeeff_remote_access"
|
||||
entity_registry = er.async_get(hass)
|
||||
assert (
|
||||
entity_registry.async_get(entity_id).unique_id
|
||||
== f"{config_entry.entry_id}_remote_access"
|
||||
)
|
||||
|
||||
|
||||
async def test_remote_access_on_off(hass: HomeAssistant) -> None:
|
||||
"""Test enable/disable remote access."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE},
|
||||
data={
|
||||
CONF_REMOTE_ACCESS_HOST: "any",
|
||||
CONF_REMOTE_ACCESS_ENABLED: True,
|
||||
CONF_REMOTE_ACCESS_PORT: 1234,
|
||||
CONF_HOST: IP_ADDRESS,
|
||||
CONF_NAME: DEFAULT_ENTRY_TITLE,
|
||||
},
|
||||
unique_id=MAC_ADDRESS,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
@@ -16,7 +16,12 @@ from homeassistant.components.isy994.const import (
|
||||
ISY_URL_POSTFIX,
|
||||
UDN_UUID_PREFIX,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IMPORT, SOURCE_SSDP
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_DHCP,
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_IMPORT,
|
||||
SOURCE_SSDP,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -595,3 +600,27 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant):
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data[CONF_HOST] == f"http://1.2.3.4:1443{ISY_URL_POSTFIX}"
|
||||
assert entry.data[CONF_USERNAME] == "bob"
|
||||
|
||||
|
||||
async def test_form_dhcp_existing_ignored_entry(hass: HomeAssistant):
|
||||
"""Test we handled an ignored entry from dhcp."""
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={}, unique_id=MOCK_UUID, source=SOURCE_IGNORE
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_DHCP},
|
||||
data=dhcp.DhcpServiceInfo(
|
||||
ip="1.2.3.4",
|
||||
hostname="isy994-ems",
|
||||
macaddress=MOCK_MAC,
|
||||
),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
@@ -39,11 +39,11 @@ def _gateway_descriptor(
|
||||
) -> GatewayDescriptor:
|
||||
"""Get mock gw descriptor."""
|
||||
return GatewayDescriptor(
|
||||
"Test",
|
||||
ip,
|
||||
port,
|
||||
"eth0",
|
||||
"127.0.0.1",
|
||||
name="Test",
|
||||
ip_addr=ip,
|
||||
port=port,
|
||||
local_interface="eth0",
|
||||
local_ip="127.0.0.1",
|
||||
supports_routing=True,
|
||||
supports_tunnelling=True,
|
||||
supports_tunnelling_tcp=supports_tunnelling_tcp,
|
||||
|
||||
67
tests/components/knx/test_diagnostic.py
Normal file
67
tests/components/knx/test_diagnostic.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tests for the diagnostics data provided by the KNX integration."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
from tests.components.knx.conftest import KNXTestKit
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSession,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
knx: KNXTestKit,
|
||||
):
|
||||
"""Test diagnostics."""
|
||||
await knx.setup_integration({})
|
||||
|
||||
with patch("homeassistant.config.async_hass_config_yaml", return_value={}):
|
||||
# Overwrite the version for this test since we don't want to change this with every library bump
|
||||
knx.xknx.version = "1.0.0"
|
||||
assert await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, mock_config_entry
|
||||
) == {
|
||||
"config_entry_data": {
|
||||
"connection_type": "automatic",
|
||||
"individual_address": "15.15.250",
|
||||
"multicast_group": "224.0.23.12",
|
||||
"multicast_port": 3671,
|
||||
},
|
||||
"configuration_error": None,
|
||||
"configuration_yaml": None,
|
||||
"xknx": {"current_address": "0.0.0", "version": "1.0.0"},
|
||||
}
|
||||
|
||||
|
||||
async def test_diagnostic_config_error(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSession,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
knx: KNXTestKit,
|
||||
):
|
||||
"""Test diagnostics."""
|
||||
await knx.setup_integration({})
|
||||
|
||||
with patch(
|
||||
"homeassistant.config.async_hass_config_yaml",
|
||||
return_value={"knx": {"wrong_key": {}}},
|
||||
):
|
||||
# Overwrite the version for this test since we don't want to change this with every library bump
|
||||
knx.xknx.version = "1.0.0"
|
||||
assert await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, mock_config_entry
|
||||
) == {
|
||||
"config_entry_data": {
|
||||
"connection_type": "automatic",
|
||||
"individual_address": "15.15.250",
|
||||
"multicast_group": "224.0.23.12",
|
||||
"multicast_port": 3671,
|
||||
},
|
||||
"configuration_error": "extra keys not allowed @ data['knx']['wrong_key']",
|
||||
"configuration_yaml": {"wrong_key": {}},
|
||||
"xknx": {"current_address": "0.0.0", "version": "1.0.0"},
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Test KNX expose."""
|
||||
from homeassistant.components.knx import CONF_KNX_EXPOSE, KNX_ADDRESS
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS
|
||||
from homeassistant.components.knx.schema import ExposeSchema
|
||||
from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -123,3 +126,95 @@ async def test_expose_attribute_with_default(hass: HomeAssistant, knx: KNXTestKi
|
||||
# Change state to "off"; no attribute
|
||||
hass.states.async_set(entity_id, "off", {})
|
||||
await knx.assert_write("1/1/8", (0,))
|
||||
|
||||
|
||||
async def test_expose_string(hass: HomeAssistant, knx: KNXTestKit):
|
||||
"""Test an expose to send string values of up to 14 bytes only."""
|
||||
|
||||
entity_id = "fake.entity"
|
||||
attribute = "fake_attribute"
|
||||
await knx.setup_integration(
|
||||
{
|
||||
CONF_KNX_EXPOSE: {
|
||||
CONF_TYPE: "string",
|
||||
KNX_ADDRESS: "1/1/8",
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
CONF_ATTRIBUTE: attribute,
|
||||
ExposeSchema.CONF_KNX_EXPOSE_DEFAULT: "Test",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert not hass.states.async_all()
|
||||
|
||||
# Before init default value shall be sent as response
|
||||
await knx.receive_read("1/1/8")
|
||||
await knx.assert_response(
|
||||
"1/1/8", (84, 101, 115, 116, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
)
|
||||
|
||||
# Change attribute; keep state
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
"on",
|
||||
{attribute: "This is a very long string that is larger than 14 bytes"},
|
||||
)
|
||||
await knx.assert_write(
|
||||
"1/1/8", (84, 104, 105, 115, 32, 105, 115, 32, 97, 32, 118, 101, 114, 121)
|
||||
)
|
||||
|
||||
|
||||
async def test_expose_conversion_exception(hass: HomeAssistant, knx: KNXTestKit):
|
||||
"""Test expose throws exception."""
|
||||
|
||||
entity_id = "fake.entity"
|
||||
attribute = "fake_attribute"
|
||||
await knx.setup_integration(
|
||||
{
|
||||
CONF_KNX_EXPOSE: {
|
||||
CONF_TYPE: "percent",
|
||||
KNX_ADDRESS: "1/1/8",
|
||||
CONF_ENTITY_ID: entity_id,
|
||||
CONF_ATTRIBUTE: attribute,
|
||||
ExposeSchema.CONF_KNX_EXPOSE_DEFAULT: 1,
|
||||
}
|
||||
},
|
||||
)
|
||||
assert not hass.states.async_all()
|
||||
|
||||
# Before init default value shall be sent as response
|
||||
await knx.receive_read("1/1/8")
|
||||
await knx.assert_response("1/1/8", (3,))
|
||||
|
||||
# Change attribute: Expect no exception
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
"on",
|
||||
{attribute: 101},
|
||||
)
|
||||
|
||||
await knx.assert_no_telegram()
|
||||
|
||||
|
||||
@patch("time.localtime")
|
||||
async def test_expose_with_date(localtime, hass: HomeAssistant, knx: KNXTestKit):
|
||||
"""Test an expose with a date."""
|
||||
localtime.return_value = time.struct_time([2022, 1, 7, 9, 13, 14, 6, 0, 0])
|
||||
await knx.setup_integration(
|
||||
{
|
||||
CONF_KNX_EXPOSE: {
|
||||
CONF_TYPE: "datetime",
|
||||
KNX_ADDRESS: "1/1/8",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert not hass.states.async_all()
|
||||
|
||||
await knx.assert_write("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80))
|
||||
|
||||
await knx.receive_read("1/1/8")
|
||||
await knx.assert_response("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80))
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
assert await hass.config_entries.async_unload(entries[0].entry_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Common libraries for test setup."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
import copy
|
||||
import shutil
|
||||
from typing import Any
|
||||
@@ -8,6 +9,7 @@ from unittest.mock import patch
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from google_nest_sdm import diagnostics
|
||||
from google_nest_sdm.auth import AbstractAuth
|
||||
from google_nest_sdm.device_manager import DeviceManager
|
||||
import pytest
|
||||
@@ -234,3 +236,10 @@ async def setup_platform(
|
||||
) -> PlatformSetup:
|
||||
"""Fixture to setup the integration platform and subscriber."""
|
||||
return setup_base_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_diagnostics() -> Generator[None, None, None]:
|
||||
"""Fixture to reset client library diagnostic counters."""
|
||||
yield
|
||||
diagnostics.reset()
|
||||
|
||||
@@ -56,13 +56,21 @@ async def test_entry_diagnostics(hass, hass_client):
|
||||
assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {
|
||||
"devices": [
|
||||
{
|
||||
"traits": {
|
||||
"sdm.devices.traits.Humidity": {"ambientHumidityPercent": 35.0},
|
||||
"sdm.devices.traits.Temperature": {
|
||||
"ambientTemperatureCelsius": 25.1
|
||||
"data": {
|
||||
"assignee": "**REDACTED**",
|
||||
"name": "**REDACTED**",
|
||||
"parentRelations": [
|
||||
{"displayName": "**REDACTED**", "parent": "**REDACTED**"}
|
||||
],
|
||||
"traits": {
|
||||
"sdm.devices.traits.Info": {"customName": "**REDACTED**"},
|
||||
"sdm.devices.traits.Humidity": {"ambientHumidityPercent": 35.0},
|
||||
"sdm.devices.traits.Temperature": {
|
||||
"ambientTemperatureCelsius": 25.1
|
||||
},
|
||||
},
|
||||
},
|
||||
"type": "sdm.devices.types.THERMOSTAT",
|
||||
"type": "sdm.devices.types.THERMOSTAT",
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -12,32 +12,38 @@ MOCK_UUID = "77a6b7b3-925d-4695-a415-76d76dca4444"
|
||||
MOCK_ADDRESS = "127.0.0.1"
|
||||
MOCK_MAC = "20:F8:5E:92:5A:75"
|
||||
|
||||
device = MagicMock(auto_spec=SensemeDevice)
|
||||
device.async_update = AsyncMock()
|
||||
device.model = "Haiku Fan"
|
||||
device.fan_speed_max = 7
|
||||
device.mac = "aa:bb:cc:dd:ee:ff"
|
||||
device.fan_dir = "REV"
|
||||
device.room_name = "Main"
|
||||
device.room_type = "Main"
|
||||
device.fw_version = "1"
|
||||
device.fan_autocomfort = "on"
|
||||
device.fan_smartmode = "on"
|
||||
device.fan_whoosh_mode = "on"
|
||||
device.name = MOCK_NAME
|
||||
device.uuid = MOCK_UUID
|
||||
device.address = MOCK_ADDRESS
|
||||
device.get_device_info = {
|
||||
"name": MOCK_NAME,
|
||||
"uuid": MOCK_UUID,
|
||||
"mac": MOCK_ADDRESS,
|
||||
"address": MOCK_ADDRESS,
|
||||
"base_model": "FAN,HAIKU,HSERIES",
|
||||
"has_light": False,
|
||||
"has_sensor": True,
|
||||
"is_fan": True,
|
||||
"is_light": False,
|
||||
}
|
||||
|
||||
def _mock_device():
|
||||
device = MagicMock(auto_spec=SensemeDevice)
|
||||
device.async_update = AsyncMock()
|
||||
device.model = "Haiku Fan"
|
||||
device.fan_speed_max = 7
|
||||
device.mac = "aa:bb:cc:dd:ee:ff"
|
||||
device.fan_dir = "REV"
|
||||
device.has_light = True
|
||||
device.is_light = False
|
||||
device.light_brightness = 50
|
||||
device.room_name = "Main"
|
||||
device.room_type = "Main"
|
||||
device.fw_version = "1"
|
||||
device.fan_autocomfort = "COOLING"
|
||||
device.fan_smartmode = "OFF"
|
||||
device.fan_whoosh_mode = "on"
|
||||
device.name = MOCK_NAME
|
||||
device.uuid = MOCK_UUID
|
||||
device.address = MOCK_ADDRESS
|
||||
device.get_device_info = {
|
||||
"name": MOCK_NAME,
|
||||
"uuid": MOCK_UUID,
|
||||
"mac": MOCK_ADDRESS,
|
||||
"address": MOCK_ADDRESS,
|
||||
"base_model": "FAN,HAIKU,HSERIES",
|
||||
"has_light": False,
|
||||
"has_sensor": True,
|
||||
"is_fan": True,
|
||||
"is_light": False,
|
||||
}
|
||||
return device
|
||||
|
||||
|
||||
device_alternate_ip = MagicMock(auto_spec=SensemeDevice)
|
||||
@@ -99,7 +105,7 @@ device_no_uuid = MagicMock(auto_spec=SensemeDevice)
|
||||
device_no_uuid.uuid = None
|
||||
|
||||
|
||||
MOCK_DEVICE = device
|
||||
MOCK_DEVICE = _mock_device()
|
||||
MOCK_DEVICE_ALTERNATE_IP = device_alternate_ip
|
||||
MOCK_DEVICE2 = device2
|
||||
MOCK_DEVICE_NO_UUID = device_no_uuid
|
||||
@@ -121,3 +127,17 @@ def _patch_discovery(device=None, no_device=None):
|
||||
yield
|
||||
|
||||
return _patcher()
|
||||
|
||||
|
||||
def _patch_device(device=None, no_device=False):
|
||||
async def _device_mocker(*args, **kwargs):
|
||||
if no_device:
|
||||
return False, None
|
||||
if device:
|
||||
return True, device
|
||||
return True, _mock_device()
|
||||
|
||||
return patch(
|
||||
"homeassistant.components.senseme.async_get_device_by_device_info",
|
||||
new=_device_mocker,
|
||||
)
|
||||
|
||||
103
tests/components/senseme/test_light.py
Normal file
103
tests/components/senseme/test_light.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Tests for senseme light platform."""
|
||||
|
||||
|
||||
from aiosenseme import SensemeDevice
|
||||
|
||||
from homeassistant.components import senseme
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP,
|
||||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
COLOR_MODE_BRIGHTNESS,
|
||||
COLOR_MODE_COLOR_TEMP,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.components.senseme.const import DOMAIN
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import _mock_device, _patch_device, _patch_discovery
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def _setup_mocked_entry(hass: HomeAssistant, device: SensemeDevice) -> None:
|
||||
"""Set up a mocked entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={"info": device.get_device_info},
|
||||
unique_id=device.uuid,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
with _patch_discovery(), _patch_device(device=device):
|
||||
await async_setup_component(hass, senseme.DOMAIN, {senseme.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_light_unique_id(hass: HomeAssistant) -> None:
|
||||
"""Test a light unique id."""
|
||||
device = _mock_device()
|
||||
await _setup_mocked_entry(hass, device)
|
||||
entity_id = "light.haiku_fan"
|
||||
entity_registry = er.async_get(hass)
|
||||
assert entity_registry.async_get(entity_id).unique_id == f"{device.uuid}-LIGHT"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_fan_light(hass: HomeAssistant) -> None:
|
||||
"""Test a fan light."""
|
||||
device = _mock_device()
|
||||
await _setup_mocked_entry(hass, device)
|
||||
entity_id = "light.haiku_fan"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ON
|
||||
attributes = state.attributes
|
||||
assert attributes[ATTR_BRIGHTNESS] == 255
|
||||
assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_BRIGHTNESS
|
||||
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_BRIGHTNESS]
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert device.light_on is False
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert device.light_on is True
|
||||
|
||||
|
||||
async def test_standalone_light(hass: HomeAssistant) -> None:
|
||||
"""Test a standalone light."""
|
||||
device = _mock_device()
|
||||
device.is_light = True
|
||||
device.light_color_temp_max = 6500
|
||||
device.light_color_temp_min = 2700
|
||||
device.light_color_temp = 4000
|
||||
await _setup_mocked_entry(hass, device)
|
||||
entity_id = "light.haiku_fan_light"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ON
|
||||
attributes = state.attributes
|
||||
assert attributes[ATTR_BRIGHTNESS] == 255
|
||||
assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_COLOR_TEMP
|
||||
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_COLOR_TEMP]
|
||||
assert attributes[ATTR_COLOR_TEMP] == 250
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert device.light_on is False
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert device.light_on is True
|
||||
119
tests/components/simplisafe/conftest.py
Normal file
119
tests/components/simplisafe/conftest.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Define test fixtures for SimpliSafe."""
|
||||
import json
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from simplipy.system.v3 import SystemV3
|
||||
|
||||
from homeassistant.components.simplisafe.config_flow import CONF_AUTH_CODE
|
||||
from homeassistant.components.simplisafe.const import CONF_USER_ID, DOMAIN
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
REFRESH_TOKEN = "token123"
|
||||
SYSTEM_ID = "system_123"
|
||||
USER_ID = "12345"
|
||||
|
||||
|
||||
@pytest.fixture(name="api")
|
||||
def api_fixture(system_v3, websocket):
|
||||
"""Define a fixture for a simplisafe-python API object."""
|
||||
return Mock(
|
||||
async_get_systems=AsyncMock(return_value={SYSTEM_ID: system_v3}),
|
||||
refresh_token=REFRESH_TOKEN,
|
||||
user_id=USER_ID,
|
||||
websocket=websocket,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def config_entry_fixture(hass, config):
|
||||
"""Define a config entry fixture."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=config)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture(name="config")
|
||||
def config_fixture(hass):
|
||||
"""Define a config entry data fixture."""
|
||||
return {
|
||||
CONF_USER_ID: USER_ID,
|
||||
CONF_TOKEN: REFRESH_TOKEN,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="config_code")
|
||||
def config_code_fixture(hass):
|
||||
"""Define a authorization code."""
|
||||
return {
|
||||
CONF_AUTH_CODE: "code123",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="data_latest_event", scope="session")
|
||||
def data_latest_event_fixture():
|
||||
"""Define latest event data."""
|
||||
return json.loads(load_fixture("latest_event_data.json", "simplisafe"))
|
||||
|
||||
|
||||
@pytest.fixture(name="data_sensor", scope="session")
|
||||
def data_sensor_fixture():
|
||||
"""Define sensor data."""
|
||||
return json.loads(load_fixture("sensor_data.json", "simplisafe"))
|
||||
|
||||
|
||||
@pytest.fixture(name="data_settings", scope="session")
|
||||
def data_settings_fixture():
|
||||
"""Define settings data."""
|
||||
return json.loads(load_fixture("settings_data.json", "simplisafe"))
|
||||
|
||||
|
||||
@pytest.fixture(name="data_subscription", scope="session")
|
||||
def data_subscription_fixture():
|
||||
"""Define subscription data."""
|
||||
return json.loads(load_fixture("subscription_data.json", "simplisafe"))
|
||||
|
||||
|
||||
@pytest.fixture(name="setup_simplisafe")
|
||||
async def setup_simplisafe_fixture(hass, api, config):
|
||||
"""Define a fixture to set up SimpliSafe."""
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.config_flow.API.async_from_auth",
|
||||
return_value=api,
|
||||
), patch(
|
||||
"homeassistant.components.simplisafe.API.async_from_auth", return_value=api
|
||||
), patch(
|
||||
"homeassistant.components.simplisafe.API.async_from_refresh_token",
|
||||
return_value=api,
|
||||
), patch(
|
||||
"homeassistant.components.simplisafe.SimpliSafe._async_start_websocket_loop"
|
||||
), patch(
|
||||
"homeassistant.components.simplisafe.PLATFORMS", []
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="system_v3")
|
||||
def system_v3_fixture(data_latest_event, data_sensor, data_settings, data_subscription):
|
||||
"""Define a fixture for a simplisafe-python V3 System object."""
|
||||
system = SystemV3(Mock(subscription_data=data_subscription), SYSTEM_ID)
|
||||
system.async_get_latest_event = AsyncMock(return_value=data_latest_event)
|
||||
system.sensor_data = data_sensor
|
||||
system.settings_data = data_settings
|
||||
system.generate_device_objects()
|
||||
return system
|
||||
|
||||
|
||||
@pytest.fixture(name="websocket")
|
||||
def websocket_fixture():
|
||||
"""Define a fixture for a simplisafe-python websocket object."""
|
||||
return Mock(
|
||||
async_connect=AsyncMock(),
|
||||
async_disconnect=AsyncMock(),
|
||||
async_listen=AsyncMock(),
|
||||
)
|
||||
21
tests/components/simplisafe/fixtures/latest_event_data.json
Normal file
21
tests/components/simplisafe/fixtures/latest_event_data.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"eventId": 1234567890,
|
||||
"eventTimestamp": 1564018073,
|
||||
"eventCid": 1400,
|
||||
"zoneCid": "2",
|
||||
"sensorType": 1,
|
||||
"sensorSerial": "01010101",
|
||||
"account": "00011122",
|
||||
"userId": 12345,
|
||||
"sid": "system_123",
|
||||
"info": "System Disarmed by PIN 2",
|
||||
"pinName": "",
|
||||
"sensorName": "Kitchen",
|
||||
"messageSubject": "SimpliSafe System Disarmed",
|
||||
"messageBody": "System Disarmed: Your SimpliSafe security system was ...",
|
||||
"eventType": "activity",
|
||||
"timezone": 2,
|
||||
"locationOffset": -360,
|
||||
"videoStartedBy": "",
|
||||
"video": {}
|
||||
}
|
||||
75
tests/components/simplisafe/fixtures/sensor_data.json
Normal file
75
tests/components/simplisafe/fixtures/sensor_data.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"825": {
|
||||
"type": 5,
|
||||
"serial": "825",
|
||||
"name": "Fire Door",
|
||||
"setting": {
|
||||
"instantTrigger": false,
|
||||
"away2": 1,
|
||||
"away": 1,
|
||||
"home2": 1,
|
||||
"home": 1,
|
||||
"off": 0
|
||||
},
|
||||
"status": {
|
||||
"triggered": false
|
||||
},
|
||||
"flags": {
|
||||
"swingerShutdown": false,
|
||||
"lowBattery": false,
|
||||
"offline": false
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"type": 12,
|
||||
"serial": "14",
|
||||
"name": "Front Door",
|
||||
"setting": {
|
||||
"instantTrigger": false,
|
||||
"away2": 1,
|
||||
"away": 1,
|
||||
"home2": 1,
|
||||
"home": 1,
|
||||
"off": 0
|
||||
},
|
||||
"status": {
|
||||
"triggered": false
|
||||
},
|
||||
"flags": {
|
||||
"swingerShutdown": false,
|
||||
"lowBattery": false,
|
||||
"offline": false
|
||||
}
|
||||
},
|
||||
"987": {
|
||||
"serial": "987",
|
||||
"type": 16,
|
||||
"status": {
|
||||
"pinPadState": 0,
|
||||
"lockState": 1,
|
||||
"pinPadOffline": false,
|
||||
"pinPadLowBattery": false,
|
||||
"lockDisabled": false,
|
||||
"lockLowBattery": false,
|
||||
"calibrationErrDelta": 0,
|
||||
"calibrationErrZero": 0,
|
||||
"lockJamState": 0
|
||||
},
|
||||
"name": "Front Door",
|
||||
"deviceGroupID": 1,
|
||||
"firmwareVersion": "1.0.0",
|
||||
"bootVersion": "1.0.0",
|
||||
"setting": {
|
||||
"autoLock": 3,
|
||||
"away": 1,
|
||||
"home": 1,
|
||||
"awayToOff": 0,
|
||||
"homeToOff": 1
|
||||
},
|
||||
"flags": {
|
||||
"swingerShutdown": false,
|
||||
"lowBattery": false,
|
||||
"offline": false
|
||||
}
|
||||
}
|
||||
}
|
||||
69
tests/components/simplisafe/fixtures/settings_data.json
Normal file
69
tests/components/simplisafe/fixtures/settings_data.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"account": "12345012",
|
||||
"settings": {
|
||||
"normal": {
|
||||
"wifiSSID": "MY_WIFI",
|
||||
"alarmDuration": 240,
|
||||
"alarmVolume": 3,
|
||||
"doorChime": 2,
|
||||
"entryDelayAway": 30,
|
||||
"entryDelayAway2": 30,
|
||||
"entryDelayHome": 30,
|
||||
"entryDelayHome2": 30,
|
||||
"exitDelayAway": 60,
|
||||
"exitDelayAway2": 60,
|
||||
"exitDelayHome": 0,
|
||||
"exitDelayHome2": 0,
|
||||
"lastUpdated": "2019-07-03T03:24:20.999Z",
|
||||
"light": true,
|
||||
"voicePrompts": 2,
|
||||
"_id": "1197192618725121765212"
|
||||
},
|
||||
"pins": {
|
||||
"lastUpdated": "2019-07-04T20:47:44.016Z",
|
||||
"_id": "asd6281526381253123",
|
||||
"users": [
|
||||
{
|
||||
"_id": "1271279d966212121124c7",
|
||||
"pin": "3456",
|
||||
"name": "Test 1"
|
||||
},
|
||||
{
|
||||
"_id": "1271279d966212121124c6",
|
||||
"pin": "5423",
|
||||
"name": "Test 2"
|
||||
},
|
||||
{
|
||||
"_id": "1271279d966212121124c5",
|
||||
"pin": "",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"_id": "1271279d966212121124c4",
|
||||
"pin": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"duress": {
|
||||
"pin": "9876"
|
||||
},
|
||||
"master": {
|
||||
"pin": "1234"
|
||||
}
|
||||
}
|
||||
},
|
||||
"basestationStatus": {
|
||||
"lastUpdated": "2019-07-15T15:28:22.961Z",
|
||||
"rfJamming": false,
|
||||
"ethernetStatus": 4,
|
||||
"gsmRssi": -73,
|
||||
"gsmStatus": 3,
|
||||
"backupBattery": 5293,
|
||||
"wallPower": 5933,
|
||||
"wifiRssi": -49,
|
||||
"wifiStatus": 1,
|
||||
"_id": "6128153715231t237123",
|
||||
"encryptionErrors": []
|
||||
},
|
||||
"lastUpdated": 1562273264
|
||||
}
|
||||
374
tests/components/simplisafe/fixtures/subscription_data.json
Normal file
374
tests/components/simplisafe/fixtures/subscription_data.json
Normal file
@@ -0,0 +1,374 @@
|
||||
{
|
||||
"system_123": {
|
||||
"uid": 12345,
|
||||
"sid": "system_123",
|
||||
"sStatus": 20,
|
||||
"activated": 1445034752,
|
||||
"planSku": "SSEDSM2",
|
||||
"planName": "Interactive Monitoring",
|
||||
"price": 24.99,
|
||||
"currency": "USD",
|
||||
"country": "US",
|
||||
"expires": 1602887552,
|
||||
"canceled": 0,
|
||||
"extraTime": 0,
|
||||
"creditCard": {
|
||||
"lastFour": "",
|
||||
"type": "",
|
||||
"ppid": "ABCDE12345",
|
||||
"uid": 12345
|
||||
},
|
||||
"time": 2628000,
|
||||
"paymentProfileId": "ABCDE12345",
|
||||
"features": {
|
||||
"monitoring": true,
|
||||
"alerts": true,
|
||||
"online": true,
|
||||
"hazard": true,
|
||||
"video": true,
|
||||
"cameras": 10,
|
||||
"dispatch": true,
|
||||
"proInstall": false,
|
||||
"discount": 0,
|
||||
"vipCS": false,
|
||||
"medical": true,
|
||||
"careVisit": false,
|
||||
"storageDays": 30
|
||||
},
|
||||
"status": {
|
||||
"hasBaseStation": true,
|
||||
"isActive": true,
|
||||
"monitoring": "Active"
|
||||
},
|
||||
"subscriptionFeatures": {
|
||||
"monitoredSensorsTypes": [
|
||||
"Entry",
|
||||
"Motion",
|
||||
"GlassBreak",
|
||||
"Smoke",
|
||||
"CO",
|
||||
"Freeze",
|
||||
"Water"
|
||||
],
|
||||
"monitoredPanicConditions": [
|
||||
"Fire",
|
||||
"Medical",
|
||||
"Duress"
|
||||
],
|
||||
"dispatchTypes": [
|
||||
"Police",
|
||||
"Fire",
|
||||
"Medical",
|
||||
"Guard"
|
||||
],
|
||||
"remoteControl": [
|
||||
"ArmDisarm",
|
||||
"LockUnlock",
|
||||
"ViewSettings",
|
||||
"ConfigureSettings"
|
||||
],
|
||||
"cameraFeatures": {
|
||||
"liveView": true,
|
||||
"maxRecordingCameras": 10,
|
||||
"recordingStorageDays": 30,
|
||||
"videoVerification": true
|
||||
},
|
||||
"support": {
|
||||
"level": "Basic",
|
||||
"annualVisit": false,
|
||||
"professionalInstall": false
|
||||
},
|
||||
"cellCommunicationBackup": true,
|
||||
"alertChannels": [
|
||||
"Push",
|
||||
"SMS",
|
||||
"Email"
|
||||
],
|
||||
"alertTypes": [
|
||||
"Alarm",
|
||||
"Error",
|
||||
"Activity",
|
||||
"Camera"
|
||||
],
|
||||
"alarmModes": [
|
||||
"Alarm",
|
||||
"SecretAlert",
|
||||
"Disabled"
|
||||
],
|
||||
"supportedIntegrations": [
|
||||
"GoogleAssistant",
|
||||
"AmazonAlexa",
|
||||
"AugustLock"
|
||||
],
|
||||
"timeline": {}
|
||||
},
|
||||
"dispatcher": "cops",
|
||||
"dcid": 0,
|
||||
"location": {
|
||||
"sid": 12345,
|
||||
"uid": 12345,
|
||||
"lStatus": 10,
|
||||
"account": "1234ABCD",
|
||||
"street1": "1234 Main Street",
|
||||
"street2": "",
|
||||
"locationName": "",
|
||||
"city": "Atlantis",
|
||||
"county": "SEA",
|
||||
"state": "UW",
|
||||
"zip": "12345",
|
||||
"country": "US",
|
||||
"crossStreet": "River 1 and River 2",
|
||||
"notes": "",
|
||||
"residenceType": 2,
|
||||
"numAdults": 2,
|
||||
"numChildren": 0,
|
||||
"locationOffset": -360,
|
||||
"safeWord": "TRITON",
|
||||
"signature": "Atlantis Citizen 1",
|
||||
"timeZone": 2,
|
||||
"primaryContacts": [
|
||||
{
|
||||
"name": "John Doe",
|
||||
"phone": "1234567890"
|
||||
}
|
||||
],
|
||||
"secondaryContacts": [
|
||||
{
|
||||
"name": "Jane Doe",
|
||||
"phone": "9876543210"
|
||||
}
|
||||
],
|
||||
"copsOptIn": false,
|
||||
"certificateUri": "https://simplisafe.com/account2/12345/alarm-certificate/12345",
|
||||
"nestStructureId": "",
|
||||
"system": {
|
||||
"serial": "1234ABCD",
|
||||
"alarmState": "OFF",
|
||||
"alarmStateTimestamp": 0,
|
||||
"isAlarming": false,
|
||||
"version": 3,
|
||||
"capabilities": {
|
||||
"setWifiOverCell": true,
|
||||
"setDoorbellChimeVolume": true,
|
||||
"outdoorBattCamera": true
|
||||
},
|
||||
"temperature": 67,
|
||||
"exitDelayRemaining": 60,
|
||||
"cameras": [
|
||||
{
|
||||
"staleSettingsTypes": [],
|
||||
"upgradeWhitelisted": false,
|
||||
"model": "SS001",
|
||||
"uuid": "1234567890",
|
||||
"uid": 12345,
|
||||
"sid": 12345,
|
||||
"cameraSettings": {
|
||||
"cameraName": "Camera",
|
||||
"pictureQuality": "720p",
|
||||
"nightVision": "auto",
|
||||
"statusLight": "off",
|
||||
"micSensitivity": 100,
|
||||
"micEnable": true,
|
||||
"speakerVolume": 75,
|
||||
"motionSensitivity": 0,
|
||||
"shutterHome": "closedAlarmOnly",
|
||||
"shutterAway": "open",
|
||||
"shutterOff": "closedAlarmOnly",
|
||||
"wifiSsid": "",
|
||||
"canStream": false,
|
||||
"canRecord": false,
|
||||
"pirEnable": true,
|
||||
"vaEnable": true,
|
||||
"notificationsEnable": false,
|
||||
"enableDoorbellNotification": true,
|
||||
"doorbellChimeVolume": "off",
|
||||
"privacyEnable": false,
|
||||
"hdr": false,
|
||||
"vaZoningEnable": false,
|
||||
"vaZoningRows": 0,
|
||||
"vaZoningCols": 0,
|
||||
"vaZoningMask": [],
|
||||
"maxDigitalZoom": 10,
|
||||
"supportedResolutions": [
|
||||
"480p",
|
||||
"720p"
|
||||
],
|
||||
"admin": {
|
||||
"IRLED": 0,
|
||||
"pirSens": 0,
|
||||
"statusLEDState": 1,
|
||||
"lux": "lowLux",
|
||||
"motionDetectionEnabled": false,
|
||||
"motionThresholdZero": 0,
|
||||
"motionThresholdOne": 10000,
|
||||
"levelChangeDelayZero": 30,
|
||||
"levelChangeDelayOne": 10,
|
||||
"audioDetectionEnabled": false,
|
||||
"audioChannelNum": 2,
|
||||
"audioSampleRate": 16000,
|
||||
"audioChunkBytes": 2048,
|
||||
"audioSampleFormat": 3,
|
||||
"audioSensitivity": 50,
|
||||
"audioThreshold": 50,
|
||||
"audioDirection": 0,
|
||||
"bitRate": 284,
|
||||
"longPress": 2000,
|
||||
"kframe": 1,
|
||||
"gopLength": 40,
|
||||
"idr": 1,
|
||||
"fps": 20,
|
||||
"firmwareVersion": "2.6.1.107",
|
||||
"netConfigVersion": "",
|
||||
"camAgentVersion": "",
|
||||
"lastLogin": 1600639997,
|
||||
"lastLogout": 1600639944,
|
||||
"pirSampleRateMs": 800,
|
||||
"pirHysteresisHigh": 2,
|
||||
"pirHysteresisLow": 10,
|
||||
"pirFilterCoefficient": 1,
|
||||
"logEnabled": true,
|
||||
"logLevel": 3,
|
||||
"logQDepth": 20,
|
||||
"firmwareGroup": "public",
|
||||
"irOpenThreshold": 445,
|
||||
"irCloseThreshold": 840,
|
||||
"irOpenDelay": 3,
|
||||
"irCloseDelay": 3,
|
||||
"irThreshold1x": 388,
|
||||
"irThreshold2x": 335,
|
||||
"irThreshold3x": 260,
|
||||
"rssi": [
|
||||
[
|
||||
1600935204,
|
||||
-43
|
||||
]
|
||||
],
|
||||
"battery": [],
|
||||
"dbm": 0,
|
||||
"vmUse": 161592,
|
||||
"resSet": 10540,
|
||||
"uptime": 810043.74,
|
||||
"wifiDisconnects": 1,
|
||||
"wifiDriverReloads": 1,
|
||||
"statsPeriod": 3600000,
|
||||
"sarlaccDebugLogTypes": 0,
|
||||
"odProcessingFps": 8,
|
||||
"odObjectMinWidthPercent": 6,
|
||||
"odObjectMinHeightPercent": 24,
|
||||
"odEnableObjectDetection": true,
|
||||
"odClassificationMask": 2,
|
||||
"odClassificationConfidenceThreshold": 0.95,
|
||||
"odEnableOverlay": false,
|
||||
"odAnalyticsLib": 2,
|
||||
"odSensitivity": 85,
|
||||
"odEventObjectMask": 2,
|
||||
"odLuxThreshold": 445,
|
||||
"odLuxHysteresisHigh": 4,
|
||||
"odLuxHysteresisLow": 4,
|
||||
"odLuxSamplingFrequency": 30,
|
||||
"odFGExtractorMode": 2,
|
||||
"odVideoScaleFactor": 1,
|
||||
"odSceneType": 1,
|
||||
"odCameraView": 3,
|
||||
"odCameraFOV": 2,
|
||||
"odBackgroundLearnStationary": true,
|
||||
"odBackgroundLearnStationarySpeed": 15,
|
||||
"odClassifierQualityProfile": 1,
|
||||
"odEnableVideoAnalyticsWhileStreaming": false,
|
||||
"wlanMac": "XX:XX:XX:XX:XX:XX",
|
||||
"region": "us-east-1",
|
||||
"enableWifiAnalyticsLib": false,
|
||||
"ivLicense": ""
|
||||
},
|
||||
"pirLevel": "medium",
|
||||
"odLevel": "medium"
|
||||
},
|
||||
"__v": 0,
|
||||
"cameraStatus": {
|
||||
"firmwareVersion": "2.6.1.107",
|
||||
"netConfigVersion": "",
|
||||
"camAgentVersion": "",
|
||||
"lastLogin": 1600639997,
|
||||
"lastLogout": 1600639944,
|
||||
"wlanMac": "XX:XX:XX:XX:XX:XX",
|
||||
"fwDownloadVersion": "",
|
||||
"fwDownloadPercentage": 0,
|
||||
"recovered": false,
|
||||
"recoveredFromVersion": "",
|
||||
"_id": "1234567890",
|
||||
"initErrors": [],
|
||||
"speedTestTokenCreated": 1600235629
|
||||
},
|
||||
"supportedFeatures": {
|
||||
"providers": {
|
||||
"webrtc": "none",
|
||||
"recording": "simplisafe",
|
||||
"live": "simplisafe"
|
||||
},
|
||||
"audioEncodings": [
|
||||
"speex"
|
||||
],
|
||||
"resolutions": [
|
||||
"480p",
|
||||
"720p"
|
||||
],
|
||||
"_id": "1234567890",
|
||||
"pir": true,
|
||||
"videoAnalytics": false,
|
||||
"privacyShutter": true,
|
||||
"microphone": true,
|
||||
"fullDuplexAudio": false,
|
||||
"wired": true,
|
||||
"networkSpeedTest": false,
|
||||
"videoEncoding": "h264"
|
||||
},
|
||||
"subscription": {
|
||||
"enabled": true,
|
||||
"freeTrialActive": false,
|
||||
"freeTrialUsed": true,
|
||||
"freeTrialEnds": 0,
|
||||
"freeTrialExpires": 0,
|
||||
"planSku": "SSVM1",
|
||||
"price": 0,
|
||||
"expires": 0,
|
||||
"storageDays": 30,
|
||||
"trialUsed": true,
|
||||
"trialActive": false,
|
||||
"trialExpires": 0
|
||||
},
|
||||
"status": "online"
|
||||
}
|
||||
],
|
||||
"connType": "wifi",
|
||||
"stateUpdated": 1601502948,
|
||||
"messages": [
|
||||
{
|
||||
"_id": "xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"id": "xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"textTemplate": "Power Outage - Backup battery in use.",
|
||||
"data": {
|
||||
"time": "2020-02-16T03:20:28+00:00"
|
||||
},
|
||||
"text": "Power Outage - Backup battery in use.",
|
||||
"code": "2000",
|
||||
"filters": [],
|
||||
"link": "http://link.to.info",
|
||||
"linkLabel": "More Info",
|
||||
"expiration": 0,
|
||||
"category": "error",
|
||||
"timestamp": 1581823228
|
||||
}
|
||||
],
|
||||
"powerOutage": false,
|
||||
"lastPowerOutage": 1581991064,
|
||||
"lastSuccessfulWifiTS": 1601424776,
|
||||
"isOffline": false
|
||||
}
|
||||
},
|
||||
"pinUnlocked": true,
|
||||
"billDate": 1602887552,
|
||||
"billInterval": 2628000,
|
||||
"pinUnlockedBy": "pin",
|
||||
"autoActivation": null
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,39 @@
|
||||
"""Define tests for the SimpliSafe config flow."""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from simplipy.errors import InvalidCredentialsError, SimplipyError
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.simplisafe import DOMAIN
|
||||
from homeassistant.components.simplisafe.config_flow import CONF_AUTH_CODE
|
||||
from homeassistant.components.simplisafe.const import CONF_USER_ID
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
|
||||
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from homeassistant.const import CONF_CODE
|
||||
|
||||
|
||||
@pytest.fixture(name="api")
|
||||
def api_fixture():
|
||||
"""Define a fixture for simplisafe-python API object."""
|
||||
api = Mock()
|
||||
api.refresh_token = "token123"
|
||||
api.user_id = "12345"
|
||||
return api
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_async_from_auth")
|
||||
def mock_async_from_auth_fixture(api):
|
||||
"""Define a fixture for simplipy.API.async_from_auth."""
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.config_flow.API.async_from_auth",
|
||||
) as mock_async_from_auth:
|
||||
mock_async_from_auth.side_effect = AsyncMock(return_value=api)
|
||||
yield mock_async_from_auth
|
||||
|
||||
|
||||
async def test_duplicate_error(hass, mock_async_from_auth):
|
||||
async def test_duplicate_error(hass, config_entry, config_code, setup_simplisafe):
|
||||
"""Test that errors are shown when duplicates are added."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="12345",
|
||||
data={
|
||||
CONF_USER_ID: "12345",
|
||||
CONF_TOKEN: "token123",
|
||||
},
|
||||
).add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=config_code
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc,error_string",
|
||||
[(InvalidCredentialsError, "invalid_auth"), (SimplipyError, "unknown")],
|
||||
)
|
||||
async def test_errors(hass, config_code, exc, error_string):
|
||||
"""Test that exceptions show the appropriate error."""
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
"homeassistant.components.simplisafe.API.async_from_auth",
|
||||
side_effect=exc,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
@@ -54,135 +42,75 @@ async def test_duplicate_error(hass, mock_async_from_auth):
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_AUTH_CODE: "code123"}
|
||||
result["flow_id"], user_input=config_code
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["errors"] == {"base": error_string}
|
||||
|
||||
|
||||
async def test_invalid_credentials(hass, mock_async_from_auth):
|
||||
"""Test that invalid credentials show the correct error."""
|
||||
mock_async_from_auth.side_effect = AsyncMock(side_effect=InvalidCredentialsError)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_AUTH_CODE: "code123"}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_options_flow(hass):
|
||||
async def test_options_flow(hass, config_entry):
|
||||
"""Test config flow options."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="abcde12345",
|
||||
data={CONF_USER_ID: "12345", CONF_TOKEN: "token456"},
|
||||
options={CONF_CODE: "1234"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
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"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={CONF_CODE: "4321"}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert entry.options == {CONF_CODE: "4321"}
|
||||
assert config_entry.options == {CONF_CODE: "4321"}
|
||||
|
||||
|
||||
async def test_step_reauth_old_format(hass, mock_async_from_auth):
|
||||
async def test_step_reauth_old_format(
|
||||
hass, config, config_code, config_entry, setup_simplisafe
|
||||
):
|
||||
"""Test the re-auth step with "old" config entries (those with user IDs)."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="user@email.com",
|
||||
data={
|
||||
CONF_USERNAME: "user@email.com",
|
||||
CONF_PASSWORD: "password",
|
||||
},
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_REAUTH},
|
||||
data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"},
|
||||
DOMAIN, context={"source": SOURCE_REAUTH}, data=config
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
), patch("homeassistant.config_entries.ConfigEntries.async_reload"):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_AUTH_CODE: "code123"}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=config_code
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
[config_entry] = hass.config_entries.async_entries(DOMAIN)
|
||||
assert config_entry.data == {CONF_USER_ID: "12345", CONF_TOKEN: "token123"}
|
||||
assert config_entry.data == config
|
||||
|
||||
|
||||
async def test_step_reauth_new_format(hass, mock_async_from_auth):
|
||||
async def test_step_reauth_new_format(
|
||||
hass, config, config_code, config_entry, setup_simplisafe
|
||||
):
|
||||
"""Test the re-auth step with "new" config entries (those with user IDs)."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="12345",
|
||||
data={
|
||||
CONF_USER_ID: "12345",
|
||||
CONF_TOKEN: "token123",
|
||||
},
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_REAUTH},
|
||||
data={CONF_USER_ID: "12345", CONF_TOKEN: "token123"},
|
||||
DOMAIN, context={"source": SOURCE_REAUTH}, data=config
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
), patch("homeassistant.config_entries.ConfigEntries.async_reload"):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_AUTH_CODE: "code123"}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=config_code
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
[config_entry] = hass.config_entries.async_entries(DOMAIN)
|
||||
assert config_entry.data == {CONF_USER_ID: "12345", CONF_TOKEN: "token123"}
|
||||
assert config_entry.data == config
|
||||
|
||||
|
||||
async def test_step_reauth_wrong_account(hass, api, mock_async_from_auth):
|
||||
async def test_step_reauth_wrong_account(
|
||||
hass, api, config, config_code, config_entry, setup_simplisafe
|
||||
):
|
||||
"""Test the re-auth step returning a different account from this one."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="12345",
|
||||
data={
|
||||
CONF_USER_ID: "12345",
|
||||
CONF_TOKEN: "token123",
|
||||
},
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_REAUTH},
|
||||
data={CONF_USER_ID: "12345", CONF_TOKEN: "token123"},
|
||||
DOMAIN, context={"source": SOURCE_REAUTH}, data=config
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
@@ -190,52 +118,29 @@ async def test_step_reauth_wrong_account(hass, api, mock_async_from_auth):
|
||||
# identified as this entry's unique ID:
|
||||
api.user_id = "67890"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
), patch("homeassistant.config_entries.ConfigEntries.async_reload"):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_AUTH_CODE: "code123"}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "wrong_account"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=config_code
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "wrong_account"
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
[config_entry] = hass.config_entries.async_entries(DOMAIN)
|
||||
assert config_entry.unique_id == "12345"
|
||||
|
||||
|
||||
async def test_step_user(hass, mock_async_from_auth):
|
||||
async def test_step_user(hass, config, config_code, setup_simplisafe):
|
||||
"""Test the user step."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
), patch("homeassistant.config_entries.ConfigEntries.async_reload"):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_AUTH_CODE: "code123"}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=config_code
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
[config_entry] = hass.config_entries.async_entries(DOMAIN)
|
||||
assert config_entry.data == {CONF_USER_ID: "12345", CONF_TOKEN: "token123"}
|
||||
|
||||
|
||||
async def test_unknown_error(hass, mock_async_from_auth):
|
||||
"""Test that an unknown error shows ohe correct error."""
|
||||
mock_async_from_auth.side_effect = AsyncMock(side_effect=SimplipyError)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_AUTH_CODE: "code123"}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
assert config_entry.data == config
|
||||
|
||||
226
tests/components/simplisafe/test_diagnostics.py
Normal file
226
tests/components/simplisafe/test_diagnostics.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Test SimpliSafe diagnostics."""
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
|
||||
|
||||
async def test_entry_diagnostics(hass, config_entry, hass_client, setup_simplisafe):
|
||||
"""Test config entry diagnostics."""
|
||||
assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {
|
||||
"entry": {"options": {}},
|
||||
"systems": [
|
||||
{
|
||||
"address": REDACTED,
|
||||
"alarm_going_off": False,
|
||||
"connection_type": "wifi",
|
||||
"notifications": [],
|
||||
"serial": REDACTED,
|
||||
"state": 99,
|
||||
"system_id": REDACTED,
|
||||
"temperature": 67,
|
||||
"version": 3,
|
||||
"sensors": [
|
||||
{
|
||||
"name": "Fire Door",
|
||||
"serial": REDACTED,
|
||||
"type": 5,
|
||||
"error": False,
|
||||
"low_battery": False,
|
||||
"offline": False,
|
||||
"settings": {
|
||||
"instantTrigger": False,
|
||||
"away2": 1,
|
||||
"away": 1,
|
||||
"home2": 1,
|
||||
"home": 1,
|
||||
"off": 0,
|
||||
},
|
||||
"trigger_instantly": False,
|
||||
"triggered": False,
|
||||
},
|
||||
{
|
||||
"name": "Front Door",
|
||||
"serial": REDACTED,
|
||||
"type": 12,
|
||||
"error": False,
|
||||
"low_battery": False,
|
||||
"offline": False,
|
||||
"settings": {
|
||||
"instantTrigger": False,
|
||||
"away2": 1,
|
||||
"away": 1,
|
||||
"home2": 1,
|
||||
"home": 1,
|
||||
"off": 0,
|
||||
},
|
||||
"trigger_instantly": False,
|
||||
"triggered": False,
|
||||
},
|
||||
],
|
||||
"alarm_duration": 240,
|
||||
"alarm_volume": 3,
|
||||
"battery_backup_power_level": 5293,
|
||||
"cameras": [
|
||||
{
|
||||
"camera_settings": {
|
||||
"cameraName": "Camera",
|
||||
"pictureQuality": "720p",
|
||||
"nightVision": "auto",
|
||||
"statusLight": "off",
|
||||
"micSensitivity": 100,
|
||||
"micEnable": True,
|
||||
"speakerVolume": 75,
|
||||
"motionSensitivity": 0,
|
||||
"shutterHome": "closedAlarmOnly",
|
||||
"shutterAway": "open",
|
||||
"shutterOff": "closedAlarmOnly",
|
||||
"wifiSsid": "",
|
||||
"canStream": False,
|
||||
"canRecord": False,
|
||||
"pirEnable": True,
|
||||
"vaEnable": True,
|
||||
"notificationsEnable": False,
|
||||
"enableDoorbellNotification": True,
|
||||
"doorbellChimeVolume": "off",
|
||||
"privacyEnable": False,
|
||||
"hdr": False,
|
||||
"vaZoningEnable": False,
|
||||
"vaZoningRows": 0,
|
||||
"vaZoningCols": 0,
|
||||
"vaZoningMask": [],
|
||||
"maxDigitalZoom": 10,
|
||||
"supportedResolutions": ["480p", "720p"],
|
||||
"admin": {
|
||||
"IRLED": 0,
|
||||
"pirSens": 0,
|
||||
"statusLEDState": 1,
|
||||
"lux": "lowLux",
|
||||
"motionDetectionEnabled": False,
|
||||
"motionThresholdZero": 0,
|
||||
"motionThresholdOne": 10000,
|
||||
"levelChangeDelayZero": 30,
|
||||
"levelChangeDelayOne": 10,
|
||||
"audioDetectionEnabled": False,
|
||||
"audioChannelNum": 2,
|
||||
"audioSampleRate": 16000,
|
||||
"audioChunkBytes": 2048,
|
||||
"audioSampleFormat": 3,
|
||||
"audioSensitivity": 50,
|
||||
"audioThreshold": 50,
|
||||
"audioDirection": 0,
|
||||
"bitRate": 284,
|
||||
"longPress": 2000,
|
||||
"kframe": 1,
|
||||
"gopLength": 40,
|
||||
"idr": 1,
|
||||
"fps": 20,
|
||||
"firmwareVersion": "2.6.1.107",
|
||||
"netConfigVersion": "",
|
||||
"camAgentVersion": "",
|
||||
"lastLogin": 1600639997,
|
||||
"lastLogout": 1600639944,
|
||||
"pirSampleRateMs": 800,
|
||||
"pirHysteresisHigh": 2,
|
||||
"pirHysteresisLow": 10,
|
||||
"pirFilterCoefficient": 1,
|
||||
"logEnabled": True,
|
||||
"logLevel": 3,
|
||||
"logQDepth": 20,
|
||||
"firmwareGroup": "public",
|
||||
"irOpenThreshold": 445,
|
||||
"irCloseThreshold": 840,
|
||||
"irOpenDelay": 3,
|
||||
"irCloseDelay": 3,
|
||||
"irThreshold1x": 388,
|
||||
"irThreshold2x": 335,
|
||||
"irThreshold3x": 260,
|
||||
"rssi": [[1600935204, -43]],
|
||||
"battery": [],
|
||||
"dbm": 0,
|
||||
"vmUse": 161592,
|
||||
"resSet": 10540,
|
||||
"uptime": 810043.74,
|
||||
"wifiDisconnects": 1,
|
||||
"wifiDriverReloads": 1,
|
||||
"statsPeriod": 3600000,
|
||||
"sarlaccDebugLogTypes": 0,
|
||||
"odProcessingFps": 8,
|
||||
"odObjectMinWidthPercent": 6,
|
||||
"odObjectMinHeightPercent": 24,
|
||||
"odEnableObjectDetection": True,
|
||||
"odClassificationMask": 2,
|
||||
"odClassificationConfidenceThreshold": 0.95,
|
||||
"odEnableOverlay": False,
|
||||
"odAnalyticsLib": 2,
|
||||
"odSensitivity": 85,
|
||||
"odEventObjectMask": 2,
|
||||
"odLuxThreshold": 445,
|
||||
"odLuxHysteresisHigh": 4,
|
||||
"odLuxHysteresisLow": 4,
|
||||
"odLuxSamplingFrequency": 30,
|
||||
"odFGExtractorMode": 2,
|
||||
"odVideoScaleFactor": 1,
|
||||
"odSceneType": 1,
|
||||
"odCameraView": 3,
|
||||
"odCameraFOV": 2,
|
||||
"odBackgroundLearnStationary": True,
|
||||
"odBackgroundLearnStationarySpeed": 15,
|
||||
"odClassifierQualityProfile": 1,
|
||||
"odEnableVideoAnalyticsWhileStreaming": False,
|
||||
"wlanMac": "XX:XX:XX:XX:XX:XX",
|
||||
"region": "us-east-1",
|
||||
"enableWifiAnalyticsLib": False,
|
||||
"ivLicense": "",
|
||||
},
|
||||
"pirLevel": "medium",
|
||||
"odLevel": "medium",
|
||||
},
|
||||
"camera_type": 0,
|
||||
"name": "Camera",
|
||||
"serial": REDACTED,
|
||||
"shutter_open_when_away": True,
|
||||
"shutter_open_when_home": False,
|
||||
"shutter_open_when_off": False,
|
||||
"status": "online",
|
||||
"subscription_enabled": True,
|
||||
},
|
||||
],
|
||||
"chime_volume": 2,
|
||||
"entry_delay_away": 30,
|
||||
"entry_delay_home": 30,
|
||||
"exit_delay_away": 60,
|
||||
"exit_delay_home": 0,
|
||||
"gsm_strength": -73,
|
||||
"light": True,
|
||||
"locks": [
|
||||
{
|
||||
"name": "Front Door",
|
||||
"serial": REDACTED,
|
||||
"type": 16,
|
||||
"error": False,
|
||||
"low_battery": False,
|
||||
"offline": False,
|
||||
"settings": {
|
||||
"autoLock": 3,
|
||||
"away": 1,
|
||||
"home": 1,
|
||||
"awayToOff": 0,
|
||||
"homeToOff": 1,
|
||||
},
|
||||
"disabled": False,
|
||||
"lock_low_battery": False,
|
||||
"pin_pad_low_battery": False,
|
||||
"pin_pad_offline": False,
|
||||
"state": 1,
|
||||
}
|
||||
],
|
||||
"offline": False,
|
||||
"power_outage": False,
|
||||
"rf_jamming": False,
|
||||
"voice_prompt_volume": 2,
|
||||
"wall_power_level": 5933,
|
||||
"wifi_ssid": REDACTED,
|
||||
"wifi_strength": -49,
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -11,27 +11,10 @@ from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_CLIENT_KEYS, TV_NAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
FAKE_UUID = "some-fake-uuid"
|
||||
TV_NAME = "fake_webos"
|
||||
ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}"
|
||||
HOST = "1.2.3.4"
|
||||
CLIENT_KEY = "some-secret"
|
||||
MOCK_CLIENT_KEYS = {HOST: CLIENT_KEY}
|
||||
MOCK_JSON = '{"1.2.3.4": "some-secret"}'
|
||||
|
||||
CHANNEL_1 = {
|
||||
"channelNumber": "1",
|
||||
"channelName": "Channel 1",
|
||||
"channelId": "ch1id",
|
||||
}
|
||||
CHANNEL_2 = {
|
||||
"channelNumber": "20",
|
||||
"channelName": "Channel Name 2",
|
||||
"channelId": "ch2id",
|
||||
}
|
||||
|
||||
|
||||
async def setup_webostv(hass, unique_id=FAKE_UUID):
|
||||
"""Initialize webostv and media_player for tests."""
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from homeassistant.components.webostv.const import LIVE_TV_APP_ID
|
||||
from homeassistant.helpers import entity_registry
|
||||
|
||||
from . import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID
|
||||
from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_INPUTS
|
||||
|
||||
from tests.common import async_mock_service
|
||||
|
||||
@@ -28,18 +28,8 @@ def client_fixture():
|
||||
client.software_info = {"major_ver": "major", "minor_ver": "minor"}
|
||||
client.system_info = {"modelName": "TVFAKE"}
|
||||
client.client_key = CLIENT_KEY
|
||||
client.apps = {
|
||||
LIVE_TV_APP_ID: {
|
||||
"title": "Live TV",
|
||||
"id": LIVE_TV_APP_ID,
|
||||
"largeIcon": "large-icon",
|
||||
"icon": "icon",
|
||||
},
|
||||
}
|
||||
client.inputs = {
|
||||
"in1": {"label": "Input01", "id": "in1", "appId": "app0"},
|
||||
"in2": {"label": "Input02", "id": "in2", "appId": "app1"},
|
||||
}
|
||||
client.apps = MOCK_APPS
|
||||
client.inputs = MOCK_INPUTS
|
||||
client.current_app_id = LIVE_TV_APP_ID
|
||||
|
||||
client.channels = [CHANNEL_1, CHANNEL_2]
|
||||
|
||||
36
tests/components/webostv/const.py
Normal file
36
tests/components/webostv/const.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Constants for LG webOS Smart TV tests."""
|
||||
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||
from homeassistant.components.webostv.const import LIVE_TV_APP_ID
|
||||
|
||||
FAKE_UUID = "some-fake-uuid"
|
||||
TV_NAME = "fake_webos"
|
||||
ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}"
|
||||
HOST = "1.2.3.4"
|
||||
CLIENT_KEY = "some-secret"
|
||||
MOCK_CLIENT_KEYS = {HOST: CLIENT_KEY}
|
||||
MOCK_JSON = '{"1.2.3.4": "some-secret"}'
|
||||
|
||||
CHANNEL_1 = {
|
||||
"channelNumber": "1",
|
||||
"channelName": "Channel 1",
|
||||
"channelId": "ch1id",
|
||||
}
|
||||
CHANNEL_2 = {
|
||||
"channelNumber": "20",
|
||||
"channelName": "Channel Name 2",
|
||||
"channelId": "ch2id",
|
||||
}
|
||||
|
||||
MOCK_APPS = {
|
||||
LIVE_TV_APP_ID: {
|
||||
"title": "Live TV",
|
||||
"id": LIVE_TV_APP_ID,
|
||||
"largeIcon": "large-icon",
|
||||
"icon": "icon",
|
||||
},
|
||||
}
|
||||
|
||||
MOCK_INPUTS = {
|
||||
"in1": {"label": "Input01", "id": "in1", "appId": "app0"},
|
||||
"in2": {"label": "Input02", "id": "in2", "appId": "app1"},
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN
|
||||
from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID
|
||||
from homeassistant.config_entries import SOURCE_SSDP
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_SECRET,
|
||||
@@ -24,7 +24,8 @@ from homeassistant.data_entry_flow import (
|
||||
RESULT_TYPE_FORM,
|
||||
)
|
||||
|
||||
from . import CLIENT_KEY, FAKE_UUID, HOST, TV_NAME, setup_webostv
|
||||
from . import setup_webostv
|
||||
from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME
|
||||
|
||||
MOCK_YAML_CONFIG = {
|
||||
CONF_HOST: HOST,
|
||||
@@ -149,8 +150,27 @@ async def test_form(hass, client):
|
||||
assert result["title"] == TV_NAME
|
||||
|
||||
|
||||
async def test_options_flow(hass, client):
|
||||
"""Test options config flow."""
|
||||
@pytest.mark.parametrize(
|
||||
"apps, inputs",
|
||||
[
|
||||
# Live TV in apps (default)
|
||||
(MOCK_APPS, MOCK_INPUTS),
|
||||
# Live TV in inputs
|
||||
(
|
||||
{},
|
||||
{
|
||||
**MOCK_INPUTS,
|
||||
"livetv": {"label": "Live TV", "id": "livetv", "appId": LIVE_TV_APP_ID},
|
||||
},
|
||||
),
|
||||
# Live TV not found
|
||||
({}, MOCK_INPUTS),
|
||||
],
|
||||
)
|
||||
async def test_options_flow_live_tv_in_apps(hass, client, apps, inputs):
|
||||
"""Test options config flow Live TV found in apps."""
|
||||
client.apps = apps
|
||||
client.inputs = inputs
|
||||
entry = await setup_webostv(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
@@ -161,20 +181,24 @@ async def test_options_flow(hass, client):
|
||||
|
||||
result2 = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_SOURCES: ["Input01", "Input02"]},
|
||||
user_input={CONF_SOURCES: ["Live TV", "Input01", "Input02"]},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["data"][CONF_SOURCES] == ["Input01", "Input02"]
|
||||
assert result2["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"]
|
||||
|
||||
|
||||
async def test_options_flow_cannot_retrieve(hass, client):
|
||||
"""Test options config flow cannot retrieve sources."""
|
||||
entry = await setup_webostv(hass)
|
||||
|
||||
client.connect = Mock(side_effect=ConnectionRefusedError())
|
||||
result3 = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result3["type"] == RESULT_TYPE_FORM
|
||||
assert result3["errors"] == {"base": "cannot_retrieve"}
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["errors"] == {"base": "cannot_retrieve"}
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass, client):
|
||||
|
||||
@@ -11,7 +11,8 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import async_get as get_dev_reg
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import ENTITY_ID, FAKE_UUID, setup_webostv
|
||||
from . import setup_webostv
|
||||
from .const import ENTITY_ID, FAKE_UUID
|
||||
|
||||
from tests.common import MockConfigEntry, async_get_device_automations
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||
from homeassistant.components.webostv import DOMAIN
|
||||
|
||||
from . import (
|
||||
MOCK_JSON,
|
||||
create_memory_sqlite_engine,
|
||||
is_entity_unique_id_updated,
|
||||
setup_legacy_component,
|
||||
)
|
||||
from .const import MOCK_JSON
|
||||
|
||||
|
||||
async def test_missing_keys_file_abort(hass, client, caplog):
|
||||
|
||||
@@ -64,7 +64,8 @@ from homeassistant.helpers import device_registry
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt
|
||||
|
||||
from . import CHANNEL_2, ENTITY_ID, TV_NAME, setup_webostv
|
||||
from . import setup_webostv
|
||||
from .const import CHANNEL_2, ENTITY_ID, TV_NAME
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ from homeassistant.components.webostv import DOMAIN
|
||||
from homeassistant.const import CONF_ICON, CONF_SERVICE_DATA
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import TV_NAME, setup_webostv
|
||||
from . import setup_webostv
|
||||
from .const import TV_NAME
|
||||
|
||||
ICON_PATH = "/some/path"
|
||||
MESSAGE = "one, two, testing, testing"
|
||||
|
||||
@@ -7,7 +7,8 @@ from homeassistant.const import SERVICE_RELOAD
|
||||
from homeassistant.helpers.device_registry import async_get as get_dev_reg
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import ENTITY_ID, FAKE_UUID, setup_webostv
|
||||
from . import setup_webostv
|
||||
from .const import ENTITY_ID, FAKE_UUID
|
||||
|
||||
from tests.common import MockEntity, MockEntityPlatform
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -71,6 +71,33 @@ def mock_whois() -> Generator[MagicMock, None, None]:
|
||||
yield whois_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_whois_missing_some_attrs() -> Generator[Mock, None, None]:
|
||||
"""Return a mocked query that only sets admin."""
|
||||
|
||||
class LimitedWhoisMock:
|
||||
"""A limited mock of whois_query."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Mock only attributes the library always sets being available."""
|
||||
self.creation_date = datetime(2019, 1, 1, 0, 0, 0)
|
||||
self.dnssec = True
|
||||
self.expiration_date = datetime(2023, 1, 1, 0, 0, 0)
|
||||
self.last_updated = datetime(
|
||||
2022, 1, 1, 0, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Amsterdam")
|
||||
)
|
||||
self.name = "home-assistant.io"
|
||||
self.name_servers = ["ns1.example.com", "ns2.example.com"]
|
||||
self.registrar = "My Registrar"
|
||||
self.status = "OK"
|
||||
self.statuses = ["OK"]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.whois.whois_query", LimitedWhoisMock
|
||||
) as whois_mock:
|
||||
yield whois_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_whois: MagicMock
|
||||
@@ -84,6 +111,21 @@ async def init_integration(
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration_missing_some_attrs(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_whois_missing_some_attrs: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up thewhois integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_all_entities() -> Generator[AsyncMock, None, None]:
|
||||
"""Test fixture that ensures all entities are enabled in the registry."""
|
||||
|
||||
@@ -30,7 +30,7 @@ async def test_load_unload_config_entry(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(mock_whois.mock_calls) == 2
|
||||
assert len(mock_whois.mock_calls) == 1
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -76,5 +76,5 @@ async def test_import_config(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_whois.mock_calls) == 2
|
||||
assert len(mock_whois.mock_calls) == 1
|
||||
assert "the Whois platform in YAML is deprecated" in caplog.text
|
||||
|
||||
@@ -143,6 +143,32 @@ async def test_whois_sensors(
|
||||
assert device_entry.sw_version is None
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2022-01-01 12:00:00", tz_offset=0)
|
||||
async def test_whois_sensors_missing_some_attrs(
|
||||
hass: HomeAssistant,
|
||||
enable_all_entities: AsyncMock,
|
||||
init_integration_missing_some_attrs: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the Whois sensors with owner and reseller missing."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
state = hass.states.get("sensor.home_assistant_io_last_updated")
|
||||
entry = entity_registry.async_get("sensor.home_assistant_io_last_updated")
|
||||
assert entry
|
||||
assert state
|
||||
assert entry.unique_id == "home-assistant.io_last_updated"
|
||||
assert entry.entity_category == EntityCategory.DIAGNOSTIC
|
||||
assert state.state == "2021-12-31T23:00:00+00:00"
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Last Updated"
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP
|
||||
assert ATTR_ICON not in state.attributes
|
||||
|
||||
assert hass.states.get("sensor.home_assistant_io_owner").state == STATE_UNKNOWN
|
||||
assert hass.states.get("sensor.home_assistant_io_reseller").state == STATE_UNKNOWN
|
||||
assert hass.states.get("sensor.home_assistant_io_registrant").state == STATE_UNKNOWN
|
||||
assert hass.states.get("sensor.home_assistant_io_admin").state == STATE_UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entity_id",
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user