diff --git a/.coveragerc b/.coveragerc index c95f47815e8fcd8fd1717bef7b25ad815801b890..e35695fb8b26cc5d3915cc8b4336dd0112b0ccbd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1028,7 +1028,6 @@ 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 diff --git a/CODEOWNERS b/CODEOWNERS index ec55887e883f8743d71292b144e17c2d6e1a6a61..8804380fc727ca4abea68378f99dc8dbcc2e26fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -504,7 +504,7 @@ homeassistant/components/zeroconf/* @Kane610 homeassistant/components/zerproc/* @emlove homeassistant/components/zha/* @dmulcahey @adminiuga homeassistant/components/zone/* @home-assistant/core -homeassistant/components/zoneminder/* @rohankapoorcom +homeassistant/components/zoneminder/* @rohankapoorcom @vangorra homeassistant/components/zwave/* @home-assistant/z-wave # Individual files diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index c631406b0e34f546217e25edfc13b16b834c8ef3..92186b7a0b5c78a2f9695996791df048a5fa7dce 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -2,97 +2,169 @@ 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, ) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +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, +) _LOGGER = logging.getLogger(__name__) - -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" +PLATFORM_DOMAINS = tuple( + [BINARY_SENSOR_DOMAIN, CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] +) HOST_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - 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_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_USERNAME): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=const.DEFAULT_VERIFY_SSL): cv.boolean, } ) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [HOST_CONFIG_SCHEMA])}, extra=vol.ALLOW_EXTRA +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, + ), ) -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} ) -def setup(hass, config): +async def async_setup(hass: HomeAssistant, base_config: dict): """Set up the ZoneMinder component.""" - hass.data[DOMAIN] = {} + # 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 + ], + ) + + config = base_config.get(const.DOMAIN) + + 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) - success = True + result = await async_test_client_availability(hass, zm_client) + if result != ClientAvailabilityResult.AVAILABLE: + raise ConfigEntryNotReady - for conf in config[DOMAIN]: - protocol = "https" if conf[CONF_SSL] else "http" + set_client_to_data(hass, config_entry.unique_id, zm_client) - 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), + for platform_domain in PLATFORM_DOMAINS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform_domain) ) - 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, + + 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 ) + ) - hass.services.register( - DOMAIN, SERVICE_SET_RUN_STATE, set_active_state, schema=SET_RUN_STATE_SCHEMA - ) + # 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) - hass.async_create_task( - async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) - ) + del_client_from_data(hass, config_entry.unique_id) - return success + return True diff --git a/homeassistant/components/zoneminder/binary_sensor.py b/homeassistant/components/zoneminder/binary_sensor.py index 73d6877ef2d7f8fdabf65281ac4ce1c7dc22f7dc..73f7ce2f4c9b0735147f830d62d3e3ab7d86b73c 100644 --- a/homeassistant/components/zoneminder/binary_sensor.py +++ b/homeassistant/components/zoneminder/binary_sensor.py @@ -1,29 +1,43 @@ """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 . import DOMAIN as ZONEMINDER_DOMAIN +from .common import get_client_from_data -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 +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)]) class ZMAvailabilitySensor(BinarySensorEntity): """Representation of the availability of ZoneMinder as a binary sensor.""" - def __init__(self, host_name, client): + def __init__(self, client: ZoneMinder, config_entry: ConfigEntry): """Initialize availability sensor.""" self._state = None - self._name = host_name + self._name = config_entry.unique_id 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): diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 6144fe112266ba0e81f983705ddced899ef94c6e..c4ef1b14772a258ea6d9754b71941968b4f9623b 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -1,5 +1,8 @@ """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, @@ -7,9 +10,12 @@ 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 . import DOMAIN as ZONEMINDER_DOMAIN +from .common import get_client_from_data _LOGGER = logging.getLogger(__name__) @@ -17,23 +23,28 @@ _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 - for monitor in monitors: - _LOGGER.info("Initializing camera %s", monitor.id) - cameras.append(ZoneMinderCamera(monitor, zm_client.verify_ssl)) - add_entities(cameras) + +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) + ] + ) class ZoneMinderCamera(MjpegCamera): """Representation of a ZoneMinder Monitor Stream.""" - def __init__(self, monitor, verify_ssl): + def __init__(self, monitor: Monitor, verify_ssl: bool, config_entry: ConfigEntry): """Initialize as a subclass of MjpegCamera.""" device_info = { CONF_NAME: monitor.name, @@ -45,6 +56,12 @@ 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): diff --git a/homeassistant/components/zoneminder/common.py b/homeassistant/components/zoneminder/common.py new file mode 100644 index 0000000000000000000000000000000000000000..970289ea136b0d8134d908603b4d8c3fa1680fb5 --- /dev/null +++ b/homeassistant/components/zoneminder/common.py @@ -0,0 +1,110 @@ +"""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 diff --git a/homeassistant/components/zoneminder/config_flow.py b/homeassistant/components/zoneminder/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..1d50e8c1bb124f7ad5399abb12bd71724e1a3046 --- /dev/null +++ b/homeassistant/components/zoneminder/config_flow.py @@ -0,0 +1,99 @@ +"""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) diff --git a/homeassistant/components/zoneminder/const.py b/homeassistant/components/zoneminder/const.py new file mode 100644 index 0000000000000000000000000000000000000000..ad890a1d4d6c292c94c8daa1e937e277a1ae7700 --- /dev/null +++ b/homeassistant/components/zoneminder/const.py @@ -0,0 +1,14 @@ +"""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" diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json index b3a87510e5ace0b73d52dc899cfefd58c679bed0..13bec8c4d9a0f6c48216b7178721e23f258bf36e 100644 --- a/homeassistant/components/zoneminder/manifest.json +++ b/homeassistant/components/zoneminder/manifest.json @@ -1,7 +1,8 @@ { "domain": "zoneminder", "name": "ZoneMinder", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zoneminder", "requirements": ["zm-py==0.4.0"], - "codeowners": ["@rohankapoorcom"] + "codeowners": ["@rohankapoorcom", "@vangorra"] } diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 75531e79e13b819bb6fd9041f911127b0d9ca642..8605e842813775fb16744f7cb1c49ace007bcd4f 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -1,15 +1,19 @@ """Support for ZoneMinder sensors.""" import logging +from typing import Callable, List, Optional import voluptuous as vol -from zoneminder.monitor import TimePeriod +from zoneminder.monitor import Monitor, TimePeriod +from zoneminder.zm import ZoneMinder -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA +from homeassistant.config_entries import ConfigEntry 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 . import DOMAIN as ZONEMINDER_DOMAIN +from .common import get_client_from_data, get_platform_configs _LOGGER = logging.getLogger(__name__) @@ -37,35 +41,50 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ZoneMinder sensor platform.""" - include_archived = config.get(CONF_INCLUDE_ARCHIVED) +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") sensors = [] - 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 monitor in monitors: + sensors.append(ZMSensorMonitors(monitor, config_entry)) - for monitor in monitors: - sensors.append(ZMSensorMonitors(monitor)) + for config in get_platform_configs(hass, SENSOR_DOMAIN): + include_archived = config.get(CONF_INCLUDE_ARCHIVED) for sensor in config[CONF_MONITORED_CONDITIONS]: - sensors.append(ZMSensorEvents(monitor, include_archived, sensor)) + sensors.append( + ZMSensorEvents(monitor, include_archived, sensor, config_entry) + ) + + sensors.append(ZMSensorRunState(zm_client, config_entry)) - sensors.append(ZMSensorRunState(zm_client)) - add_entities(sensors) + async_add_entities(sensors, True) class ZMSensorMonitors(Entity): """Get the status of each ZoneMinder monitor.""" - def __init__(self, monitor): + def __init__(self, monitor: Monitor, config_entry: ConfigEntry): """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.""" @@ -94,14 +113,26 @@ class ZMSensorMonitors(Entity): class ZMSensorEvents(Entity): """Get the number of events for each monitor.""" - def __init__(self, monitor, include_archived, sensor_type): + def __init__( + self, + monitor: Monitor, + include_archived: bool, + sensor_type: str, + config_entry: ConfigEntry, + ): """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.""" @@ -125,11 +156,17 @@ class ZMSensorEvents(Entity): class ZMSensorRunState(Entity): """Get the ZoneMinder run state.""" - def __init__(self, client): + def __init__(self, client: ZoneMinder, config_entry: ConfigEntry): """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): diff --git a/homeassistant/components/zoneminder/services.yaml b/homeassistant/components/zoneminder/services.yaml index a6fb85b641de1018b264f2f3a52d40fc7f2c2930..52e8a3bf0bbcb4b14f6d325c74349f831b3964cf 100644 --- a/homeassistant/components/zoneminder/services.yaml +++ b/homeassistant/components/zoneminder/services.yaml @@ -1,6 +1,9 @@ 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" diff --git a/homeassistant/components/zoneminder/strings.json b/homeassistant/components/zoneminder/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..8b722c9af2cc28e3e8e27a902ea195bb7d16c02b --- /dev/null +++ b/homeassistant/components/zoneminder/strings.json @@ -0,0 +1,28 @@ +{ + "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." } + } +} diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 0428ddbf888f89fbf1f3167946afe6dd7b0d32f6..d8d1cc78797c15fff7787e9484aedee32e108551 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -1,41 +1,61 @@ """Support for ZoneMinder switches.""" import logging +from typing import Callable, List, Optional import voluptuous as vol -from zoneminder.monitor import MonitorState +from zoneminder.monitor import Monitor, MonitorState -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + PLATFORM_SCHEMA, + SwitchEntity, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity -from . import DOMAIN as ZONEMINDER_DOMAIN +from .common import get_client_from_data, get_platform_configs _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): cv.string, - vol.Required(CONF_COMMAND_OFF): cv.string, + vol.Required(CONF_COMMAND_ON): vol.All(vol.In(MONITOR_STATES.keys())), + vol.Required(CONF_COMMAND_OFF): vol.All(vol.In(MONITOR_STATES.keys())), } ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ZoneMinder switch platform.""" +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) - on_state = MonitorState(config.get(CONF_COMMAND_ON)) - off_state = MonitorState(config.get(CONF_COMMAND_OFF)) + if not monitors: + _LOGGER.warning("Could not fetch monitors from ZoneMinder") + return switches = [] - 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 + 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]] + + switches.append( + ZMSwitchMonitors(monitor, on_state, off_state, config_entry) + ) - for monitor in monitors: - switches.append(ZMSwitchMonitors(monitor, on_state, off_state)) - add_entities(switches) + async_add_entities(switches, True) class ZMSwitchMonitors(SwitchEntity): @@ -43,13 +63,25 @@ class ZMSwitchMonitors(SwitchEntity): icon = "mdi:record-rec" - def __init__(self, monitor, on_state, off_state): + def __init__( + self, + monitor: Monitor, + on_state: MonitorState, + off_state: MonitorState, + config_entry: ConfigEntry, + ): """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.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 21336463393775cdb053552605e2897582329ac6..045c5c26285f446822823d55a6635950bda06316 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -214,5 +214,6 @@ FLOWS = [ "yeelight", "zerproc", "zha", + "zoneminder", "zwave" ] diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9654dd741ed259d672aa514c7f25542073dc1a1d..61a0b79d5be90f650b44fbab2ae4143046f44015 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1081,3 +1081,6 @@ zigpy-znp==0.1.1 # homeassistant.components.zha zigpy==0.23.2 + +# homeassistant.components.zoneminder +zm-py==0.4.0 diff --git a/tests/components/zoneminder/__init__.py b/tests/components/zoneminder/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9ea5189a7b9fa105854aa97577ee233651f60e2d --- /dev/null +++ b/tests/components/zoneminder/__init__.py @@ -0,0 +1 @@ +"""Tests for the zoneminder component.""" diff --git a/tests/components/zoneminder/test_binary_sensor.py b/tests/components/zoneminder/test_binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..ee9883283e96c9238403e580fe2b433f0a038dd8 --- /dev/null +++ b/tests/components/zoneminder/test_binary_sensor.py @@ -0,0 +1,65 @@ +"""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" diff --git a/tests/components/zoneminder/test_camera.py b/tests/components/zoneminder/test_camera.py new file mode 100644 index 0000000000000000000000000000000000000000..06f4c3554dfdd05a41157ed1612a54d98d73a141 --- /dev/null +++ b/tests/components/zoneminder/test_camera.py @@ -0,0 +1,89 @@ +"""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" diff --git a/tests/components/zoneminder/test_config_flow.py b/tests/components/zoneminder/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..279613e2b38f078b5500119a82c06998e5fc9f25 --- /dev/null +++ b/tests/components/zoneminder/test_config_flow.py @@ -0,0 +1,119 @@ +"""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 diff --git a/tests/components/zoneminder/test_init.py b/tests/components/zoneminder/test_init.py new file mode 100644 index 0000000000000000000000000000000000000000..333106946bdcec6c659735a96e6b9ccef61c1913 --- /dev/null +++ b/tests/components/zoneminder/test_init.py @@ -0,0 +1,122 @@ +"""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) diff --git a/tests/components/zoneminder/test_sensor.py b/tests/components/zoneminder/test_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..0b9db89938753a6b264e27bac588066af303343a --- /dev/null +++ b/tests/components/zoneminder/test_sensor.py @@ -0,0 +1,167 @@ +"""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" diff --git a/tests/components/zoneminder/test_switch.py b/tests/components/zoneminder/test_switch.py new file mode 100644 index 0000000000000000000000000000000000000000..3665b2fa17eeea2c9b51abf030c5671e660cf6a2 --- /dev/null +++ b/tests/components/zoneminder/test_switch.py @@ -0,0 +1,126 @@ +"""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