Initial Home Assistant Configuration
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
"""Meross devices platform loader"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Tuple, Dict, Optional, Collection
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from meross_iot.controller.device import BaseDevice
|
||||
from meross_iot.http_api import MerossHttpClient, ErrorCodes
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.credentials import MerossCloudCreds
|
||||
from meross_iot.model.enums import OnlineStatus, Namespace
|
||||
from meross_iot.model.exception import CommandTimeoutError
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
from meross_iot.model.http.exception import (
|
||||
TokenExpiredException,
|
||||
TooManyTokensException,
|
||||
UnauthorizedException,
|
||||
HttpApiError, BadLoginException,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
ATTR_CONFIG,
|
||||
CLOUD_HANDLER,
|
||||
DOMAIN,
|
||||
HA_CLIMATE,
|
||||
HA_COVER,
|
||||
HA_FAN,
|
||||
HA_LIGHT,
|
||||
HA_SENSOR,
|
||||
HA_SWITCH,
|
||||
MANAGER,
|
||||
MEROSS_PLATFORMS,
|
||||
SENSORS,
|
||||
dismiss_notification,
|
||||
notify_error,
|
||||
log_exception,
|
||||
CONF_STORED_CREDS,
|
||||
LIMITER,
|
||||
CONF_HTTP_ENDPOINT, CONF_MQTT_SKIP_CERT_VALIDATION, HTTP_API_RE,
|
||||
HTTP_UPDATE_INTERVAL, DEVICE_LIST_COORDINATOR, calculate_id, DEFAULT_USER_AGENT, CONF_OPT_CUSTOM_USER_AGENT,
|
||||
CONF_OVERRIDE_MQTT_ENDPOINT, CONF_OPT_LAN, CONF_OPT_LAN_MQTT_ONLY, TRANSPORT_MODES_TO_ENUM,
|
||||
MEROSS_DEFAULT_CLOUD_API_URL
|
||||
)
|
||||
from .version import MEROSS_IOT_VERSION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HTTP_ENDPOINT): cv.string,
|
||||
vol.Required(CONF_MQTT_SKIP_CERT_VALIDATION): cv.boolean,
|
||||
vol.Optional(CONF_STORED_CREDS): cv.string,
|
||||
vol.Optional(CONF_OVERRIDE_MQTT_ENDPOINT): cv.string
|
||||
}
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def print_startup_message(http_devices: List[HttpDeviceInfo]):
|
||||
http_info = "\n".join(
|
||||
[f"- {x.dev_name} ({x.device_type}) - {x.online_status}" for x in http_devices]
|
||||
)
|
||||
|
||||
start_message = (
|
||||
f"\n"
|
||||
f"===============================\n"
|
||||
f"Meross Cloud Custom component\n"
|
||||
f"Developed by Alberto Geniola\n"
|
||||
f"Low level library version: {MEROSS_IOT_VERSION}\n"
|
||||
f"-------------------------------\n"
|
||||
f"This custom component is under development and not yet ready for production use.\n"
|
||||
f"In case of errors/misbehave, please report it here: \n"
|
||||
f"https://github.com/albertogeniola/meross-homeassistant/issues\n"
|
||||
f"\n"
|
||||
f"If you like this extension and you want to support it, please consider donating.\n"
|
||||
f"-------------------------------\n"
|
||||
f"List of devices reported by HTTP API:\n"
|
||||
f"{http_info}"
|
||||
f"\n==============================="
|
||||
)
|
||||
_LOGGER.warning(start_message)
|
||||
|
||||
|
||||
class MerossCoordinator(DataUpdateCoordinator):
|
||||
def __init__(self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
http_api_endpoint: str,
|
||||
creds: MerossCloudCreds,
|
||||
mqtt_skip_cert_validation: bool,
|
||||
mqtt_override_address: Optional[Tuple[str, int]],
|
||||
update_interval: timedelta,
|
||||
ua_header: str):
|
||||
|
||||
self._entry = config_entry
|
||||
self._http_api_endpoint = http_api_endpoint
|
||||
self._cached_creds = creds
|
||||
self._skip_cert_validation = mqtt_skip_cert_validation
|
||||
self._mqtt_override_address = mqtt_override_address
|
||||
self._setup_done = False
|
||||
self._ua_header = ua_header
|
||||
|
||||
# Objects not to be initialized here
|
||||
self._client = None
|
||||
self._manager = None
|
||||
|
||||
super().__init__(hass=hass, logger=_LOGGER, name="meross_http_coordinator", update_interval=update_interval,
|
||||
update_method=self._async_fetch_http_data)
|
||||
|
||||
async def _async_fetch_http_data(self):
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
# Fetch devices and compose a quick-access dictionary
|
||||
devices = await self._client.async_list_devices()
|
||||
return {device.uuid: device for device in devices}
|
||||
|
||||
except (BadLoginException, TokenExpiredException, UnauthorizedException) as err:
|
||||
# Raising ConfigEntryAuthFailed will cancel future updates
|
||||
# and start a config flow with SOURCE_REAUTH (async_step_reauth)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except HttpApiError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}")
|
||||
|
||||
async def initial_setup(self):
|
||||
if self._setup_done:
|
||||
raise ValueError("This coordinator was already set up")
|
||||
|
||||
# Test the stored credentials if any. In case the credentials are invalid
|
||||
# try to retrieve a new token
|
||||
try:
|
||||
self._client, http_devices, creds_renewed = await get_or_test_creds(
|
||||
http_api_url=self._http_api_endpoint,
|
||||
creds=self._cached_creds,
|
||||
ua_header=self._ua_header
|
||||
)
|
||||
except (BadLoginException, TokenExpiredException, UnauthorizedException) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except HttpApiError as err:
|
||||
raise ConfigEntryNotReady(f"Error communicating with API: {err}") from err
|
||||
|
||||
# If a new token was issued, store it into the current entry
|
||||
if creds_renewed:
|
||||
# Override the new credentials and store them into HA entry
|
||||
self._cached_creds = self._client.cloud_credentials
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry=self._entry,
|
||||
data={
|
||||
CONF_HTTP_ENDPOINT: self._cached_creds.domain,
|
||||
CONF_STORED_CREDS: {
|
||||
"token": self._cached_creds.token,
|
||||
"key": self._cached_creds.key,
|
||||
"user_id": self._cached_creds.user_id,
|
||||
"user_email": self._cached_creds.user_email,
|
||||
"issued_on": self._cached_creds.issued_on.isoformat(),
|
||||
"domain": self._cached_creds.domain,
|
||||
"mqtt_domain": self._cached_creds.mqtt_domain
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Now that we are logged in at HTTP api level, instantiate the manager.
|
||||
self._manager = MerossManager(
|
||||
http_client=self._client,
|
||||
mqtt_override_server=self._mqtt_override_address,
|
||||
auto_reconnect=True,
|
||||
mqtt_skip_cert_validation=self._skip_cert_validation,
|
||||
)
|
||||
|
||||
# Since we already have fetched for the DeviceList, publish it right away
|
||||
self.async_set_updated_data({device.uuid: device for device in http_devices})
|
||||
|
||||
# Print startup message, start the manager and issue a first discovery
|
||||
print_startup_message(http_devices=self.data.values())
|
||||
_LOGGER.info("Starting meross manager")
|
||||
await self._manager.async_init()
|
||||
_LOGGER.info("Discovering Meross devices...")
|
||||
await self._manager.async_device_discovery()
|
||||
|
||||
# If no exception is thrown so far, it means setup was successful
|
||||
self._setup_done = True
|
||||
|
||||
@property
|
||||
def manager(self) -> MerossManager:
|
||||
return self._manager
|
||||
|
||||
@property
|
||||
def client(self) -> MerossHttpClient:
|
||||
return self._client
|
||||
|
||||
|
||||
class MerossDevice(Entity):
|
||||
def __init__(self,
|
||||
device: BaseDevice,
|
||||
channel: int,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]],
|
||||
platform: str,
|
||||
supplementary_classifiers: Optional[List[str]] = None,
|
||||
override_channel_name: str = None):
|
||||
self._coordinator = device_list_coordinator
|
||||
self._device = device
|
||||
self._channel_id = channel
|
||||
self._last_http_state = None
|
||||
self._cb_async_remove_listener = None
|
||||
|
||||
base_name = f"{device.name} ({device.type})"
|
||||
if supplementary_classifiers is not None:
|
||||
self._id = calculate_id(platform=platform, uuid=device.internal_id, channel=channel,
|
||||
supplementary_classifiers=supplementary_classifiers)
|
||||
base_name += f" " + " ".join(supplementary_classifiers)
|
||||
else:
|
||||
self._id = calculate_id(platform=platform, uuid=device.internal_id, channel=channel)
|
||||
|
||||
if override_channel_name:
|
||||
channel_name = override_channel_name
|
||||
elif device.channels is not None and len(device.channels) > 0:
|
||||
channel_data = device.channels[channel]
|
||||
channel_name = channel_data.name
|
||||
else:
|
||||
channel_name = None
|
||||
self._entity_name = f"{base_name} - {channel_name}" if channel_name is not None else base_name
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return False
|
||||
|
||||
async def async_update(self):
|
||||
if self.online:
|
||||
try:
|
||||
await self._device.async_update()
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
|
||||
def _http_data_changed(self) -> None:
|
||||
new_data = self._coordinator.data.get(self._device.uuid)
|
||||
if self._last_http_state is not None and self._last_http_state.online_status != OnlineStatus.ONLINE and new_data.online_status == OnlineStatus.ONLINE:
|
||||
self._last_http_state = new_data
|
||||
self.async_schedule_update_ha_state(force_refresh=True)
|
||||
else:
|
||||
self._last_http_state = new_data
|
||||
self.async_schedule_update_ha_state(force_refresh=False)
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
if not self._coordinator.last_update_success:
|
||||
return False
|
||||
elif self._last_http_state is not None:
|
||||
return self._last_http_state.online_status == OnlineStatus.ONLINE
|
||||
else:
|
||||
return self._coordinator.data.get(self._device.uuid).online_status == OnlineStatus.ONLINE
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._entity_name
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
'identifiers': {(DOMAIN, self._device.internal_id)},
|
||||
'name': self._device.name,
|
||||
'manufacturer': 'Meross',
|
||||
'model': self._device.type + " " + self._device.hardware_version,
|
||||
'sw_version': self._device.firmware_version
|
||||
}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._coordinator.last_update_success and self.online
|
||||
|
||||
async def _async_push_notification_received(self, namespace: Namespace, data: dict, device_internal_id: str):
|
||||
update_state = False
|
||||
full_update = False
|
||||
|
||||
if namespace == Namespace.CONTROL_UNBIND:
|
||||
_LOGGER.warning(f"Received unbind event. Removing device %s from HA", self.name)
|
||||
await self.platform.async_remove_entity(self.entity_id)
|
||||
elif namespace == Namespace.SYSTEM_ONLINE:
|
||||
_LOGGER.info(f"Device %s reported online event.", self.name)
|
||||
online = OnlineStatus(int(data.get('online').get('status')))
|
||||
update_state = True
|
||||
full_update = online == OnlineStatus.ONLINE
|
||||
elif namespace == Namespace.HUB_ONLINE:
|
||||
_LOGGER.info(f"Device {self.name} reported (HUB) online event.")
|
||||
online = OnlineStatus(int(data.get('status')))
|
||||
update_state = True
|
||||
full_update = online == OnlineStatus.ONLINE
|
||||
else:
|
||||
update_state = True
|
||||
full_update = False
|
||||
|
||||
# In all other cases, just tell HA to update the internal state representation
|
||||
if update_state:
|
||||
self.async_schedule_update_ha_state(force_refresh=full_update)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self._device.register_push_notification_handler_coroutine(self._async_push_notification_received)
|
||||
self._cb_async_remove_listener = self._coordinator.async_add_listener(self._http_data_changed)
|
||||
self.hass.data[DOMAIN]["ADDED_ENTITIES_IDS"].add(self.unique_id)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
self._device.unregister_push_notification_handler_coroutine(self._async_push_notification_received)
|
||||
if self._cb_async_remove_listener is not None:
|
||||
self._cb_async_remove_listener()
|
||||
self.hass.data[DOMAIN]["ADDED_ENTITIES_IDS"].remove(self.unique_id)
|
||||
|
||||
|
||||
async def get_or_test_creds(
|
||||
creds: MerossCloudCreds = None,
|
||||
http_api_url: str = MEROSS_DEFAULT_CLOUD_API_URL,
|
||||
ua_header: str = DEFAULT_USER_AGENT
|
||||
) -> Tuple[MerossHttpClient, List[HttpDeviceInfo], bool]:
|
||||
|
||||
renewed = False
|
||||
http_client = MerossHttpClient(
|
||||
cloud_credentials=creds, ua_header=ua_header
|
||||
)
|
||||
|
||||
# The local addon api might not be able to provide the correct domain and mqtt values. In this case,
|
||||
# we patch them here.
|
||||
if http_api_url is not None and (http_client.cloud_credentials.domain is None or creds.domain.lower().strip() == MEROSS_DEFAULT_CLOUD_API_URL):
|
||||
_LOGGER.warning("Returned/Stored DOMAIN within existing credentials is <%s>. Patching the stored credentials with the correct value right away.", http_client.cloud_credentials.domain)
|
||||
http_client.cloud_credentials.domain = http_api_url
|
||||
renewed = True
|
||||
|
||||
# Test device listing. If goes ok, return it immediately. This will make API fail and ask re-login
|
||||
http_devices = await http_client.async_list_devices()
|
||||
return http_client, http_devices, renewed
|
||||
|
||||
|
||||
def _http_info_changed(known: Collection[HttpDeviceInfo], discovered: Collection[HttpDeviceInfo]) -> bool:
|
||||
"""Tells when a new device is discovered among the known ones"""
|
||||
known_ids = [dev.uuid for dev in known]
|
||||
unknown = [dev for dev in discovered if dev.uuid not in known_ids]
|
||||
return len(unknown) > 0
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||
"""
|
||||
This class is called by the HomeAssistant framework when a configuration entry is provided.
|
||||
For us, the configuration entry is the username-password credentials that the user
|
||||
needs to access the Meross cloud.
|
||||
"""
|
||||
|
||||
# Retrieve the stored credentials from config-flow
|
||||
http_api_endpoint = config_entry.data.get(CONF_HTTP_ENDPOINT)
|
||||
_LOGGER.info("Loaded %s: %s", CONF_HTTP_ENDPOINT, http_api_endpoint)
|
||||
|
||||
str_creds = config_entry.data.get(CONF_STORED_CREDS)
|
||||
_LOGGER.info("Loaded %s: %s", CONF_STORED_CREDS, "******")
|
||||
|
||||
mqtt_skip_cert_validation = config_entry.data.get(CONF_MQTT_SKIP_CERT_VALIDATION, True)
|
||||
_LOGGER.info("Skip MQTT cert validation option set to: %s", mqtt_skip_cert_validation)
|
||||
|
||||
mqtt_override_address = config_entry.data.get(CONF_OVERRIDE_MQTT_ENDPOINT)
|
||||
_LOGGER.info("Override MQTT address set to: %s", "no" if mqtt_override_address is None else "yes -> %s" % mqtt_override_address)
|
||||
|
||||
# Make sure we have all the needed requirements
|
||||
if http_api_endpoint is None or HTTP_API_RE.fullmatch(http_api_endpoint) is None:
|
||||
raise ConfigEntryAuthFailed("Missing or wrong HTTP_API_ENDPOINT")
|
||||
if str_creds is None:
|
||||
raise ConfigEntryAuthFailed("Missing credentials. Please re-authenticate.")
|
||||
if mqtt_override_address is not None:
|
||||
mqtt_host = mqtt_override_address.split(":")[0]
|
||||
mqtt_port = int(mqtt_override_address.split(":")[1])
|
||||
mqtt_override_address = (mqtt_host, mqtt_port)
|
||||
|
||||
issued_on = datetime.fromisoformat(str_creds.get("issued_on"))
|
||||
creds = MerossCloudCreds(
|
||||
domain=str_creds.get("domain", MEROSS_DEFAULT_CLOUD_API_URL),
|
||||
mqtt_domain=str_creds.get("mqtt_domain"),
|
||||
token=str_creds.get("token"),
|
||||
key=str_creds.get("key"),
|
||||
user_id=str_creds.get("user_id"),
|
||||
user_email=str_creds.get("user_email"),
|
||||
issued_on=issued_on
|
||||
)
|
||||
|
||||
# Initialize the HASS structure
|
||||
hass.data[DOMAIN] = {}
|
||||
hass.data[DOMAIN]["ADDED_ENTITIES_IDS"] = set()
|
||||
|
||||
# Retrieve options we need
|
||||
ua_header = config_entry.options.get(CONF_OPT_CUSTOM_USER_AGENT, DEFAULT_USER_AGENT)
|
||||
if ua_header == "" or not isinstance(ua_header, str):
|
||||
_LOGGER.warning("Invalid user-agent option specified in config <%s>; defaulting to <%s>", str(ua_header),
|
||||
str(DEFAULT_USER_AGENT))
|
||||
ua_header = DEFAULT_USER_AGENT
|
||||
|
||||
try:
|
||||
# Setup the coordinator
|
||||
meross_coordinator = MerossCoordinator(
|
||||
hass=hass,
|
||||
config_entry=config_entry,
|
||||
http_api_endpoint=http_api_endpoint,
|
||||
creds=creds,
|
||||
mqtt_skip_cert_validation=mqtt_skip_cert_validation,
|
||||
mqtt_override_address=mqtt_override_address,
|
||||
update_interval=timedelta(seconds=HTTP_UPDATE_INTERVAL),
|
||||
ua_header=ua_header
|
||||
)
|
||||
|
||||
# Initiate the coordinator. This method will also make sure to login to the API,
|
||||
# instantiates the manager, starts it and issues a first discovery.
|
||||
await meross_coordinator.initial_setup()
|
||||
manager = meross_coordinator.manager
|
||||
hass.data[DOMAIN][MANAGER] = manager
|
||||
hass.data[DOMAIN][DEVICE_LIST_COORDINATOR] = meross_coordinator
|
||||
|
||||
# Once the manager is ok and the first discovery was issued, we can proceed with platforms setup.
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, MEROSS_PLATFORMS)
|
||||
|
||||
def _http_api_polled(*args, **kwargs):
|
||||
# Whenever a new HTTP device is seen, we issue a discovery
|
||||
discovered_devices = meross_coordinator.data
|
||||
known_devices = manager.find_devices(device_uuids=discovered_devices.keys())
|
||||
if _http_info_changed(known_devices, discovered_devices.values()):
|
||||
_LOGGER.info("The HTTP API has found new devices that were unknown to us. Triggering discovery.")
|
||||
hass.create_task(manager.async_device_discovery(update_subdevice_status=True,
|
||||
cached_http_device_list=discovered_devices.values()))
|
||||
|
||||
# Register a handler for HTTP events so that we can check for new devices and trigger
|
||||
# a discovery when needed
|
||||
config_entry.async_on_unload(meross_coordinator.async_add_listener(_http_api_polled))
|
||||
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
|
||||
|
||||
return True
|
||||
|
||||
except TooManyTokensException:
|
||||
msg = (
|
||||
"Too many tokens have been issued to this account. "
|
||||
"The Remote API refused to issue a new one."
|
||||
)
|
||||
notify_error(hass, "http_connection", "Meross Cloud", msg)
|
||||
log_exception(msg, logger=_LOGGER)
|
||||
raise ConfigEntryAuthFailed("Too many tokens have been issued")
|
||||
|
||||
except (UnauthorizedException, HttpApiError) as ex:
|
||||
# Do not retry setup: user must update its credentials
|
||||
if ex is UnauthorizedException or ex.error_code in (
|
||||
ErrorCodes.CODE_TOKEN_INVALID,
|
||||
ErrorCodes.CODE_TOKEN_EXPIRED,
|
||||
ErrorCodes.CODE_TOKEN_ERROR,
|
||||
):
|
||||
raise ConfigEntryAuthFailed("Invalid token or credentials")
|
||||
else:
|
||||
msg = "Your Meross login credentials are invalid or the network could not be reached at the moment."
|
||||
notify_error(
|
||||
hass,
|
||||
"http_connection",
|
||||
"Meross Cloud",
|
||||
"Could not connect to the Meross cloud. Please check"
|
||||
" your internet connection and your Meross credentials",
|
||||
)
|
||||
log_exception(msg, logger=_LOGGER)
|
||||
raise ConfigEntryNotReady()
|
||||
|
||||
|
||||
async def update_listener(hass, entry):
|
||||
"""Handle options update."""
|
||||
# Update options
|
||||
custom_ua = entry.options.get(CONF_OPT_CUSTOM_USER_AGENT, DEFAULT_USER_AGENT)
|
||||
transport_mode = entry.options.get(CONF_OPT_LAN, CONF_OPT_LAN_MQTT_ONLY)
|
||||
manager_transport_mode = TRANSPORT_MODES_TO_ENUM[transport_mode]
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER]
|
||||
manager.default_transport_mode = manager_transport_mode
|
||||
# So far, the underlying Meross Library requires some "monkey patching" to set the
|
||||
# http user agent to be used. It's not nice, but until a public setter gets exposed, we need
|
||||
# to do so.
|
||||
manager._http_client._ua_header = custom_ua
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a config entry."""
|
||||
# Unload entities first
|
||||
_LOGGER.info("Removing Meross Cloud integration.")
|
||||
_LOGGER.info("Cleaning up resources...")
|
||||
|
||||
for platform in MEROSS_PLATFORMS:
|
||||
_LOGGER.info(f"Cleaning up platform {platform}")
|
||||
await hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
|
||||
_LOGGER.info("Stopping manager...")
|
||||
manager = hass.data[DOMAIN][MANAGER]
|
||||
# TODO: Invalidate the token?
|
||||
manager.close()
|
||||
|
||||
_LOGGER.info("Cleaning up memory...")
|
||||
for plat in MEROSS_PLATFORMS:
|
||||
if plat in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][plat].clear()
|
||||
del hass.data[DOMAIN][plat]
|
||||
del hass.data[DOMAIN][MANAGER]
|
||||
hass.data[DOMAIN].clear()
|
||||
del hass.data[DOMAIN]
|
||||
|
||||
_LOGGER.info("Meross cloud component removal done.")
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass, entry) -> None:
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""
|
||||
This method gets called if HomeAssistant has a valid meross_cloud: configuration entry within
|
||||
configurations.yaml.
|
||||
|
||||
Thus, in this method we simply trigger the creation of a config entry.
|
||||
|
||||
:return:
|
||||
"""
|
||||
|
||||
conf = config.get(DOMAIN)
|
||||
hass.data[DOMAIN] = {}
|
||||
hass.data[DOMAIN][ATTR_CONFIG] = conf
|
||||
|
||||
if conf is not None:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,300 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from meross_iot.controller.device import BaseDevice
|
||||
from meross_iot.controller.mixins.thermostat import ThermostatModeMixin
|
||||
from meross_iot.controller.subdevice import Mts100v3Valve
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.enums import ThermostatV3Mode, ThermostatMode
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
|
||||
# Conditional import for switch device
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.components.climate import ClimateEntity
|
||||
from homeassistant.components.climate import ClimateEntityFeature, HVACMode, HVACAction
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import MerossDevice
|
||||
from .common import (DOMAIN, MANAGER, HA_CLIMATE, DEVICE_LIST_COORDINATOR)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValveEntityWrapper(MerossDevice, ClimateEntity):
|
||||
"""Wrapper class to adapt the Meross devices into the Homeassistant platform"""
|
||||
_device: Mts100v3Valve
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
# For now, we assume that every Meross Valve supports the following modes.
|
||||
# This might be improved in the future by looking at the device abilities via get_abilities()
|
||||
_flags = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: Mts100v3Valve,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_CLIMATE)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
|
||||
# Turn on the device if not already on
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self._device.async_turn_off()
|
||||
return
|
||||
elif not self._device.is_on():
|
||||
await self._device.async_turn_on()
|
||||
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
await self._device.async_set_mode(ThermostatV3Mode.HEAT)
|
||||
elif hvac_mode == HVACMode.AUTO:
|
||||
await self._device.async_set_mode(ThermostatV3Mode.AUTO)
|
||||
elif hvac_mode == HVACMode.COOL:
|
||||
await self._device.async_set_mode(ThermostatV3Mode.COOL)
|
||||
else:
|
||||
_LOGGER.warning(f"Unsupported mode for this device ({self.name}): {hvac_mode}")
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
await self._device.async_set_mode(ThermostatV3Mode[preset_mode])
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
target = kwargs.get('temperature')
|
||||
await self._device.async_set_target_temperature(target)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> Optional[float]:
|
||||
return self._device.last_sampled_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> Optional[float]:
|
||||
return self._device.target_temperature
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> Optional[float]:
|
||||
return 0.5
|
||||
|
||||
@property
|
||||
def max_temp(self) -> Optional[float]:
|
||||
return self._device.max_supported_temperature
|
||||
|
||||
@property
|
||||
def min_temp(self) -> Optional[float]:
|
||||
return self._device.min_supported_temperature
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> str:
|
||||
if not self._device.is_on():
|
||||
return HVACMode.OFF
|
||||
elif self._device.mode == ThermostatV3Mode.AUTO:
|
||||
return HVACMode.AUTO
|
||||
elif self._device.mode == ThermostatV3Mode.HEAT:
|
||||
return HVACMode.HEAT
|
||||
elif self._device.mode == ThermostatV3Mode.COOL:
|
||||
return HVACMode.COOL
|
||||
elif self._device.mode == ThermostatV3Mode.ECONOMY:
|
||||
return HVACMode.AUTO
|
||||
elif self._device.mode == ThermostatV3Mode.CUSTOM:
|
||||
if self._device.last_sampled_temperature < self._device.target_temperature:
|
||||
return HVACMode.HEAT
|
||||
else:
|
||||
return HVACMode.COOL
|
||||
else:
|
||||
raise ValueError("Unsupported thermostat mode reported.")
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> Optional[str]:
|
||||
if not self._device.is_on():
|
||||
return HVACAction.OFF
|
||||
elif self._device.is_heating:
|
||||
return HVACAction.HEATING
|
||||
elif self._device.mode == HVACAction.COOLING:
|
||||
return HVACAction.COOLING
|
||||
else:
|
||||
return HVACAction.IDLE
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> List[str]:
|
||||
return [HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, HVACMode.COOL]
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> Optional[str]:
|
||||
if self._device.mode is not None:
|
||||
return self._device.mode.name
|
||||
return None
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> List[str]:
|
||||
return [e.name for e in ThermostatV3Mode]
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
return self._flags
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
await self.async_set_hvac_mode(HVACMode.HEATING)
|
||||
|
||||
|
||||
class MerossThermostatDevice(ThermostatModeMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ThermostatEntityWrapper(MerossDevice, ClimateEntity):
|
||||
"""Wrapper class to adapt the Meross thermostat-enabled devices into the Homeassistant platform"""
|
||||
_device: MerossThermostatDevice
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_flags = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF # | ClimateEntityFeature.PRESET_MODE
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: Mts100v3Valve,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_CLIMATE)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
|
||||
# Turn on the device if not already on
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self._device.async_set_thermostat_config(on_not_off=False, channel=self._channel_id)
|
||||
return
|
||||
elif not self._device.get_thermostat_state(channel=self._channel_id).is_on:
|
||||
await self._device.async_set_thermostat_config(on_not_off=True, channel=self._channel_id)
|
||||
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
await self._device.async_set_thermostat_config(mode=ThermostatMode.HEAT)
|
||||
elif hvac_mode == HVACMode.AUTO:
|
||||
await self._device.async_set_thermostat_config(mode=ThermostatMode.AUTO)
|
||||
elif hvac_mode == HVACMode.COOL:
|
||||
await self._device.async_set_thermostat_config(mode=ThermostatMode.COOL)
|
||||
else:
|
||||
_LOGGER.warning(f"Unsupported mode for this device ({self.name}): {hvac_mode}")
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
target = kwargs.get('temperature')
|
||||
await self._device.async_set_thermostat_config(channel=self._channel_id, mode=ThermostatMode.MANUAL, manual_temperature_celsius=target)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
# TODO: Check if there is a way for retrieving the Merasurement Unit from the library
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> Optional[float]:
|
||||
return self._device.get_thermostat_state(channel=self._channel_id).current_temperature_celsius
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> Optional[float]:
|
||||
return self._device.get_thermostat_state(channel=self._channel_id).target_temperature_celsius
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> Optional[float]:
|
||||
return 0.5
|
||||
|
||||
@property
|
||||
def max_temp(self) -> Optional[float]:
|
||||
return self._device.get_thermostat_state().max_temperature_celsius
|
||||
|
||||
@property
|
||||
def min_temp(self) -> Optional[float]:
|
||||
return self._device.get_thermostat_state().min_temperature_celsius
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
status = self._device.get_thermostat_state(channel=self._channel_id)
|
||||
if not status.is_on:
|
||||
return HVACMode.OFF
|
||||
elif status.mode == ThermostatMode.AUTO:
|
||||
return HVACMode.AUTO
|
||||
elif status.mode == ThermostatMode.HEAT:
|
||||
return HVACMode.HEAT
|
||||
elif status.mode == ThermostatMode.COOL:
|
||||
return HVACMode.COOL
|
||||
elif status.mode == ThermostatMode.ECONOMY:
|
||||
return HVACMode.AUTO
|
||||
elif status.mode == ThermostatMode.MANUAL:
|
||||
if status.current_temperature_celsius < status.target_temperature_celsius:
|
||||
return HVACMode.HEAT
|
||||
else:
|
||||
return HVACMode.COOL
|
||||
else:
|
||||
raise ValueError("Unsupported thermostat mode reported.")
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> Optional[str]:
|
||||
status = self._device.get_thermostat_state(channel=self._channel_id)
|
||||
if not status.is_on:
|
||||
return HVACAction.OFF
|
||||
elif status.current_temperature_celsius < status.target_temperature_celsius:
|
||||
return HVACAction.HEATING
|
||||
elif status.current_temperature_celsius > status.target_temperature_celsius:
|
||||
return HVACAction.COOLING
|
||||
elif status.current_temperature_celsius == status.target_temperature_celsius:
|
||||
return HVACAction.IDLE
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> List[HVACMode]:
|
||||
return [HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, HVACMode.COOL]
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
return self._flags
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
await self.async_set_hvac_mode(HVACMode.HEATING)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
def entity_adder_callback():
|
||||
"""Discover and adds new Meross entities"""
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER] # type
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
devices = manager.find_devices()
|
||||
new_entities = []
|
||||
valves = filter(lambda d: isinstance(d, Mts100v3Valve), devices)
|
||||
thermostats = filter(lambda d: isinstance(d, ThermostatModeMixin), devices)
|
||||
|
||||
for d in valves:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
w = ValveEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
for d in thermostats:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
w = ThermostatEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
async_add_entities(new_entities, True)
|
||||
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
coordinator.async_add_listener(entity_adder_callback)
|
||||
# Run the entity adder a first time during setup
|
||||
entity_adder_callback()
|
||||
|
||||
# TODO: Implement entry unload
|
||||
# TODO: Unload entry
|
||||
# TODO: Remove entry
|
||||
|
||||
|
||||
def setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
from meross_iot.controller.device import BaseDevice
|
||||
from meross_iot.manager import TransportMode
|
||||
|
||||
from . import version
|
||||
from .version import MEROSS_IOT_VERSION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
MEROSS_DEFAULT_CLOUD_API_URL = "https://iot.meross.com"
|
||||
MEROSS_LOCAL_API_URL = "http://homeassistant.local:2003"
|
||||
MEROSS_LOCAL_MQTT_BROKER_URI = "homeassistant.local:2001"
|
||||
MEROSS_LOCAL_MDNS_API_SERVICE_TYPE = "_meross-api._tcp.local."
|
||||
MEROSS_LOCAL_MDNS_MQTT_SERVICE_TYPE = "_meross-mqtt._tcp.local."
|
||||
MEROSS_LOCAL_MDNS_SERVICE_TYPES = [MEROSS_LOCAL_MDNS_API_SERVICE_TYPE, MEROSS_LOCAL_MDNS_MQTT_SERVICE_TYPE]
|
||||
DOMAIN = "meross_cloud"
|
||||
ATTR_CONFIG = "config"
|
||||
MANAGER = "manager"
|
||||
DEVICE_LIST_COORDINATOR = "device_list_coordinator"
|
||||
LIMITER = "limiter"
|
||||
CLOUD_HANDLER = "cloud_handler"
|
||||
MEROSS_MANAGER = "%s.%s" % (DOMAIN, MANAGER)
|
||||
SENSORS = "sensors"
|
||||
HA_SWITCH = "switch"
|
||||
HA_LIGHT = "light"
|
||||
HA_SENSOR = "sensor"
|
||||
HA_COVER = "cover"
|
||||
HA_CLIMATE = "climate"
|
||||
HA_FAN = "fan"
|
||||
HA_HUMIDIFIER = "humidifier"
|
||||
MEROSS_PLATFORMS = (HA_SWITCH, HA_LIGHT, HA_COVER, HA_SENSOR, HA_CLIMATE, HA_HUMIDIFIER)
|
||||
CONNECTION_TIMEOUT_THRESHOLD = 5
|
||||
|
||||
CONF_STORED_CREDS = "stored_credentials"
|
||||
CONF_MQTT_SKIP_CERT_VALIDATION = "skip_mqtt_cert_validation"
|
||||
CONF_OVERRIDE_MQTT_ENDPOINT = "override_mqtt_endpoint"
|
||||
CONF_HTTP_ENDPOINT = "http_api_endpoint"
|
||||
CONF_WORKING_MODE = "working_mode"
|
||||
CONF_WORKING_MODE_CLOUD_MODE = "cloud_mode"
|
||||
CONF_WORKING_MODE_LOCAL_MODE = "local_mode"
|
||||
CONF_MFA_CODE = "mfa_code"
|
||||
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
MULTIPLE_BROKERS_FOUND = "multiple_brokers_found"
|
||||
MULTIPLE_APIS_FOUND = "multiple_apis_found"
|
||||
DIFFERENT_HOSTS_FOR_BROKER_AND_API = "different_hosts_for_broker_and_api"
|
||||
|
||||
CONF_OPT_CUSTOM_USER_AGENT = "custom_user_agent"
|
||||
CONF_OPT_LAN = "lan_transport_mode"
|
||||
CONF_OPT_LAN_MQTT_ONLY = "conf_opt_lan_mqtt_only"
|
||||
CONF_OPT_LAN_HTTP_FIRST = "conf_opt_lan_http_first"
|
||||
CONF_OPT_LAN_HTTP_FIRST_ONLY_GET = "conf_opt_lan_http_first_only_get"
|
||||
|
||||
HA_SENSOR_POLL_INTERVAL_SECONDS = 30 # HA sensor polling interval
|
||||
HTTP_UPDATE_INTERVAL = 120 # Meross Cloud "discovery" interval
|
||||
UNIT_PERCENTAGE = "%"
|
||||
|
||||
ATTR_API_CALLS_PER_SECOND = "api_calls_per_second"
|
||||
ATTR_DELAYED_API_CALLS_PER_SECOND = "delayed_api_calls_per_second"
|
||||
ATTR_DROPPED_API_CALLS_PER_SECOND = "dropped_api_calls_per_second"
|
||||
|
||||
HTTP_API_RE = re.compile("(http://|https://)?([^:]+)(:([0-9]+))?")
|
||||
|
||||
DEFAULT_USER_AGENT = f"MerossHA/{version.MEROSS_INTEGRATION_VERSION}"
|
||||
|
||||
TRANSPORT_MODES_TO_ENUM = {
|
||||
CONF_OPT_LAN_MQTT_ONLY: TransportMode.MQTT_ONLY,
|
||||
CONF_OPT_LAN_HTTP_FIRST: TransportMode.LAN_HTTP_FIRST,
|
||||
CONF_OPT_LAN_HTTP_FIRST_ONLY_GET: TransportMode.LAN_HTTP_FIRST_ONLY_GET
|
||||
}
|
||||
|
||||
|
||||
def calculate_id(platform: str, uuid: str, channel: int, supplementary_classifiers: List[str] = None) -> str:
|
||||
base = "%s:%s:%d" % (platform, uuid, channel)
|
||||
if supplementary_classifiers is not None:
|
||||
extrastr = ":".join(supplementary_classifiers)
|
||||
if extrastr != "":
|
||||
extrastr = ":" + extrastr
|
||||
return base + extrastr
|
||||
return base
|
||||
|
||||
|
||||
def dismiss_notification(hass, notification_id):
|
||||
hass.async_create_task(
|
||||
hass.services.async_call(
|
||||
domain="persistent_notification",
|
||||
service="dismiss",
|
||||
service_data={"notification_id": "%s.%s" % (DOMAIN, notification_id)},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def notify_error(hass, notification_id, title, message):
|
||||
hass.async_create_task(
|
||||
hass.services.async_call(
|
||||
domain="persistent_notification",
|
||||
service="create",
|
||||
service_data={
|
||||
"title": title,
|
||||
"message": message,
|
||||
"notification_id": "%s.%s" % (DOMAIN, notification_id),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def log_exception(
|
||||
message: str = None, logger: logging = None, device: BaseDevice = None
|
||||
):
|
||||
if logger is None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if message is None:
|
||||
message = "An exception occurred"
|
||||
|
||||
device_info = "<Unavailable>"
|
||||
if device is not None:
|
||||
device_info = (
|
||||
f"\tName: {device.name}\n"
|
||||
f"\tUUID: {device.uuid}\n"
|
||||
f"\tType: {device.type}\n\t"
|
||||
f"HW Version: {device.hardware_version}\n"
|
||||
f"\tFW Version: {device.firmware_version}"
|
||||
)
|
||||
|
||||
formatted_message = (
|
||||
f"Error occurred.\n"
|
||||
f"-------------------------------------\n"
|
||||
f"Component version: {MEROSS_IOT_VERSION}\n"
|
||||
f"Device info: \n"
|
||||
f"{device_info}\n"
|
||||
f'Error Message: "{message}"'
|
||||
)
|
||||
logger.exception(formatted_message)
|
||||
|
||||
|
||||
def invoke_method_or_property(obj, method_or_property):
|
||||
# We only call the explicit method if the sampled value is older than 10 seconds.
|
||||
attr = getattr(obj, method_or_property)
|
||||
if callable(attr):
|
||||
return attr()
|
||||
else:
|
||||
return attr
|
||||
|
||||
|
||||
def extract_subdevice_notification_data(
|
||||
data: dict, filter_accessor: str, subdevice_id: str
|
||||
) -> Dict:
|
||||
# Operate only on relative accessor
|
||||
context = data.get(filter_accessor)
|
||||
|
||||
for notification in context:
|
||||
if notification.get("id") != subdevice_id:
|
||||
continue
|
||||
return notification
|
||||
@@ -0,0 +1,436 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import voluptuous as vol
|
||||
from aiohttp import ClientConnectorSSLError, ClientConnectorError
|
||||
from zeroconf import ServiceStateChange, Zeroconf
|
||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry, OptionsFlow, ConfigError
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig, SelectSelectorMode
|
||||
from meross_iot.http_api import MerossHttpClient
|
||||
from meross_iot.model.credentials import MerossCloudCreds
|
||||
from meross_iot.model.http.exception import UnauthorizedException, MissingMFA, BadLoginException
|
||||
from requests.exceptions import ConnectTimeout
|
||||
from homeassistant.components import zeroconf
|
||||
|
||||
from .common import DOMAIN, CONF_STORED_CREDS, CONF_WORKING_MODE, CONF_WORKING_MODE_LOCAL_MODE, \
|
||||
CONF_WORKING_MODE_CLOUD_MODE, CONF_MFA_CODE, \
|
||||
CONF_HTTP_ENDPOINT, CONF_MQTT_SKIP_CERT_VALIDATION, CONF_OPT_CUSTOM_USER_AGENT, HTTP_API_RE, \
|
||||
MEROSS_DEFAULT_CLOUD_API_URL, \
|
||||
MEROSS_LOCAL_API_URL, MEROSS_LOCAL_MDNS_SERVICE_TYPES, MEROSS_LOCAL_MDNS_MQTT_SERVICE_TYPE, \
|
||||
MEROSS_LOCAL_MDNS_API_SERVICE_TYPE, CONF_OVERRIDE_MQTT_ENDPOINT, MULTIPLE_APIS_FOUND, MULTIPLE_BROKERS_FOUND, \
|
||||
UNKNOWN_ERROR, \
|
||||
DIFFERENT_HOSTS_FOR_BROKER_AND_API, MEROSS_LOCAL_MQTT_BROKER_URI, CONF_OPT_LAN, CONF_OPT_LAN_MQTT_ONLY, \
|
||||
CONF_OPT_LAN_HTTP_FIRST, CONF_OPT_LAN_HTTP_FIRST_ONLY_GET, DEFAULT_USER_AGENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class ConfigUiException(Exception):
|
||||
def __init__(self, error_code=UNKNOWN_ERROR, *args: object) -> None:
|
||||
super().__init__(*args)
|
||||
self._code = error_code
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
|
||||
class MerossFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle Meross config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize flow."""
|
||||
self._http_api: Optional[str] = None
|
||||
self._mqtt_boker: Optional[str] = None
|
||||
self._username: Optional[str] = None
|
||||
self._password: Optional[str] = None
|
||||
self._local_mode: bool = False
|
||||
self._skip_cert_validation: Optional[bool] = None
|
||||
self._discovered_services: List[AsyncServiceInfo] = []
|
||||
|
||||
def _build_setup_schema(
|
||||
self,
|
||||
http_endpoint: str = None,
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
override_mqtt_endpoint: str = None,
|
||||
skip_cert_validation: bool = None,
|
||||
requires_mfa: bool = False
|
||||
) -> vol.Schema:
|
||||
http_endpoint_default = http_endpoint if http_endpoint is not None else self._http_api
|
||||
mqtt_endpoint_default = override_mqtt_endpoint if override_mqtt_endpoint is not None else self._mqtt_boker
|
||||
username_default = username if username is not None else self._username
|
||||
password_default = password if password is not None else self._password
|
||||
skip_cert_validation_default = skip_cert_validation if skip_cert_validation is not None else self._skip_cert_validation
|
||||
|
||||
if self._local_mode:
|
||||
schema_params = {
|
||||
vol.Required(CONF_HTTP_ENDPOINT, default=http_endpoint_default): str,
|
||||
vol.Required(CONF_OVERRIDE_MQTT_ENDPOINT, default=mqtt_endpoint_default): str,
|
||||
vol.Required(CONF_USERNAME, default=username_default): str,
|
||||
vol.Required(CONF_PASSWORD, default=password_default): str,
|
||||
}
|
||||
else:
|
||||
schema_params = {
|
||||
vol.Required(CONF_HTTP_ENDPOINT, default=http_endpoint_default): str,
|
||||
vol.Required(CONF_USERNAME, default=username_default): str,
|
||||
vol.Required(CONF_PASSWORD, default=password_default): str,
|
||||
}
|
||||
|
||||
if requires_mfa:
|
||||
schema_params[vol.Required(CONF_MFA_CODE)] = str
|
||||
schema_params[vol.Required(CONF_MQTT_SKIP_CERT_VALIDATION, default=skip_cert_validation_default)] = bool
|
||||
return vol.Schema(schema_params)
|
||||
|
||||
async def async_step_reauth(self, user_input=None):
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(self, user_input=None):
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def _resolve_service(self, zeroconf: Zeroconf, service_type: str, name: str):
|
||||
_LOGGER.debug("MDNS resolving service type: %s, name: %s", service_type, name)
|
||||
info = AsyncServiceInfo(service_type, name)
|
||||
await info.async_request(zeroconf, 3000)
|
||||
if info:
|
||||
self._discovered_services.append(info)
|
||||
|
||||
def _async_on_service_state_change(self, zeroconf: Zeroconf, service_type: str, name: str,
|
||||
state_change: ServiceStateChange) -> None:
|
||||
_LOGGER.debug("MDNS discovery state: %s, service type: %s, name: %s", str(state_change), service_type, name)
|
||||
if state_change is not ServiceStateChange.Added:
|
||||
return
|
||||
# Resolve the service on a different async task
|
||||
asyncio.ensure_future(self._resolve_service(zeroconf, service_type, name))
|
||||
|
||||
async def _discover_services(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
self._discovered_services.clear()
|
||||
aiozc = await zeroconf.async_get_async_instance(self.hass)
|
||||
browser = AsyncServiceBrowser(aiozc.zeroconf, MEROSS_LOCAL_MDNS_SERVICE_TYPES,
|
||||
handlers=[self._async_on_service_state_change])
|
||||
# Wait a bit to collect MDNS responses and then stop the browser
|
||||
await asyncio.sleep(5)
|
||||
await browser.async_cancel()
|
||||
api_endpoint_info = None
|
||||
mqtt_endpoint_info = None
|
||||
|
||||
mqtt_count = 0
|
||||
api_count = 0
|
||||
_LOGGER.info("Found %d mdns services.", len(self._discovered_services))
|
||||
for info in self._discovered_services:
|
||||
if info.type == MEROSS_LOCAL_MDNS_API_SERVICE_TYPE:
|
||||
api_count += 1
|
||||
api_endpoint_info = info
|
||||
_LOGGER.info("Found [%d] Local Meross API service listening on %s:%d", api_count,
|
||||
api_endpoint_info.server, api_endpoint_info.port)
|
||||
elif info.type == MEROSS_LOCAL_MDNS_MQTT_SERVICE_TYPE:
|
||||
mqtt_count += 1
|
||||
mqtt_endpoint_info = info
|
||||
_LOGGER.info("Found [%d] Local Meross MQTT service listening on %s:%d", mqtt_count,
|
||||
mqtt_endpoint_info.server, mqtt_endpoint_info.port)
|
||||
|
||||
if mqtt_count < 1 or api_count < 1:
|
||||
_LOGGER.info("The API/MQTT discovery was unable to find any relevant service.")
|
||||
return None, None
|
||||
|
||||
if mqtt_count > 1:
|
||||
raise ConfigUiException(MULTIPLE_BROKERS_FOUND)
|
||||
|
||||
if api_count > 1:
|
||||
raise ConfigUiException(MULTIPLE_APIS_FOUND)
|
||||
|
||||
if api_endpoint_info.server != mqtt_endpoint_info.server:
|
||||
raise ConfigUiException(DIFFERENT_HOSTS_FOR_BROKER_AND_API)
|
||||
|
||||
api_endpoint = f"http://{api_endpoint_info.server[:-1]}:{api_endpoint_info.port}"
|
||||
mqtt_endpoint = f"{mqtt_endpoint_info.server[:-1]}:{mqtt_endpoint_info.port}"
|
||||
return api_endpoint, mqtt_endpoint
|
||||
|
||||
async def async_step_user(self, user_input=None) -> Dict[str, Any]:
|
||||
"""Choose mode step handler"""
|
||||
_LOGGER.debug("Starting STEP_USER")
|
||||
if not user_input:
|
||||
_LOGGER.debug("Empty user_input, showing mode selection form")
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_WORKING_MODE, default=CONF_WORKING_MODE_CLOUD_MODE): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": CONF_WORKING_MODE_CLOUD_MODE,
|
||||
"label": "Connect to Meross Official Cloud (requires internet)"},
|
||||
{"value": CONF_WORKING_MODE_LOCAL_MODE,
|
||||
"label": "Connect to LAN-only broker (requires Meross Local Addon)"}],
|
||||
mode=SelectSelectorMode.LIST,
|
||||
))}),
|
||||
errors={})
|
||||
|
||||
mode = user_input.get(CONF_WORKING_MODE)
|
||||
if mode == CONF_WORKING_MODE_CLOUD_MODE:
|
||||
self._local_mode = False
|
||||
self._http_api = MEROSS_DEFAULT_CLOUD_API_URL
|
||||
self._skip_cert_validation = False
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(),
|
||||
errors={},
|
||||
)
|
||||
elif mode == CONF_WORKING_MODE_LOCAL_MODE:
|
||||
self._local_mode = True
|
||||
self._http_api = MEROSS_LOCAL_API_URL
|
||||
self._mqtt_boker = MEROSS_LOCAL_MQTT_BROKER_URI
|
||||
self._skip_cert_validation = True
|
||||
|
||||
# Look for local brokers via zeroconf
|
||||
api = None
|
||||
mqtt = None
|
||||
try:
|
||||
api, mqtt = await self._discover_services()
|
||||
except ConfigUiException as e:
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(http_endpoint=api, override_mqtt_endpoint=mqtt),
|
||||
errors={"base": e.code},
|
||||
)
|
||||
|
||||
# If no service was found, set an error
|
||||
errors = {}
|
||||
if api is None or mqtt is None:
|
||||
errors["base"] = "mdns_lookup_failed"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(http_endpoint=api, override_mqtt_endpoint=mqtt),
|
||||
errors=errors,
|
||||
)
|
||||
else:
|
||||
raise ConfigError("Invalid selection")
|
||||
|
||||
async def async_step_configure_manager(self, user_input=None) -> Dict[str, Any]:
|
||||
"""Handle a flow initialized by the user interface"""
|
||||
_LOGGER.debug("Starting CONFIGURE_MANAGER")
|
||||
if not user_input:
|
||||
_LOGGER.debug("Empty user_input, showing default prefilled form")
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(),
|
||||
errors={},
|
||||
)
|
||||
|
||||
_LOGGER.debug("UserInput was provided: form data will be populated with that")
|
||||
http_api_endpoint = user_input.get(CONF_HTTP_ENDPOINT)
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
mqtt_host = user_input.get(CONF_OVERRIDE_MQTT_ENDPOINT)
|
||||
skip_cert_validation = user_input.get(CONF_MQTT_SKIP_CERT_VALIDATION)
|
||||
mfa_code = user_input.get(CONF_MFA_CODE)
|
||||
|
||||
data_schema = self._build_setup_schema(
|
||||
http_endpoint=http_api_endpoint,
|
||||
username=username,
|
||||
password=password,
|
||||
override_mqtt_endpoint=mqtt_host,
|
||||
requires_mfa=mfa_code is not None
|
||||
)
|
||||
|
||||
# Check if we have everything we need
|
||||
if username is None or password is None or http_api_endpoint is None:
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "missing_credentials"},
|
||||
)
|
||||
|
||||
# Check the base-url is valid
|
||||
match = HTTP_API_RE.fullmatch(http_api_endpoint)
|
||||
|
||||
if match is None:
|
||||
_LOGGER.error("Invalid Meross HTTTP API endpoint: %s", http_api_endpoint)
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "invalid_http_endpoint"}
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Meross HTTP API endpoint looks good: %s.", http_api_endpoint)
|
||||
|
||||
schema, domain, colonport, port = match.groups()
|
||||
if schema is None:
|
||||
_LOGGER.warning("No schema specified, assuming http")
|
||||
http_api_endpoint = "http://" + http_api_endpoint
|
||||
|
||||
# Test the connection to the Meross Cloud.
|
||||
try:
|
||||
creds = await self._test_authorization(
|
||||
api_base_url=http_api_endpoint, username=username, password=password, mfa_code=mfa_code
|
||||
)
|
||||
_LOGGER.info("HTTP API successful tested against %s.", http_api_endpoint)
|
||||
except MissingMFA as ex:
|
||||
data_schema = self._build_setup_schema(
|
||||
http_endpoint=http_api_endpoint,
|
||||
username=username,
|
||||
password=password,
|
||||
override_mqtt_endpoint=mqtt_host,
|
||||
requires_mfa=True
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "missing_mfa"}
|
||||
)
|
||||
except (BadLoginException, UnauthorizedException) as ex:
|
||||
_LOGGER.error("Unable to connect to Meross HTTP api: %s", str(ex))
|
||||
_LOGGER.debug("Passing data_schema: %s", str(data_schema))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "invalid_credentials"}
|
||||
)
|
||||
except (UnauthorizedException, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Meross HTTP api: %s", str(ex))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "connection_error"}
|
||||
)
|
||||
except ClientConnectorSSLError as ex:
|
||||
_LOGGER.error("Unable to connect to Meross HTTP api: %s", str(ex))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "api_invalid_ssl_code"}
|
||||
)
|
||||
except ClientConnectorError as ex:
|
||||
_LOGGER.error("Connection ERROR to HTTP api: %s", str(ex))
|
||||
if isinstance(ex.os_error, ConnectionRefusedError):
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "api_connection_refused"}
|
||||
)
|
||||
else:
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "client_connection_error"}
|
||||
)
|
||||
except Exception as ex:
|
||||
_LOGGER.exception("Unable to connect to Meross HTTP api, ex: %s", str(ex))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "unknown_error"}
|
||||
)
|
||||
|
||||
# TODO: Test MQTT connection?
|
||||
data = {
|
||||
CONF_HTTP_ENDPOINT: http_api_endpoint,
|
||||
CONF_OVERRIDE_MQTT_ENDPOINT: mqtt_host,
|
||||
CONF_STORED_CREDS: {
|
||||
"domain": creds.domain,
|
||||
"mqtt_domain": creds.mqtt_domain,
|
||||
"token": creds.token,
|
||||
"key": creds.key,
|
||||
"user_id": creds.user_id,
|
||||
"user_email": creds.user_email,
|
||||
"issued_on": creds.issued_on.isoformat()
|
||||
},
|
||||
CONF_MQTT_SKIP_CERT_VALIDATION: skip_cert_validation
|
||||
}
|
||||
entry = await self.async_set_unique_id(http_api_endpoint)
|
||||
|
||||
# If this is a re-auth for an existing entry, just update the entry configuration.
|
||||
if entry is not None:
|
||||
self._abort_if_unique_id_configured(updates=data, reload_on_update=True) # No more needed
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
# Otherwise create a new entry from scratch
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HTTP_ENDPOINT],
|
||||
data=data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
return MerossOptionsFlowHandler(config_entry=config_entry)
|
||||
|
||||
@staticmethod
|
||||
async def _test_authorization(
|
||||
api_base_url: str, username: str, password: str, mfa_code: str = None
|
||||
) -> MerossCloudCreds:
|
||||
client = await MerossHttpClient.async_from_user_password(
|
||||
api_base_url=api_base_url, email=username, password=password, mfa_code=mfa_code
|
||||
)
|
||||
return client.cloud_credentials
|
||||
|
||||
async def async_step_import(self, import_config):
|
||||
"""Import a config entry from configuration.yaml."""
|
||||
if self._async_current_entries():
|
||||
_LOGGER.warning(
|
||||
"Only one configuration of Meross is allowed. If you added Meross via configuration.yaml, "
|
||||
"you should now remove that and use the integration menu con configure it."
|
||||
)
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
return await self.async_step_user(import_config)
|
||||
|
||||
|
||||
class MerossOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle an options flow for Meross Component."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize Meross options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={k: v for k, v in user_input.items() if v not in (None, "")},
|
||||
)
|
||||
|
||||
saved_options = {}
|
||||
if self.config_entry is not None:
|
||||
saved_options = self.config_entry.options
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({
|
||||
vol.Optional(CONF_OPT_CUSTOM_USER_AGENT,
|
||||
default=saved_options.get(CONF_OPT_CUSTOM_USER_AGENT, DEFAULT_USER_AGENT)): str,
|
||||
vol.Required(CONF_OPT_LAN,
|
||||
default=saved_options.get(CONF_OPT_LAN, CONF_OPT_LAN_MQTT_ONLY)): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": CONF_OPT_LAN_MQTT_ONLY,
|
||||
"label": "Do not rely on local HTTP communication at all, just use the MQTT broker"},
|
||||
{"value": CONF_OPT_LAN_HTTP_FIRST,
|
||||
"label": "Attempt local HTTP communication first and fall-back to MQTT broker"},
|
||||
{"value": CONF_OPT_LAN_HTTP_FIRST_ONLY_GET,
|
||||
"label": "Attempt local HTTP communication first only for GET commands, fall-back to MQTT broker"}
|
||||
], mode=SelectSelectorMode.LIST)
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from meross_iot.controller.device import BaseDevice
|
||||
from meross_iot.model.enums import RollerShutterState, Namespace
|
||||
from meross_iot.controller.mixins.garage import GarageOpenerMixin
|
||||
from meross_iot.controller.mixins.roller_shutter import RollerShutterTimerMixin
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
|
||||
# Conditional Light import with backwards compatibility
|
||||
from homeassistant.components.cover import (
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
CoverDeviceClass,
|
||||
ATTR_POSITION,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import MerossDevice
|
||||
from .common import (DOMAIN, MANAGER, HA_COVER, DEVICE_LIST_COORDINATOR)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MerossGarageDevice(GarageOpenerMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CoverTransientStatus(Enum):
|
||||
CLOSING = 1,
|
||||
OPENING = 2
|
||||
|
||||
|
||||
class GarageOpenerEntityWrapper(MerossDevice, CoverEntity):
|
||||
"""Wrapper class to adapt the Meross Garage Opener into the Homeassistant platform"""
|
||||
|
||||
_device: MerossGarageDevice
|
||||
_cover_transient_status: CoverTransientStatus | None = None
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: Union[MerossGarageDevice, GarageOpenerMixin],
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_COVER)
|
||||
|
||||
async def async_close_cover(self, **kwargs):
|
||||
await self._device.async_close(channel=self._channel_id, skip_rate_limits=True)
|
||||
self._cover_transient_status = CoverTransientStatus.CLOSING
|
||||
self.async_schedule_update_ha_state(force_refresh=False)
|
||||
|
||||
async def async_open_cover(self, **kwargs):
|
||||
await self._device.async_open(channel=self._channel_id, skip_rate_limits=True)
|
||||
self._cover_transient_status = CoverTransientStatus.OPENING
|
||||
self.async_schedule_update_ha_state(force_refresh=False)
|
||||
|
||||
def open_cover(self, **kwargs: Any) -> None:
|
||||
self.hass.async_add_executor_job(self.async_open_cover, **kwargs)
|
||||
|
||||
def close_cover(self, **kwargs: Any) -> None:
|
||||
self.hass.async_add_executor_job(self.async_close_cover, **kwargs)
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||
return CoverDeviceClass.GARAGE
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
open_status = self._device.get_is_open(channel=self._channel_id)
|
||||
return not open_status
|
||||
|
||||
async def _async_push_notification_received(self, namespace: Namespace, data: dict, device_internal_id: str):
|
||||
if namespace == Namespace.GARAGE_DOOR_STATE:
|
||||
self._cover_transient_status = None
|
||||
await super()._async_push_notification_received(namespace=namespace, data=data, device_internal_id=device_internal_id)
|
||||
|
||||
@property
|
||||
def is_closing(self):
|
||||
return self._cover_transient_status is not None and self._cover_transient_status == CoverTransientStatus.CLOSING
|
||||
|
||||
@property
|
||||
def is_opening(self):
|
||||
return self._cover_transient_status is not None and self._cover_transient_status == CoverTransientStatus.OPENING
|
||||
|
||||
|
||||
class MerossRollerShutterDevice(RollerShutterTimerMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class RollerShutterEntityWrapper(MerossDevice, CoverEntity):
|
||||
"""Wrapper class to adapt the Meross roller shutter into the Homeassistant platform"""
|
||||
|
||||
_device: MerossRollerShutterDevice
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: Union[MerossRollerShutterDevice, RollerShutterTimerMixin],
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_COVER)
|
||||
|
||||
async def async_close_cover(self, **kwargs):
|
||||
await self._device.async_close(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_open_cover(self, **kwargs):
|
||||
await self._device.async_open(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_stop_cover(self, **kwargs):
|
||||
await self._device.async_stop(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
def open_cover(self, **kwargs: Any) -> None:
|
||||
self.hass.async_add_executor_job(self.async_open_cover, **kwargs)
|
||||
|
||||
def close_cover(self, **kwargs: Any) -> None:
|
||||
self.hass.async_add_executor_job(self.async_close_cover, **kwargs)
|
||||
|
||||
def stop_cover(self, **kwargs) -> None:
|
||||
self.hass.async_add_executor_job(self.async_stop_cover, **kwargs)
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||
return CoverDeviceClass.SHUTTER
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
# So far, the Roller Shutter RST100 supports position, but it looks like it is fake and not reliable.
|
||||
# So we don't support that on HA neither.
|
||||
return CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION
|
||||
|
||||
@property
|
||||
def current_cover_position(self):
|
||||
return self._device.get_position(channel=self._channel_id)
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
return self._device.get_position(channel=self._channel_id) == 0
|
||||
|
||||
@property
|
||||
def is_closing(self):
|
||||
status = self._device.get_status(channel=self._channel_id)
|
||||
return status == RollerShutterState.CLOSING
|
||||
|
||||
@property
|
||||
def is_opening(self):
|
||||
status = self._device.get_status(channel=self._channel_id)
|
||||
return status == RollerShutterState.OPENING
|
||||
|
||||
async def async_set_cover_position(self, position: int):
|
||||
await self._device.async_set_position(position=position, channel=self._channel_id)
|
||||
|
||||
def set_cover_position(self, **kwargs):
|
||||
position = round(kwargs.get(ATTR_POSITION) or 0)
|
||||
self.hass.async_add_executor_job(self.async_set_cover_position, int(position))
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
def entity_adder_callback():
|
||||
"""Discover and adds new Meross entities"""
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER]
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
devices = manager.find_devices(device_class=[GarageOpenerMixin, RollerShutterTimerMixin])
|
||||
new_entities = []
|
||||
for d in devices:
|
||||
# For multi-channel garage doors opener (like MSG200), the main channel is not operable and
|
||||
# does not provide meaningful states. For this reason, we will ignore the "main channel"
|
||||
# of any cover device which has more than 1 channels. Of course, we will keep working with channel
|
||||
# 0 when dealing with dingle-door openers.
|
||||
if len(d.channels) > 1:
|
||||
channels = [c.index for c in d.channels if c.index > 0]
|
||||
else:
|
||||
channels = [0]
|
||||
for channel_index in channels:
|
||||
if isinstance(d, GarageOpenerMixin):
|
||||
w = GarageOpenerEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
elif isinstance(d, RollerShutterTimerMixin):
|
||||
w = RollerShutterEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
else:
|
||||
_LOGGER.warn("Invalid/Unsupported device class for cover platform.")
|
||||
continue
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
async_add_entities(new_entities, True)
|
||||
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
coordinator.async_add_listener(entity_adder_callback)
|
||||
# Run the entity adder a first time during setup
|
||||
entity_adder_callback()
|
||||
|
||||
|
||||
# TODO: Implement entry unload
|
||||
# TODO: Unload entry
|
||||
# TODO: Remove entry
|
||||
|
||||
|
||||
def setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
pass
|
||||
@@ -0,0 +1,184 @@
|
||||
import logging
|
||||
from typing import Any, Optional, List, Dict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from meross_iot.controller.device import BaseDevice
|
||||
from meross_iot.controller.mixins.spray import SprayMixin
|
||||
from meross_iot.controller.mixins.diffuser_spray import DiffuserSprayMixin
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.enums import SprayMode, DiffuserSprayMode
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
|
||||
from homeassistant.components.humidifier import HumidifierEntity, HumidifierEntityFeature, HumidifierDeviceClass
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import MerossDevice
|
||||
from .common import (DOMAIN, MANAGER, HA_HUMIDIFIER, DEVICE_LIST_COORDINATOR)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SPRAY_MODE_FROM_HA = {
|
||||
"CONTINUOUS": SprayMode.CONTINUOUS,
|
||||
"INTERMITTENT": SprayMode.INTERMITTENT
|
||||
}
|
||||
|
||||
SPRAY_MODE_TO_HA = {
|
||||
SprayMode.CONTINUOUS: "CONTINUOUS",
|
||||
SprayMode.INTERMITTENT: "INTERMITTENT"
|
||||
}
|
||||
|
||||
OILSPRAY_MODE_FROM_HA = {
|
||||
"HEAVY SPRAY": DiffuserSprayMode.STRONG,
|
||||
"LIGHT SPRAY": DiffuserSprayMode.LIGHT,
|
||||
}
|
||||
|
||||
OILSPRAY_MODE_TO_HA = {
|
||||
DiffuserSprayMode.STRONG: "HEAVY SPRAY",
|
||||
DiffuserSprayMode.LIGHT: "LIGHT SPRAY"
|
||||
}
|
||||
|
||||
|
||||
class MerossHumidifierDevice(SprayMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper for humidifier
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MerossOilDiffuserDevice(DiffuserSprayMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper for oil diffuser
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class HumidifierEntityWrapper(MerossDevice, HumidifierEntity):
|
||||
"""Wrapper class to adapt the Meross humidifier into the Homeassistant platform"""
|
||||
|
||||
_device: MerossHumidifierDevice
|
||||
_attr_device_class = HumidifierDeviceClass.HUMIDIFIER
|
||||
_attr_supported_features: HumidifierEntityFeature = HumidifierEntityFeature.MODES
|
||||
_attr_available_modes = ["CONTINUOUS", "INTERMITTENT"]
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: MerossHumidifierDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_HUMIDIFIER)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self._device.async_set_mode(mode=SprayMode.OFF, channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
mode = self.mode
|
||||
if mode is None:
|
||||
mode = SprayMode.CONTINUOUS
|
||||
await self._device.async_set_mode(mode=mode, channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_set_mode(self, mode: str) -> None:
|
||||
parsed_mode = SPRAY_MODE_FROM_HA[mode]
|
||||
await self._device.async_set_mode(mode=parsed_mode, channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
@property
|
||||
def mode(self) -> str | None:
|
||||
"""Return the current mode
|
||||
|
||||
Requires HumidifierEntityFeature.MODES.
|
||||
"""
|
||||
return SPRAY_MODE_TO_HA.get(self._device.get_current_mode())
|
||||
|
||||
@property
|
||||
def is_on(self) -> Optional[bool]:
|
||||
mode = self._device.get_current_mode(channel=self._channel_id)
|
||||
if mode is None:
|
||||
return None
|
||||
return mode != SprayMode.OFF
|
||||
|
||||
|
||||
class OilDiffuserEntityWrapper(MerossDevice, HumidifierEntity):
|
||||
"""Wrapper class to adapt the Meross OilDiffuser into the Homeassistant platform"""
|
||||
|
||||
_device: MerossOilDiffuserDevice
|
||||
_attr_device_class = HumidifierDeviceClass.HUMIDIFIER
|
||||
_attr_supported_features: HumidifierEntityFeature = HumidifierEntityFeature.MODES
|
||||
_attr_available_modes = ["HEAVY SPRAY", "LIGHT SPRAY"]
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: MerossOilDiffuserDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_HUMIDIFIER)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self._device.async_set_spray_mode(mode=DiffuserSprayMode.OFF, channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self._device.async_set_spray_mode(mode=DiffuserSprayMode.LIGHT, channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_set_mode(self, mode: str) -> None:
|
||||
parsed_mode = OILSPRAY_MODE_FROM_HA[mode]
|
||||
await self._device.async_set_spray_mode(mode=parsed_mode, channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
@property
|
||||
def mode(self) -> str | None:
|
||||
"""Return the current mode
|
||||
|
||||
Requires HumidifierEntityFeature.MODES.
|
||||
"""
|
||||
return OILSPRAY_MODE_TO_HA.get(self._device.get_current_spray_mode())
|
||||
|
||||
@property
|
||||
def is_on(self) -> Optional[bool]:
|
||||
mode = self._device.get_current_spray_mode(channel=self._channel_id)
|
||||
if mode is None:
|
||||
return None
|
||||
return mode != DiffuserSprayMode.OFF
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
def entity_adder_callback():
|
||||
"""Discover and adds new Meross entities"""
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER] # type
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
new_entities = []
|
||||
|
||||
# Add Humidifiers
|
||||
devices = manager.find_devices(device_class=SprayMixin)
|
||||
for d in devices:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
w = HumidifierEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
# Add OilDiffuser
|
||||
devices = manager.find_devices(device_class=DiffuserSprayMixin)
|
||||
for d in devices:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
w = OilDiffuserEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
async_add_entities(new_entities, True)
|
||||
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
coordinator.async_add_listener(entity_adder_callback)
|
||||
# Run the entity adder a first time during setup
|
||||
entity_adder_callback()
|
||||
|
||||
# TODO: Implement entry unload
|
||||
# TODO: Unload entry
|
||||
# TODO: Remove entry
|
||||
|
||||
|
||||
def setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
pass
|
||||
@@ -0,0 +1,238 @@
|
||||
import logging
|
||||
from typing import Optional, Dict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from meross_iot.controller.device import BaseDevice
|
||||
from meross_iot.controller.mixins.light import LightMixin
|
||||
from meross_iot.controller.mixins.diffuser_light import DiffuserLightMixin
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
from meross_iot.model.enums import DiffuserLightMode
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.components.light import LightEntity
|
||||
from homeassistant.components.light import ColorMode, \
|
||||
ATTR_HS_COLOR, ATTR_COLOR_TEMP, ATTR_BRIGHTNESS, ATTR_RGB_COLOR
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import MerossDevice
|
||||
from .common import (DOMAIN, MANAGER, HA_LIGHT, DEVICE_LIST_COORDINATOR)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MerossOilDiffuserLightDevice(DiffuserLightMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MerossLightDevice(LightMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DiffuserLightEntityWrapper(MerossDevice, LightEntity):
|
||||
"""Wrapper class to adapt the Meross OilDiffuserLight"""
|
||||
_device: MerossOilDiffuserLightDevice
|
||||
# For now, we assume OilDiffuserLight supports all the following features.
|
||||
# From Meross API it is in fact impossible to determine which exact features are supported by the device.
|
||||
_attr_supported_color_modes = {ColorMode.WHITE, ColorMode.RGB, ColorMode.COLOR_TEMP}
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: MerossOilDiffuserLightDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_LIGHT)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self._device.async_turn_off(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
if not self.is_on:
|
||||
await self._device.async_turn_on(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
h, s = kwargs[ATTR_HS_COLOR]
|
||||
rgb = color_util.color_hsv_to_RGB(h, s, 100)
|
||||
_LOGGER.debug("color change: rgb=%r -- h=%r s=%r" % (rgb, h, s))
|
||||
await self._device.async_set_light_mode(channel=self._channel_id, mode=DiffuserLightMode.FIXED_RGB, rgb=rgb, onoff=True, skip_rate_limits=True)
|
||||
elif ATTR_COLOR_TEMP in kwargs:
|
||||
mired = kwargs[ATTR_COLOR_TEMP]
|
||||
norm_value = (mired - self.min_mireds) / (self.max_mireds - self.min_mireds)
|
||||
temperature = 100 - (norm_value * 100)
|
||||
_LOGGER.debug("temperature change: mired=%r meross=%r" % (mired, temperature))
|
||||
await self._device.async_set_light_mode(channel=self._channel_id, mode=DiffuserLightMode.FIXED_LUMINANCE, onoff=True, rgb=65293, brightness=temperature, skip_rate_limits=True)
|
||||
|
||||
# Brightness must always be set, so take previous luminance if not explicitly set now.
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = kwargs[ATTR_BRIGHTNESS] * 100 / 255
|
||||
_LOGGER.debug("brightness change: %r" % brightness)
|
||||
await self._device.async_set_light_mode(channel=self._channel_id, luminance=brightness, skip_rate_limits=True)
|
||||
|
||||
@property
|
||||
def is_on(self) -> Optional[bool]:
|
||||
return self._device.get_light_is_on(channel=self._channel_id)
|
||||
|
||||
@property
|
||||
def hs_color(self):
|
||||
rgb = self._device.get_light_rgb_color(channel=self._channel_id)
|
||||
if rgb is not None and isinstance(rgb, tuple) and len(rgb) == 3:
|
||||
return color_util.color_RGB_to_hs(*rgb)
|
||||
else:
|
||||
return None # Return None if RGB value is not available
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
luminance = self._device.get_light_brightness()
|
||||
if luminance is not None:
|
||||
return float(luminance) / 100 * 255
|
||||
return None
|
||||
|
||||
|
||||
class LightEntityWrapper(MerossDevice, LightEntity):
|
||||
"""Wrapper class to adapt the Meross bulbs into the Homeassistant platform"""
|
||||
_device: MerossLightDevice
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: MerossLightDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_LIGHT)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self._device.async_turn_off(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
if not self.is_on:
|
||||
await self._device.async_turn_on(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
# Color is taken from either of these 2 values, but not both.
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
await self._device.async_set_light_color(channel=self._channel_id, rgb=kwargs[ATTR_RGB_COLOR], onoff=True, skip_rate_limits=True)
|
||||
elif ATTR_HS_COLOR in kwargs:
|
||||
h, s = kwargs[ATTR_HS_COLOR]
|
||||
rgb = color_util.color_hsv_to_RGB(h, s, 100)
|
||||
_LOGGER.debug("color change: rgb=%r -- h=%r s=%r" % (rgb, h, s))
|
||||
await self._device.async_set_light_color(channel=self._channel_id, rgb=rgb, onoff=True, skip_rate_limits=True)
|
||||
elif ATTR_COLOR_TEMP in kwargs:
|
||||
mired = kwargs[ATTR_COLOR_TEMP]
|
||||
norm_value = (mired - self.min_mireds) / (self.max_mireds - self.min_mireds)
|
||||
temperature = 100 - (norm_value * 100)
|
||||
_LOGGER.debug("temperature change: mired=%r meross=%r" % (mired, temperature))
|
||||
await self._device.async_set_light_color(channel=self._channel_id, temperature=temperature, skip_rate_limits=True)
|
||||
|
||||
# Brightness must always be set, so take previous luminance if not explicitly set now.
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = kwargs[ATTR_BRIGHTNESS] * 100 / 255
|
||||
_LOGGER.debug("brightness change: %r" % brightness)
|
||||
await self._device.async_set_light_color(channel=self._channel_id, luminance=brightness, skip_rate_limits=True)
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
|
||||
res = set()
|
||||
if self._device.get_supports_luminance(channel=self._channel_id):
|
||||
res.add(ColorMode.WHITE)
|
||||
if self._device.get_supports_rgb(channel=self._channel_id):
|
||||
res.add(ColorMode.RGB)
|
||||
if self._device.get_supports_temperature(channel=self._channel_id):
|
||||
res.add(ColorMode.COLOR_TEMP)
|
||||
if len(res) < 1:
|
||||
res.add(ColorMode.ONOFF)
|
||||
return res
|
||||
|
||||
@property
|
||||
def is_on(self) -> Optional[bool]:
|
||||
return self._device.get_light_is_on(channel=self._channel_id)
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
if not self._device.get_supports_luminance(self._channel_id):
|
||||
return None
|
||||
|
||||
luminance = self._device.get_luminance()
|
||||
if luminance is not None:
|
||||
return float(luminance) / 100 * 255
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode | str | None:
|
||||
"""Return the color mode of the light."""
|
||||
# TODO: we need support from low-level library in order to keep track of mode that haas been set.
|
||||
if self._device.get_supports_rgb(channel=self._channel_id):
|
||||
return ColorMode.RGB
|
||||
elif self._device.get_supports_luminance(channel=self._channel_id):
|
||||
return ColorMode.WHITE
|
||||
if self._device.get_supports_temperature(channel=self._channel_id):
|
||||
return ColorMode.COLOR_TEMP
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def hs_color(self):
|
||||
rgb = self._device.get_rgb_color(channel=self._channel_id)
|
||||
if rgb is not None and isinstance(rgb, tuple) and len(rgb) == 3:
|
||||
return color_util.color_RGB_to_hs(*rgb)
|
||||
else:
|
||||
return None # Return None if RGB value is not available
|
||||
|
||||
@property
|
||||
def color_temp(self):
|
||||
if self._device.get_supports_temperature(channel=self._channel_id):
|
||||
value = self._device.get_color_temperature()
|
||||
norm_value = (100 - value) / 100.0
|
||||
return self.min_mireds + (norm_value * (self.max_mireds - self.min_mireds))
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
def entity_adder_callback():
|
||||
"""Discover and adds new Meross entities"""
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER] # type
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
devices = manager.find_devices()
|
||||
|
||||
new_entities = []
|
||||
|
||||
light_devs = filter(lambda d: isinstance(d, LightMixin), devices)
|
||||
for d in light_devs:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
w = LightEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
diffuser_devs = filter(lambda d: isinstance(d, DiffuserLightMixin), devices)
|
||||
for d in diffuser_devs:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
w = DiffuserLightEntityWrapper(device=d, channel=channel_index, device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
async_add_entities(new_entities, True)
|
||||
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
coordinator.async_add_listener(entity_adder_callback)
|
||||
# Run the entity adder a first time during setup
|
||||
entity_adder_callback()
|
||||
|
||||
|
||||
# TODO: Implement entry unload
|
||||
# TODO: Unload entry
|
||||
# TODO: Remove entry
|
||||
|
||||
|
||||
def setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
pass
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "meross_cloud",
|
||||
"name": "Meross Cloud IoT",
|
||||
"documentation": "https://www.home-assistant.io/components/meross_cloud",
|
||||
"issue_tracker": "https://github.com/albertogeniola/meross-homeassistant",
|
||||
"dependencies": ["persistent_notification"],
|
||||
"codeowners": ["@albertogeniola"],
|
||||
"requirements": ["meross_iot==0.4.9.1"],
|
||||
"config_flow": true,
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_push",
|
||||
"version": "1.3.9"
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from typing import Optional, Dict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from meross_iot.controller.device import BaseDevice, GenericSubDevice, HubDevice
|
||||
from meross_iot.controller.mixins.consumption import ConsumptionXMixin
|
||||
from meross_iot.controller.mixins.electricity import ElectricityMixin
|
||||
from meross_iot.controller.subdevice import Ms100Sensor, Mts100v3Valve
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.enums import OnlineStatus
|
||||
from meross_iot.model.exception import CommandTimeoutError
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass, SensorEntity, SensorDeviceClass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfPower
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import MerossDevice
|
||||
from .common import (DOMAIN, MANAGER, log_exception, HA_SENSOR,
|
||||
HA_SENSOR_POLL_INTERVAL_SECONDS, invoke_method_or_property, DEVICE_LIST_COORDINATOR)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(seconds=HA_SENSOR_POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
class GenericSensorWrapper(MerossDevice, SensorEntity):
|
||||
"""Wrapper class to adapt the a generic Meross sensor into the Homeassistant platform"""
|
||||
|
||||
def __init__(self,
|
||||
sensor_class: str,
|
||||
measurement_unit: Optional[str],
|
||||
device_method_or_property: str,
|
||||
state_class: str,
|
||||
device: BaseDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]],
|
||||
channel: int = 0):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_SENSOR,
|
||||
supplementary_classifiers=[sensor_class, measurement_unit])
|
||||
|
||||
# Make sure the given device supports exposes the device_method_or_property passed as arg
|
||||
if not hasattr(device, device_method_or_property):
|
||||
_LOGGER.error("The device %s (%s) does not expose property %s", device.uuid, device.name,
|
||||
device_method_or_property)
|
||||
raise ValueError(f"The device {device} does not expose property {device_method_or_property}")
|
||||
|
||||
self._device_method_or_property = device_method_or_property
|
||||
self._attr_native_unit_of_measurement = measurement_unit
|
||||
self._attr_device_class = sensor_class
|
||||
self._attr_state_class = state_class
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the entity."""
|
||||
return invoke_method_or_property(self._device, self._device_method_or_property)
|
||||
|
||||
|
||||
class Ms100TemperatureSensorWrapper(GenericSensorWrapper):
|
||||
def __init__(self, device: Ms100Sensor, device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]],
|
||||
channel: int = 0):
|
||||
super().__init__(
|
||||
sensor_class=SensorDeviceClass.TEMPERATURE,
|
||||
measurement_unit=UnitOfTemperature.CELSIUS,
|
||||
device_method_or_property='last_sampled_temperature',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
|
||||
class Ms100HumiditySensorWrapper(GenericSensorWrapper):
|
||||
def __init__(self, device: Ms100Sensor, device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]],
|
||||
channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.HUMIDITY,
|
||||
measurement_unit=PERCENTAGE,
|
||||
device_method_or_property='last_sampled_humidity',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
|
||||
class Mts100TemperatureSensorWrapper(GenericSensorWrapper):
|
||||
_device: Mts100v3Valve
|
||||
|
||||
def __init__(self, device: Mts100v3Valve,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(sensor_class=SensorDeviceClass.TEMPERATURE,
|
||||
measurement_unit=UnitOfTemperature.CELSIUS,
|
||||
device_method_or_property='last_sampled_temperature',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
device=device)
|
||||
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_temperature()
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class ElectricitySensorDevice(ElectricityMixin, BaseDevice):
|
||||
""" Helper type """
|
||||
pass
|
||||
|
||||
|
||||
class EnergySensorDevice(ConsumptionXMixin, BaseDevice):
|
||||
""" Helper type """
|
||||
pass
|
||||
|
||||
|
||||
class PowerSensorWrapper(GenericSensorWrapper):
|
||||
_device: ElectricitySensorDevice
|
||||
|
||||
def __init__(self, device: ElectricitySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.POWER,
|
||||
measurement_unit=UnitOfPower.WATT,
|
||||
device_method_or_property='get_last_sample',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
# We only call the explicit method if the sampled value is older than 10 seconds.
|
||||
power_info = self._device.get_last_sample(channel=self._channel_id)
|
||||
now = datetime.utcnow()
|
||||
if power_info is None or (now - power_info.sample_timestamp).total_seconds() > 10:
|
||||
# Force device refresh
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_instant_metrics(channel=self._channel_id)
|
||||
else:
|
||||
# Use the cached value
|
||||
_LOGGER.debug("Skipping data refresh for %s as its value is recent enough", self.name)
|
||||
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
pass
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
sample = self._device.get_last_sample(channel=self._channel_id)
|
||||
if sample is not None:
|
||||
return sample.power
|
||||
|
||||
|
||||
class CurrentSensorWrapper(GenericSensorWrapper):
|
||||
_device: ElectricitySensorDevice
|
||||
|
||||
def __init__(self, device: ElectricitySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.CURRENT,
|
||||
measurement_unit="A",
|
||||
device_method_or_property='get_last_sample',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_Get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
# We only call the explicit method if the sampled value is older than 10 seconds.
|
||||
power_info = self._device.get_last_sample(channel=self._channel_id)
|
||||
now = datetime.utcnow()
|
||||
if power_info is None or (now - power_info.sample_timestamp).total_seconds() > 10:
|
||||
# Force device refresh
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_instant_metrics(channel=self._channel_id)
|
||||
else:
|
||||
# Use the cached value
|
||||
_LOGGER.debug(f"Skipping data refresh for {self.name} as its value is recent enough")
|
||||
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
pass
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
sample = self._device.get_last_sample(channel=self._channel_id)
|
||||
if sample is not None:
|
||||
return sample.current
|
||||
return 0
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class VoltageSensorWrapper(GenericSensorWrapper):
|
||||
_device: ElectricitySensorDevice
|
||||
|
||||
def __init__(self, device: ElectricitySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.VOLTAGE,
|
||||
measurement_unit="V",
|
||||
device_method_or_property='get_last_sample',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_Get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
# We only call the explicit method if the sampled value is older than 10 seconds.
|
||||
power_info = self._device.get_last_sample(channel=self._channel_id)
|
||||
now = datetime.utcnow()
|
||||
if power_info is None or (now - power_info.sample_timestamp).total_seconds() > 10:
|
||||
# Force device refresh
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_instant_metrics(channel=self._channel_id)
|
||||
else:
|
||||
# Use the cached value
|
||||
_LOGGER.debug(f"Skipping data refresh for {self.name} as its value is recent enough")
|
||||
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
pass
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
sample = self._device.get_last_sample(channel=self._channel_id)
|
||||
if sample is not None:
|
||||
return sample.voltage
|
||||
return 0
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class EnergySensorWrapper(GenericSensorWrapper):
|
||||
_device: EnergySensorDevice
|
||||
|
||||
def __init__(self, device: EnergySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.ENERGY,
|
||||
measurement_unit="kWh",
|
||||
device_method_or_property='async_get_daily_power_consumption',
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# Device properties
|
||||
self._daily_consumption = None
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_Get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self.online:
|
||||
await super().async_update()
|
||||
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
self._daily_consumption = await self._device.async_get_daily_power_consumption(channel=self._channel_id)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
if self._daily_consumption is not None:
|
||||
today = datetime.today()
|
||||
total = 0
|
||||
daystart = datetime(year=today.year, month=today.month, day=today.day, hour=0, second=0)
|
||||
for x in self._daily_consumption:
|
||||
if x['date'] == daystart:
|
||||
total = x['total_consumption_kwh']
|
||||
return total
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class BatterySensorWrapper(GenericSensorWrapper):
|
||||
_device: GenericSubDevice
|
||||
|
||||
def __init__(self, device: GenericSubDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.BATTERY,
|
||||
measurement_unit="%",
|
||||
device_method_or_property='async_get_battery_life',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# Device properties
|
||||
self._battery_percentage = None
|
||||
|
||||
async def async_update(self):
|
||||
if self.online:
|
||||
await super().async_update()
|
||||
|
||||
_LOGGER.debug(f"Refreshing battery state info for device {self.name}")
|
||||
self._battery_percentage = await self._device.async_get_battery_life()
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
if self._battery_percentage is not None:
|
||||
return self._battery_percentage.remaining_charge
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ----------------------------------------------
|
||||
# PLATFORM METHODS
|
||||
# ----------------------------------------------
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
def entity_adder_callback():
|
||||
"""Discover and adds new Meross entities"""
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER] # type
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
devices = manager.find_devices()
|
||||
|
||||
new_entities = []
|
||||
|
||||
# For now, we handle the following sensors:
|
||||
# -> Temperature-Humidity (Ms100Sensor)
|
||||
# -> Power-sensing smart plugs (Mss310)
|
||||
# -> MTS100 Valve temperature (MTS100V3)
|
||||
humidity_temp_sensors = filter(lambda d: isinstance(d, Ms100Sensor), devices)
|
||||
mts100_temp_sensors = filter(lambda d: isinstance(d, Mts100v3Valve), devices)
|
||||
power_sensors = filter(lambda d: isinstance(d, ElectricityMixin), devices)
|
||||
energy_sensors = filter(lambda d: isinstance(d, ConsumptionXMixin), devices)
|
||||
subdevs = filter(lambda d: isinstance(d, GenericSubDevice), devices)
|
||||
|
||||
# Add MS100 Temperature & Humidity sensors
|
||||
for d in humidity_temp_sensors:
|
||||
new_entities.append(Ms100HumiditySensorWrapper(device=d, device_list_coordinator=coordinator, channel=0))
|
||||
new_entities.append(Ms100TemperatureSensorWrapper(device=d, device_list_coordinator=coordinator, channel=0))
|
||||
|
||||
# Add MTS100Valve Temperature sensors
|
||||
for d in mts100_temp_sensors:
|
||||
new_entities.append(Mts100TemperatureSensorWrapper(device=d, device_list_coordinator=coordinator))
|
||||
|
||||
# Add Power Sensors
|
||||
for d in power_sensors:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
new_entities.append(
|
||||
PowerSensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
new_entities.append(
|
||||
CurrentSensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
new_entities.append(
|
||||
VoltageSensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
|
||||
# Add Energy Sensors
|
||||
for d in energy_sensors:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
new_entities.append(
|
||||
EnergySensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
|
||||
# Add battery level sensors for subdevices
|
||||
for s in subdevs:
|
||||
new_entities.append(BatterySensorWrapper(device=s, device_list_coordinator=coordinator, channel=0))
|
||||
|
||||
unique_new_devs = filter(lambda d: d.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"], new_entities)
|
||||
async_add_entities(list(unique_new_devs), True)
|
||||
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
coordinator.async_add_listener(entity_adder_callback)
|
||||
# Run the entity adder a first time during setup
|
||||
entity_adder_callback()
|
||||
|
||||
|
||||
# TODO: Implement entry unload
|
||||
# TODO: Unload entry
|
||||
# TODO: Remove entry
|
||||
|
||||
|
||||
def setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
pass
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Meross",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Chose broker",
|
||||
"data": {}
|
||||
},
|
||||
"configure_manager": {
|
||||
"title": "Login to Meross Cloud",
|
||||
"data": {
|
||||
"username": "Email Address",
|
||||
"password": "Password",
|
||||
"http_api_endpoint": "HTTP API Endpoint",
|
||||
"override_mqtt_endpoint": "MQTT Address (host:port)",
|
||||
"skip_mqtt_cert_validation": "Skip MQTT certificate validation checks",
|
||||
"mfa_code": "MFA Code"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthentication required",
|
||||
"description": "The Meross integration needs to re-authenticate your account"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_credentials": "Invalid credentials.",
|
||||
"connection_error": "Unable to connect to Meross.",
|
||||
"invalid_http_endpoint": "Invalid Meross HTTTP api endpoint",
|
||||
"api_invalid_ssl_code": "Invalid SSL response received by the server. Are you sure the server is exposed in HTTPS? Try plain http and see if this happens again.",
|
||||
"api_connection_refused": "Cannot connect to HTTP(S) API server. Make sure the address is valid",
|
||||
"mdns_lookup_failed": "The discovery was unable to find MQTT/API service. Default values have been selected.",
|
||||
"missing_mfa": "Your account requires MFA code to proceed. Please provide it."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single configuration of Meross is allowed."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"custom_user_agent": "Custom HTTP User Agent header for API polling",
|
||||
"lan_transport_mode": "Device communication options"
|
||||
},
|
||||
"title": "Meross Cloud Options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from meross_iot.controller.device import BaseDevice
|
||||
from meross_iot.controller.mixins.consumption import ConsumptionXMixin
|
||||
from meross_iot.controller.mixins.electricity import ElectricityMixin
|
||||
from meross_iot.controller.mixins.garage import GarageOpenerMixin
|
||||
from meross_iot.controller.mixins.light import LightMixin
|
||||
from meross_iot.controller.mixins.dnd import SystemDndMixin
|
||||
from meross_iot.controller.mixins.toggle import ToggleXMixin, ToggleMixin
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
from meross_iot.model.enums import DNDMode
|
||||
|
||||
# Conditional import for switch device
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import MerossDevice
|
||||
from .common import (DOMAIN, MANAGER, DEVICE_LIST_COORDINATOR, HA_SWITCH)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MerossSwitchDevice(ToggleXMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MerossDndDevice(SystemDndMixin, BaseDevice):
|
||||
"""
|
||||
Type hints helper
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SwitchEntityWrapper(MerossDevice, SwitchEntity):
|
||||
"""Wrapper class to adapt the Meross switches into the Homeassistant platform"""
|
||||
_device: MerossSwitchDevice
|
||||
|
||||
def __init__(self,
|
||||
channel: int,
|
||||
device: MerossSwitchDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_SWITCH)
|
||||
|
||||
# Device properties
|
||||
self._last_power_sample = None
|
||||
self._daily_consumption = None
|
||||
|
||||
async def async_update(self):
|
||||
if self.online:
|
||||
await super().async_update()
|
||||
|
||||
# If the device supports power reading, update it
|
||||
if isinstance(self._device, ElectricityMixin):
|
||||
self._last_power_sample = await self._device.async_get_instant_metrics(channel=self._channel_id)
|
||||
|
||||
if isinstance(self._device, ConsumptionXMixin):
|
||||
self._daily_consumption = await self._device.async_get_daily_power_consumption(channel=self._channel_id)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
dev = self._device
|
||||
return dev.is_on(channel=self._channel_id)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
dev = self._device
|
||||
await dev.async_turn_off(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
dev = self._device
|
||||
await dev.async_turn_on(channel=self._channel_id, skip_rate_limits=True)
|
||||
|
||||
@property
|
||||
def current_power_w(self) -> Optional[float]:
|
||||
if self._last_power_sample is not None:
|
||||
return self._last_power_sample.power
|
||||
|
||||
@property
|
||||
def today_energy_kwh(self) -> Optional[float]:
|
||||
if self._daily_consumption is not None:
|
||||
today = datetime.today()
|
||||
total = 0
|
||||
daystart = datetime(year=today.year, month=today.month, day=today.day, hour=0, second=0)
|
||||
for x in self._daily_consumption:
|
||||
if x['date'] == daystart:
|
||||
total = x['total_consumption_kwh']
|
||||
return total
|
||||
|
||||
|
||||
class DndEntityWrapper(MerossDevice, SwitchEntity):
|
||||
"""Wrapper class to adapt the Meross switches into the Homeassistant platform"""
|
||||
_device: MerossDndDevice
|
||||
|
||||
# The DNDMode change does not trigger any push notification, so we cannot we
|
||||
_attr_should_poll = True
|
||||
_dnd_mode: Optional[DNDMode] = None
|
||||
|
||||
def __init__(self,
|
||||
device: MerossDndDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=-1, # DND devices do not relate to channels
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_SWITCH,
|
||||
override_channel_name="Do Not Disturb")
|
||||
|
||||
async def async_update(self):
|
||||
if self.online:
|
||||
await super().async_update()
|
||||
self._dnd_mode = await self._device.async_get_dnd_mode()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
if self._dnd_mode is None:
|
||||
return None
|
||||
return self._dnd_mode == DNDMode.DND_DISABLED
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
dev = self._device
|
||||
await dev.set_dnd_mode(mode=DNDMode.DND_ENABLED, skip_rate_limits=True)
|
||||
self._dnd_mode = DNDMode.DND_ENABLED
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
dev = self._device
|
||||
await dev.set_dnd_mode(mode=DNDMode.DND_DISABLED, skip_rate_limits=True)
|
||||
self._dnd_mode = DNDMode.DND_DISABLED
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
def entity_adder_callback():
|
||||
"""Discover and adds new Meross entities"""
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER] # type
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
devices = manager.find_devices()
|
||||
|
||||
new_entities = []
|
||||
|
||||
# Identify all the devices that expose the Toggle or ToggleX capabilities
|
||||
devs = filter(lambda d: isinstance(d, ToggleXMixin) or isinstance(d, ToggleMixin), devices)
|
||||
|
||||
# Exclude garage openers, lights.
|
||||
devs = filter(lambda d: not (isinstance(d, GarageOpenerMixin) or isinstance(d, LightMixin)), devs)
|
||||
|
||||
for d in devs:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
w = SwitchEntityWrapper(device=d, channel=channel_index,
|
||||
device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
dnd_switches = filter(lambda d: isinstance(d, SystemDndMixin), devices)
|
||||
for d in dnd_switches:
|
||||
w = DndEntityWrapper(device=d, device_list_coordinator=coordinator)
|
||||
if w.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"]:
|
||||
new_entities.append(w)
|
||||
|
||||
async_add_entities(new_entities, True)
|
||||
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
coordinator.async_add_listener(entity_adder_callback)
|
||||
# Run the entity adder a first time during setup
|
||||
entity_adder_callback()
|
||||
|
||||
# TODO: Implement entry unload
|
||||
# TODO: Unload entry
|
||||
# TODO: Remove entry
|
||||
|
||||
|
||||
def setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
pass
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single configuration of Meross is allowed."
|
||||
},
|
||||
"error": {
|
||||
"invalid_credentials": "Invalid credentials.",
|
||||
"connection_error": "Unable to connect to Meross.",
|
||||
"invalid_http_endpoint": "Invalid Meross HTTTP api endpoint",
|
||||
"api_invalid_ssl_code": "Invalid SSL response received by the server. Are you sure the server is exposed in HTTPS? Try plain http and see if this happens again.",
|
||||
"unknown_error": "An unexpected error occurred",
|
||||
"api_connection_refused": "Cannot connect to HTTP(S) API server. Make sure the address is valid",
|
||||
"client_connection_error": "An error occurred while connecting to the HTTP API",
|
||||
"missing_credentials": "Please provide missing credentials",
|
||||
"mdns_lookup_failed": "The discovery was unable to automatically find MQTT/API addresses. Default values have been selected."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Chose broker",
|
||||
"data": {}
|
||||
},
|
||||
"configure_manager": {
|
||||
"title": "Login to Meross Cloud",
|
||||
"data": {
|
||||
"username": "Email Address",
|
||||
"password": "Password",
|
||||
"http_api_endpoint": "HTTP API Endpoint",
|
||||
"override_mqtt_endpoint": "MQTT Address (host:port)",
|
||||
"skip_mqtt_cert_validation": "Skip MQTT certificate validation checks"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthentication required",
|
||||
"description": "The Meross integration needs to re-authenticate your account"
|
||||
}
|
||||
},
|
||||
"title": "Meross"
|
||||
},
|
||||
"options": {
|
||||
"error": {},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"custom_user_agent": "Custom HTTP User Agent header for API polling",
|
||||
"lan_transport_mode": "Device communication options"
|
||||
},
|
||||
"title": "Meross Cloud Options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "El complemento Meross ya está instalado y no se puede volver a instalar."
|
||||
},
|
||||
"error": {
|
||||
"invalid_credentials": "Credenciales no válidas.",
|
||||
"connection_error": "No se puede conectar con Meross.",
|
||||
"invalid_http_endpoint": "Punto de conexión de la API de HTTTP de Meross no válido",
|
||||
"api_invalid_ssl_code": "Respuesta SSL no válida recibida por el servidor. ¿Estás seguro de que el servidor está expuesto en HTTPS? Intentalo con http y comprueba si esto sucede nuevamente.",
|
||||
"unknown_error": "Ocurrió un error inesperado",
|
||||
"api_connection_refused": "No se puede conectar al servidor de API HTTP (S). Asegurate de que la dirección es correcta",
|
||||
"client_connection_error": "Ha ocurrido un error mientras se conectaba al API HTTP",
|
||||
"missing_credentials": "Proporciona las credenciales que faltan"
|
||||
},
|
||||
"step": {
|
||||
"configure_manager": {
|
||||
"data": {
|
||||
"password": "Contraseña",
|
||||
"username": "Correo electrónico",
|
||||
"http_api_endpoint": "HTTP API Endpoint",
|
||||
"mqtt_hostname": "Dirección IP MQTT",
|
||||
"mqtt_port": "Puerto MQTT",
|
||||
"skip_mqtt_cert_validation": "Omite las comprobaciones de validación del certificadoSkip MQTT"
|
||||
},
|
||||
"title": "Ingrese las credenciales de su cuenta Meross"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Requiere volver a autenticarse",
|
||||
"description": "La integración de Meross requiere que vuelvas a autenticarte con tu cuenta"
|
||||
}
|
||||
},
|
||||
"title": "Meross"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Il plugin Meross risulta già installato e non può essere installato nuovamente."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Impossibile connettersi a Meross.",
|
||||
"invalid_credentials": "Credenziali non valide.",
|
||||
"invalid_http_endpoint": "Endpoint HTTP invalido",
|
||||
"api_invalid_ssl_code": "Il server ha riportato un errore SSL. Accertarsi che il server sia esposto in HTTPS o provare con HTTP. ",
|
||||
"unknown_error": "Si è verificato un errore inatteso",
|
||||
"api_connection_refused": "Impossibile connettersi al server API. Controlla l'indirizzo del server.",
|
||||
"client_connection_error": "Si è verificato un errore durante la connessione alle API HTTP",
|
||||
"missing_credentials": "Per favore inserire le credenziali di accesso",
|
||||
"mdns_lookup_failed": "La ricerca automatica non è stata in grado di identificare gli indirizzi dei servizi API/MQTT. Sono stati inseriti quelli di default."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Seleziona il broker",
|
||||
"data": {}
|
||||
},
|
||||
"configure_manager": {
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Indirizzo Email",
|
||||
"http_api_endpoint": "Endpoint HTTP",
|
||||
"override_mqtt_endpoint": "Indirizzo MQTT (host:port)",
|
||||
"skip_mqtt_cert_validation": "Salta la validazione SSL per MQTT"
|
||||
},
|
||||
"title": "Inserisci le credenziali del tuo account Meross"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Rinnovo dell'autenticazione richiesto",
|
||||
"description": "L'integrazione con Meross richiede la ri-autenticazione dell'account"
|
||||
}
|
||||
},
|
||||
"title": "Meross"
|
||||
},
|
||||
"options": {
|
||||
"error": {},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"custom_user_agent": "Header User-Agent personalizzato",
|
||||
"lan_transport_mode": "Opzioni di comunicazione con i dispositivi"
|
||||
},
|
||||
"title": "Opzioni Meross Cloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import logging
|
||||
from meross_iot.utilities.misc import current_version
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MEROSS_IOT_VERSION = current_version()
|
||||
MEROSS_INTEGRATION_VERSION = "N/A"
|
||||
try:
|
||||
import json
|
||||
import os
|
||||
fname = os.path.join(os.path.dirname(__file__), "manifest.json")
|
||||
with open(fname, "rt") as f:
|
||||
data = json.load(f)
|
||||
MEROSS_INTEGRATION_VERSION = data.get("version")
|
||||
except:
|
||||
_LOGGER.error("Failed to retrieve integration version")
|
||||
|
||||
_LOGGER.info(f"MerossIot Version: {MEROSS_IOT_VERSION}")
|
||||
_LOGGER.info(f"Integration Version: {MEROSS_INTEGRATION_VERSION}")
|
||||
Reference in New Issue
Block a user