Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ homeassistant/components/tado/* @michaelarnauts @bdraco
homeassistant/components/tag/* @balloob @dmulcahey
homeassistant/components/tahoma/* @philklei
homeassistant/components/tankerkoenig/* @guillempages
homeassistant/components/tasmota/* @emontnemery
homeassistant/components/tautulli/* @ludeeus
homeassistant/components/tellduslive/* @fredrike
homeassistant/components/template/* @PhracturedBlue @tetienne
Expand Down
30 changes: 30 additions & 0 deletions homeassistant/components/mqtt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1465,3 +1465,33 @@ async def forward_messages(mqttmsg: Message):
)

connection.send_message(websocket_api.result_message(msg["id"]))


@callback
def async_subscribe_connection_status(hass, connection_status_callback):
"""Subscribe to MQTT connection changes."""

@callback
def connected():
hass.async_add_job(connection_status_callback, True)

@callback
def disconnected():
_LOGGER.error("Calling connection_status_callback, False")
hass.async_add_job(connection_status_callback, False)

subscriptions = {
"connect": async_dispatcher_connect(hass, MQTT_CONNECTED, connected),
"disconnect": async_dispatcher_connect(hass, MQTT_DISCONNECTED, disconnected),
}

def unsubscribe():
subscriptions["connect"]()
subscriptions["disconnect"]()

return unsubscribe


def is_connected(hass):
"""Return if MQTT client is connected."""
return hass.data[DATA_MQTT].connected
104 changes: 104 additions & 0 deletions homeassistant/components/tasmota/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""The Tasmota integration."""
import logging

from hatasmota.const import (
CONF_MAC,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_NAME,
CONF_SW_VERSION,
)
from hatasmota.discovery import clear_discovery_topic
from hatasmota.mqtt import TasmotaMQTTClient

from homeassistant.components import mqtt
from homeassistant.components.mqtt.subscription import (
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType

from . import discovery
from .const import CONF_DISCOVERY_PREFIX
from .discovery import TASMOTA_DISCOVERY_DEVICE

_LOGGER = logging.getLogger(__name__)

DEVICE_MACS = "tasmota_devices"


async def async_setup(hass: HomeAssistantType, config: dict):
"""Set up the Tasmota component."""
return True


async def async_setup_entry(hass, entry):
"""Set up Tasmota from a config entry."""
hass.data[DEVICE_MACS] = {}

def _publish(*args, **kwds):
mqtt.async_publish(hass, *args, **kwds)

async def _subscribe_topics(sub_state, topics):
# Optionally mark message handlers as callback
for topic in topics.values():
if "msg_callback" in topic and "event_loop_safe" in topic:
topic["msg_callback"] = callback(topic["msg_callback"])
return await async_subscribe_topics(hass, sub_state, topics)

async def _unsubscribe_topics(sub_state):
return await async_unsubscribe_topics(hass, sub_state)

tasmota_mqtt = TasmotaMQTTClient(_publish, _subscribe_topics, _unsubscribe_topics)

discovery_prefix = entry.data[CONF_DISCOVERY_PREFIX]
await discovery.async_start(hass, discovery_prefix, entry, tasmota_mqtt)

async def async_discover_device(config, mac):
"""Discover and add a Tasmota device."""
await async_setup_device(hass, mac, config, entry, tasmota_mqtt)

async_dispatcher_connect(hass, TASMOTA_DISCOVERY_DEVICE, async_discover_device)

return True


async def _remove_device(hass, config_entry, mac, tasmota_mqtt):
"""Remove device from device registry."""
device_registry = await hass.helpers.device_registry.async_get_registry()
device = device_registry.async_get_device(set(), {(CONNECTION_NETWORK_MAC, mac)})

if device is None:
return

_LOGGER.debug("Removing tasmota device %s", mac)
device_registry.async_remove_device(device.id)
clear_discovery_topic(mac, config_entry.data[CONF_DISCOVERY_PREFIX], tasmota_mqtt)


async def _update_device(hass, config_entry, config):
"""Add or update device registry."""
device_registry = await hass.helpers.device_registry.async_get_registry()
config_entry_id = config_entry.entry_id
device_info = {
"connections": {(CONNECTION_NETWORK_MAC, config[CONF_MAC])},
"manufacturer": config[CONF_MANUFACTURER],
"model": config[CONF_MODEL],
"name": config[CONF_NAME],
"sw_version": config[CONF_SW_VERSION],
"config_entry_id": config_entry_id,
}
_LOGGER.debug("Adding or updating tasmota device %s", config[CONF_MAC])
device = device_registry.async_get_or_create(**device_info)
hass.data[DEVICE_MACS][device.id] = config[CONF_MAC]


async def async_setup_device(hass, mac, config, config_entry, tasmota_mqtt):
"""Set up the Tasmota device."""
if not config:
await _remove_device(hass, config_entry, mac, tasmota_mqtt)
else:
await _update_device(hass, config_entry, config)
56 changes: 56 additions & 0 deletions homeassistant/components/tasmota/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Config flow for Tasmota."""
import logging

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.components.mqtt import valid_subscribe_topic

from .const import ( # pylint:disable=unused-import
CONF_DISCOVERY_PREFIX,
DEFAULT_PREFIX,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)


class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH

async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")

return await self.async_step_config()

async def async_step_config(self, user_input=None):
"""Confirm the setup."""
errors = {}
data = {CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX}

if user_input is not None:
bad_prefix = False
if self.show_advanced_options:
prefix = user_input[CONF_DISCOVERY_PREFIX]
try:
valid_subscribe_topic(f"{prefix}/#")
except vol.Invalid:
errors["base"] = "invalid_discovery_topic"
bad_prefix = True
else:
data = user_input
if not bad_prefix:
return self.async_create_entry(title="Tasmota", data=data)

fields = {}
if self.show_advanced_options:
fields[vol.Optional(CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX)] = str

return self.async_show_form(
step_id="config", data_schema=vol.Schema(fields), errors=errors
)
6 changes: 6 additions & 0 deletions homeassistant/components/tasmota/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants used by multiple Tasmota modules."""
CONF_DISCOVERY_PREFIX = "discovery_prefix"

DEFAULT_PREFIX = "tasmota/discovery"

DOMAIN = "tasmota"
121 changes: 121 additions & 0 deletions homeassistant/components/tasmota/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Support for MQTT discovery."""
import asyncio
import logging

from hatasmota.const import CONF_RELAY
from hatasmota.discovery import (
TasmotaDiscovery,
get_device_config as tasmota_get_device_config,
get_entities_for_platform as tasmota_get_entities_for_platform,
get_entity as tasmota_get_entity,
has_entities_with_platform as tasmota_has_entities_with_platform,
)

from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import HomeAssistantType

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

SUPPORTED_COMPONENTS = {
"switch": CONF_RELAY,
}

ALREADY_DISCOVERED = "tasmota_discovered_components"
CONFIG_ENTRY_IS_SETUP = "tasmota_config_entry_is_setup"
DATA_CONFIG_ENTRY_LOCK = "tasmota_config_entry_lock"
TASMOTA_DISCOVERY_DEVICE = "tasmota_discovery_device"
TASMOTA_DISCOVERY_ENTITY_NEW = "tasmota_discovery_entity_new_{}"
TASMOTA_DISCOVERY_ENTITY_UPDATED = "tasmota_discovery_entity_updated_{}_{}_{}"


def clear_discovery_hash(hass, discovery_hash):
"""Clear entry in ALREADY_DISCOVERED list."""
del hass.data[ALREADY_DISCOVERED][discovery_hash]


def set_discovery_hash(hass, discovery_hash):
"""Set entry in ALREADY_DISCOVERED list."""
hass.data[ALREADY_DISCOVERED][discovery_hash] = {}


async def async_start(
hass: HomeAssistantType, discovery_topic, config_entry, tasmota_mqtt
) -> bool:
"""Start MQTT Discovery."""

async def async_device_discovered(payload, mac):
"""Process the received message."""

if ALREADY_DISCOVERED not in hass.data:
hass.data[ALREADY_DISCOVERED] = {}

_LOGGER.debug("Received discovery data for tasmota device: %s", mac)
tasmota_device_config = tasmota_get_device_config(payload)
async_dispatcher_send(
hass, TASMOTA_DISCOVERY_DEVICE, tasmota_device_config, mac
)

async with hass.data[DATA_CONFIG_ENTRY_LOCK]:
for component, component_key in SUPPORTED_COMPONENTS.items():
if not tasmota_has_entities_with_platform(payload, component_key):
continue
config_entries_key = f"{component}.tasmota"
if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]:
await hass.config_entries.async_forward_entry_setup(
config_entry, component
)
hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key)

for component, component_key in SUPPORTED_COMPONENTS.items():
tasmota_entities = tasmota_get_entities_for_platform(payload, component_key)
for (idx, tasmota_entity_config) in enumerate(tasmota_entities):
Comment thread
MartinHjelmare marked this conversation as resolved.
discovery_hash = (mac, component, idx)
if not tasmota_entity_config:
# Entity disabled, clean up entity registry
entity_registry = (
await hass.helpers.entity_registry.async_get_registry()
)
unique_id = "{}_{}_{}".format(*discovery_hash)
entity_id = entity_registry.async_get_entity_id(
component, DOMAIN, unique_id
)
if entity_id:
_LOGGER.debug(
"Removing entity: %s %s", component, discovery_hash
)
entity_registry.async_remove(entity_id)
continue

if discovery_hash in hass.data[ALREADY_DISCOVERED]:
_LOGGER.debug(
"Entity already added, sending update: %s %s",
component,
discovery_hash,
)
async_dispatcher_send(
hass,
TASMOTA_DISCOVERY_ENTITY_UPDATED.format(*discovery_hash),
tasmota_entity_config,
)
else:
_LOGGER.debug("Adding new entity: %s %s", component, discovery_hash)
hass.data[ALREADY_DISCOVERED][discovery_hash] = None

tasmota_entity = tasmota_get_entity(
tasmota_entity_config, component_key, tasmota_mqtt
)

async_dispatcher_send(
hass,
TASMOTA_DISCOVERY_ENTITY_NEW.format(component),
tasmota_entity,
discovery_hash,
)

hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock()
hass.data[CONFIG_ENTRY_IS_SETUP] = set()

tasmota_discovery = TasmotaDiscovery(discovery_topic, tasmota_mqtt)
await tasmota_discovery.start_discovery(async_device_discovered)
9 changes: 9 additions & 0 deletions homeassistant/components/tasmota/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be time to introduce a new Integration MQTT discovery type so that the MQTT integration can notify other integrations if it finds data at topics that those integrations can manage.

So in here we would do "mqtt_discovery": "tasmota/#" and if any message comes in, the MQTT integration starts the Tasmota config flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a great idea and I didn't forget about it!
Let's add MQTT integration discovery in a separate PR though?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yesss

"domain": "tasmota",
"name": "Tasmota (beta)",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tasmota",
"requirements": ["hatasmota==0.0.9"],
"dependencies": ["mqtt"],
"codeowners": ["@emontnemery"]
}
Loading