Compare commits

...

28 Commits

Author SHA1 Message Date
Franck Nijhof
55958bcfb7 Merge pull request #41406 from home-assistant/rc 2020-10-07 17:50:57 +02:00
Franck Nijhof
cde6400482 Bumped version to 0.116.0 2020-10-07 17:10:04 +02:00
Paulus Schoutsen
d2077acc92 Warn when using Python 3.7 (#41400) 2020-10-07 17:07:27 +02:00
Martin Hjelmare
f4991794d4 Revert zoneminder config flow (#41395) 2020-10-07 17:05:46 +02:00
Paulus Schoutsen
088fb7eff3 Reduce Somfy polling (#41389) 2020-10-07 17:00:59 +02:00
Glenn Waters
c3e679f69b Fix elkm1 changed by (#41378) 2020-10-07 13:14:03 +02:00
Alexei Chetroi
aa8e336af5 Bump up zha dependency to 0.26.0 (#41371) 2020-10-07 13:14:00 +02:00
Paulus Schoutsen
ac87c0eea2 Add additionalAttributes to Alexa discovery payload (#41370) 2020-10-07 13:13:56 +02:00
Quentame
281456b252 Set longer timeout during synology_dsm config flow (#41364) 2020-10-07 13:13:48 +02:00
Paulus Schoutsen
aefa305f77 Bumped version to 0.116.0b6 2020-10-06 20:46:27 +00:00
J. Nick Koston
a11fa832ef Resolve memory leak in recorder (#41349)
Avoids a build up of the InstanceState.
2020-10-06 20:46:20 +00:00
Franck Nijhof
0470142701 Fix TTS ID3 Tag capability check (#41343) 2020-10-06 20:46:19 +00:00
Steven Looman
3c22834751 Don't set upnp config_entry.unique_id from setup entry (#40988)
* Don't set config_entry.unique_id from setup entry. Fixes #40168

* Ensure entry has a unique_id

* Add test test_flow_import_incomplete

* Add test test_flow_import_duplicate

* Re-add testing import_info

* Simplify import flow

* Remove not needed line

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2020-10-06 20:46:19 +00:00
Paulus Schoutsen
570d1e7d8f Bumped version to 0.116.0b5 2020-10-06 07:55:25 +00:00
J. Nick Koston
23be039b7f Prevent collecting states already referenced by domain or all (#41308)
The template engine would collect all the states in
a domain or all states while iterating even though
they were already included in all or the domain

This lead to the rate limit not being applied to
templates that iterated all states that also
accessed a collectable property because the engine
incorrectly believed they were specifically
referenced.
2020-10-06 07:55:18 +00:00
Bram Kragten
859632d636 Exclude media_dirs from YAML config check (#41299) 2020-10-06 07:55:18 +00:00
Andrew Sayre
874cbf808e Update pysmartthings (#41294) 2020-10-06 07:55:17 +00:00
Bram Kragten
56b5ddb06d Bumped version to 0.116.0b4 2020-10-05 18:05:31 +02:00
Bram Kragten
df371d99dc Updated frontend to 20201001.1 (#41273) 2020-10-05 18:03:37 +02:00
Paulus Schoutsen
74b5db9ca5 Bumped version to 0.116.0b3 2020-10-05 12:05:54 +00:00
Paulus Schoutsen
6f4225b51d Add extended validation for script repeat/choose (#41265) 2020-10-05 12:04:33 +00:00
Paulus Schoutsen
b524cc9c56 Allow any value when triggering on state attribute (#41261) 2020-10-05 12:04:32 +00:00
Raman Gupta
a6d50ba89b Bump apprise version to avoid sync in async issues (#41253) 2020-10-05 12:04:31 +00:00
J. Nick Koston
228de5807c Remove manual rate_limit control directive from templates (#41225)
Increase default rate limit for all states and entire
domain states to one minute

Ensure specifically referenced entities are excluded from
the rate limit
2020-10-05 12:04:30 +00:00
Franck Nijhof
d4b40154e5 Remove deprecation invalidation version from cast integration (#41197) 2020-10-05 12:04:30 +00:00
Justin Paupore
6e3aa004c4 Fix TTS handling of non-ID3 metadata tags (#41191)
Change #40666 used mutagen's ID3 TextFrame to wrap metadata information.
While this is the correct behavior for container formats that use ID3
metadata tags, such as MP3 and linear PCM, Ogg container formats use
a different metadata format. For these containers, the metadata needs to
be a bare str, not wrapped in a TextFrame.
2020-10-05 12:04:29 +00:00
J. Nick Koston
149cc5cbeb Simplify template tracking and make it easier to follow (#41030) 2020-10-05 12:04:28 +00:00
Simone Chemelli
37acf9b165 Handle Shelly channel names (if available) for emeters devices (#40820) 2020-10-05 12:04:27 +00:00
56 changed files with 829 additions and 1651 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ DEVICES = "devices"
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
SCAN_INTERVAL = timedelta(minutes=1)
CONF_OPTIMISTIC = "optimistic"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -217,6 +217,5 @@ FLOWS = [
"yeelight",
"zerproc",
"zha",
"zoneminder",
"zwave"
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
"""Tests for the zoneminder component."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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