Initial Home Assistant Configuration

This commit is contained in:
root
2025-09-11 10:47:34 +03:00
commit ac8b542e1b
2360 changed files with 41412 additions and 0 deletions
+541
View File
@@ -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
+300
View File
@@ -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
+159
View File
@@ -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)
)
})
)
+220
View File
@@ -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
+238
View File
@@ -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"
}
+396
View File
@@ -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"
}
}
}
}
+181
View File
@@ -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"
}
}
}
}
+19
View File
@@ -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}")