diff --git a/CODEOWNERS b/CODEOWNERS
index 788f3636143..a9d4ce63209 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -243,6 +243,7 @@ homeassistant/components/keba/* @dannerph
homeassistant/components/keenetic_ndms2/* @foxel
homeassistant/components/kef/* @basnijholt
homeassistant/components/keyboard_remote/* @bendavid
+homeassistant/components/kmtronic/* @dgomes
homeassistant/components/knx/* @Julius2342 @farmio @marvin-w
homeassistant/components/kodi/* @OnFreund @cgtobi
homeassistant/components/konnected/* @heythisisnate @kit-klein
diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py
new file mode 100644
index 00000000000..b55ab9e1c9c
--- /dev/null
+++ b/homeassistant/components/kmtronic/__init__.py
@@ -0,0 +1,104 @@
+"""The kmtronic integration."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import aiohttp
+import async_timeout
+from pykmtronic.auth import Auth
+from pykmtronic.hub import KMTronicHubAPI
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import (
+ CONF_HOSTNAME,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ DATA_COORDINATOR,
+ DATA_HOST,
+ DATA_HUB,
+ DOMAIN,
+ MANUFACTURER,
+)
+
+CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
+
+PLATFORMS = ["switch"]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the kmtronic component."""
+ hass.data[DOMAIN] = {}
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up kmtronic from a config entry."""
+
+ session = aiohttp_client.async_get_clientsession(hass)
+ auth = Auth(
+ session,
+ f"http://{entry.data[CONF_HOSTNAME]}",
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_PASSWORD],
+ )
+ hub = KMTronicHubAPI(auth)
+
+ async def async_update_data():
+ try:
+ async with async_timeout.timeout(10):
+ await hub.async_update_relays()
+ except aiohttp.client_exceptions.ClientResponseError as err:
+ raise UpdateFailed(f"Wrong credentials: {err}") from err
+ except (
+ asyncio.TimeoutError,
+ aiohttp.client_exceptions.ClientConnectorError,
+ ) as err:
+ raise UpdateFailed(f"Error communicating with API: {err}") from err
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name=f"{MANUFACTURER} {hub.name}",
+ update_method=async_update_data,
+ update_interval=timedelta(seconds=30),
+ )
+ await coordinator.async_refresh()
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][entry.entry_id] = {
+ DATA_HUB: hub,
+ DATA_HOST: entry.data[DATA_HOST],
+ DATA_COORDINATOR: coordinator,
+ }
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in PLATFORMS
+ ]
+ )
+ )
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py
new file mode 100644
index 00000000000..376bb64c34c
--- /dev/null
+++ b/homeassistant/components/kmtronic/config_flow.py
@@ -0,0 +1,74 @@
+"""Config flow for kmtronic integration."""
+import logging
+
+import aiohttp
+from pykmtronic.auth import Auth
+from pykmtronic.hub import KMTronicHubAPI
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.helpers import aiohttp_client
+
+from .const import CONF_HOSTNAME, CONF_PASSWORD, CONF_USERNAME
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema({CONF_HOSTNAME: str, CONF_USERNAME: str, CONF_PASSWORD: str})
+
+
+async def validate_input(hass: core.HomeAssistant, data):
+ """Validate the user input allows us to connect."""
+
+ session = aiohttp_client.async_get_clientsession(hass)
+ auth = Auth(
+ session,
+ f"http://{data[CONF_HOSTNAME]}",
+ data[CONF_USERNAME],
+ data[CONF_PASSWORD],
+ )
+ hub = KMTronicHubAPI(auth)
+
+ try:
+ await hub.async_get_status()
+ except aiohttp.client_exceptions.ClientResponseError as err:
+ raise InvalidAuth from err
+ except aiohttp.client_exceptions.ClientConnectorError as err:
+ raise CannotConnect from err
+
+ return data
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for kmtronic."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ try:
+ info = await validate_input(self.hass, user_input)
+
+ return self.async_create_entry(title=info["host"], data=user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+ """Error to indicate there is invalid auth."""
diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py
new file mode 100644
index 00000000000..58553217799
--- /dev/null
+++ b/homeassistant/components/kmtronic/const.py
@@ -0,0 +1,16 @@
+"""Constants for the kmtronic integration."""
+
+DOMAIN = "kmtronic"
+
+CONF_HOSTNAME = "host"
+CONF_USERNAME = "username"
+CONF_PASSWORD = "password"
+
+DATA_HUB = "hub"
+DATA_HOST = "host"
+DATA_COORDINATOR = "coordinator"
+
+MANUFACTURER = "KMtronic"
+ATTR_MANUFACTURER = "manufacturer"
+ATTR_IDENTIFIERS = "identifiers"
+ATTR_NAME = "name"
diff --git a/homeassistant/components/kmtronic/manifest.json b/homeassistant/components/kmtronic/manifest.json
new file mode 100644
index 00000000000..27e9f953eb7
--- /dev/null
+++ b/homeassistant/components/kmtronic/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "kmtronic",
+ "name": "KMtronic",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/kmtronic",
+ "requirements": ["pykmtronic==0.0.3"],
+ "codeowners": ["@dgomes"]
+}
diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json
new file mode 100644
index 00000000000..7becb830d91
--- /dev/null
+++ b/homeassistant/components/kmtronic/strings.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+}
diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py
new file mode 100644
index 00000000000..5970ec20cb8
--- /dev/null
+++ b/homeassistant/components/kmtronic/switch.py
@@ -0,0 +1,67 @@
+"""KMtronic Switch integration."""
+
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DATA_COORDINATOR, DATA_HOST, DATA_HUB, DOMAIN
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Config entry example."""
+ coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
+ hub = hass.data[DOMAIN][entry.entry_id][DATA_HUB]
+ host = hass.data[DOMAIN][entry.entry_id][DATA_HOST]
+ await hub.async_get_relays()
+
+ async_add_entities(
+ [
+ KMtronicSwitch(coordinator, host, relay, entry.unique_id)
+ for relay in hub.relays
+ ]
+ )
+
+
+class KMtronicSwitch(CoordinatorEntity, SwitchEntity):
+ """KMtronic Switch Entity."""
+
+ def __init__(self, coordinator, host, relay, config_entry_id):
+ """Pass coordinator to CoordinatorEntity."""
+ super().__init__(coordinator)
+ self._host = host
+ self._relay = relay
+ self._config_entry_id = config_entry_id
+
+ @property
+ def available(self) -> bool:
+ """Return whether the entity is available."""
+ return self.coordinator.last_update_success
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return f"Relay{self._relay.id}"
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID of the entity."""
+ return f"{self._config_entry_id}_relay{self._relay.id}"
+
+ @property
+ def entity_registry_enabled_default(self) -> bool:
+ """Return if the entity should be enabled when first added to the entity registry."""
+ return True
+
+ @property
+ def is_on(self):
+ """Return entity state."""
+ return self._relay.is_on
+
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn the switch on."""
+ await self._relay.turn_on()
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn the switch off."""
+ await self._relay.turn_off()
+ self.async_write_ha_state()
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 7e17a839068..8e8949e5788 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -115,6 +115,7 @@ FLOWS = [
"izone",
"juicenet",
"keenetic_ndms2",
+ "kmtronic",
"kodi",
"konnected",
"kulersky",
diff --git a/requirements_all.txt b/requirements_all.txt
index 0d8203279b5..05ed5e7aa92 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -1473,6 +1473,9 @@ pyitachip2ir==0.0.7
# homeassistant.components.kira
pykira==0.1.1
+# homeassistant.components.kmtronic
+pykmtronic==0.0.3
+
# homeassistant.components.kodi
pykodi==0.2.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 99a0351ee37..46c91feeb16 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -775,6 +775,9 @@ pyisy==2.1.0
# homeassistant.components.kira
pykira==0.1.1
+# homeassistant.components.kmtronic
+pykmtronic==0.0.3
+
# homeassistant.components.kodi
pykodi==0.2.1
diff --git a/tests/components/kmtronic/__init__.py b/tests/components/kmtronic/__init__.py
new file mode 100644
index 00000000000..2f089d6495f
--- /dev/null
+++ b/tests/components/kmtronic/__init__.py
@@ -0,0 +1 @@
+"""Tests for the kmtronic integration."""
diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py
new file mode 100644
index 00000000000..ebbbf626451
--- /dev/null
+++ b/tests/components/kmtronic/test_config_flow.py
@@ -0,0 +1,145 @@
+"""Test the kmtronic config flow."""
+from unittest.mock import Mock, patch
+
+from aiohttp import ClientConnectorError, ClientResponseError
+
+from homeassistant import config_entries, setup
+from homeassistant.components.kmtronic.const import DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
+
+from tests.common import MockConfigEntry
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status",
+ return_value=[Mock()],
+ ), patch(
+ "homeassistant.components.kmtronic.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.kmtronic.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "host": "1.1.1.1",
+ "username": "test-username",
+ "password": "test-password",
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "1.1.1.1"
+ assert result2["data"] == {
+ "host": "1.1.1.1",
+ "username": "test-username",
+ "password": "test-password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status",
+ side_effect=ClientResponseError(None, None, status=401),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "host": "1.1.1.1",
+ "username": "test-username",
+ "password": "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status",
+ side_effect=ClientConnectorError(None, Mock()),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "host": "1.1.1.1",
+ "username": "test-username",
+ "password": "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_unknown_error(hass):
+ """Test we handle unknown errors."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status",
+ side_effect=Exception(),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "host": "1.1.1.1",
+ "username": "test-username",
+ "password": "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_unload_config_entry(hass, aioclient_mock):
+ """Test entry unloading."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={"host": "1.1.1.1", "username": "admin", "password": "admin"},
+ )
+ config_entry.add_to_hass(hass)
+
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ text="00",
+ )
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ config_entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(config_entries) == 1
+ assert config_entries[0] is config_entry
+ assert config_entry.state == ENTRY_STATE_LOADED
+
+ await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state == ENTRY_STATE_NOT_LOADED
diff --git a/tests/components/kmtronic/test_switch.py b/tests/components/kmtronic/test_switch.py
new file mode 100644
index 00000000000..5eec3537176
--- /dev/null
+++ b/tests/components/kmtronic/test_switch.py
@@ -0,0 +1,150 @@
+"""The tests for the KMtronic switch platform."""
+import asyncio
+from datetime import timedelta
+
+from homeassistant.components.kmtronic.const import DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY
+from homeassistant.const import STATE_UNAVAILABLE
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+async def test_relay_on_off(hass, aioclient_mock):
+ """Tests the relay turns on correctly."""
+
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ text="00",
+ )
+
+ MockConfigEntry(
+ domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"}
+ ).add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+ # Mocks the response for turning a relay1 on
+ aioclient_mock.get(
+ "http://1.1.1.1/FF0101",
+ text="",
+ )
+
+ state = hass.states.get("switch.relay1")
+ assert state.state == "off"
+
+ await hass.services.async_call(
+ "switch", "turn_on", {"entity_id": "switch.relay1"}, blocking=True
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("switch.relay1")
+ assert state.state == "on"
+
+ # Mocks the response for turning a relay1 off
+ aioclient_mock.get(
+ "http://1.1.1.1/FF0100",
+ text="",
+ )
+
+ await hass.services.async_call(
+ "switch", "turn_off", {"entity_id": "switch.relay1"}, blocking=True
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("switch.relay1")
+ assert state.state == "off"
+
+
+async def test_update(hass, aioclient_mock):
+ """Tests switch refreshes status periodically."""
+ now = dt_util.utcnow()
+ future = now + timedelta(minutes=10)
+
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ text="00",
+ )
+
+ MockConfigEntry(
+ domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"}
+ ).add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, {})
+
+ await hass.async_block_till_done()
+ state = hass.states.get("switch.relay1")
+ assert state.state == "off"
+
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ text="11",
+ )
+ async_fire_time_changed(hass, future)
+
+ await hass.async_block_till_done()
+ state = hass.states.get("switch.relay1")
+ assert state.state == "on"
+
+
+async def test_config_entry_not_ready(hass, aioclient_mock):
+ """Tests configuration entry not ready."""
+
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ exc=asyncio.TimeoutError(),
+ )
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"}
+ )
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_failed_update(hass, aioclient_mock):
+ """Tests coordinator update fails."""
+ now = dt_util.utcnow()
+ future = now + timedelta(minutes=10)
+
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ text="00",
+ )
+
+ MockConfigEntry(
+ domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"}
+ ).add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, {})
+
+ await hass.async_block_till_done()
+ state = hass.states.get("switch.relay1")
+ assert state.state == "off"
+
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ text="401 Unauthorized: Password required",
+ status=401,
+ )
+ async_fire_time_changed(hass, future)
+
+ await hass.async_block_till_done()
+ state = hass.states.get("switch.relay1")
+ assert state.state == STATE_UNAVAILABLE
+
+ future += timedelta(minutes=10)
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(
+ "http://1.1.1.1/status.xml",
+ exc=asyncio.TimeoutError(),
+ )
+ async_fire_time_changed(hass, future)
+
+ await hass.async_block_till_done()
+ state = hass.states.get("switch.relay1")
+ assert state.state == STATE_UNAVAILABLE