mirror of
https://github.com/home-assistant/core.git
synced 2025-09-19 09:59:43 +00:00
Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
55958bcfb7 | ||
![]() |
cde6400482 | ||
![]() |
d2077acc92 | ||
![]() |
f4991794d4 | ||
![]() |
088fb7eff3 | ||
![]() |
c3e679f69b | ||
![]() |
aa8e336af5 | ||
![]() |
ac87c0eea2 | ||
![]() |
281456b252 | ||
![]() |
aefa305f77 | ||
![]() |
a11fa832ef | ||
![]() |
0470142701 | ||
![]() |
3c22834751 | ||
![]() |
570d1e7d8f | ||
![]() |
23be039b7f | ||
![]() |
859632d636 | ||
![]() |
874cbf808e | ||
![]() |
56b5ddb06d | ||
![]() |
df371d99dc | ||
![]() |
74b5db9ca5 | ||
![]() |
6f4225b51d | ||
![]() |
b524cc9c56 | ||
![]() |
a6d50ba89b | ||
![]() |
228de5807c | ||
![]() |
d4b40154e5 | ||
![]() |
6e3aa004c4 | ||
![]() |
149cc5cbeb | ||
![]() |
37acf9b165 |
@@ -1050,6 +1050,7 @@ omit =
|
||||
homeassistant/components/zhong_hong/climate.py
|
||||
homeassistant/components/xbee/*
|
||||
homeassistant/components/ziggo_mediabox_xl/media_player.py
|
||||
homeassistant/components/zoneminder/*
|
||||
homeassistant/components/supla/*
|
||||
homeassistant/components/zwave/util.py
|
||||
homeassistant/components/ozw/__init__.py
|
||||
|
@@ -512,7 +512,7 @@ homeassistant/components/zerproc/* @emlove
|
||||
homeassistant/components/zha/* @dmulcahey @adminiuga
|
||||
homeassistant/components/zodiac/* @JulienTant
|
||||
homeassistant/components/zone/* @home-assistant/core
|
||||
homeassistant/components/zoneminder/* @rohankapoorcom @vangorra
|
||||
homeassistant/components/zoneminder/* @rohankapoorcom
|
||||
homeassistant/components/zwave/* @home-assistant/z-wave
|
||||
|
||||
# Individual files
|
||||
|
@@ -33,6 +33,7 @@ from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
TEMP_CELSIUS,
|
||||
TEMP_FAHRENHEIT,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import network
|
||||
@@ -286,6 +287,12 @@ class AlexaEntity:
|
||||
"friendlyName": self.friendly_name(),
|
||||
"description": self.description(),
|
||||
"manufacturerName": "Home Assistant",
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Home Assistant",
|
||||
"model": self.entity.domain,
|
||||
"softwareVersion": __version__,
|
||||
"customIdentifier": self.entity_id,
|
||||
},
|
||||
}
|
||||
|
||||
locale = self.config.locale
|
||||
|
@@ -2,6 +2,6 @@
|
||||
"domain": "apprise",
|
||||
"name": "Apprise",
|
||||
"documentation": "https://www.home-assistant.io/integrations/apprise",
|
||||
"requirements": ["apprise==0.8.8"],
|
||||
"requirements": ["apprise==0.8.9"],
|
||||
"codeowners": ["@caronc"]
|
||||
}
|
||||
|
@@ -28,12 +28,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
|
||||
def get_service(hass, config, discovery_info=None):
|
||||
"""Get the Apprise notification service."""
|
||||
|
||||
# Create our Apprise Asset Object
|
||||
asset = apprise.AppriseAsset(async_mode=False)
|
||||
|
||||
# Create our Apprise Instance (reference our asset)
|
||||
a_obj = apprise.Apprise(asset=asset)
|
||||
a_obj = apprise.Apprise()
|
||||
|
||||
if config.get(CONF_FILE):
|
||||
# Sourced from a Configuration File
|
||||
|
@@ -10,7 +10,7 @@ from homeassistant.config import async_log_exception, config_without_domain
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_per_platform
|
||||
from homeassistant.helpers.condition import async_validate_condition_config
|
||||
from homeassistant.helpers.script import async_validate_action_config
|
||||
from homeassistant.helpers.script import async_validate_actions_config
|
||||
from homeassistant.helpers.trigger import async_validate_trigger_config
|
||||
from homeassistant.loader import IntegrationNotFound
|
||||
|
||||
@@ -36,9 +36,7 @@ async def async_validate_config_item(hass, config, full_config=None):
|
||||
]
|
||||
)
|
||||
|
||||
config[CONF_ACTION] = await asyncio.gather(
|
||||
*[async_validate_action_config(hass, action) for action in config[CONF_ACTION]]
|
||||
)
|
||||
config[CONF_ACTION] = await async_validate_actions_config(hass, config[CONF_ACTION])
|
||||
|
||||
return config
|
||||
|
||||
|
@@ -86,7 +86,7 @@ SUPPORT_CAST = (
|
||||
|
||||
|
||||
ENTITY_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_HOST, invalidation_version="0.116"),
|
||||
cv.deprecated(CONF_HOST),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_HOST, "device_identifier"): cv.string,
|
||||
@@ -97,7 +97,7 @@ ENTITY_SCHEMA = vol.All(
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_HOST, invalidation_version="0.116"),
|
||||
cv.deprecated(CONF_HOST),
|
||||
PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Exclusive(CONF_HOST, "device_identifier"): cv.string,
|
||||
|
@@ -155,18 +155,16 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _watch_area(self, area, changeset):
|
||||
if not changeset.get("log_event"):
|
||||
last_log = changeset.get("last_log")
|
||||
if not last_log:
|
||||
return
|
||||
# user_number only set for arm/disarm logs
|
||||
if not last_log.get("user_number"):
|
||||
return
|
||||
self._changed_by_keypad = None
|
||||
self._changed_by_id = area.log_number
|
||||
self._changed_by = username(self._elk, area.log_number - 1)
|
||||
self._changed_by_time = "%04d-%02d-%02dT%02d:%02d" % (
|
||||
area.log_year,
|
||||
area.log_month,
|
||||
area.log_day,
|
||||
area.log_hour,
|
||||
area.log_minute,
|
||||
)
|
||||
self._changed_by_id = last_log["user_number"]
|
||||
self._changed_by = username(self._elk, self._changed_by_id - 1)
|
||||
self._changed_by_time = last_log["timestamp"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "elkm1",
|
||||
"name": "Elk-M1 Control",
|
||||
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
||||
"requirements": ["elkm1-lib==0.7.19"],
|
||||
"requirements": ["elkm1-lib==0.8.0"],
|
||||
"codeowners": ["@gwww", "@bdraco"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20201001.0"],
|
||||
"requirements": ["home-assistant-frontend==20201001.1"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Offer state listening automation rules."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -25,18 +25,43 @@ CONF_ENTITY_ID = "entity_id"
|
||||
CONF_FROM = "from"
|
||||
CONF_TO = "to"
|
||||
|
||||
TRIGGER_SCHEMA = vol.Schema(
|
||||
BASE_SCHEMA = {
|
||||
vol.Required(CONF_PLATFORM): "state",
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(CONF_FOR): cv.positive_time_period_template,
|
||||
vol.Optional(CONF_ATTRIBUTE): cv.match_all,
|
||||
}
|
||||
|
||||
TRIGGER_STATE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): "state",
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
|
||||
**BASE_SCHEMA,
|
||||
# These are str on purpose. Want to catch YAML conversions
|
||||
vol.Optional(CONF_FROM): vol.Any(str, [str]),
|
||||
vol.Optional(CONF_TO): vol.Any(str, [str]),
|
||||
vol.Optional(CONF_FOR): cv.positive_time_period_template,
|
||||
vol.Optional(CONF_ATTRIBUTE): cv.match_all,
|
||||
}
|
||||
)
|
||||
|
||||
TRIGGER_ATTRIBUTE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**BASE_SCHEMA,
|
||||
vol.Optional(CONF_FROM): cv.match_all,
|
||||
vol.Optional(CONF_TO): cv.match_all,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def TRIGGER_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name
|
||||
"""Validate trigger."""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("Expected a dictionary")
|
||||
|
||||
# We use this approach instead of vol.Any because
|
||||
# this gives better error messages.
|
||||
if CONF_ATTRIBUTE in value:
|
||||
return TRIGGER_ATTRIBUTE_SCHEMA(value)
|
||||
|
||||
return TRIGGER_STATE_SCHEMA(value)
|
||||
|
||||
|
||||
async def async_attach_trigger(
|
||||
hass: HomeAssistant,
|
||||
|
@@ -240,6 +240,7 @@ class Recorder(threading.Thread):
|
||||
self._timechanges_seen = 0
|
||||
self._keepalive_count = 0
|
||||
self._old_states = {}
|
||||
self._pending_expunge = []
|
||||
self.event_session = None
|
||||
self.get_session = None
|
||||
self._completed_database_setup = False
|
||||
@@ -403,6 +404,7 @@ class Recorder(threading.Thread):
|
||||
self.event_session.add(dbstate)
|
||||
if has_new_state:
|
||||
self._old_states[dbstate.entity_id] = dbstate
|
||||
self._pending_expunge.append(dbstate)
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.warning(
|
||||
"State is not JSON serializable: %s",
|
||||
@@ -488,6 +490,12 @@ class Recorder(threading.Thread):
|
||||
|
||||
def _commit_event_session(self):
|
||||
try:
|
||||
self.event_session.flush()
|
||||
for dbstate in self._pending_expunge:
|
||||
# Expunge the state so its not expired
|
||||
# until we use it later for dbstate.old_state
|
||||
self.event_session.expunge(dbstate)
|
||||
self._pending_expunge = []
|
||||
self.event_session.commit()
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error executing query: %s", err)
|
||||
@@ -573,9 +581,7 @@ class Recorder(threading.Thread):
|
||||
sqlalchemy_event.listen(self.engine, "connect", setup_recorder_connection)
|
||||
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.get_session = scoped_session(
|
||||
sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||
)
|
||||
self.get_session = scoped_session(sessionmaker(bind=self.engine))
|
||||
|
||||
def _close_connection(self):
|
||||
"""Close the connection."""
|
||||
|
@@ -23,7 +23,12 @@ def temperature_unit(block_info: dict) -> str:
|
||||
def shelly_naming(self, block, entity_type: str):
|
||||
"""Naming for switch and sensors."""
|
||||
|
||||
entity_name = self.wrapper.name
|
||||
if not block:
|
||||
return f"{entity_name} {self.description.name}"
|
||||
|
||||
channels = 0
|
||||
mode = "relays"
|
||||
if "num_outputs" in self.wrapper.device.shelly:
|
||||
channels = self.wrapper.device.shelly["num_outputs"]
|
||||
if (
|
||||
@@ -31,12 +36,21 @@ def shelly_naming(self, block, entity_type: str):
|
||||
and self.wrapper.device.settings["mode"] == "roller"
|
||||
):
|
||||
channels = 1
|
||||
|
||||
entity_name = self.wrapper.name
|
||||
if block.type == "emeter" and "num_emeters" in self.wrapper.device.shelly:
|
||||
channels = self.wrapper.device.shelly["num_emeters"]
|
||||
mode = "emeters"
|
||||
if channels > 1 and block.type != "device":
|
||||
entity_name = self.wrapper.device.settings["relays"][int(block.channel)]["name"]
|
||||
# Shelly EM (SHEM) with firmware v1.8.1 doesn't have "name" key; will be fixed in next firmware release
|
||||
if "name" in self.wrapper.device.settings[mode][int(block.channel)]:
|
||||
entity_name = self.wrapper.device.settings[mode][int(block.channel)]["name"]
|
||||
else:
|
||||
entity_name = None
|
||||
if not entity_name:
|
||||
entity_name = f"{self.wrapper.name} channel {int(block.channel)+1}"
|
||||
if self.wrapper.model == "SHEM-3":
|
||||
base = ord("A")
|
||||
else:
|
||||
base = ord("1")
|
||||
entity_name = f"{self.wrapper.name} channel {chr(int(block.channel)+base)}"
|
||||
|
||||
if entity_type == "switch":
|
||||
return entity_name
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "SmartThings",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/smartthings",
|
||||
"requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.3"],
|
||||
"requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.4"],
|
||||
"dependencies": ["webhook"],
|
||||
"after_dependencies": ["cloud"],
|
||||
"codeowners": ["@andrewsayre"]
|
||||
|
@@ -28,7 +28,7 @@ DEVICES = "devices"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
CONF_OPTIMISTIC = "optimistic"
|
||||
|
@@ -126,7 +126,7 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
port = DEFAULT_PORT
|
||||
|
||||
api = SynologyDSM(host, port, username, password, use_ssl)
|
||||
api = SynologyDSM(host, port, username, password, use_ssl, timeout=30)
|
||||
|
||||
try:
|
||||
serial = await self.hass.async_add_executor_job(
|
||||
|
@@ -11,7 +11,7 @@ from typing import Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
import mutagen
|
||||
from mutagen.id3 import TextFrame as ID3Text
|
||||
from mutagen.id3 import ID3FileType, TextFrame as ID3Text
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
@@ -468,9 +468,14 @@ class SpeechManager:
|
||||
try:
|
||||
tts_file = mutagen.File(data_bytes)
|
||||
if tts_file is not None:
|
||||
tts_file["artist"] = ID3Text(encoding=3, text=artist)
|
||||
tts_file["album"] = ID3Text(encoding=3, text=album)
|
||||
tts_file["title"] = ID3Text(encoding=3, text=message)
|
||||
if isinstance(tts_file, ID3FileType):
|
||||
tts_file["artist"] = ID3Text(encoding=3, text=artist)
|
||||
tts_file["album"] = ID3Text(encoding=3, text=album)
|
||||
tts_file["title"] = ID3Text(encoding=3, text=message)
|
||||
else:
|
||||
tts_file["artist"] = artist
|
||||
tts_file["album"] = album
|
||||
tts_file["title"] = message
|
||||
tts_file.save(data_bytes)
|
||||
except mutagen.MutagenError as err:
|
||||
_LOGGER.error("ID3 tag error: %s", err)
|
||||
|
@@ -56,7 +56,9 @@ async def async_discover_and_construct(
|
||||
filtered = [di for di in discovery_infos if di[DISCOVERY_ST] == st]
|
||||
if not filtered:
|
||||
_LOGGER.warning(
|
||||
'Wanted UPnP/IGD device with UDN "%s" not found, aborting', udn
|
||||
'Wanted UPnP/IGD device with UDN/ST "%s"/"%s" not found, aborting',
|
||||
udn,
|
||||
st,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -104,7 +106,7 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
|
||||
"""Set up UPnP/IGD device from a config entry."""
|
||||
_LOGGER.debug("async_setup_entry, config_entry: %s", config_entry.data)
|
||||
|
||||
# discover and construct
|
||||
# Discover and construct.
|
||||
udn = config_entry.data.get(CONFIG_ENTRY_UDN)
|
||||
st = config_entry.data.get(CONFIG_ENTRY_ST) # pylint: disable=invalid-name
|
||||
try:
|
||||
@@ -116,11 +118,11 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
|
||||
_LOGGER.info("Unable to create UPnP/IGD, aborting")
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
# Save device
|
||||
# Save device.
|
||||
hass.data[DOMAIN][DOMAIN_DEVICES][device.udn] = device
|
||||
|
||||
# Ensure entry has proper unique_id.
|
||||
if config_entry.unique_id != device.unique_id:
|
||||
# Ensure entry has a unique_id.
|
||||
if not config_entry.unique_id:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry=config_entry,
|
||||
unique_id=device.unique_id,
|
||||
|
@@ -104,19 +104,10 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""
|
||||
_LOGGER.debug("async_step_import: import_info: %s", import_info)
|
||||
|
||||
if import_info is None:
|
||||
# Landed here via configuration.yaml entry.
|
||||
# Any device already added, then abort.
|
||||
if self._async_current_entries():
|
||||
_LOGGER.debug("aborting, already configured")
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test if import_info isn't already configured.
|
||||
if import_info is not None and any(
|
||||
import_info["udn"] == entry.data[CONFIG_ENTRY_UDN]
|
||||
and import_info["st"] == entry.data[CONFIG_ENTRY_ST]
|
||||
for entry in self._async_current_entries()
|
||||
):
|
||||
# Landed here via configuration.yaml entry.
|
||||
# Any device already added, then abort.
|
||||
if self._async_current_entries():
|
||||
_LOGGER.debug("Already configured, aborting")
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Discover devices.
|
||||
@@ -127,8 +118,17 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.info("No UPnP devices discovered, aborting")
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
discovery = self._discoveries[0]
|
||||
return await self._async_create_entry_from_discovery(discovery)
|
||||
# Ensure complete discovery.
|
||||
discovery_info = self._discoveries[0]
|
||||
if DISCOVERY_USN not in discovery_info:
|
||||
_LOGGER.debug("Incomplete discovery, ignoring")
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
# Ensure not already configuring/configured.
|
||||
usn = discovery_info[DISCOVERY_USN]
|
||||
await self.async_set_unique_id(usn)
|
||||
|
||||
return await self._async_create_entry_from_discovery(discovery_info)
|
||||
|
||||
async def async_step_ssdp(self, discovery_info: Mapping):
|
||||
"""Handle a discovered UPnP/IGD device.
|
||||
@@ -191,7 +191,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
):
|
||||
"""Create an entry from discovery."""
|
||||
_LOGGER.debug(
|
||||
"_async_create_entry_from_data: discovery: %s",
|
||||
"_async_create_entry_from_discovery: discovery: %s",
|
||||
discovery,
|
||||
)
|
||||
# Get name from device, if not found already.
|
||||
|
@@ -9,7 +9,7 @@
|
||||
"zha-quirks==0.0.45",
|
||||
"zigpy-cc==0.5.2",
|
||||
"zigpy-deconz==0.10.0",
|
||||
"zigpy==0.25.0",
|
||||
"zigpy==0.26.0",
|
||||
"zigpy-xbee==0.13.0",
|
||||
"zigpy-zigate==0.6.2",
|
||||
"zigpy-znp==0.2.1"
|
||||
|
@@ -2,169 +2,97 @@
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
import homeassistant.config_entries as config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ID,
|
||||
ATTR_NAME,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_SOURCE,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from . import const
|
||||
from .common import (
|
||||
ClientAvailabilityResult,
|
||||
async_test_client_availability,
|
||||
create_client_from_config,
|
||||
del_client_from_data,
|
||||
get_client_from_data,
|
||||
is_client_in_data,
|
||||
set_client_to_data,
|
||||
set_platform_configs,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORM_DOMAINS = tuple(
|
||||
[BINARY_SENSOR_DOMAIN, CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
|
||||
)
|
||||
|
||||
CONF_PATH_ZMS = "path_zms"
|
||||
|
||||
DEFAULT_PATH = "/zm/"
|
||||
DEFAULT_PATH_ZMS = "/zm/cgi-bin/nph-zms"
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_TIMEOUT = 10
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
DOMAIN = "zoneminder"
|
||||
|
||||
HOST_CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_PATH, default=const.DEFAULT_PATH): cv.string,
|
||||
vol.Optional(const.CONF_PATH_ZMS, default=const.DEFAULT_PATH_ZMS): cv.string,
|
||||
vol.Optional(CONF_SSL, default=const.DEFAULT_SSL): cv.boolean,
|
||||
vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
|
||||
vol.Optional(CONF_PATH_ZMS, default=DEFAULT_PATH_ZMS): cv.string,
|
||||
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=const.DEFAULT_VERIFY_SSL): cv.boolean,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
cv.deprecated(const.DOMAIN, invalidation_version="0.118"),
|
||||
vol.Schema(
|
||||
{const.DOMAIN: vol.All(cv.ensure_list, [HOST_CONFIG_SCHEMA])},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{DOMAIN: vol.All(cv.ensure_list, [HOST_CONFIG_SCHEMA])}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
SERVICE_SET_RUN_STATE = "set_run_state"
|
||||
SET_RUN_STATE_SCHEMA = vol.Schema(
|
||||
{vol.Required(ATTR_ID): cv.string, vol.Required(ATTR_NAME): cv.string}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, base_config: dict):
|
||||
def setup(hass, config):
|
||||
"""Set up the ZoneMinder component."""
|
||||
|
||||
# Collect the platform specific configs. It's necessary to collect these configs
|
||||
# here instead of the platform's setup_platform function because the invocation order
|
||||
# of setup_platform and async_setup_entry is not consistent.
|
||||
set_platform_configs(
|
||||
hass,
|
||||
SENSOR_DOMAIN,
|
||||
[
|
||||
platform_config
|
||||
for platform_config in base_config.get(SENSOR_DOMAIN, [])
|
||||
if platform_config[CONF_PLATFORM] == const.DOMAIN
|
||||
],
|
||||
)
|
||||
set_platform_configs(
|
||||
hass,
|
||||
SWITCH_DOMAIN,
|
||||
[
|
||||
platform_config
|
||||
for platform_config in base_config.get(SWITCH_DOMAIN, [])
|
||||
if platform_config[CONF_PLATFORM] == const.DOMAIN
|
||||
],
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
success = True
|
||||
|
||||
for conf in config[DOMAIN]:
|
||||
protocol = "https" if conf[CONF_SSL] else "http"
|
||||
|
||||
host_name = conf[CONF_HOST]
|
||||
server_origin = f"{protocol}://{host_name}"
|
||||
zm_client = ZoneMinder(
|
||||
server_origin,
|
||||
conf.get(CONF_USERNAME),
|
||||
conf.get(CONF_PASSWORD),
|
||||
conf.get(CONF_PATH),
|
||||
conf.get(CONF_PATH_ZMS),
|
||||
conf.get(CONF_VERIFY_SSL),
|
||||
)
|
||||
hass.data[DOMAIN][host_name] = zm_client
|
||||
|
||||
success = zm_client.login() and success
|
||||
|
||||
def set_active_state(call):
|
||||
"""Set the ZoneMinder run state to the given state name."""
|
||||
zm_id = call.data[ATTR_ID]
|
||||
state_name = call.data[ATTR_NAME]
|
||||
if zm_id not in hass.data[DOMAIN]:
|
||||
_LOGGER.error("Invalid ZoneMinder host provided: %s", zm_id)
|
||||
if not hass.data[DOMAIN][zm_id].set_active_state(state_name):
|
||||
_LOGGER.error(
|
||||
"Unable to change ZoneMinder state. Host: %s, state: %s",
|
||||
zm_id,
|
||||
state_name,
|
||||
)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SET_RUN_STATE, set_active_state, schema=SET_RUN_STATE_SCHEMA
|
||||
)
|
||||
|
||||
config = base_config.get(const.DOMAIN)
|
||||
hass.async_create_task(
|
||||
async_load_platform(hass, "binary_sensor", DOMAIN, {}, config)
|
||||
)
|
||||
|
||||
if not config:
|
||||
return True
|
||||
|
||||
for config_item in config:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
const.DOMAIN,
|
||||
context={CONF_SOURCE: config_entries.SOURCE_IMPORT},
|
||||
data=config_item,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Set up Zoneminder config entry."""
|
||||
zm_client = create_client_from_config(config_entry.data)
|
||||
|
||||
result = await async_test_client_availability(hass, zm_client)
|
||||
if result != ClientAvailabilityResult.AVAILABLE:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
set_client_to_data(hass, config_entry.unique_id, zm_client)
|
||||
|
||||
for platform_domain in PLATFORM_DOMAINS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(config_entry, platform_domain)
|
||||
)
|
||||
|
||||
if not hass.services.has_service(const.DOMAIN, const.SERVICE_SET_RUN_STATE):
|
||||
|
||||
@callback
|
||||
def set_active_state(call):
|
||||
"""Set the ZoneMinder run state to the given state name."""
|
||||
zm_id = call.data[ATTR_ID]
|
||||
state_name = call.data[ATTR_NAME]
|
||||
if not is_client_in_data(hass, zm_id):
|
||||
_LOGGER.error("Invalid ZoneMinder host provided: %s", zm_id)
|
||||
return
|
||||
|
||||
if not get_client_from_data(hass, zm_id).set_active_state(state_name):
|
||||
_LOGGER.error(
|
||||
"Unable to change ZoneMinder state. Host: %s, state: %s",
|
||||
zm_id,
|
||||
state_name,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
const.DOMAIN,
|
||||
const.SERVICE_SET_RUN_STATE,
|
||||
set_active_state,
|
||||
schema=SET_RUN_STATE_SCHEMA,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload Zoneminder config entry."""
|
||||
for platform_domain in PLATFORM_DOMAINS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_unload(
|
||||
config_entry, platform_domain
|
||||
)
|
||||
)
|
||||
|
||||
# If this is the last config to exist, remove the service too.
|
||||
if len(hass.config_entries.async_entries(const.DOMAIN)) <= 1:
|
||||
hass.services.async_remove(const.DOMAIN, const.SERVICE_SET_RUN_STATE)
|
||||
|
||||
del_client_from_data(hass, config_entry.unique_id)
|
||||
|
||||
return True
|
||||
return success
|
||||
|
@@ -1,43 +1,29 @@
|
||||
"""Support for ZoneMinder binary sensors."""
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .common import get_client_from_data
|
||||
from . import DOMAIN as ZONEMINDER_DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: Callable[[List[Entity], Optional[bool]], None],
|
||||
) -> None:
|
||||
"""Set up the sensor config entry."""
|
||||
zm_client = get_client_from_data(hass, config_entry.unique_id)
|
||||
async_add_entities([ZMAvailabilitySensor(zm_client, config_entry)])
|
||||
async def async_setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the ZoneMinder binary sensor platform."""
|
||||
sensors = []
|
||||
for host_name, zm_client in hass.data[ZONEMINDER_DOMAIN].items():
|
||||
sensors.append(ZMAvailabilitySensor(host_name, zm_client))
|
||||
add_entities(sensors)
|
||||
return True
|
||||
|
||||
|
||||
class ZMAvailabilitySensor(BinarySensorEntity):
|
||||
"""Representation of the availability of ZoneMinder as a binary sensor."""
|
||||
|
||||
def __init__(self, client: ZoneMinder, config_entry: ConfigEntry):
|
||||
def __init__(self, host_name, client):
|
||||
"""Initialize availability sensor."""
|
||||
self._state = None
|
||||
self._name = config_entry.unique_id
|
||||
self._name = host_name
|
||||
self._client = client
|
||||
self._config_entry = config_entry
|
||||
|
||||
@property
|
||||
def unique_id(self) -> Optional[str]:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._config_entry.unique_id}_availability"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@@ -1,8 +1,5 @@
|
||||
"""Support for ZoneMinder camera streaming."""
|
||||
import logging
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from zoneminder.monitor import Monitor
|
||||
|
||||
from homeassistant.components.mjpeg.camera import (
|
||||
CONF_MJPEG_URL,
|
||||
@@ -10,12 +7,9 @@ from homeassistant.components.mjpeg.camera import (
|
||||
MjpegCamera,
|
||||
filter_urllib3_logging,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .common import get_client_from_data
|
||||
from . import DOMAIN as ZONEMINDER_DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,28 +17,23 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the ZoneMinder cameras."""
|
||||
filter_urllib3_logging()
|
||||
cameras = []
|
||||
for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
|
||||
monitors = zm_client.get_monitors()
|
||||
if not monitors:
|
||||
_LOGGER.warning("Could not fetch monitors from ZoneMinder host: %s")
|
||||
return
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: Callable[[List[Entity], Optional[bool]], None],
|
||||
) -> None:
|
||||
"""Set up the sensor config entry."""
|
||||
zm_client = get_client_from_data(hass, config_entry.unique_id)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
ZoneMinderCamera(monitor, zm_client.verify_ssl, config_entry)
|
||||
for monitor in await hass.async_add_job(zm_client.get_monitors)
|
||||
]
|
||||
)
|
||||
for monitor in monitors:
|
||||
_LOGGER.info("Initializing camera %s", monitor.id)
|
||||
cameras.append(ZoneMinderCamera(monitor, zm_client.verify_ssl))
|
||||
add_entities(cameras)
|
||||
|
||||
|
||||
class ZoneMinderCamera(MjpegCamera):
|
||||
"""Representation of a ZoneMinder Monitor Stream."""
|
||||
|
||||
def __init__(self, monitor: Monitor, verify_ssl: bool, config_entry: ConfigEntry):
|
||||
def __init__(self, monitor, verify_ssl):
|
||||
"""Initialize as a subclass of MjpegCamera."""
|
||||
device_info = {
|
||||
CONF_NAME: monitor.name,
|
||||
@@ -56,12 +45,6 @@ class ZoneMinderCamera(MjpegCamera):
|
||||
self._is_recording = None
|
||||
self._is_available = None
|
||||
self._monitor = monitor
|
||||
self._config_entry = config_entry
|
||||
|
||||
@property
|
||||
def unique_id(self) -> Optional[str]:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._config_entry.unique_id}_{self._monitor.id}_camera"
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
|
@@ -1,110 +0,0 @@
|
||||
"""Common code for the ZoneMinder component."""
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import const
|
||||
|
||||
|
||||
def prime_domain_data(hass: HomeAssistant) -> None:
|
||||
"""Prime the data structures."""
|
||||
hass.data.setdefault(const.DOMAIN, {})
|
||||
|
||||
|
||||
def prime_platform_configs(hass: HomeAssistant, domain: str) -> None:
|
||||
"""Prime the data structures."""
|
||||
prime_domain_data(hass)
|
||||
hass.data[const.DOMAIN].setdefault(const.PLATFORM_CONFIGS, {})
|
||||
hass.data[const.DOMAIN][const.PLATFORM_CONFIGS].setdefault(domain, [])
|
||||
|
||||
|
||||
def set_platform_configs(hass: HomeAssistant, domain: str, configs: List[dict]) -> None:
|
||||
"""Set platform configs."""
|
||||
prime_platform_configs(hass, domain)
|
||||
hass.data[const.DOMAIN][const.PLATFORM_CONFIGS][domain] = configs
|
||||
|
||||
|
||||
def get_platform_configs(hass: HomeAssistant, domain: str) -> List[dict]:
|
||||
"""Get platform configs."""
|
||||
prime_platform_configs(hass, domain)
|
||||
return hass.data[const.DOMAIN][const.PLATFORM_CONFIGS][domain]
|
||||
|
||||
|
||||
def prime_config_data(hass: HomeAssistant, unique_id: str) -> None:
|
||||
"""Prime the data structures."""
|
||||
prime_domain_data(hass)
|
||||
hass.data[const.DOMAIN].setdefault(const.CONFIG_DATA, {})
|
||||
hass.data[const.DOMAIN][const.CONFIG_DATA].setdefault(unique_id, {})
|
||||
|
||||
|
||||
def set_client_to_data(hass: HomeAssistant, unique_id: str, client: ZoneMinder) -> None:
|
||||
"""Put a ZoneMinder client in the Home Assistant data."""
|
||||
prime_config_data(hass, unique_id)
|
||||
hass.data[const.DOMAIN][const.CONFIG_DATA][unique_id][const.API_CLIENT] = client
|
||||
|
||||
|
||||
def is_client_in_data(hass: HomeAssistant, unique_id: str) -> bool:
|
||||
"""Check if ZoneMinder client is in the Home Assistant data."""
|
||||
prime_config_data(hass, unique_id)
|
||||
return const.API_CLIENT in hass.data[const.DOMAIN][const.CONFIG_DATA][unique_id]
|
||||
|
||||
|
||||
def get_client_from_data(hass: HomeAssistant, unique_id: str) -> ZoneMinder:
|
||||
"""Get a ZoneMinder client from the Home Assistant data."""
|
||||
prime_config_data(hass, unique_id)
|
||||
return hass.data[const.DOMAIN][const.CONFIG_DATA][unique_id][const.API_CLIENT]
|
||||
|
||||
|
||||
def del_client_from_data(hass: HomeAssistant, unique_id: str) -> None:
|
||||
"""Delete a ZoneMinder client from the Home Assistant data."""
|
||||
prime_config_data(hass, unique_id)
|
||||
del hass.data[const.DOMAIN][const.CONFIG_DATA][unique_id][const.API_CLIENT]
|
||||
|
||||
|
||||
def create_client_from_config(conf: dict) -> ZoneMinder:
|
||||
"""Create a new ZoneMinder client from a config."""
|
||||
protocol = "https" if conf[CONF_SSL] else "http"
|
||||
|
||||
host_name = conf[CONF_HOST]
|
||||
server_origin = f"{protocol}://{host_name}"
|
||||
|
||||
return ZoneMinder(
|
||||
server_origin,
|
||||
conf.get(CONF_USERNAME),
|
||||
conf.get(CONF_PASSWORD),
|
||||
conf.get(CONF_PATH),
|
||||
conf.get(const.CONF_PATH_ZMS),
|
||||
conf.get(CONF_VERIFY_SSL),
|
||||
)
|
||||
|
||||
|
||||
class ClientAvailabilityResult(Enum):
|
||||
"""Client availability test result."""
|
||||
|
||||
AVAILABLE = "available"
|
||||
ERROR_AUTH_FAIL = "auth_fail"
|
||||
ERROR_CONNECTION_ERROR = "connection_error"
|
||||
|
||||
|
||||
async def async_test_client_availability(
|
||||
hass: HomeAssistant, client: ZoneMinder
|
||||
) -> ClientAvailabilityResult:
|
||||
"""Test the availability of a ZoneMinder client."""
|
||||
try:
|
||||
if await hass.async_add_job(client.login):
|
||||
return ClientAvailabilityResult.AVAILABLE
|
||||
return ClientAvailabilityResult.ERROR_AUTH_FAIL
|
||||
except requests.exceptions.ConnectionError:
|
||||
return ClientAvailabilityResult.ERROR_CONNECTION_ERROR
|
@@ -1,99 +0,0 @@
|
||||
"""ZoneMinder config flow."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_SOURCE,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
ClientAvailabilityResult,
|
||||
async_test_client_availability,
|
||||
create_client_from_config,
|
||||
)
|
||||
from .const import (
|
||||
CONF_PATH_ZMS,
|
||||
DEFAULT_PATH,
|
||||
DEFAULT_PATH_ZMS,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
)
|
||||
from .const import DOMAIN # pylint: disable=unused-import
|
||||
|
||||
|
||||
class ZoneminderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Flow handler for zoneminder integration."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
async def async_step_import(self, config: dict):
|
||||
"""Handle a flow initialized by import."""
|
||||
return await self.async_step_finish(
|
||||
{**config, **{CONF_SOURCE: config_entries.SOURCE_IMPORT}}
|
||||
)
|
||||
|
||||
async def async_step_user(self, user_input: dict = None):
|
||||
"""Handle user step."""
|
||||
user_input = user_input or {}
|
||||
errors = {}
|
||||
|
||||
if user_input:
|
||||
zm_client = create_client_from_config(user_input)
|
||||
result = await async_test_client_availability(self.hass, zm_client)
|
||||
if result == ClientAvailabilityResult.AVAILABLE:
|
||||
return await self.async_step_finish(user_input)
|
||||
|
||||
errors["base"] = result.value
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=config_entries.SOURCE_USER,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PATH, default=user_input.get(CONF_PATH, DEFAULT_PATH)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PATH_ZMS,
|
||||
default=user_input.get(CONF_PATH_ZMS, DEFAULT_PATH_ZMS),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_SSL, default=user_input.get(CONF_SSL, DEFAULT_SSL)
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL,
|
||||
default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_finish(self, config: dict):
|
||||
"""Finish config flow."""
|
||||
zm_client = create_client_from_config(config)
|
||||
hostname = urlparse(zm_client.get_zms_url()).hostname
|
||||
result = await async_test_client_availability(self.hass, zm_client)
|
||||
|
||||
if result != ClientAvailabilityResult.AVAILABLE:
|
||||
return self.async_abort(reason=str(result.value))
|
||||
|
||||
await self.async_set_unique_id(hostname)
|
||||
self._abort_if_unique_id_configured(config)
|
||||
|
||||
return self.async_create_entry(title=hostname, data=config)
|
@@ -1,14 +0,0 @@
|
||||
"""Constants for zoneminder component."""
|
||||
|
||||
CONF_PATH_ZMS = "path_zms"
|
||||
|
||||
DEFAULT_PATH = "/zm/"
|
||||
DEFAULT_PATH_ZMS = "/zm/cgi-bin/nph-zms"
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
DOMAIN = "zoneminder"
|
||||
SERVICE_SET_RUN_STATE = "set_run_state"
|
||||
|
||||
PLATFORM_CONFIGS = "platform_configs"
|
||||
CONFIG_DATA = "config_data"
|
||||
API_CLIENT = "api_client"
|
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"domain": "zoneminder",
|
||||
"name": "ZoneMinder",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zoneminder",
|
||||
"requirements": ["zm-py==0.4.0"],
|
||||
"codeowners": ["@rohankapoorcom", "@vangorra"]
|
||||
"codeowners": ["@rohankapoorcom"]
|
||||
}
|
||||
|
@@ -1,19 +1,15 @@
|
||||
"""Support for ZoneMinder sensors."""
|
||||
import logging
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from zoneminder.monitor import Monitor, TimePeriod
|
||||
from zoneminder.zm import ZoneMinder
|
||||
from zoneminder.monitor import TimePeriod
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .common import get_client_from_data, get_platform_configs
|
||||
from . import DOMAIN as ZONEMINDER_DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,50 +37,35 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: Callable[[List[Entity], Optional[bool]], None],
|
||||
) -> None:
|
||||
"""Set up the sensor config entry."""
|
||||
zm_client = get_client_from_data(hass, config_entry.unique_id)
|
||||
monitors = await hass.async_add_job(zm_client.get_monitors)
|
||||
|
||||
if not monitors:
|
||||
_LOGGER.warning("Did not fetch any monitors from ZoneMinder")
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the ZoneMinder sensor platform."""
|
||||
include_archived = config.get(CONF_INCLUDE_ARCHIVED)
|
||||
|
||||
sensors = []
|
||||
for monitor in monitors:
|
||||
sensors.append(ZMSensorMonitors(monitor, config_entry))
|
||||
for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
|
||||
monitors = zm_client.get_monitors()
|
||||
if not monitors:
|
||||
_LOGGER.warning("Could not fetch any monitors from ZoneMinder")
|
||||
|
||||
for config in get_platform_configs(hass, SENSOR_DOMAIN):
|
||||
include_archived = config.get(CONF_INCLUDE_ARCHIVED)
|
||||
for monitor in monitors:
|
||||
sensors.append(ZMSensorMonitors(monitor))
|
||||
|
||||
for sensor in config[CONF_MONITORED_CONDITIONS]:
|
||||
sensors.append(
|
||||
ZMSensorEvents(monitor, include_archived, sensor, config_entry)
|
||||
)
|
||||
sensors.append(ZMSensorEvents(monitor, include_archived, sensor))
|
||||
|
||||
sensors.append(ZMSensorRunState(zm_client, config_entry))
|
||||
|
||||
async_add_entities(sensors, True)
|
||||
sensors.append(ZMSensorRunState(zm_client))
|
||||
add_entities(sensors)
|
||||
|
||||
|
||||
class ZMSensorMonitors(Entity):
|
||||
"""Get the status of each ZoneMinder monitor."""
|
||||
|
||||
def __init__(self, monitor: Monitor, config_entry: ConfigEntry):
|
||||
def __init__(self, monitor):
|
||||
"""Initialize monitor sensor."""
|
||||
self._monitor = monitor
|
||||
self._config_entry = config_entry
|
||||
self._state = None
|
||||
self._is_available = None
|
||||
|
||||
@property
|
||||
def unique_id(self) -> Optional[str]:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._config_entry.unique_id}_{self._monitor.id}_status"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
@@ -113,26 +94,14 @@ class ZMSensorMonitors(Entity):
|
||||
class ZMSensorEvents(Entity):
|
||||
"""Get the number of events for each monitor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
monitor: Monitor,
|
||||
include_archived: bool,
|
||||
sensor_type: str,
|
||||
config_entry: ConfigEntry,
|
||||
):
|
||||
def __init__(self, monitor, include_archived, sensor_type):
|
||||
"""Initialize event sensor."""
|
||||
|
||||
self._monitor = monitor
|
||||
self._include_archived = include_archived
|
||||
self.time_period = TimePeriod.get_time_period(sensor_type)
|
||||
self._config_entry = config_entry
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def unique_id(self) -> Optional[str]:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._config_entry.unique_id}_{self._monitor.id}_{self.time_period.value}_{self._include_archived}_events"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
@@ -156,17 +125,11 @@ class ZMSensorEvents(Entity):
|
||||
class ZMSensorRunState(Entity):
|
||||
"""Get the ZoneMinder run state."""
|
||||
|
||||
def __init__(self, client: ZoneMinder, config_entry: ConfigEntry):
|
||||
def __init__(self, client):
|
||||
"""Initialize run state sensor."""
|
||||
self._state = None
|
||||
self._is_available = None
|
||||
self._client = client
|
||||
self._config_entry = config_entry
|
||||
|
||||
@property
|
||||
def unique_id(self) -> Optional[str]:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._config_entry.unique_id}_runstate"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@@ -1,9 +1,6 @@
|
||||
set_run_state:
|
||||
description: "Set the ZoneMinder run state"
|
||||
description: Set the ZoneMinder run state
|
||||
fields:
|
||||
id:
|
||||
description: "The host name or IP address of the ZoneMinder instance."
|
||||
example: "10.10.0.2"
|
||||
name:
|
||||
description: "The string name of the ZoneMinder run state to set as active."
|
||||
description: The string name of the ZoneMinder run state to set as active.
|
||||
example: "Home"
|
||||
|
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "ZoneMinder",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add ZoneMinder Server.",
|
||||
"data": {
|
||||
"host": "Host and Port (ex 10.10.0.4:8010)",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"path": "ZM Path",
|
||||
"path_zms": "ZMS Path",
|
||||
"ssl": "Use SSL for connections to ZoneMinder",
|
||||
"verify_ssl": "Verify SSL Certificate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"auth_fail": "Username or password is incorrect.",
|
||||
"connection_error": "Failed to connect to a ZoneMinder server."
|
||||
},
|
||||
"error": {
|
||||
"auth_fail": "Username or password is incorrect.",
|
||||
"connection_error": "Failed to connect to a ZoneMinder server."
|
||||
},
|
||||
"create_entry": { "default": "ZoneMinder server added." }
|
||||
}
|
||||
}
|
@@ -1,61 +1,41 @@
|
||||
"""Support for ZoneMinder switches."""
|
||||
import logging
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from zoneminder.monitor import Monitor, MonitorState
|
||||
from zoneminder.monitor import MonitorState
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
PLATFORM_SCHEMA,
|
||||
SwitchEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity
|
||||
from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .common import get_client_from_data, get_platform_configs
|
||||
from . import DOMAIN as ZONEMINDER_DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MONITOR_STATES = {
|
||||
MonitorState[name].value: MonitorState[name]
|
||||
for name in dir(MonitorState)
|
||||
if not name.startswith("_")
|
||||
}
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_COMMAND_ON): vol.All(vol.In(MONITOR_STATES.keys())),
|
||||
vol.Required(CONF_COMMAND_OFF): vol.All(vol.In(MONITOR_STATES.keys())),
|
||||
vol.Required(CONF_COMMAND_ON): cv.string,
|
||||
vol.Required(CONF_COMMAND_OFF): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: Callable[[List[Entity], Optional[bool]], None],
|
||||
) -> None:
|
||||
"""Set up the sensor config entry."""
|
||||
zm_client = get_client_from_data(hass, config_entry.unique_id)
|
||||
monitors = await hass.async_add_job(zm_client.get_monitors)
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the ZoneMinder switch platform."""
|
||||
|
||||
if not monitors:
|
||||
_LOGGER.warning("Could not fetch monitors from ZoneMinder")
|
||||
return
|
||||
on_state = MonitorState(config.get(CONF_COMMAND_ON))
|
||||
off_state = MonitorState(config.get(CONF_COMMAND_OFF))
|
||||
|
||||
switches = []
|
||||
for monitor in monitors:
|
||||
for config in get_platform_configs(hass, SWITCH_DOMAIN):
|
||||
on_state = MONITOR_STATES[config[CONF_COMMAND_ON]]
|
||||
off_state = MONITOR_STATES[config[CONF_COMMAND_OFF]]
|
||||
for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
|
||||
monitors = zm_client.get_monitors()
|
||||
if not monitors:
|
||||
_LOGGER.warning("Could not fetch monitors from ZoneMinder")
|
||||
return
|
||||
|
||||
switches.append(
|
||||
ZMSwitchMonitors(monitor, on_state, off_state, config_entry)
|
||||
)
|
||||
|
||||
async_add_entities(switches, True)
|
||||
for monitor in monitors:
|
||||
switches.append(ZMSwitchMonitors(monitor, on_state, off_state))
|
||||
add_entities(switches)
|
||||
|
||||
|
||||
class ZMSwitchMonitors(SwitchEntity):
|
||||
@@ -63,25 +43,13 @@ class ZMSwitchMonitors(SwitchEntity):
|
||||
|
||||
icon = "mdi:record-rec"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
monitor: Monitor,
|
||||
on_state: MonitorState,
|
||||
off_state: MonitorState,
|
||||
config_entry: ConfigEntry,
|
||||
):
|
||||
def __init__(self, monitor, on_state, off_state):
|
||||
"""Initialize the switch."""
|
||||
self._monitor = monitor
|
||||
self._on_state = on_state
|
||||
self._off_state = off_state
|
||||
self._config_entry = config_entry
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def unique_id(self) -> Optional[str]:
|
||||
"""Return a unique ID."""
|
||||
return f"{self._config_entry.unique_id}_{self._monitor.id}_switch_{self._on_state.value}_{self._off_state.value}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
|
@@ -488,7 +488,6 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non
|
||||
CONF_UNIT_SYSTEM,
|
||||
CONF_EXTERNAL_URL,
|
||||
CONF_INTERNAL_URL,
|
||||
CONF_MEDIA_DIRS,
|
||||
]
|
||||
):
|
||||
hac.config_source = SOURCE_YAML
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"""Constants used by Home Assistant components."""
|
||||
MAJOR_VERSION = 0
|
||||
MINOR_VERSION = 116
|
||||
PATCH_VERSION = "0b2"
|
||||
PATCH_VERSION = "0"
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER = (3, 7, 1)
|
||||
# Truthy date string triggers showing related deprecation warning messages.
|
||||
REQUIRED_NEXT_PYTHON_VER = (3, 8, 0)
|
||||
REQUIRED_NEXT_PYTHON_DATE = ""
|
||||
REQUIRED_NEXT_PYTHON_DATE = "December 7, 2020"
|
||||
|
||||
# Format for platform files
|
||||
PLATFORM_FORMAT = "{platform}.{domain}"
|
||||
|
@@ -217,6 +217,5 @@ FLOWS = [
|
||||
"yeelight",
|
||||
"zerproc",
|
||||
"zha",
|
||||
"zoneminder",
|
||||
"zwave"
|
||||
]
|
||||
|
@@ -297,7 +297,7 @@ def async_numeric_state_from_config(
|
||||
def state(
|
||||
hass: HomeAssistant,
|
||||
entity: Union[None, str, State],
|
||||
req_state: Union[str, List[str]],
|
||||
req_state: Any,
|
||||
for_period: Optional[timedelta] = None,
|
||||
attribute: Optional[str] = None,
|
||||
) -> bool:
|
||||
@@ -314,17 +314,20 @@ def state(
|
||||
assert isinstance(entity, State)
|
||||
|
||||
if attribute is None:
|
||||
value = entity.state
|
||||
value: Any = entity.state
|
||||
else:
|
||||
value = str(entity.attributes.get(attribute))
|
||||
value = entity.attributes.get(attribute)
|
||||
|
||||
if isinstance(req_state, str):
|
||||
if not isinstance(req_state, list):
|
||||
req_state = [req_state]
|
||||
|
||||
is_state = False
|
||||
for req_state_value in req_state:
|
||||
state_value = req_state_value
|
||||
if INPUT_ENTITY_ID.match(req_state_value) is not None:
|
||||
if (
|
||||
isinstance(req_state_value, str)
|
||||
and INPUT_ENTITY_ID.match(req_state_value) is not None
|
||||
):
|
||||
state_entity = hass.states.get(req_state_value)
|
||||
if not state_entity:
|
||||
continue
|
||||
|
@@ -929,22 +929,44 @@ NUMERIC_STATE_CONDITION_SCHEMA = vol.All(
|
||||
has_at_least_one_key(CONF_BELOW, CONF_ABOVE),
|
||||
)
|
||||
|
||||
STATE_CONDITION_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CONDITION): "state",
|
||||
vol.Required(CONF_ENTITY_ID): entity_ids,
|
||||
vol.Optional(CONF_ATTRIBUTE): str,
|
||||
vol.Required(CONF_STATE): vol.Any(str, [str]),
|
||||
vol.Optional(CONF_FOR): positive_time_period,
|
||||
# To support use_trigger_value in automation
|
||||
# Deprecated 2016/04/25
|
||||
vol.Optional("from"): str,
|
||||
}
|
||||
),
|
||||
key_dependency("for", "state"),
|
||||
STATE_CONDITION_BASE_SCHEMA = {
|
||||
vol.Required(CONF_CONDITION): "state",
|
||||
vol.Required(CONF_ENTITY_ID): entity_ids,
|
||||
vol.Optional(CONF_ATTRIBUTE): str,
|
||||
vol.Optional(CONF_FOR): positive_time_period,
|
||||
# To support use_trigger_value in automation
|
||||
# Deprecated 2016/04/25
|
||||
vol.Optional("from"): str,
|
||||
}
|
||||
|
||||
STATE_CONDITION_STATE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**STATE_CONDITION_BASE_SCHEMA,
|
||||
vol.Required(CONF_STATE): vol.Any(str, [str]),
|
||||
}
|
||||
)
|
||||
|
||||
STATE_CONDITION_ATTRIBUTE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**STATE_CONDITION_BASE_SCHEMA,
|
||||
vol.Required(CONF_STATE): match_all,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def STATE_CONDITION_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name
|
||||
"""Validate a state condition."""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("Expected a dictionary")
|
||||
|
||||
if CONF_ATTRIBUTE in value:
|
||||
validated: dict = STATE_CONDITION_ATTRIBUTE_SCHEMA(value)
|
||||
else:
|
||||
validated = STATE_CONDITION_STATE_SCHEMA(value)
|
||||
|
||||
return key_dependency("for", "state")(validated)
|
||||
|
||||
|
||||
SUN_CONDITION_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
|
@@ -774,16 +774,65 @@ class _TrackTemplateResultInfo:
|
||||
"""Force recalculate the template."""
|
||||
self._refresh(None)
|
||||
|
||||
@callback
|
||||
def _event_triggers_template(self, template: Template, event: Event) -> bool:
|
||||
"""Determine if a template should be re-rendered from an event."""
|
||||
entity_id = event.data.get(ATTR_ENTITY_ID)
|
||||
return (
|
||||
self._info[template].filter(entity_id)
|
||||
or event.data.get("new_state") is None
|
||||
or event.data.get("old_state") is None
|
||||
and self._info[template].filter_lifecycle(entity_id)
|
||||
)
|
||||
def _render_template_if_ready(
|
||||
self,
|
||||
track_template_: TrackTemplate,
|
||||
now: datetime,
|
||||
event: Optional[Event],
|
||||
) -> Union[bool, TrackTemplateResult]:
|
||||
"""Re-render the template if conditions match.
|
||||
|
||||
Returns False if the template was not be re-rendered
|
||||
|
||||
Returns True if the template re-rendered and did not
|
||||
change.
|
||||
|
||||
Returns TrackTemplateResult if the template re-render
|
||||
generates a new result.
|
||||
"""
|
||||
template = track_template_.template
|
||||
|
||||
if event:
|
||||
info = self._info[template]
|
||||
|
||||
if not self._rate_limit.async_has_timer(
|
||||
template
|
||||
) and not _event_triggers_rerender(event, info):
|
||||
return False
|
||||
|
||||
if self._rate_limit.async_schedule_action(
|
||||
template,
|
||||
_rate_limit_for_event(event, info, track_template_),
|
||||
now,
|
||||
self._refresh,
|
||||
event,
|
||||
):
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Template update %s triggered by event: %s",
|
||||
template.template,
|
||||
event,
|
||||
)
|
||||
|
||||
self._rate_limit.async_triggered(template, now)
|
||||
self._info[template] = template.async_render_to_info(track_template_.variables)
|
||||
|
||||
try:
|
||||
result: Union[str, TemplateError] = self._info[template].result()
|
||||
except TemplateError as ex:
|
||||
result = ex
|
||||
|
||||
last_result = self._last_result.get(template)
|
||||
|
||||
# Check to see if the result has changed
|
||||
if result == last_result:
|
||||
return True
|
||||
|
||||
if isinstance(result, TemplateError) and isinstance(last_result, TemplateError):
|
||||
return True
|
||||
|
||||
return TrackTemplateResult(template, last_result, result)
|
||||
|
||||
@callback
|
||||
def _refresh(self, event: Optional[Event]) -> None:
|
||||
@@ -792,51 +841,13 @@ class _TrackTemplateResultInfo:
|
||||
now = dt_util.utcnow()
|
||||
|
||||
for track_template_ in self._track_templates:
|
||||
template = track_template_.template
|
||||
if event:
|
||||
if not self._rate_limit.async_has_timer(
|
||||
template
|
||||
) and not self._event_triggers_template(template, event):
|
||||
continue
|
||||
update = self._render_template_if_ready(track_template_, now, event)
|
||||
if not update:
|
||||
continue
|
||||
|
||||
if self._rate_limit.async_schedule_action(
|
||||
template,
|
||||
self._info[template].rate_limit or track_template_.rate_limit,
|
||||
now,
|
||||
self._refresh,
|
||||
event,
|
||||
):
|
||||
continue
|
||||
|
||||
_LOGGER.debug(
|
||||
"Template update %s triggered by event: %s",
|
||||
template.template,
|
||||
event,
|
||||
)
|
||||
|
||||
self._rate_limit.async_triggered(template, now)
|
||||
self._info[template] = template.async_render_to_info(
|
||||
track_template_.variables
|
||||
)
|
||||
info_changed = True
|
||||
|
||||
try:
|
||||
result: Union[str, TemplateError] = self._info[template].result()
|
||||
except TemplateError as ex:
|
||||
result = ex
|
||||
|
||||
last_result = self._last_result.get(template)
|
||||
|
||||
# Check to see if the result has changed
|
||||
if result == last_result:
|
||||
continue
|
||||
|
||||
if isinstance(result, TemplateError) and isinstance(
|
||||
last_result, TemplateError
|
||||
):
|
||||
continue
|
||||
|
||||
updates.append(TrackTemplateResult(template, last_result, result))
|
||||
if isinstance(update, TrackTemplateResult):
|
||||
updates.append(update)
|
||||
|
||||
if info_changed:
|
||||
assert self._track_state_changes
|
||||
@@ -1348,3 +1359,39 @@ def _render_infos_to_track_states(render_infos: Iterable[RenderInfo]) -> TrackSt
|
||||
return TrackStates(True, set(), set())
|
||||
|
||||
return TrackStates(False, *_entities_domains_from_render_infos(render_infos))
|
||||
|
||||
|
||||
@callback
|
||||
def _event_triggers_rerender(event: Event, info: RenderInfo) -> bool:
|
||||
"""Determine if a template should be re-rendered from an event."""
|
||||
entity_id = event.data.get(ATTR_ENTITY_ID)
|
||||
|
||||
if info.filter(entity_id):
|
||||
return True
|
||||
|
||||
if (
|
||||
event.data.get("new_state") is not None
|
||||
and event.data.get("old_state") is not None
|
||||
):
|
||||
return False
|
||||
|
||||
return bool(info.filter_lifecycle(entity_id))
|
||||
|
||||
|
||||
@callback
|
||||
def _rate_limit_for_event(
|
||||
event: Event, info: RenderInfo, track_template_: TrackTemplate
|
||||
) -> Optional[timedelta]:
|
||||
"""Determine the rate limit for an event."""
|
||||
entity_id = event.data.get(ATTR_ENTITY_ID)
|
||||
|
||||
# Specifically referenced entities are excluded
|
||||
# from the rate limit
|
||||
if entity_id in info.entities:
|
||||
return None
|
||||
|
||||
if track_template_.rate_limit is not None:
|
||||
return track_template_.rate_limit
|
||||
|
||||
rate_limit: Optional[timedelta] = info.rate_limit
|
||||
return rate_limit
|
||||
|
@@ -123,30 +123,71 @@ def make_script_schema(schema, default_script_mode, extra=vol.PREVENT_EXTRA):
|
||||
)
|
||||
|
||||
|
||||
STATIC_VALIDATION_ACTION_TYPES = (
|
||||
cv.SCRIPT_ACTION_CALL_SERVICE,
|
||||
cv.SCRIPT_ACTION_DELAY,
|
||||
cv.SCRIPT_ACTION_WAIT_TEMPLATE,
|
||||
cv.SCRIPT_ACTION_FIRE_EVENT,
|
||||
cv.SCRIPT_ACTION_ACTIVATE_SCENE,
|
||||
cv.SCRIPT_ACTION_VARIABLES,
|
||||
)
|
||||
|
||||
|
||||
async def async_validate_actions_config(
|
||||
hass: HomeAssistant, actions: List[ConfigType]
|
||||
) -> List[ConfigType]:
|
||||
"""Validate a list of actions."""
|
||||
return await asyncio.gather(
|
||||
*[async_validate_action_config(hass, action) for action in actions]
|
||||
)
|
||||
|
||||
|
||||
async def async_validate_action_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
action_type = cv.determine_script_action(config)
|
||||
|
||||
if action_type == cv.SCRIPT_ACTION_DEVICE_AUTOMATION:
|
||||
if action_type in STATIC_VALIDATION_ACTION_TYPES:
|
||||
pass
|
||||
|
||||
elif action_type == cv.SCRIPT_ACTION_DEVICE_AUTOMATION:
|
||||
platform = await device_automation.async_get_device_automation_platform(
|
||||
hass, config[CONF_DOMAIN], "action"
|
||||
)
|
||||
config = platform.ACTION_SCHEMA(config) # type: ignore
|
||||
elif (
|
||||
action_type == cv.SCRIPT_ACTION_CHECK_CONDITION
|
||||
and config[CONF_CONDITION] == "device"
|
||||
):
|
||||
platform = await device_automation.async_get_device_automation_platform(
|
||||
hass, config[CONF_DOMAIN], "condition"
|
||||
)
|
||||
config = platform.CONDITION_SCHEMA(config) # type: ignore
|
||||
|
||||
elif action_type == cv.SCRIPT_ACTION_CHECK_CONDITION:
|
||||
if config[CONF_CONDITION] == "device":
|
||||
platform = await device_automation.async_get_device_automation_platform(
|
||||
hass, config[CONF_DOMAIN], "condition"
|
||||
)
|
||||
config = platform.CONDITION_SCHEMA(config) # type: ignore
|
||||
|
||||
elif action_type == cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER:
|
||||
config[CONF_WAIT_FOR_TRIGGER] = await async_validate_trigger_config(
|
||||
hass, config[CONF_WAIT_FOR_TRIGGER]
|
||||
)
|
||||
|
||||
elif action_type == cv.SCRIPT_ACTION_REPEAT:
|
||||
config[CONF_SEQUENCE] = await async_validate_actions_config(
|
||||
hass, config[CONF_REPEAT][CONF_SEQUENCE]
|
||||
)
|
||||
|
||||
elif action_type == cv.SCRIPT_ACTION_CHOOSE:
|
||||
if CONF_DEFAULT in config:
|
||||
config[CONF_DEFAULT] = await async_validate_actions_config(
|
||||
hass, config[CONF_DEFAULT]
|
||||
)
|
||||
|
||||
for choose_conf in config[CONF_CHOOSE]:
|
||||
choose_conf[CONF_SEQUENCE] = await async_validate_actions_config(
|
||||
hass, choose_conf[CONF_SEQUENCE]
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"No validation for {action_type}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
@@ -72,7 +72,7 @@ _COLLECTABLE_STATE_ATTRIBUTES = {
|
||||
"name",
|
||||
}
|
||||
|
||||
DEFAULT_RATE_LIMIT = timedelta(seconds=1)
|
||||
DEFAULT_RATE_LIMIT = timedelta(minutes=1)
|
||||
|
||||
|
||||
@bind_hass
|
||||
@@ -489,26 +489,6 @@ class Template:
|
||||
return 'Template("' + self.template + '")'
|
||||
|
||||
|
||||
class RateLimit:
|
||||
"""Class to control update rate limits."""
|
||||
|
||||
def __init__(self, hass: HomeAssistantType):
|
||||
"""Initialize rate limit."""
|
||||
self._hass = hass
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> str:
|
||||
"""Handle a call to the class."""
|
||||
render_info = self._hass.data.get(_RENDER_INFO)
|
||||
if render_info is not None:
|
||||
render_info.rate_limit = timedelta(*args, **kwargs)
|
||||
|
||||
return ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representation of a RateLimit."""
|
||||
return "<template RateLimit>"
|
||||
|
||||
|
||||
class AllStates:
|
||||
"""Class to expose all HA states as attributes."""
|
||||
|
||||
@@ -607,17 +587,18 @@ class DomainStates:
|
||||
class TemplateState(State):
|
||||
"""Class to represent a state object in a template."""
|
||||
|
||||
__slots__ = ("_hass", "_state")
|
||||
__slots__ = ("_hass", "_state", "_collect")
|
||||
|
||||
# Inheritance is done so functions that check against State keep working
|
||||
# pylint: disable=super-init-not-called
|
||||
def __init__(self, hass, state):
|
||||
def __init__(self, hass, state, collect=True):
|
||||
"""Initialize template state."""
|
||||
self._hass = hass
|
||||
self._state = state
|
||||
self._collect = collect
|
||||
|
||||
def _collect_state(self):
|
||||
if _RENDER_INFO in self._hass.data:
|
||||
if self._collect and _RENDER_INFO in self._hass.data:
|
||||
self._hass.data[_RENDER_INFO].entities.add(self._state.entity_id)
|
||||
|
||||
# Jinja will try __getitem__ first and it avoids the need
|
||||
@@ -626,7 +607,7 @@ class TemplateState(State):
|
||||
"""Return a property as an attribute for jinja."""
|
||||
if item in _COLLECTABLE_STATE_ATTRIBUTES:
|
||||
# _collect_state inlined here for performance
|
||||
if _RENDER_INFO in self._hass.data:
|
||||
if self._collect and _RENDER_INFO in self._hass.data:
|
||||
self._hass.data[_RENDER_INFO].entities.add(self._state.entity_id)
|
||||
return getattr(self._state, item)
|
||||
if item == "entity_id":
|
||||
@@ -717,7 +698,7 @@ def _collect_state(hass: HomeAssistantType, entity_id: str) -> None:
|
||||
def _state_generator(hass: HomeAssistantType, domain: Optional[str]) -> Generator:
|
||||
"""State generator for a domain or all states."""
|
||||
for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")):
|
||||
yield TemplateState(hass, state)
|
||||
yield TemplateState(hass, state, collect=False)
|
||||
|
||||
|
||||
def _get_state_if_valid(
|
||||
@@ -1310,11 +1291,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
self.globals["is_state_attr"] = hassfunction(is_state_attr)
|
||||
self.globals["state_attr"] = hassfunction(state_attr)
|
||||
self.globals["states"] = AllStates(hass)
|
||||
self.globals["rate_limit"] = RateLimit(hass)
|
||||
|
||||
def is_safe_callable(self, obj):
|
||||
"""Test if callback is safe."""
|
||||
return isinstance(obj, (AllStates, RateLimit)) or super().is_safe_callable(obj)
|
||||
return isinstance(obj, AllStates) or super().is_safe_callable(obj)
|
||||
|
||||
def is_safe_attribute(self, obj, attr, value):
|
||||
"""Test if attribute is safe."""
|
||||
|
@@ -13,7 +13,7 @@ defusedxml==0.6.0
|
||||
distro==1.5.0
|
||||
emoji==0.5.4
|
||||
hass-nabucasa==0.37.0
|
||||
home-assistant-frontend==20201001.0
|
||||
home-assistant-frontend==20201001.1
|
||||
importlib-metadata==1.6.0;python_version<'3.8'
|
||||
jinja2>=2.11.2
|
||||
netdisco==2.8.2
|
||||
|
@@ -263,7 +263,7 @@ apcaccess==0.0.13
|
||||
apns2==0.3.0
|
||||
|
||||
# homeassistant.components.apprise
|
||||
apprise==0.8.8
|
||||
apprise==0.8.9
|
||||
|
||||
# homeassistant.components.aprs
|
||||
aprslib==0.6.46
|
||||
@@ -538,7 +538,7 @@ elgato==0.2.0
|
||||
eliqonline==1.2.2
|
||||
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==0.7.19
|
||||
elkm1-lib==0.8.0
|
||||
|
||||
# homeassistant.components.mobile_app
|
||||
emoji==0.5.4
|
||||
@@ -753,7 +753,7 @@ hole==0.5.1
|
||||
holidays==0.10.3
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20201001.0
|
||||
home-assistant-frontend==20201001.1
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -1653,7 +1653,7 @@ pysmappee==0.2.13
|
||||
pysmartapp==0.3.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==0.7.3
|
||||
pysmartthings==0.7.4
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty==0.8
|
||||
@@ -2332,7 +2332,7 @@ zigpy-zigate==0.6.2
|
||||
zigpy-znp==0.2.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.25.0
|
||||
zigpy==0.26.0
|
||||
|
||||
# homeassistant.components.zoneminder
|
||||
zm-py==0.4.0
|
||||
|
@@ -158,7 +158,7 @@ androidtv[async]==0.0.50
|
||||
apns2==0.3.0
|
||||
|
||||
# homeassistant.components.apprise
|
||||
apprise==0.8.8
|
||||
apprise==0.8.9
|
||||
|
||||
# homeassistant.components.aprs
|
||||
aprslib==0.6.46
|
||||
@@ -278,7 +278,7 @@ eebrightbox==0.0.4
|
||||
elgato==0.2.0
|
||||
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==0.7.19
|
||||
elkm1-lib==0.8.0
|
||||
|
||||
# homeassistant.components.mobile_app
|
||||
emoji==0.5.4
|
||||
@@ -376,7 +376,7 @@ hole==0.5.1
|
||||
holidays==0.10.3
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20201001.0
|
||||
home-assistant-frontend==20201001.1
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -800,7 +800,7 @@ pysmappee==0.2.13
|
||||
pysmartapp==0.3.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==0.7.3
|
||||
pysmartthings==0.7.4
|
||||
|
||||
# homeassistant.components.soma
|
||||
pysoma==0.0.10
|
||||
@@ -1089,7 +1089,4 @@ zigpy-zigate==0.6.2
|
||||
zigpy-znp==0.2.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.25.0
|
||||
|
||||
# homeassistant.components.zoneminder
|
||||
zm-py==0.4.0
|
||||
zigpy==0.26.0
|
||||
|
@@ -1240,3 +1240,32 @@ async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop(
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_attribute_if_fires_on_entity_change_with_both_filters_boolean(
|
||||
hass, calls
|
||||
):
|
||||
"""Test for firing if both filters are match attribute."""
|
||||
hass.states.async_set("test.entity", "bla", {"happening": False})
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
"platform": "state",
|
||||
"entity_id": "test.entity",
|
||||
"from": False,
|
||||
"to": True,
|
||||
"attribute": "happening",
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
hass.states.async_set("test.entity", "bla", {"happening": True})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
@@ -98,10 +98,9 @@ async def test_flow_user(hass: HomeAssistantType):
|
||||
"""Test config flow: discovered + configured through user."""
|
||||
udn = "uuid:device_1"
|
||||
mock_device = MockDevice(udn)
|
||||
usn = f"{mock_device.udn}::{mock_device.device_type}"
|
||||
discovery_infos = [
|
||||
{
|
||||
DISCOVERY_USN: usn,
|
||||
DISCOVERY_USN: mock_device.unique_id,
|
||||
DISCOVERY_ST: mock_device.device_type,
|
||||
DISCOVERY_UDN: mock_device.udn,
|
||||
DISCOVERY_LOCATION: "dummy",
|
||||
@@ -121,7 +120,7 @@ async def test_flow_user(hass: HomeAssistantType):
|
||||
# Confirmed via step user.
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={"usn": usn},
|
||||
user_input={"usn": mock_device.unique_id},
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
@@ -132,14 +131,13 @@ async def test_flow_user(hass: HomeAssistantType):
|
||||
}
|
||||
|
||||
|
||||
async def test_flow_config(hass: HomeAssistantType):
|
||||
async def test_flow_import(hass: HomeAssistantType):
|
||||
"""Test config flow: discovered + configured through configuration.yaml."""
|
||||
udn = "uuid:device_1"
|
||||
mock_device = MockDevice(udn)
|
||||
usn = f"{mock_device.udn}::{mock_device.device_type}"
|
||||
discovery_infos = [
|
||||
{
|
||||
DISCOVERY_USN: usn,
|
||||
DISCOVERY_USN: mock_device.unique_id,
|
||||
DISCOVERY_ST: mock_device.device_type,
|
||||
DISCOVERY_UDN: mock_device.udn,
|
||||
DISCOVERY_LOCATION: "dummy",
|
||||
@@ -162,6 +160,66 @@ async def test_flow_config(hass: HomeAssistantType):
|
||||
}
|
||||
|
||||
|
||||
async def test_flow_import_duplicate(hass: HomeAssistantType):
|
||||
"""Test config flow: discovered, but already configured."""
|
||||
udn = "uuid:device_1"
|
||||
mock_device = MockDevice(udn)
|
||||
discovery_infos = [
|
||||
{
|
||||
DISCOVERY_USN: mock_device.unique_id,
|
||||
DISCOVERY_ST: mock_device.device_type,
|
||||
DISCOVERY_UDN: mock_device.udn,
|
||||
DISCOVERY_LOCATION: "dummy",
|
||||
}
|
||||
]
|
||||
|
||||
# Existing entry.
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONFIG_ENTRY_UDN: mock_device.udn,
|
||||
CONFIG_ENTRY_ST: mock_device.device_type,
|
||||
},
|
||||
options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.object(
|
||||
Device, "async_create_device", AsyncMock(return_value=mock_device)
|
||||
), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)):
|
||||
# Discovered via step import.
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_flow_import_incomplete(hass: HomeAssistantType):
|
||||
"""Test config flow: incomplete discovery, configured through configuration.yaml."""
|
||||
udn = "uuid:device_1"
|
||||
mock_device = MockDevice(udn)
|
||||
discovery_infos = [
|
||||
{
|
||||
DISCOVERY_ST: mock_device.device_type,
|
||||
DISCOVERY_UDN: mock_device.udn,
|
||||
DISCOVERY_LOCATION: "dummy",
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
Device, "async_create_device", AsyncMock(return_value=mock_device)
|
||||
), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)):
|
||||
# Discovered via step import.
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "incomplete_discovery"
|
||||
|
||||
|
||||
async def test_options_flow(hass: HomeAssistantType):
|
||||
"""Test options flow."""
|
||||
# Set up config entry.
|
||||
|
@@ -1 +0,0 @@
|
||||
"""Tests for the zoneminder component."""
|
@@ -1,65 +0,0 @@
|
||||
"""Binary sensor tests."""
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant.components.zoneminder import const
|
||||
from homeassistant.config import async_process_ha_core_config
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.async_mock import MagicMock, patch
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_async_setup_entry(hass: HomeAssistant) -> None:
|
||||
"""Test setup of binary sensor entities."""
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zm_client.login.return_value = True
|
||||
zm_client.is_available = True
|
||||
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=const.DOMAIN,
|
||||
unique_id="host1",
|
||||
data={
|
||||
CONF_HOST: "host1",
|
||||
CONF_USERNAME: "username1",
|
||||
CONF_PASSWORD: "password1",
|
||||
CONF_PATH: "path1",
|
||||
const.CONF_PATH_ZMS: "path_zms1",
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await async_process_ha_core_config(hass, {})
|
||||
await async_setup_component(hass, HASS_DOMAIN, {})
|
||||
await async_setup_component(hass, const.DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "binary_sensor.host1"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.host1").state == "on"
|
||||
|
||||
zm_client.is_available = False
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "binary_sensor.host1"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.host1").state == "off"
|
@@ -1,89 +0,0 @@
|
||||
"""Binary sensor tests."""
|
||||
from zoneminder.monitor import Monitor
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant.components.zoneminder import const
|
||||
from homeassistant.config import async_process_ha_core_config
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.async_mock import MagicMock, patch
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_async_setup_entry(hass: HomeAssistant) -> None:
|
||||
"""Test setup of camera entities."""
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
monitor1 = MagicMock(spec=Monitor)
|
||||
monitor1.name = "monitor1"
|
||||
monitor1.mjpeg_image_url = "mjpeg_image_url1"
|
||||
monitor1.still_image_url = "still_image_url1"
|
||||
monitor1.is_recording = True
|
||||
monitor1.is_available = True
|
||||
|
||||
monitor2 = MagicMock(spec=Monitor)
|
||||
monitor2.name = "monitor2"
|
||||
monitor2.mjpeg_image_url = "mjpeg_image_url2"
|
||||
monitor2.still_image_url = "still_image_url2"
|
||||
monitor2.is_recording = False
|
||||
monitor2.is_available = False
|
||||
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zm_client.login.return_value = True
|
||||
zm_client.get_monitors.return_value = [monitor1, monitor2]
|
||||
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=const.DOMAIN,
|
||||
unique_id="host1",
|
||||
data={
|
||||
CONF_HOST: "host1",
|
||||
CONF_USERNAME: "username1",
|
||||
CONF_PASSWORD: "password1",
|
||||
CONF_PATH: "path1",
|
||||
const.CONF_PATH_ZMS: "path_zms1",
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await async_process_ha_core_config(hass, {})
|
||||
await async_setup_component(hass, HASS_DOMAIN, {})
|
||||
await async_setup_component(hass, const.DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "camera.monitor1"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "camera.monitor2"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("camera.monitor1").state == "recording"
|
||||
assert hass.states.get("camera.monitor2").state == "unavailable"
|
||||
|
||||
monitor1.is_recording = False
|
||||
monitor2.is_recording = True
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "camera.monitor1"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "camera.monitor2"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("camera.monitor1").state == "idle"
|
||||
assert hass.states.get("camera.monitor2").state == "unavailable"
|
@@ -1,119 +0,0 @@
|
||||
"""Config flow tests."""
|
||||
import requests
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.zoneminder import ClientAvailabilityResult, const
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_SOURCE,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.async_mock import MagicMock, patch
|
||||
|
||||
|
||||
async def test_import(hass: HomeAssistant) -> None:
|
||||
"""Test import from configuration yaml."""
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
conf_data = {
|
||||
CONF_HOST: "host1",
|
||||
CONF_USERNAME: "username1",
|
||||
CONF_PASSWORD: "password1",
|
||||
CONF_PATH: "path1",
|
||||
const.CONF_PATH_ZMS: "path_zms1",
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: True,
|
||||
}
|
||||
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
zm_client.login.return_value = False
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
const.DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=conf_data,
|
||||
)
|
||||
assert result
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "auth_fail"
|
||||
|
||||
zm_client.login.return_value = True
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
const.DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=conf_data,
|
||||
)
|
||||
assert result
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {
|
||||
**conf_data,
|
||||
CONF_SOURCE: config_entries.SOURCE_IMPORT,
|
||||
}
|
||||
|
||||
|
||||
async def test_user(hass: HomeAssistant) -> None:
|
||||
"""Test user initiated creation."""
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
conf_data = {
|
||||
CONF_HOST: "host1",
|
||||
CONF_USERNAME: "username1",
|
||||
CONF_PASSWORD: "password1",
|
||||
CONF_PATH: "path1",
|
||||
const.CONF_PATH_ZMS: "path_zms1",
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: True,
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
const.DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result
|
||||
assert result["type"] == "form"
|
||||
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
zm_client.login.side_effect = requests.exceptions.ConnectionError()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
conf_data,
|
||||
)
|
||||
assert result
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {
|
||||
"base": ClientAvailabilityResult.ERROR_CONNECTION_ERROR.value
|
||||
}
|
||||
|
||||
zm_client.login.side_effect = None
|
||||
zm_client.login.return_value = False
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
conf_data,
|
||||
)
|
||||
assert result
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {
|
||||
"base": ClientAvailabilityResult.ERROR_AUTH_FAIL.value
|
||||
}
|
||||
|
||||
zm_client.login.return_value = True
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
conf_data,
|
||||
)
|
||||
assert result
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == conf_data
|
@@ -1,122 +0,0 @@
|
||||
"""Tests for init functions."""
|
||||
from datetime import timedelta
|
||||
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.zoneminder import const
|
||||
from homeassistant.components.zoneminder.common import is_client_in_data
|
||||
from homeassistant.config_entries import (
|
||||
ENTRY_STATE_LOADED,
|
||||
ENTRY_STATE_NOT_LOADED,
|
||||
ENTRY_STATE_SETUP_RETRY,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ID,
|
||||
ATTR_NAME,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_SOURCE,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from tests.async_mock import MagicMock, patch
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
async def test_no_yaml_config(hass: HomeAssistant) -> None:
|
||||
"""Test empty yaml config."""
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zm_client.login.return_value = True
|
||||
zm_client.get_monitors.return_value = []
|
||||
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
hass_config = {const.DOMAIN: []}
|
||||
await async_setup_component(hass, const.DOMAIN, hass_config)
|
||||
await hass.async_block_till_done()
|
||||
assert not hass.services.has_service(const.DOMAIN, const.SERVICE_SET_RUN_STATE)
|
||||
|
||||
|
||||
async def test_yaml_config_import(hass: HomeAssistant) -> None:
|
||||
"""Test yaml config import."""
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zm_client.login.return_value = True
|
||||
zm_client.get_monitors.return_value = []
|
||||
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
hass_config = {const.DOMAIN: [{CONF_HOST: "host1"}]}
|
||||
await async_setup_component(hass, const.DOMAIN, hass_config)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.services.has_service(const.DOMAIN, const.SERVICE_SET_RUN_STATE)
|
||||
|
||||
|
||||
async def test_load_call_service_and_unload(hass: HomeAssistant) -> None:
|
||||
"""Test config entry load/unload and calling of service."""
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zm_client.login.side_effect = [True, True, False, True]
|
||||
zm_client.get_monitors.return_value = []
|
||||
zm_client.is_available.return_value = True
|
||||
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
await hass.config_entries.flow.async_init(
|
||||
const.DOMAIN,
|
||||
context={CONF_SOURCE: config_entries.SOURCE_USER},
|
||||
data={
|
||||
CONF_HOST: "host1",
|
||||
CONF_USERNAME: "username1",
|
||||
CONF_PASSWORD: "password1",
|
||||
CONF_PATH: "path1",
|
||||
const.CONF_PATH_ZMS: "path_zms1",
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
config_entry = next(iter(hass.config_entries.async_entries(const.DOMAIN)), None)
|
||||
assert config_entry
|
||||
|
||||
assert config_entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
assert not is_client_in_data(hass, "host1")
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state == ENTRY_STATE_LOADED
|
||||
assert is_client_in_data(hass, "host1")
|
||||
|
||||
assert hass.services.has_service(const.DOMAIN, const.SERVICE_SET_RUN_STATE)
|
||||
|
||||
await hass.services.async_call(
|
||||
const.DOMAIN,
|
||||
const.SERVICE_SET_RUN_STATE,
|
||||
{ATTR_ID: "host1", ATTR_NAME: "away"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
zm_client.set_active_state.assert_called_with("away")
|
||||
|
||||
await config_entry.async_unload(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state == ENTRY_STATE_NOT_LOADED
|
||||
assert not is_client_in_data(hass, "host1")
|
||||
assert not hass.services.has_service(const.DOMAIN, const.SERVICE_SET_RUN_STATE)
|
@@ -1,167 +0,0 @@
|
||||
"""Binary sensor tests."""
|
||||
from zoneminder.monitor import Monitor, MonitorState, TimePeriod
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.zoneminder import const
|
||||
from homeassistant.components.zoneminder.sensor import CONF_INCLUDE_ARCHIVED
|
||||
from homeassistant.config import async_process_ha_core_config
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_MONITORED_CONDITIONS,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.async_mock import MagicMock, patch
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_async_setup_entry(hass: HomeAssistant) -> None:
|
||||
"""Test setup of sensor entities."""
|
||||
|
||||
def _get_events(monitor_id: int, time_period: TimePeriod, include_archived: bool):
|
||||
enum_list = [name for name in dir(TimePeriod) if not name.startswith("_")]
|
||||
tp_index = enum_list.index(time_period.name)
|
||||
return (100 * monitor_id) + (tp_index * 10) + include_archived
|
||||
|
||||
def _monitor1_get_events(time_period: TimePeriod, include_archived: bool):
|
||||
return _get_events(1, time_period, include_archived)
|
||||
|
||||
def _monitor2_get_events(time_period: TimePeriod, include_archived: bool):
|
||||
return _get_events(2, time_period, include_archived)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
monitor1 = MagicMock(spec=Monitor)
|
||||
monitor1.name = "monitor1"
|
||||
monitor1.mjpeg_image_url = "mjpeg_image_url1"
|
||||
monitor1.still_image_url = "still_image_url1"
|
||||
monitor1.is_recording = True
|
||||
monitor1.is_available = True
|
||||
monitor1.function = MonitorState.MONITOR
|
||||
monitor1.get_events.side_effect = _monitor1_get_events
|
||||
|
||||
monitor2 = MagicMock(spec=Monitor)
|
||||
monitor2.name = "monitor2"
|
||||
monitor2.mjpeg_image_url = "mjpeg_image_url2"
|
||||
monitor2.still_image_url = "still_image_url2"
|
||||
monitor2.is_recording = False
|
||||
monitor2.is_available = False
|
||||
monitor2.function = MonitorState.MODECT
|
||||
monitor2.get_events.side_effect = _monitor2_get_events
|
||||
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zm_client.login.return_value = True
|
||||
zm_client.get_monitors.return_value = [monitor1, monitor2]
|
||||
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=const.DOMAIN,
|
||||
unique_id="host1",
|
||||
data={
|
||||
CONF_HOST: "host1",
|
||||
CONF_USERNAME: "username1",
|
||||
CONF_PASSWORD: "password1",
|
||||
CONF_PATH: "path1",
|
||||
const.CONF_PATH_ZMS: "path_zms1",
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
hass_config = {
|
||||
HASS_DOMAIN: {},
|
||||
SENSOR_DOMAIN: [
|
||||
{
|
||||
CONF_PLATFORM: const.DOMAIN,
|
||||
CONF_INCLUDE_ARCHIVED: True,
|
||||
CONF_MONITORED_CONDITIONS: ["all", "day"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
await async_process_ha_core_config(hass, hass_config[HASS_DOMAIN])
|
||||
await async_setup_component(hass, HASS_DOMAIN, hass_config)
|
||||
await async_setup_component(hass, SENSOR_DOMAIN, hass_config)
|
||||
await hass.async_block_till_done()
|
||||
await async_setup_component(hass, const.DOMAIN, hass_config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor1_status"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor1_events"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN,
|
||||
"update_entity",
|
||||
{ATTR_ENTITY_ID: "sensor.monitor1_events_last_day"},
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor2_status"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor2_events"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN,
|
||||
"update_entity",
|
||||
{ATTR_ENTITY_ID: "sensor.monitor2_events_last_day"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get("sensor.monitor1_status").state
|
||||
== MonitorState.MONITOR.value
|
||||
)
|
||||
assert hass.states.get("sensor.monitor1_events").state == "101"
|
||||
assert hass.states.get("sensor.monitor1_events_last_day").state == "111"
|
||||
assert hass.states.get("sensor.monitor2_status").state == "unavailable"
|
||||
assert hass.states.get("sensor.monitor2_events").state == "201"
|
||||
assert hass.states.get("sensor.monitor2_events_last_day").state == "211"
|
||||
|
||||
monitor1.function = MonitorState.NONE
|
||||
monitor2.function = MonitorState.NODECT
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor1_status"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor1_events"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN,
|
||||
"update_entity",
|
||||
{ATTR_ENTITY_ID: "sensor.monitor1_events_last_day"},
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor2_status"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "sensor.monitor2_events"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN,
|
||||
"update_entity",
|
||||
{ATTR_ENTITY_ID: "sensor.monitor2_events_last_day"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get("sensor.monitor1_status").state == MonitorState.NONE.value
|
||||
)
|
||||
assert hass.states.get("sensor.monitor1_events").state == "101"
|
||||
assert hass.states.get("sensor.monitor1_events_last_day").state == "111"
|
||||
assert hass.states.get("sensor.monitor2_status").state == "unavailable"
|
||||
assert hass.states.get("sensor.monitor2_events").state == "201"
|
||||
assert hass.states.get("sensor.monitor2_events_last_day").state == "211"
|
@@ -1,126 +0,0 @@
|
||||
"""Binary sensor tests."""
|
||||
from zoneminder.monitor import Monitor, MonitorState
|
||||
from zoneminder.zm import ZoneMinder
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.components.zoneminder import const
|
||||
from homeassistant.config import async_process_ha_core_config
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_COMMAND_OFF,
|
||||
CONF_COMMAND_ON,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.async_mock import MagicMock, patch
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_async_setup_entry(hass: HomeAssistant) -> None:
|
||||
"""Test setup of sensor entities."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.zoneminder.common.ZoneMinder", autospec=ZoneMinder
|
||||
) as zoneminder_mock:
|
||||
monitor1 = MagicMock(spec=Monitor)
|
||||
monitor1.name = "monitor1"
|
||||
monitor1.mjpeg_image_url = "mjpeg_image_url1"
|
||||
monitor1.still_image_url = "still_image_url1"
|
||||
monitor1.is_recording = True
|
||||
monitor1.is_available = True
|
||||
monitor1.function = MonitorState.MONITOR
|
||||
|
||||
monitor2 = MagicMock(spec=Monitor)
|
||||
monitor2.name = "monitor2"
|
||||
monitor2.mjpeg_image_url = "mjpeg_image_url2"
|
||||
monitor2.still_image_url = "still_image_url2"
|
||||
monitor2.is_recording = False
|
||||
monitor2.is_available = False
|
||||
monitor2.function = MonitorState.MODECT
|
||||
|
||||
zm_client: ZoneMinder = MagicMock(spec=ZoneMinder)
|
||||
zm_client.get_zms_url.return_value = "http://host1/path_zms1"
|
||||
zm_client.login.return_value = True
|
||||
zm_client.get_monitors.return_value = [monitor1, monitor2]
|
||||
|
||||
zoneminder_mock.return_value = zm_client
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=const.DOMAIN,
|
||||
unique_id="host1",
|
||||
data={
|
||||
CONF_HOST: "host1",
|
||||
CONF_USERNAME: "username1",
|
||||
CONF_PASSWORD: "password1",
|
||||
CONF_PATH: "path1",
|
||||
const.CONF_PATH_ZMS: "path_zms1",
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
hass_config = {
|
||||
HASS_DOMAIN: {},
|
||||
SWITCH_DOMAIN: [
|
||||
{
|
||||
CONF_PLATFORM: const.DOMAIN,
|
||||
CONF_COMMAND_ON: MonitorState.MONITOR.value,
|
||||
CONF_COMMAND_OFF: MonitorState.MODECT.value,
|
||||
},
|
||||
{
|
||||
CONF_PLATFORM: const.DOMAIN,
|
||||
CONF_COMMAND_ON: MonitorState.MODECT.value,
|
||||
CONF_COMMAND_OFF: MonitorState.MONITOR.value,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await async_process_ha_core_config(hass, hass_config[HASS_DOMAIN])
|
||||
await async_setup_component(hass, HASS_DOMAIN, hass_config)
|
||||
await async_setup_component(hass, SWITCH_DOMAIN, hass_config)
|
||||
await hass.async_block_till_done()
|
||||
await async_setup_component(hass, const.DOMAIN, hass_config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: "switch.monitor1_state"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: "switch.monitor1_state_2"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.monitor1_state").state == STATE_ON
|
||||
assert hass.states.get("switch.monitor1_state_2").state == STATE_OFF
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: "switch.monitor1_state"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: "switch.monitor1_state_2"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.monitor1_state").state == STATE_OFF
|
||||
assert hass.states.get("switch.monitor1_state_2").state == STATE_ON
|
||||
|
||||
monitor1.function = MonitorState.NONE
|
||||
monitor2.function = MonitorState.NODECT
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "switch.monitor1_state"}
|
||||
)
|
||||
await hass.services.async_call(
|
||||
HASS_DOMAIN, "update_entity", {ATTR_ENTITY_ID: "switch.monitor1_state_2"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.monitor1_state").state == STATE_OFF
|
||||
assert hass.states.get("switch.monitor1_state_2").state == STATE_OFF
|
@@ -422,7 +422,7 @@ async def test_state_attribute(hass):
|
||||
"condition": "state",
|
||||
"entity_id": "sensor.temperature",
|
||||
"attribute": "attribute1",
|
||||
"state": "200",
|
||||
"state": 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -435,7 +435,7 @@ async def test_state_attribute(hass):
|
||||
assert test(hass)
|
||||
|
||||
hass.states.async_set("sensor.temperature", 100, {"attribute1": "200"})
|
||||
assert test(hass)
|
||||
assert not test(hass)
|
||||
|
||||
hass.states.async_set("sensor.temperature", 100, {"attribute1": 201})
|
||||
assert not test(hass)
|
||||
@@ -444,6 +444,31 @@ async def test_state_attribute(hass):
|
||||
assert not test(hass)
|
||||
|
||||
|
||||
async def test_state_attribute_boolean(hass):
|
||||
"""Test with boolean state attribute in condition."""
|
||||
test = await condition.async_from_config(
|
||||
hass,
|
||||
{
|
||||
"condition": "state",
|
||||
"entity_id": "sensor.temperature",
|
||||
"attribute": "happening",
|
||||
"state": False,
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("sensor.temperature", 100, {"happening": 200})
|
||||
assert not test(hass)
|
||||
|
||||
hass.states.async_set("sensor.temperature", 100, {"happening": True})
|
||||
assert not test(hass)
|
||||
|
||||
hass.states.async_set("sensor.temperature", 100, {"no_happening": 201})
|
||||
assert not test(hass)
|
||||
|
||||
hass.states.async_set("sensor.temperature", 100, {"happening": False})
|
||||
assert test(hass)
|
||||
|
||||
|
||||
async def test_state_using_input_entities(hass):
|
||||
"""Test state conditions using input_* entities."""
|
||||
await async_setup_component(
|
||||
|
@@ -927,7 +927,6 @@ async def test_track_template_result_complex(hass):
|
||||
"""Test tracking template."""
|
||||
specific_runs = []
|
||||
template_complex_str = """
|
||||
{{ rate_limit(seconds=0) }}
|
||||
{% if states("sensor.domain") == "light" %}
|
||||
{{ states.light | map(attribute='entity_id') | list }}
|
||||
{% elif states("sensor.domain") == "lock" %}
|
||||
@@ -948,7 +947,9 @@ async def test_track_template_result_complex(hass):
|
||||
hass.states.async_set("lock.one", "locked")
|
||||
|
||||
info = async_track_template_result(
|
||||
hass, [TrackTemplate(template_complex, None)], specific_run_callback
|
||||
hass,
|
||||
[TrackTemplate(template_complex, None, timedelta(seconds=0))],
|
||||
specific_run_callback,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -1236,7 +1237,7 @@ async def test_track_template_result_iterator(hass):
|
||||
[
|
||||
TrackTemplate(
|
||||
Template(
|
||||
"""{{ rate_limit(seconds=0) }}
|
||||
"""
|
||||
{% for state in states.sensor %}
|
||||
{% if state.state == 'on' %}
|
||||
{{ state.entity_id }},
|
||||
@@ -1246,6 +1247,7 @@ async def test_track_template_result_iterator(hass):
|
||||
hass,
|
||||
),
|
||||
None,
|
||||
timedelta(seconds=0),
|
||||
)
|
||||
],
|
||||
iterator_callback,
|
||||
@@ -1268,11 +1270,12 @@ async def test_track_template_result_iterator(hass):
|
||||
[
|
||||
TrackTemplate(
|
||||
Template(
|
||||
"""{{ rate_limit(seconds=0) }}{{ states.sensor|selectattr("state","equalto","on")
|
||||
"""{{ states.sensor|selectattr("state","equalto","on")
|
||||
|join(",", attribute="entity_id") }}""",
|
||||
hass,
|
||||
),
|
||||
None,
|
||||
timedelta(seconds=0),
|
||||
)
|
||||
],
|
||||
filter_callback,
|
||||
@@ -1281,7 +1284,7 @@ async def test_track_template_result_iterator(hass):
|
||||
assert info.listeners == {
|
||||
"all": False,
|
||||
"domains": {"sensor"},
|
||||
"entities": {"sensor.test"},
|
||||
"entities": set(),
|
||||
}
|
||||
|
||||
hass.states.async_set("sensor.test", 6)
|
||||
@@ -1452,62 +1455,6 @@ async def test_track_template_rate_limit(hass):
|
||||
assert refresh_runs == ["0", "1", "2", "4"]
|
||||
|
||||
|
||||
async def test_track_template_rate_limit_overridden(hass):
|
||||
"""Test template rate limit can be overridden from the template."""
|
||||
template_refresh = Template(
|
||||
"{% set x = rate_limit(seconds=0.1) %}{{ states | count }}", hass
|
||||
)
|
||||
|
||||
refresh_runs = []
|
||||
|
||||
@ha.callback
|
||||
def refresh_listener(event, updates):
|
||||
refresh_runs.append(updates.pop().result)
|
||||
|
||||
info = async_track_template_result(
|
||||
hass,
|
||||
[TrackTemplate(template_refresh, None, timedelta(seconds=5))],
|
||||
refresh_listener,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
info.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert refresh_runs == ["0"]
|
||||
hass.states.async_set("sensor.one", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0"]
|
||||
info.async_refresh()
|
||||
assert refresh_runs == ["0", "1"]
|
||||
hass.states.async_set("sensor.two", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1"]
|
||||
next_time = dt_util.utcnow() + timedelta(seconds=0.125)
|
||||
with patch(
|
||||
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
|
||||
):
|
||||
async_fire_time_changed(hass, next_time)
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
hass.states.async_set("sensor.three", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
hass.states.async_set("sensor.four", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
next_time = dt_util.utcnow() + timedelta(seconds=0.125 * 2)
|
||||
with patch(
|
||||
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
|
||||
):
|
||||
async_fire_time_changed(hass, next_time)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2", "4"]
|
||||
hass.states.async_set("sensor.five", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2", "4"]
|
||||
|
||||
|
||||
async def test_track_template_rate_limit_five(hass):
|
||||
"""Test template rate limit of 5 seconds."""
|
||||
template_refresh = Template("{{ states | count }}", hass)
|
||||
@@ -1541,17 +1488,45 @@ async def test_track_template_rate_limit_five(hass):
|
||||
assert refresh_runs == ["0", "1"]
|
||||
|
||||
|
||||
async def test_track_template_rate_limit_changes(hass):
|
||||
"""Test template rate limit can be changed."""
|
||||
async def test_track_template_has_default_rate_limit(hass):
|
||||
"""Test template has a rate limit by default."""
|
||||
hass.states.async_set("sensor.zero", "any")
|
||||
template_refresh = Template("{{ states | list | count }}", hass)
|
||||
|
||||
refresh_runs = []
|
||||
|
||||
@ha.callback
|
||||
def refresh_listener(event, updates):
|
||||
refresh_runs.append(updates.pop().result)
|
||||
|
||||
info = async_track_template_result(
|
||||
hass,
|
||||
[TrackTemplate(template_refresh, None)],
|
||||
refresh_listener,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
info.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert refresh_runs == ["1"]
|
||||
hass.states.async_set("sensor.one", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["1"]
|
||||
info.async_refresh()
|
||||
assert refresh_runs == ["1", "2"]
|
||||
hass.states.async_set("sensor.two", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["1", "2"]
|
||||
hass.states.async_set("sensor.three", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["1", "2"]
|
||||
|
||||
|
||||
async def test_track_template_unavailable_sates_has_default_rate_limit(hass):
|
||||
"""Test template watching for unavailable states has a rate limit by default."""
|
||||
hass.states.async_set("sensor.zero", "unknown")
|
||||
template_refresh = Template(
|
||||
"""
|
||||
{% if states.sensor.two.state == "any" %}
|
||||
{% set x = rate_limit(seconds=5) %}
|
||||
{% else %}
|
||||
{% set x = rate_limit(seconds=0.1) %}
|
||||
{% endif %}
|
||||
{{ states | count }}
|
||||
""",
|
||||
"{{ states | selectattr('state', 'in', ['unavailable', 'unknown', 'none']) | list | count }}",
|
||||
hass,
|
||||
)
|
||||
|
||||
@@ -1570,55 +1545,28 @@ async def test_track_template_rate_limit_changes(hass):
|
||||
info.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert refresh_runs == ["0"]
|
||||
hass.states.async_set("sensor.one", "any")
|
||||
assert refresh_runs == ["1"]
|
||||
hass.states.async_set("sensor.one", "unknown")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0"]
|
||||
assert refresh_runs == ["1"]
|
||||
info.async_refresh()
|
||||
assert refresh_runs == ["0", "1"]
|
||||
assert refresh_runs == ["1", "2"]
|
||||
hass.states.async_set("sensor.two", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1"]
|
||||
next_time = dt_util.utcnow() + timedelta(seconds=0.125 * 1)
|
||||
with patch(
|
||||
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
|
||||
):
|
||||
async_fire_time_changed(hass, next_time)
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["1", "2"]
|
||||
hass.states.async_set("sensor.three", "unknown")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
hass.states.async_set("sensor.three", "any")
|
||||
assert refresh_runs == ["1", "2"]
|
||||
info.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
hass.states.async_set("sensor.four", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
next_time = dt_util.utcnow() + timedelta(seconds=0.125 * 2)
|
||||
with patch(
|
||||
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
|
||||
):
|
||||
async_fire_time_changed(hass, next_time)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
hass.states.async_set("sensor.five", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
assert refresh_runs == ["1", "2", "3"]
|
||||
|
||||
|
||||
async def test_track_template_rate_limit_removed(hass):
|
||||
"""Test template rate limit can be removed."""
|
||||
template_refresh = Template(
|
||||
"""
|
||||
{% if states.sensor.two.state == "any" %}
|
||||
{% set x = rate_limit(0) %}
|
||||
{% else %}
|
||||
{% set x = rate_limit(seconds=0.1) %}
|
||||
{% endif %}
|
||||
{{ states | count }}
|
||||
""",
|
||||
hass,
|
||||
)
|
||||
async def test_specifically_referenced_entity_is_not_rate_limited(hass):
|
||||
"""Test template rate limit of 5 seconds."""
|
||||
hass.states.async_set("sensor.one", "none")
|
||||
|
||||
template_refresh = Template('{{ states | count }}_{{ states("sensor.one") }}', hass)
|
||||
|
||||
refresh_runs = []
|
||||
|
||||
@@ -1628,49 +1576,34 @@ async def test_track_template_rate_limit_removed(hass):
|
||||
|
||||
info = async_track_template_result(
|
||||
hass,
|
||||
[TrackTemplate(template_refresh, None)],
|
||||
[TrackTemplate(template_refresh, None, timedelta(seconds=5))],
|
||||
refresh_listener,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
info.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert refresh_runs == ["0"]
|
||||
assert refresh_runs == ["1_none"]
|
||||
hass.states.async_set("sensor.one", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0"]
|
||||
assert refresh_runs == ["1_none", "1_any"]
|
||||
info.async_refresh()
|
||||
assert refresh_runs == ["0", "1"]
|
||||
assert refresh_runs == ["1_none", "1_any"]
|
||||
hass.states.async_set("sensor.two", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1"]
|
||||
next_time = dt_util.utcnow() + timedelta(seconds=0.125 * 1)
|
||||
with patch(
|
||||
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
|
||||
):
|
||||
async_fire_time_changed(hass, next_time)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2"]
|
||||
assert refresh_runs == ["1_none", "1_any"]
|
||||
hass.states.async_set("sensor.three", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2", "3"]
|
||||
hass.states.async_set("sensor.four", "any")
|
||||
assert refresh_runs == ["1_none", "1_any"]
|
||||
hass.states.async_set("sensor.one", "none")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2", "3", "4"]
|
||||
hass.states.async_set("sensor.five", "any")
|
||||
await hass.async_block_till_done()
|
||||
assert refresh_runs == ["0", "1", "2", "3", "4", "5"]
|
||||
assert refresh_runs == ["1_none", "1_any", "3_none"]
|
||||
|
||||
|
||||
async def test_track_two_templates_with_different_rate_limits(hass):
|
||||
"""Test two templates with different rate limits."""
|
||||
template_one = Template(
|
||||
"{% set x = rate_limit(seconds=0.1) %}{{ states | count }}", hass
|
||||
)
|
||||
template_five = Template(
|
||||
"{% set x = rate_limit(seconds=5) %}{{ states | count }}", hass
|
||||
)
|
||||
template_one = Template("{{ states | count }} ", hass)
|
||||
template_five = Template("{{ states | count }}", hass)
|
||||
|
||||
refresh_runs = {
|
||||
template_one: [],
|
||||
@@ -1684,7 +1617,10 @@ async def test_track_two_templates_with_different_rate_limits(hass):
|
||||
|
||||
info = async_track_template_result(
|
||||
hass,
|
||||
[TrackTemplate(template_one, None), TrackTemplate(template_five, None)],
|
||||
[
|
||||
TrackTemplate(template_one, None, timedelta(seconds=0.1)),
|
||||
TrackTemplate(template_five, None, timedelta(seconds=5)),
|
||||
],
|
||||
refresh_listener,
|
||||
)
|
||||
|
||||
@@ -1867,9 +1803,7 @@ async def test_async_track_template_result_multiple_templates_mixing_domain(hass
|
||||
template_1 = Template("{{ states.switch.test.state == 'on' }}")
|
||||
template_2 = Template("{{ states.switch.test.state == 'on' }}")
|
||||
template_3 = Template("{{ states.switch.test.state == 'off' }}")
|
||||
template_4 = Template(
|
||||
"{{ rate_limit(seconds=0) }}{{ states.switch | map(attribute='entity_id') | list }}"
|
||||
)
|
||||
template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}")
|
||||
|
||||
refresh_runs = []
|
||||
|
||||
@@ -1883,7 +1817,7 @@ async def test_async_track_template_result_multiple_templates_mixing_domain(hass
|
||||
TrackTemplate(template_1, None),
|
||||
TrackTemplate(template_2, None),
|
||||
TrackTemplate(template_3, None),
|
||||
TrackTemplate(template_4, None),
|
||||
TrackTemplate(template_4, None, timedelta(seconds=0)),
|
||||
],
|
||||
refresh_listener,
|
||||
)
|
||||
|
@@ -16,6 +16,7 @@ import homeassistant.components.scene as scene
|
||||
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON
|
||||
from homeassistant.core import Context, CoreState, callback
|
||||
from homeassistant.helpers import config_validation as cv, script
|
||||
from homeassistant.setup import async_setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from tests.async_mock import patch
|
||||
@@ -1828,3 +1829,114 @@ async def test_set_redefines_variable(hass, caplog):
|
||||
|
||||
assert mock_calls[0].data["value"] == "1"
|
||||
assert mock_calls[1].data["value"] == "2"
|
||||
|
||||
|
||||
async def test_validate_action_config(hass):
|
||||
"""Validate action config."""
|
||||
configs = {
|
||||
cv.SCRIPT_ACTION_CALL_SERVICE: {"service": "light.turn_on"},
|
||||
cv.SCRIPT_ACTION_DELAY: {"delay": 5},
|
||||
cv.SCRIPT_ACTION_WAIT_TEMPLATE: {
|
||||
"wait_template": "{{ states.light.kitchen.state == 'on' }}"
|
||||
},
|
||||
cv.SCRIPT_ACTION_FIRE_EVENT: {"event": "my_event"},
|
||||
cv.SCRIPT_ACTION_CHECK_CONDITION: {
|
||||
"condition": "{{ states.light.kitchen.state == 'on' }}"
|
||||
},
|
||||
cv.SCRIPT_ACTION_DEVICE_AUTOMATION: {
|
||||
"domain": "light",
|
||||
"entity_id": "light.kitchen",
|
||||
"device_id": "abcd",
|
||||
"type": "turn_on",
|
||||
},
|
||||
cv.SCRIPT_ACTION_ACTIVATE_SCENE: {"scene": "scene.relax"},
|
||||
cv.SCRIPT_ACTION_REPEAT: {
|
||||
"repeat": {"count": 3, "sequence": [{"event": "repeat_event"}]}
|
||||
},
|
||||
cv.SCRIPT_ACTION_CHOOSE: {
|
||||
"choose": [
|
||||
{
|
||||
"condition": "{{ states.light.kitchen.state == 'on' }}",
|
||||
"sequence": [{"event": "choose_event"}],
|
||||
}
|
||||
],
|
||||
"default": [{"event": "choose_default_event"}],
|
||||
},
|
||||
cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: {
|
||||
"wait_for_trigger": [
|
||||
{"platform": "event", "event_type": "wait_for_trigger_event"}
|
||||
]
|
||||
},
|
||||
cv.SCRIPT_ACTION_VARIABLES: {"variables": {"hello": "world"}},
|
||||
}
|
||||
|
||||
for key in cv.ACTION_TYPE_SCHEMAS:
|
||||
assert key in configs, f"No validate config test found for {key}"
|
||||
|
||||
# Verify we raise if we don't know the action type
|
||||
with patch(
|
||||
"homeassistant.helpers.config_validation.determine_script_action",
|
||||
return_value="non-existing",
|
||||
), pytest.raises(ValueError):
|
||||
await script.async_validate_action_config(hass, {})
|
||||
|
||||
for action_type, config in configs.items():
|
||||
assert cv.determine_script_action(config) == action_type
|
||||
try:
|
||||
await script.async_validate_action_config(hass, config)
|
||||
except vol.Invalid as err:
|
||||
assert False, f"{action_type} config invalid: {err}"
|
||||
|
||||
|
||||
async def test_embedded_wait_for_trigger_in_automation(hass):
|
||||
"""Test an embedded wait for trigger."""
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"automation",
|
||||
{
|
||||
"automation": {
|
||||
"trigger": {"platform": "event", "event_type": "test_event"},
|
||||
"action": {
|
||||
"repeat": {
|
||||
"while": [
|
||||
{
|
||||
"condition": "template",
|
||||
"value_template": '{{ is_state("test.value1", "trigger-while") }}',
|
||||
}
|
||||
],
|
||||
"sequence": [
|
||||
{"event": "trigger_wait_event"},
|
||||
{
|
||||
"wait_for_trigger": [
|
||||
{
|
||||
"platform": "template",
|
||||
"value_template": '{{ is_state("test.value2", "trigger-wait") }}',
|
||||
}
|
||||
]
|
||||
},
|
||||
{"service": "test.script"},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set("test.value1", "trigger-while")
|
||||
hass.states.async_set("test.value2", "not-trigger-wait")
|
||||
mock_calls = async_mock_service(hass, "test", "script")
|
||||
|
||||
async def trigger_wait_event(_):
|
||||
# give script the time to attach the trigger.
|
||||
await asyncio.sleep(0)
|
||||
hass.states.async_set("test.value1", "not-trigger-while")
|
||||
hass.states.async_set("test.value2", "trigger-wait")
|
||||
|
||||
hass.bus.async_listen("trigger_wait_event", trigger_wait_event)
|
||||
|
||||
# Start automation
|
||||
hass.bus.async_fire("test_event")
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_calls) == 1
|
||||
|
@@ -1,5 +1,5 @@
|
||||
"""Test Home Assistant template helper methods."""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
import math
|
||||
import random
|
||||
|
||||
@@ -155,9 +155,25 @@ def test_iterating_all_states(hass):
|
||||
hass.states.async_set("sensor.temperature", 10)
|
||||
|
||||
info = render_to_info(hass, tmpl_str)
|
||||
assert_result_info(
|
||||
info, "10happy", entities=["test.object", "sensor.temperature"], all_states=True
|
||||
)
|
||||
assert_result_info(info, "10happy", entities=[], all_states=True)
|
||||
|
||||
|
||||
def test_iterating_all_states_unavailable(hass):
|
||||
"""Test iterating all states unavailable."""
|
||||
hass.states.async_set("test.object", "on")
|
||||
|
||||
tmpl_str = "{{ states | selectattr('state', 'in', ['unavailable', 'unknown', 'none']) | list | count }}"
|
||||
|
||||
info = render_to_info(hass, tmpl_str)
|
||||
|
||||
assert info.all_states is True
|
||||
assert info.rate_limit == template.DEFAULT_RATE_LIMIT
|
||||
|
||||
hass.states.async_set("test.object", "unknown")
|
||||
hass.states.async_set("sensor.temperature", 10)
|
||||
|
||||
info = render_to_info(hass, tmpl_str)
|
||||
assert_result_info(info, "1", entities=[], all_states=True)
|
||||
|
||||
|
||||
def test_iterating_domain_states(hass):
|
||||
@@ -176,7 +192,7 @@ def test_iterating_domain_states(hass):
|
||||
assert_result_info(
|
||||
info,
|
||||
"open10",
|
||||
entities=["sensor.back_door", "sensor.temperature"],
|
||||
entities=[],
|
||||
domains=["sensor"],
|
||||
)
|
||||
|
||||
@@ -1426,9 +1442,7 @@ async def test_expand(hass):
|
||||
info = render_to_info(
|
||||
hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}"
|
||||
)
|
||||
assert_result_info(
|
||||
info, "test.object", {"test.object", "group.new_group"}, ["group"]
|
||||
)
|
||||
assert_result_info(info, "test.object", {"test.object"}, ["group"])
|
||||
assert info.rate_limit == template.DEFAULT_RATE_LIMIT
|
||||
|
||||
info = render_to_info(
|
||||
@@ -1587,7 +1601,7 @@ async def test_async_render_to_info_with_wildcard_matching_entity_id(hass):
|
||||
"""Test tracking template with a wildcard."""
|
||||
template_complex_str = r"""
|
||||
|
||||
{% for state in states %}
|
||||
{% for state in states.cover %}
|
||||
{% if state.entity_id | regex_match('.*\.office_') %}
|
||||
{{ state.entity_id }}={{ state.state }}
|
||||
{% endif %}
|
||||
@@ -1599,13 +1613,9 @@ async def test_async_render_to_info_with_wildcard_matching_entity_id(hass):
|
||||
hass.states.async_set("cover.office_skylight", "open")
|
||||
info = render_to_info(hass, template_complex_str)
|
||||
|
||||
assert not info.domains
|
||||
assert info.entities == {
|
||||
"cover.office_drapes",
|
||||
"cover.office_window",
|
||||
"cover.office_skylight",
|
||||
}
|
||||
assert info.all_states is True
|
||||
assert info.domains == {"cover"}
|
||||
assert info.entities == set()
|
||||
assert info.all_states is False
|
||||
assert info.rate_limit == template.DEFAULT_RATE_LIMIT
|
||||
|
||||
|
||||
@@ -1629,13 +1639,7 @@ async def test_async_render_to_info_with_wildcard_matching_state(hass):
|
||||
info = render_to_info(hass, template_complex_str)
|
||||
|
||||
assert not info.domains
|
||||
assert info.entities == {
|
||||
"cover.x_skylight",
|
||||
"binary_sensor.door",
|
||||
"cover.office_drapes",
|
||||
"cover.office_window",
|
||||
"cover.office_skylight",
|
||||
}
|
||||
assert info.entities == set()
|
||||
assert info.all_states is True
|
||||
assert info.rate_limit == template.DEFAULT_RATE_LIMIT
|
||||
|
||||
@@ -1643,13 +1647,7 @@ async def test_async_render_to_info_with_wildcard_matching_state(hass):
|
||||
info = render_to_info(hass, template_complex_str)
|
||||
|
||||
assert not info.domains
|
||||
assert info.entities == {
|
||||
"cover.x_skylight",
|
||||
"binary_sensor.door",
|
||||
"cover.office_drapes",
|
||||
"cover.office_window",
|
||||
"cover.office_skylight",
|
||||
}
|
||||
assert info.entities == set()
|
||||
assert info.all_states is True
|
||||
assert info.rate_limit == template.DEFAULT_RATE_LIMIT
|
||||
|
||||
@@ -1666,12 +1664,7 @@ async def test_async_render_to_info_with_wildcard_matching_state(hass):
|
||||
info = render_to_info(hass, template_cover_str)
|
||||
|
||||
assert info.domains == {"cover"}
|
||||
assert info.entities == {
|
||||
"cover.x_skylight",
|
||||
"cover.office_drapes",
|
||||
"cover.office_window",
|
||||
"cover.office_skylight",
|
||||
}
|
||||
assert info.entities == set()
|
||||
assert info.all_states is False
|
||||
assert info.rate_limit == template.DEFAULT_RATE_LIMIT
|
||||
|
||||
@@ -1965,9 +1958,7 @@ def test_generate_filter_iterators(hass):
|
||||
{% endfor %}
|
||||
""",
|
||||
)
|
||||
assert_result_info(
|
||||
info, "sensor.test_sensor=off,", ["sensor.test_sensor"], ["sensor"]
|
||||
)
|
||||
assert_result_info(info, "sensor.test_sensor=off,", [], ["sensor"])
|
||||
|
||||
info = render_to_info(
|
||||
hass,
|
||||
@@ -1977,9 +1968,7 @@ def test_generate_filter_iterators(hass):
|
||||
{% endfor %}
|
||||
""",
|
||||
)
|
||||
assert_result_info(
|
||||
info, "sensor.test_sensor=value,", ["sensor.test_sensor"], ["sensor"]
|
||||
)
|
||||
assert_result_info(info, "sensor.test_sensor=value,", [], ["sensor"])
|
||||
|
||||
|
||||
def test_generate_select(hass):
|
||||
@@ -2001,7 +1990,7 @@ def test_generate_select(hass):
|
||||
assert_result_info(
|
||||
info,
|
||||
"sensor.test_sensor",
|
||||
["sensor.test_sensor", "sensor.test_sensor_on"],
|
||||
[],
|
||||
["sensor"],
|
||||
)
|
||||
assert info.domains_lifecycle == {"sensor"}
|
||||
@@ -2542,7 +2531,9 @@ async def test_lights(hass):
|
||||
|
||||
tmp = template.Template(tmpl, hass)
|
||||
info = tmp.async_render_to_info()
|
||||
assert info.entities == set(states)
|
||||
assert info.entities == set()
|
||||
assert info.domains == {"light"}
|
||||
|
||||
assert "lights are on" in info.result()
|
||||
for i in range(10):
|
||||
assert f"sensor{i}" in info.result()
|
||||
@@ -2617,28 +2608,3 @@ async def test_unavailable_states(hass):
|
||||
hass,
|
||||
)
|
||||
assert tpl.async_render() == "light.none, light.unavailable, light.unknown"
|
||||
|
||||
|
||||
async def test_rate_limit(hass):
|
||||
"""Test we can pickup a rate limit directive."""
|
||||
tmp = template.Template("{{ states | count }}", hass)
|
||||
|
||||
info = tmp.async_render_to_info()
|
||||
assert info.rate_limit is None
|
||||
|
||||
tmp = template.Template("{{ rate_limit(minutes=1) }}{{ states | count }}", hass)
|
||||
|
||||
info = tmp.async_render_to_info()
|
||||
assert info.rate_limit == timedelta(minutes=1)
|
||||
|
||||
tmp = template.Template("{{ rate_limit(minutes=1) }}random", hass)
|
||||
|
||||
info = tmp.async_render_to_info()
|
||||
assert info.result() == "random"
|
||||
assert info.rate_limit == timedelta(minutes=1)
|
||||
|
||||
tmp = template.Template("{{ rate_limit(seconds=0) }}random", hass)
|
||||
|
||||
info = tmp.async_render_to_info()
|
||||
assert info.result() == "random"
|
||||
assert info.rate_limit == timedelta(seconds=0)
|
||||
|
@@ -369,6 +369,36 @@ async def test_loading_configuration_from_storage(hass, hass_storage):
|
||||
assert hass.config.config_source == SOURCE_STORAGE
|
||||
|
||||
|
||||
async def test_loading_configuration_from_storage_with_yaml_only(hass, hass_storage):
|
||||
"""Test loading core and YAML config onto hass object."""
|
||||
hass_storage["core.config"] = {
|
||||
"data": {
|
||||
"elevation": 10,
|
||||
"latitude": 55,
|
||||
"location_name": "Home",
|
||||
"longitude": 13,
|
||||
"time_zone": "Europe/Copenhagen",
|
||||
"unit_system": "metric",
|
||||
},
|
||||
"key": "core.config",
|
||||
"version": 1,
|
||||
}
|
||||
await config_util.async_process_ha_core_config(
|
||||
hass, {"media_dirs": {"mymedia": "/usr"}, "allowlist_external_dirs": "/etc"}
|
||||
)
|
||||
|
||||
assert hass.config.latitude == 55
|
||||
assert hass.config.longitude == 13
|
||||
assert hass.config.elevation == 10
|
||||
assert hass.config.location_name == "Home"
|
||||
assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC
|
||||
assert hass.config.time_zone.zone == "Europe/Copenhagen"
|
||||
assert len(hass.config.allowlist_external_dirs) == 3
|
||||
assert "/etc" in hass.config.allowlist_external_dirs
|
||||
assert hass.config.media_dirs == {"mymedia": "/usr"}
|
||||
assert hass.config.config_source == SOURCE_STORAGE
|
||||
|
||||
|
||||
async def test_updating_configuration(hass, hass_storage):
|
||||
"""Test updating configuration stores the new configuration."""
|
||||
core_data = {
|
||||
|
Reference in New Issue
Block a user