Compare commits

..

40 Commits

Author SHA1 Message Date
Paulus Schoutsen
ef143b5eb2 Bumped version to 2022.2.0b4 2022-01-30 20:28:42 -08:00
Matthias Alphart
5d7aefa0b4 Update xknx to 0.19.1 (#65275) 2022-01-30 20:28:34 -08:00
Brynley McDonald
6b6bd381fd Fix flick_electric auth failures (#65274) 2022-01-30 20:28:34 -08:00
Shay Levy
252f5f6b35 Bump aiowebostv to 0.1.2 (#65267) 2022-01-30 20:28:33 -08:00
J. Nick Koston
8bdee9cb1c Simplify whois value_fn (#65265) 2022-01-30 20:28:32 -08:00
J. Nick Koston
7e350b8347 Handle missing attrs in whois results (#65254)
* Handle missing attrs in whois results

- Some attrs are not set depending on where the
  domain is registered

- Fixes #65164

* Set to unknown instead of do not create

* no multi-line lambda
2022-01-30 20:28:32 -08:00
J. Nick Koston
ac8a1248f9 Fix debugpy blocking the event loop at startup (#65252) 2022-01-30 20:28:31 -08:00
J. Nick Koston
ffe262abce Fix flux_led not generating unique ids when discovery fails (#65250) 2022-01-30 20:28:30 -08:00
J. Nick Koston
5174e68b16 Fix powerwall login retry when hitting rate limit (#65245) 2022-01-30 20:28:30 -08:00
Shay Levy
6e4c281e15 Fix webostv live TV source missing when configuring sources (#65243) 2022-01-30 20:28:29 -08:00
Joakim Sørensen
8e71e2e8ee Use .json.txt for diagnostics download filetype (#65236) 2022-01-30 20:28:28 -08:00
J. Nick Koston
26905115c8 Increase the timeout for flux_led directed discovery (#65222) 2022-01-30 20:28:28 -08:00
J. Nick Koston
eca3514f9e Fix senseme fan lights (#65217) 2022-01-30 20:28:27 -08:00
jjlawren
305ffc4ab6 Add activity statistics to Sonos diagnostics (#65214) 2022-01-30 20:28:26 -08:00
Robert Svensson
508fd0cb2a Add logic to avoid creating the same scene multiple times (#65207) 2022-01-30 20:28:25 -08:00
Shay Levy
5368fb6d54 Fix webostv configure sources when selected source is missing (#65195)
* Fix webostv configure sources when selected source is missing

* Add comment for filtering duplicates
2022-01-30 20:28:25 -08:00
Michael
d6527953c3 Fix "internet access" switch for Fritz connected device without known IP address (#65190)
* fix get wan access

* small improvement
- default wan_access to None
- test if dev_info.ip_address is not empty
2022-01-30 20:28:24 -08:00
Robert Svensson
14c969ef6d Better manage of nested lists (#65176) 2022-01-30 20:28:23 -08:00
Aaron Bach
f6f25fa4ff Add diagnostics to SimpliSafe (#65171)
* Add diagnostics to SimpliSafe

* Bump

* Cleanup
2022-01-30 20:28:23 -08:00
Aaron Bach
dcf6e61d4f Ensure diagnostics redaction can handle lists of lists (#65170)
* Ensure diagnostics redaction can handle lists of lists

* Code review

* Update homeassistant/components/diagnostics/util.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Code review

* Typing

* Revert "Typing"

This reverts commit 8a57f772caa5180b609175591d81dfc473769f70.

* New typing attempt

* Revert "New typing attempt"

This reverts commit e26e4aae69f62325fdd6af4d80c8fd1f74846e54.

* Fix typing

* Fix typing again

* Add tests

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-01-30 20:28:22 -08:00
Aaron Bach
2041d4c118 Clean up SimpliSafe config flow tests (#65167)
* Clean up SimpliSafe config flow tests

* Cleanup
2022-01-30 20:28:21 -08:00
starkillerOG
b40bcecac0 Aqara restore door sensor state on start (#65128)
* restore door sensor state on start

* fix import

* fix issues

* also fix Natgas, WaterLeak and Smoke sensors

* remove unnesesary async_schedule_update_ha_state
2022-01-30 20:28:21 -08:00
Erik Montnemery
2ed20df906 Minor refactoring of cast media_player (#65125) 2022-01-30 20:28:20 -08:00
Marvin Wichmann
1a6964448c Fix KNX Expose for strings longer than 14 bytes (#63026)
* Fix KNX Expose for too long strings

* Fix tests

* Catch exception and avoid error during config entry setup for exposures

* Properly catch exceptions in knx expose

* Fix pylint

* Fix CI

* Add test for conversion error
2022-01-30 20:28:19 -08:00
Marvin Wichmann
3dde12f887 Add tests for KNX diagnostic and expose (#64938)
* Add test for KNX diagnostic

* Add test for KNX expose

* Apply review suggestions
2022-01-30 20:27:37 -08:00
Paulus Schoutsen
cd6c182c07 Bumped version to 2022.2.0b3 2022-01-28 21:53:21 -08:00
J. Nick Koston
f8e0c41e91 Fix uncaught exception during isy994 dhcp discovery with ignored entry (#65165) 2022-01-28 21:53:12 -08:00
J. Nick Koston
5f56107116 Add additional blink OUIs to DHCP discovery (#65162) 2022-01-28 21:53:11 -08:00
J. Nick Koston
fb3c99a891 Add additional roomba OUIs to DHCP discovery (#65161) 2022-01-28 21:53:11 -08:00
J. Nick Koston
ca505b79b5 Add dhcp discovery to oncue (#65160) 2022-01-28 21:53:10 -08:00
J. Nick Koston
c74a8bf65a Add OUI for KL430 tplink light strip to discovery (#65159) 2022-01-28 21:53:09 -08:00
Franck Nijhof
406801ef73 Fix setting speed of Tuya fan (#65155) 2022-01-28 21:53:09 -08:00
Marc Mueller
2bfedcbdc5 Move remaining keys to setup.cfg (#65154)
* Move metadata keys

* Move options

* Delete setup.py

* Remove unused constants
* Remove deprecated test_suite key

* Improve metadata

* Only include homeassistant*, not script*
* Add long_desc_content_type
* Remove license file (auto-included by setuptools + wheels)

* Add setup.py

Pip 21.2 doesn't support editable installs without it.
2022-01-28 21:53:08 -08:00
Simone Chemelli
84f817eb25 Fix status for Fritz device tracker (#65152) 2022-01-28 21:53:07 -08:00
Simone Chemelli
4ead2f2f7e Fix excepton for SamsungTV getting device info (#65151) 2022-01-28 21:53:07 -08:00
Marc Mueller
421f9716a7 Use isolated build environments (#65145) 2022-01-28 21:53:06 -08:00
Allen Porter
25e6d8858c Update nest diagnostics (#65141) 2022-01-28 21:53:05 -08:00
Marc Mueller
3829a81d15 Move project_urls to setup.cfg (#65129) 2022-01-28 21:53:05 -08:00
Marc Mueller
9318843867 Move version metadata key to setup.cfg (#65091)
* Move version to setup.cfg
* Move python_requires to setup.cfg
* Add script to validate project metadata
* Add dedicated pre-commit hook
2022-01-28 21:53:04 -08:00
Marc Mueller
4eb787b619 Move install_requires to setup.cfg (#65095) 2022-01-28 21:52:33 -08:00
95 changed files with 2397 additions and 634 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)$

View File

@@ -1,4 +1,3 @@
include README.rst
include LICENSE.md
graft homeassistant
recursive-exclude * *.py[co]

View File

@@ -8,7 +8,11 @@
{
"hostname": "blink*",
"macaddress": "B85F98*"
}
},
{
"hostname": "blink*",
"macaddress": "00037F*"
}
],
"config_flow": true,
"iot_class": "cloud_polling"

View File

@@ -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):
"""

View File

@@ -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(

View File

@@ -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:

View File

@@ -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"'},
)

View File

@@ -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)

View File

@@ -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"],
},
)

View File

@@ -2,7 +2,6 @@
DOMAIN = "flick_electric"
CONF_TOKEN_EXPIRES_IN = "expires_in"
CONF_TOKEN_EXPIRY = "expires"
ATTR_START_AT = "start_at"

View File

@@ -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:

View File

@@ -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]

View File

@@ -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."""

View File

@@ -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"

View File

@@ -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

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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",
)

View File

@@ -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."""

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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:

View File

@@ -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",

View File

@@ -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()

View File

@@ -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"],

View File

@@ -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

View File

@@ -13,7 +13,11 @@
{
"hostname": "roomba-*",
"macaddress": "80A589*"
}
},
{
"hostname": "roomba-*",
"macaddress": "DCF505*"
}
],
"iot_class": "local_push"
}

View File

@@ -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

View File

@@ -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)

View 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,
)

View File

@@ -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": [

View File

@@ -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())

View File

@@ -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

View File

@@ -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:

View File

@@ -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(),
)

View File

@@ -25,6 +25,10 @@
"hostname": "k[lp]*",
"macaddress": "403F8C*"
},
{
"hostname": "k[lp]*",
"macaddress": "C0C9E3*"
},
{
"hostname": "ep*",
"macaddress": "E848B8*"

View File

@@ -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)),
}
]
)

View File

@@ -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()
}

View File

@@ -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()),
]

View File

@@ -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))

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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*",

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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,
]

View 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!")

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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()

View File

@@ -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): [],

View File

@@ -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

View 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"]],
},
}

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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(

View File

@@ -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(

View File

@@ -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)

View File

@@ -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"

View File

@@ -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,

View 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"},
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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",
}
}
],
}

View File

@@ -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,
)

View 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

View 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(),
)

View 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": {}
}

View 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
}
}
}

View 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
}

View 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
}
}

View File

@@ -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

View 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,
}
],
}

View File

@@ -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."""

View File

@@ -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]

View 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"},
}

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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",
(