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