diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fab8c1f8..a74a37f8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,8 @@ "name": "Blueprint integration development", "context": "..", "appPort": [ - "9123:8123" + "9123:8123", + "67:67/udp" ], "postCreateCommand": "container install", "extensions": [ diff --git a/README.md b/README.md index ac1e3491..08a6eda4 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ # Meross LAN -This [homeassistant](https://www.home-assistant.io/) integration allows you to control your *Meross* plugs all over your LAN without any need for cloud connectivity. It works through your own MQTT broker (or any other configured through the homeassistant mqtt integration). +This [homeassistant](https://www.home-assistant.io/) integration allows you to control your *Meross* devices all over your LAN without any need for cloud connectivity. It supports communication through your own MQTT broker (or any other configured through the homeassistant mqtt integration) or directly via HTTP. -In order for this to work you need to bind your *Meross* appliances to this same MQTT broker. Follow the guide at https://github.com/bytespider/Meross/wiki/MQTT to re-configure your devices and start integrating them locally from the HA Integrations page +These are the two main use cases: +- Keep your devices paired with the offical Meross App (and cloud infrastructure) and communicate directly to them via HTTP. This will allow for greater flexibility and less configuration pain since you don't have to setup and configure the MQTT pairing of these devices. The integration will just 'side-communicate' over HTTP to the devices and poll them for status updates. (This is different from https://github.com/albertogeniola/meross-homeassistant since my componenent does not talk to the Meross Cloud service so it doesn't use credentials or any) +- Bind your devices to your 'private' MQTT broker so to completely disconnect them from the Meross infrastructure and interact only locally (The procedure for MQTT binding is here: https://github.com/bytespider/Meross/wiki/MQTT or better, you can use the pairer app from @albertogeniola at https://github.com/albertogeniola/meross_pair ) HAVE FUN! 😎 @@ -30,45 +32,51 @@ Restart HA to let it play ## Setup -Make sure your *Meross* plugs are correctly connected to the MQTT broker by checking they are effectively publishing state updates. +Once installed and restarted your Meross devices should be automatically discovered by the 'dhcp' integration and will then pop-up in your integrations panel ready to be configured (the exact timing will depend since the dhcp discovery has different strategies but a simple boot of the device should be sufficient even if not necessary) -The best test here is to enter the mqtt integration configuration in HA and subscribe to all topics to see if your HA instance is receiving the relevant messages by listening to the `/appliance/#` topic since *Meross* devices will publish to a subdomain of this one. +If you are using the 'MQTT way' you can help the discovery process by adding the 'MQTT Hub' feature of this integration (This was needed in the previous versions while you should be able to skip this step if the dhcp discovery works fine). If you need, just go to your homeassistant `Configuration -> Integrations` and add the Meross LAN by looking through the list of available ones. Here you can configure the device key used to sign the messages exchanged: this need to be the same key used when re-binding your hardware else the integration will not be able to discover new devices (dhcp discovery should instead work anyway: the key will be asked and set when configuring every single appliance) -Manually switch your plug and check if you received any message in your MQTT configuration pane. The topic should be something in the form `appliance/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/publish/` where the `XXX...` is the device identifier which is unique for every appliance. +You can also manually add your device by adding a new integration entry and providing the host address. -Once you are set up with the MQTT integration and device pairing, you can go to your homeassistant `Configuration -> Integrations` and add the Meross LAN by looking through the list of available ones. - -When you add it the first time, it will setup a 'software hub' (you will see the 'MQTT Hub' name on the configured integration) for the devices so it can start listening for MQTT messages from your Meross devices. If you configured your device to use a *key* different than *''* you can configure the 'MQTT Hub' accordingly by setting a key in the 'Options' pane for the configuration entry of the integration - -You can now start adding devices by manually toggling them so they *push* a status message to the broker and then to HA: you will receive a notification for a discovered integration and you will be able to set it up by clicking 'Configure' on the related panel in the Integrations page. Confirm and you are all done. In case of bulbs just plug/unplug them so they'll broadcast some status to the mqtt when my hub is listening - -If everything goes the way should, the component will be able to auto detect the device and nothing need to be done. The optional device *key* you configured for the hub will be propagated to the discovered entry and 'fixed' in it's own configuration so you can eventually manage to change the key when discovering other appliances - -If your device is not discovered then it is probably my fault since I currently do not have many of them to test +When configuring a device entry you'll have the option to set: +- host address: this is available when manually adding a device or when a device is discovered via DHCP: provide the ip address or a valid network host name. When you set the ip address, ensure it is 'stable' and not changing between re-boots else the integration will 'loose' access to the device +- device key: this is used to sign messages according to the official Meross protocol behaviour. Provide the same key you used to re-configure your appliance or, in case you're side-communicating and/or don't know the key leave it empty: this way the HTTP stack will be instructed to 'hack' the protocol by using a simple trick ## Supported hardware -At the moment this software has been developed and tested on the Meross MSS310 plug and MSL100 bulb. I have tried to make it the more optimistic and generalistic as possible based on the work from [@albertogeniola] and [@bytespider] so it should work with most of the plugs out there but I did not test anything other than mines +Most of this software has been developed and tested on my owned Meross devices which, over the time, are slowly expanding. I have tried to make it the more optimistic and generalistic as possible based on the work from [@albertogeniola] and [@bytespider] so it should work with most of the hardware out there but I did not test anything other than mines. There are some user reports confirming it works with other devices and the 'official' complete list is here: - Switches - [MSS310R](https://www.meross.com/product/38/article/): power plug with metering capabilties - [MSS425](https://www.meross.com/product/16/article/): Smart WiFi Surge Protector (multiple sockets power strip) - Lights - - [MSL100R](https://www.meross.com/product/4/article/): Smart bulb with dimmable light + - [MSL100](https://www.meross.com/product/4/article/): Smart bulb with dimmable light + - [MSL120](https://www.meross.com/product/28/article/): Smart RGB bulb with dimmable light +- Hub + - [MSH300](https://www.meross.com/Detail/50/Smart%20Wi-Fi%20Hub): Smart WiFi Hub +- Sensors + - [MS100](https://www.meross.com/Detail/46/Smart%20Temperature%20and%20Humidity%20Sensor): Smart Temperature/Humidity Sensor +- Thermostats + - [MTS100](https://www.meross.com/Detail/30/Smart%20Thermostat%20Valve): Smart Thermostat Valve - Covers - Support for garage door opener and roller shutter is implemented by guess-work so I'm not expecting flawless behaviour but...could work ## Features -The component exposes the basic functionality of the underlying device (toggle on/off, dimm, report consumption through sensors) without any other effort, It should be able to detect if the device goes offline suddenly by using a periodic heartbeat on the mqtt channel (actually 5 min). The detection works faster for plugs with power metering since they're also polled every 30 second or so for the power values. +The component exposes the basic functionality of the underlying device (toggle on/off, dimm, report consumption through sensors) without any other effort, It should be able to detect if the device goes offline suddenly by using a periodic heartbeat. +It also features an automatic protocol switching capability so, if you have your MQTT setup and your broker dies or whatever, the integration will try to fallback to HTTP communication and keep the device available returning automatically to MQTT mode as soon as the MQTT infrastructure returns online. The same works for HTTP mode: when the device is not reachable it will try to use MQTT (provided it is available!). This feature is enabled by default for every new configuration entry and you can control it by setting the 'Protocol' field in the configration panel of the integration: setting 'AUTO' (or empty) will do the automatic switch. Setting any fixed protocol 'MQTT' or 'HTTP' will force the use of that option (useful if you're in trouble and want to isolate or investigate inconsistent behaviours). I'd say: leave it empty or 'AUTO' it works good in my tests. + +If you have the MSH300 Hub working with this integration, every new subdevice (thermostat or sensor) can be automatically discovered once the subdevice is paired with the hub. When the hub is configured in this integration you don't need to switch back and forth to/from the Meross app in order to 'bind' new devices: just pair the thermostat or sensor to the hub by using the subdevice pairing procedure (fast double press on the hub) +I'm sorry to not be able to write a complete wiki at the moment in order to better explain some procedures or share my knwoledge about the devices but time is constrained and writing knwoledge bases is always consuming (and sligthly boring I admit). I'm still working on some features and I've put a big effort trying to ensure a frictionless working of this software so I hope you can make use of it without deeper explanations. Something will come, slowly, but if you have any urgent issue or question I will be happy to help (and maybe this will speed up the documentation :) ## Service -There is a service (since version 0.0.4) exposed to simplify communication with the device and play with it a bit. It basically requires the needed informations to setup a command request and send it over MQTT without the hassle of signatures and timestamps computations. You can check it in the 'Developer Tools' of the HA instance, everything should be enough self-explanatory there. +There is a service (since version 0.0.4) exposed to simplify communication with the device and play with it a bit. It basically requires the needed informations to setup a command request and send it over MQTT or HTTP without the hassle of signatures and timestamps computations. You can check it in the 'Developer Tools' of the HA instance, everything should be enough self-explanatory there. I find it a bit frustrating that the HA service infrastructure does not allow to return anything from a service invocation so, the eventual reply from the device will get 'lost' in the mqtt flow. I've personally played a bit with the MQTT integration configuration pane to listen and see the mqtt responses from my devices but it's somewhat a pain unless you have a big screen to play with (or multiple monitors for the matter). Nevertheless you can use the service wherever you like to maybe invoke features at the device level or dig into it's configuration +*WARNING*: the service name has changed from 'mqtt_publish' to 'request' to accomodate the more general protocol support ## References diff --git a/custom_components/meross_lan/__init__.py b/custom_components/meross_lan/__init__.py index feedf300..8d6bc030 100644 --- a/custom_components/meross_lan/__init__.py +++ b/custom_components/meross_lan/__init__.py @@ -1,32 +1,42 @@ """The Meross IoT local LAN integration.""" -from typing import Any, Callable, Dict, List, Optional, Union -import asyncio +from typing import Callable, Dict, Optional, Union from time import time import datetime -from uuid import uuid4 -from hashlib import md5 from json import ( dumps as json_dumps, loads as json_loads, ) - - +from aiohttp.client_exceptions import ClientConnectionError from homeassistant.config_entries import ConfigEntry, SOURCE_DISCOVERY from homeassistant.core import HomeAssistant, callback from homeassistant.components import mqtt from homeassistant.helpers import device_registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, DataUpdateCoordinator, - UpdateFailed, ) +from homeassistant.exceptions import ConfigEntryNotReady + +from . import merossclient +from .merossclient import KeyType, MerossDeviceDescriptor, MerossHttpClient, const as mc -from .logger import LOGGER, LOGGER_trap from logging import WARNING, INFO +from .logger import LOGGER, LOGGER_trap + from .meross_device import MerossDevice -from .const import * +from .meross_device_switch import MerossDeviceSwitch +from .meross_device_bulb import MerossDeviceBulb +from .meross_device_hub import MerossDeviceHub + +from .const import ( + CONF_POLLING_PERIOD_DEFAULT, DOMAIN, SERVICE_REQUEST, + CONF_HOST, CONF_OPTION_MQTT, CONF_PROTOCOL, + CONF_DEVICE_ID, CONF_KEY, CONF_PAYLOAD, + DISCOVERY_TOPIC, REQUEST_TOPIC, RESPONSE_TOPIC, + PARAM_UNAVAILABILITY_TIMEOUT, +) async def async_setup(hass: HomeAssistant, config: dict): @@ -39,74 +49,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): LOGGER.debug("async_setup_entry entry_id = %s", entry.entry_id) api = hass.data.get(DOMAIN) if api == None: + api = MerossApi(hass) + hass.data[DOMAIN] = api + + device_id = entry.data.get(CONF_DEVICE_ID) + if (api.unsub_mqtt is None) and \ + ((device_id is None) or (entry.data.get(CONF_PROTOCOL) == CONF_OPTION_MQTT)): + # this is the MQTT Hub entry or a device which needs MQTT + # and we still havent registered MQTT try: - api = MerossApi(hass) - hass.data[DOMAIN] = api await api.async_mqtt_register() + except Exception as e: + raise ConfigEntryNotReady from e - def _mqtt_publish(service_call): - device_id = service_call.data.get(CONF_DEVICE_ID) - method = service_call.data.get("method") - namespace = service_call.data.get("namespace") - key = service_call.data.get(CONF_KEY, api.key) - payload = service_call.data.get("payload", "{}") - api.mqtt_publish(device_id, namespace, method, json_loads(payload), key) - return - hass.services.async_register(DOMAIN, SERVICE_MQTT_PUBLISH, _mqtt_publish) - except: - return False - - device_id = entry.data.get(CONF_DEVICE_ID) - if device_id == None: + if device_id is None: # this is the MQTT Hub entry api.key = entry.data.get(CONF_KEY) # could be 'None' : if so defaults to "" but allows key reply trick api.unsub_entry_update_listener = entry.add_update_listener(api.entry_update_listener) else: #device related entry LOGGER.debug("async_setup_entry device_id = %s", device_id) - device = MerossDevice(api, device_id, entry) - api.devices[device_id] = device - - p_system = entry.data.get(CONF_DISCOVERY_PAYLOAD, {}).get("all", {}).get("system", {}) - p_hardware = p_system.get("hardware", {}) - p_firmware = p_system.get("firmware", {}) - p_hardware_type = p_hardware.get("type", MANUFACTURER) - - try: - #use newer api - device_registry.async_get(hass).async_get_or_create( - config_entry_id = entry.entry_id, - connections = {(device_registry.CONNECTION_NETWORK_MAC, p_hardware.get("macAddress"))}, - identifiers = {(DOMAIN, device_id)}, - manufacturer = MANUFACTURER, - name = p_hardware_type + " " + device_id, - model = p_hardware_type + " " + p_hardware.get("version", ""), - sw_version = p_firmware.get("version"), - ) - except: - #fallback: as of 27-03-2021 this is still working - device_registry.async_get_registry(hass).async_get_or_create( - config_entry_id = entry.entry_id, - connections = {(device_registry.CONNECTION_NETWORK_MAC, p_hardware.get("macAddress"))}, - identifiers = {(DOMAIN, device_id)}, - manufacturer = MANUFACTURER, - name = p_hardware_type + " " + device_id, - model = p_hardware_type + " " + p_hardware.get("version", ""), - sw_version = p_firmware.get("version"), - ) - - - if device.has_switches: - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, "switch")) - if device.has_lights: - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, "light")) - if device.has_sensors: - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, "sensor")) - if device.has_covers: - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, "cover")) - - device.unsub_entry_update_listener = entry.add_update_listener(device_entry_update_listener) + device = api.build_device(device_id, entry) + device.unsub_entry_update_listener = entry.add_update_listener(device.entry_update_listener) device.unsub_updatecoordinator_listener = api.coordinator.async_add_listener(device.updatecoordinator_listener) + hass.config_entries.async_setup_platforms(entry, device.platforms.keys()) return True @@ -114,46 +80,33 @@ def _mqtt_publish(service_call): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" LOGGER.debug("async_unload_entry entry_id = %s", entry.entry_id) - api = hass.data.get(DOMAIN) - if api != None: + api: MerossApi = hass.data.get(DOMAIN) + if api is not None: device_id = entry.data.get(CONF_DEVICE_ID) - if device_id != None: + if device_id is not None: LOGGER.debug("async_unload_entry device_id = %s", device_id) # when removing devices we could also need to cleanup platforms device = api.devices[device_id] - platforms_unload = [] - if device.has_switches: - platforms_unload.append(hass.config_entries.async_forward_entry_unload(entry, "switch")) - if device.has_lights: - platforms_unload.append(hass.config_entries.async_forward_entry_unload(entry, "light")) - if device.has_sensors: - platforms_unload.append(hass.config_entries.async_forward_entry_unload(entry, "sensor")) - if device.has_covers: - platforms_unload.append(hass.config_entries.async_forward_entry_unload(entry, "cover")) - - if platforms_unload: - if False == all(await asyncio.gather(*platforms_unload)): - return False - - if device.unsub_entry_update_listener: + if not await hass.config_entries.async_unload_platforms(entry, device.platforms.keys()): + return False + if device.unsub_entry_update_listener is not None: device.unsub_entry_update_listener() device.unsub_entry_update_listener = None - if device.unsub_updatecoordinator_listener: + if device.unsub_updatecoordinator_listener is not None: device.unsub_updatecoordinator_listener() device.unsub_updatecoordinator_listener = None - api.devices.pop(device_id) #when removing the last configentry do a complete cleanup if (not api.devices) and (len(hass.config_entries.async_entries(DOMAIN)) == 1): - if api.unsub_mqtt: + if api.unsub_mqtt is not None: api.unsub_mqtt() api.unsub_mqtt = None - if api.unsub_entry_update_listener: + if api.unsub_entry_update_listener is not None: api.unsub_entry_update_listener() api.unsub_entry_update_listener = None - if api.unsub_updatecoordinator_listener: + if api.unsub_updatecoordinator_listener is not None: api.unsub_updatecoordinator_listener() api.unsub_updatecoordinator_listener = None hass.data.pop(DOMAIN) @@ -161,60 +114,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def device_entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): - await hass.config_entries.async_reload(config_entry.entry_id) - - -def build_payload(namespace: str, method: str, payload: dict = {}, key: Union[dict, Optional[str]] = None): # pylint: disable=unsubscriptable-object - if isinstance(key, dict): - key["namespace"] = namespace - key["method"] = method - key["payloadVersion"] = 1 - key["from"] = "" - return json_dumps({ - "header": key, - "payload": payload - }) - else: - messageid = uuid4().hex - timestamp = int(time()) - return json_dumps({ - "header": { - "messageId": messageid, - "namespace": namespace, - "method": method, - "payloadVersion": 1, - #"from": "/appliance/9109182170548290882048e1e9522946/publish", - "timestamp": timestamp, - "timestampMs": 0, - "sign": md5((messageid + (key or "") + str(timestamp)).encode('utf-8')).hexdigest() - }, - "payload": payload - }) - - - -def get_replykey(header: dict, key: Optional[str] = None) -> Union[dict, Optional[str]]: # pylint: disable=unsubscriptable-object - """ - checks header signature against key: - if ok return sign itsef else return the full header { "messageId", "timestamp", "sign", ...} - in order to be able to use it in a reply scheme - **UPDATE 28-03-2021** - the 'reply scheme' hack doesnt work on mqtt but works on http: this code will be left since it works if the key is correct - anyway and could be reused in a future attempt - """ - sign = md5((header["messageId"] + (key or "") + str(header["timestamp"])).encode('utf-8')).hexdigest() - if sign == header["sign"]: - return key - - return header - class MerossApi: def __init__(self, hass: HomeAssistant): self.hass = hass self.key = None self.devices: Dict[str, MerossDevice] = {} - self.discovering: Dict[str, {}] = {} + self.discovering: Dict[str, dict] = {} self.unsub_mqtt = None self.unsub_entry_update_listener = None self.unsub_updatecoordinator_listener = None @@ -232,8 +137,21 @@ async def async_update_data(): name=DOMAIN, update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. - update_interval=datetime.timedelta(seconds=PARAM_UPDATE_POLLING_PERIOD), + update_interval=datetime.timedelta(seconds=CONF_POLLING_PERIOD_DEFAULT), ) + + def _request(service_call): + self.request( + device_id=service_call.data.get(CONF_DEVICE_ID), + namespace=service_call.data.get(mc.KEY_NAMESPACE), + method=service_call.data.get(mc.KEY_METHOD), + payload=json_loads(service_call.data.get(mc.KEY_PAYLOAD, "{}")), + key=service_call.data.get(CONF_KEY, self.key), + host=service_call.data.get(CONF_HOST) + ) + return + hass.services.async_register(DOMAIN, SERVICE_REQUEST, _request) + return @@ -244,10 +162,10 @@ async def mqtt_receive(msg): try: device_id = msg.topic.split("/")[2] mqttpayload = json_loads(msg.payload) - header = mqttpayload.get("header") - method = header.get("method") - namespace = header.get("namespace") - payload = mqttpayload.get("payload") + header = mqttpayload.get(mc.KEY_HEADER) + method = header.get(mc.KEY_METHOD) + namespace = header.get(mc.KEY_NAMESPACE) + payload = mqttpayload.get(mc.KEY_PAYLOAD) LOGGER.debug("MerossApi: MQTT RECV device_id:(%s) method:(%s) namespace:(%s)", device_id, method, namespace) @@ -264,41 +182,41 @@ async def mqtt_receive(msg): msg_reason = "disabled" if domain_entry.disabled_by is not None \ else "ignored" if domain_entry.source == "ignore" \ else "unknown" - LOGGER_trap(INFO, "Ignoring discovery for device_id: %s (ConfigEntry is %s)", device_id, msg_reason) + LOGGER_trap(INFO, 300, "Ignoring discovery for device_id: %s (ConfigEntry is %s)", device_id, msg_reason) return #also skip discovered integrations waititng in HA queue for flow in self.hass.config_entries.flow.async_progress(): if (flow.get("handler") == DOMAIN) and (flow.get("context", {}).get("unique_id") == device_id): - LOGGER_trap(INFO, "Ignoring discovery for device_id: %s (ConfigEntry is in progress)", device_id) + LOGGER_trap(INFO, 300, "Ignoring discovery for device_id: %s (ConfigEntry is in progress)", device_id) return - replykey = get_replykey(header, self.key) + replykey = merossclient.get_replykey(header, self.key) if replykey != self.key: - LOGGER_trap(WARNING, "Meross discovery key error for device_id: %s", device_id) + LOGGER_trap(WARNING, 300, "Meross discovery key error for device_id: %s", device_id) if self.key is not None:# we're using a fixed key in discovery so ignore this device return discovered = self.discovering.get(device_id) if discovered == None: # new device discovered: try to determine the capabilities - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ALL, METHOD_GET, key=replykey) + self.mqtt_publish(device_id, mc.NS_APPLIANCE_SYSTEM_ALL, mc.METHOD_GET, key=replykey) self.discovering[device_id] = { "__time": time() } if self.unsub_updatecoordinator_listener is None: self.unsub_updatecoordinator_listener = self.coordinator.async_add_listener(self.updatecoordinator_listener) else: - if method == METHOD_GETACK: - if namespace == NS_APPLIANCE_SYSTEM_ALL: - discovered[NS_APPLIANCE_SYSTEM_ALL] = payload - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ABILITY, METHOD_GET, key=replykey) + if method == mc.METHOD_GETACK: + if namespace == mc.NS_APPLIANCE_SYSTEM_ALL: + discovered[mc.NS_APPLIANCE_SYSTEM_ALL] = payload + self.mqtt_publish(device_id, mc.NS_APPLIANCE_SYSTEM_ABILITY, mc.METHOD_GET, key=replykey) discovered["__time"] = time() return - elif namespace == NS_APPLIANCE_SYSTEM_ABILITY: - if discovered.get(NS_APPLIANCE_SYSTEM_ALL) is None: - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ALL, METHOD_GET, key=replykey) + elif namespace == mc.NS_APPLIANCE_SYSTEM_ABILITY: + if discovered.get(mc.NS_APPLIANCE_SYSTEM_ALL) is None: + self.mqtt_publish(device_id, mc.NS_APPLIANCE_SYSTEM_ALL, mc.METHOD_GET, key=replykey) discovered["__time"] = time() return - payload.update(discovered[NS_APPLIANCE_SYSTEM_ALL]) + payload.update(discovered[mc.NS_APPLIANCE_SYSTEM_ALL]) self.discovering.pop(device_id) if (len(self.discovering) == 0) and self.unsub_updatecoordinator_listener: self.unsub_updatecoordinator_listener() @@ -308,7 +226,7 @@ async def mqtt_receive(msg): context={ "source": SOURCE_DISCOVERY }, data={ CONF_DEVICE_ID: device_id, - CONF_DISCOVERY_PAYLOAD: payload, + CONF_PAYLOAD: payload, CONF_KEY: replykey }, ) @@ -316,16 +234,15 @@ async def mqtt_receive(msg): #we might get here from spurious PUSH or something sent from the device #check for timeout and eventually reset the procedure if (time() - discovered.get("__time", 0)) > PARAM_UNAVAILABILITY_TIMEOUT: - if discovered.get(NS_APPLIANCE_SYSTEM_ALL) is None: - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ALL, METHOD_GET, key=replykey) + if discovered.get(mc.NS_APPLIANCE_SYSTEM_ALL) is None: + self.mqtt_publish(device_id, mc.NS_APPLIANCE_SYSTEM_ALL, mc.METHOD_GET, key=replykey) else: - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ABILITY, METHOD_GET, key=replykey) + self.mqtt_publish(device_id, mc.NS_APPLIANCE_SYSTEM_ABILITY, mc.METHOD_GET, key=replykey) discovered["__time"] = time() return - else: - device.parsepayload(namespace, method, payload, get_replykey(header, device.key)) + device.mqtt_receive(namespace, method, payload, merossclient.get_replykey(header, device.key)) except Exception as e: LOGGER.debug("MerossApi: mqtt_receive exception:(%s) payload:(%s)", str(e), msg.payload) @@ -336,17 +253,149 @@ async def mqtt_receive(msg): DISCOVERY_TOPIC, mqtt_receive ) + def has_device(self, ipaddress: str, macaddress:str) -> bool: + # macaddress from dhcp discovery is already stripped/lower but... + macaddress = macaddress.replace(':', '').lower() + for device in self.devices.values(): + if device.descriptor.ipAddress == ipaddress: + return True + if device.descriptor.macAddress.replace(':', '').lower() == macaddress: + return True + else: + return False - def mqtt_publish(self, device_id: str, namespace: str, method: str, payload: dict = {}, key: Union[dict, Optional[str]] = None): # pylint: disable=unsubscriptable-object + def build_device(self, device_id: str, entry: ConfigEntry) -> MerossDevice: + """ + scans device descriptor to build a 'slightly' specialized MerossDevice + The base MerossDevice class is a bulk 'do it all' implementation + but some devices (i.e. Hub) need a (radically?) different behaviour + """ + descriptor = MerossDeviceDescriptor(entry.data.get(CONF_PAYLOAD, {})) + if not descriptor.digest: # legacy firmware -> switches likely + device = MerossDeviceSwitch(self, descriptor, entry) + elif (mc.KEY_HUB in descriptor.digest): + device = MerossDeviceHub(self, descriptor, entry) + elif (mc.KEY_LIGHT in descriptor.digest): + device = MerossDeviceBulb(self, descriptor, entry) + else: + device = MerossDeviceSwitch(self, descriptor, entry) + + self.devices[device_id] = device + self.update_polling_period() + + try: + # try block since this is not critical and api has recently changed + device_registry.async_get(self.hass).async_get_or_create( + config_entry_id = entry.entry_id, + connections = {(device_registry.CONNECTION_NETWORK_MAC, descriptor.macAddress)}, + identifiers = {(DOMAIN, device_id)}, + manufacturer = mc.MANUFACTURER, + name = descriptor.productname, + model = descriptor.productmodel, + sw_version = descriptor.firmware.get(mc.KEY_VERSION) + ) + except: + pass + + return device + + + def mqtt_publish(self, + device_id: str, + namespace: str, + method: str, + payload: dict = {}, + key: KeyType = None + ) -> None: LOGGER.debug("MerossApi: MQTT SEND device_id:(%s) method:(%s) namespace:(%s)", device_id, method, namespace) - mqttpayload = build_payload(namespace, method, payload, key) - return self.hass.components.mqtt.async_publish(COMMAND_TOPIC.format(device_id), mqttpayload, 0, False) + self.hass.components.mqtt.async_publish( + REQUEST_TOPIC.format(device_id), + json_dumps(merossclient.build_payload( + namespace, method, payload, + key, _from=RESPONSE_TOPIC.format(device_id))), + 0, + False) + + + async def async_http_request(self, + host: str, + namespace: str, + method: str, + payload: dict = {}, + key: KeyType = None, + callback_or_device: Union[Callable, MerossDevice] = None # pylint: disable=unsubscriptable-object + ) -> None: + try: + _httpclient:MerossHttpClient = getattr(self, '_httpclient', None) + if _httpclient is None: + _httpclient = MerossHttpClient(host, key, async_get_clientsession(self.hass), LOGGER) + self._httpclient = _httpclient + else: + _httpclient.set_host_key(host, key) + + response = await _httpclient.async_request(namespace, method, payload) + r_header = response[mc.KEY_HEADER] + r_namespace = r_header[mc.KEY_NAMESPACE] + r_method = r_header[mc.KEY_METHOD] + if callback_or_device is not None: + if isinstance(callback_or_device, MerossDevice): + callback_or_device.receive( r_namespace, r_method, + response[mc.KEY_PAYLOAD], _httpclient.replykey) + elif (r_method == mc.METHOD_SETACK): + #we're actually only using this for SET->SETACK command confirmation + callback_or_device() + + except ClientConnectionError as e: + LOGGER.info("MerossApi: client connection error in async_http_request(%s)", str(e)) + except Exception as e: + LOGGER.warning("MerossApi: error in async_http_request(%s)", str(e)) + + + def request(self, + device_id: str, + namespace: str, + method: str, + payload: dict = {}, + key: Union[dict, Optional[str]] = None, # pylint: disable=unsubscriptable-object + host: str = None, + callback_or_device: Union[Callable, MerossDevice] = None # pylint: disable=unsubscriptable-object + ) -> None: + """ + send a request with an 'adaptable protocol' behaviour i.e. use MQTT if the + api is registered with the mqtt service else fallback to HTTP + """ + #LOGGER.debug("MerossApi: MQTT SEND device_id:(%s) method:(%s) namespace:(%s)", device_id, method, namespace) + if (self.unsub_mqtt is None) or (device_id is None): + if host is None: + if device_id is None: + LOGGER.warning("MerossApi: cannot call async_http_request (missing device_id and host)") + return + device = self.devices.get(device_id) + if device is None: + LOGGER.warning("MerossApi: cannot call async_http_request (device_id(%s) not found)", device_id) + return + host = device.descriptor.ipAddress + self.hass.async_create_task( + self.async_http_request(host, namespace, method, payload, key, callback_or_device) + ) + else: + self.mqtt_publish(device_id, namespace, method, payload, key) + + + def update_polling_period(self) -> None: + """ + called whenever a new device is added or a config_entry changes + """ + polling_period = CONF_POLLING_PERIOD_DEFAULT + for device in self.devices.values(): + if device.polling_period < polling_period: + polling_period = device.polling_period + self.coordinator.update_interval = datetime.timedelta(seconds=polling_period) @callback - async def entry_update_listener(self, hass: HomeAssistant, config_entry: ConfigEntry): + async def entry_update_listener(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self.key = config_entry.data.get(CONF_KEY) - return @callback @@ -358,10 +407,10 @@ def updatecoordinator_listener(self) -> None: now = time() for device_id, discovered in self.discovering.items(): if (now - discovered.get("__time", 0)) > PARAM_UNAVAILABILITY_TIMEOUT: - if discovered.get(NS_APPLIANCE_SYSTEM_ALL) is None: - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ALL, METHOD_GET, {}, self.key) + if discovered.get(mc.NS_APPLIANCE_SYSTEM_ALL) is None: + self.mqtt_publish(device_id, mc.NS_APPLIANCE_SYSTEM_ALL, mc.METHOD_GET, {}, self.key) else: - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ABILITY, METHOD_GET, {}, self.key) + self.mqtt_publish(device_id, mc.NS_APPLIANCE_SYSTEM_ABILITY, mc.METHOD_GET, {}, self.key) discovered["__time"] = now return diff --git a/custom_components/meross_lan/binary_sensor.py b/custom_components/meross_lan/binary_sensor.py new file mode 100644 index 00000000..459d98e1 --- /dev/null +++ b/custom_components/meross_lan/binary_sensor.py @@ -0,0 +1,28 @@ + +from homeassistant.components.binary_sensor import BinarySensorEntity + +from .meross_entity import _MerossEntity, _MerossHubEntity, platform_setup_entry, platform_unload_entry +from .const import PLATFORM_BINARY_SENSOR + +async def async_setup_entry(hass: object, config_entry: object, async_add_devices): + platform_setup_entry(hass, config_entry, async_add_devices, PLATFORM_BINARY_SENSOR) + +async def async_unload_entry(hass: object, config_entry: object) -> bool: + return platform_unload_entry(hass, config_entry, PLATFORM_BINARY_SENSOR) + + +class MerossLanBinarySensor(_MerossEntity, BinarySensorEntity): + + PLATFORM = PLATFORM_BINARY_SENSOR + + def __init__(self, device: 'MerossDevice', id: object, device_class: str): + super().__init__(device, id, device_class) + + + +class MerossLanHubBinarySensor(_MerossHubEntity, BinarySensorEntity): + + PLATFORM = PLATFORM_BINARY_SENSOR + + def __init__(self, subdevice: 'MerossSubDevice', device_class: str): + super().__init__(subdevice, f"{subdevice.id}_{device_class}", device_class) diff --git a/custom_components/meross_lan/climate.py b/custom_components/meross_lan/climate.py new file mode 100644 index 00000000..320e8332 --- /dev/null +++ b/custom_components/meross_lan/climate.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from homeassistant.components.climate.const import ( + PRESET_AWAY, PRESET_COMFORT, PRESET_SLEEP, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, + CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, + HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF, +) +from homeassistant.const import TEMP_CELSIUS + +from homeassistant.components.climate import ClimateEntity + +from .merossclient import const as mc # mEROSS cONST +from .meross_entity import _MerossHubEntity, platform_setup_entry, platform_unload_entry +from .const import PLATFORM_CLIMATE + +async def async_setup_entry(hass: object, config_entry: object, async_add_devices): + platform_setup_entry(hass, config_entry, async_add_devices, PLATFORM_CLIMATE) + +async def async_unload_entry(hass: object, config_entry: object) -> bool: + return platform_unload_entry(hass, config_entry, PLATFORM_CLIMATE) + +MTS100MODE_CUSTOM = 0 +MTS100MODE_COMFORT = 1 # aka 'Heat' +MTS100MODE_SLEEP = 2 # aka 'Cool' +MTS100MODE_AWAY = 4 # aka 'Economy' +MTS100MODE_AUTO = 3 + +PRESET_OFF = 'off' +PRESET_CUSTOM = 'custom' +#PRESET_COMFORT = 'heat' +#PRESET_COOL = 'cool' +#PRESET_ECONOMY = 'economy' +PRESET_AUTO = 'auto' + +# map mts100 mode enums to HA preset keys +MODE_TO_PRESET_MAP = { + MTS100MODE_CUSTOM: PRESET_CUSTOM, + MTS100MODE_COMFORT: PRESET_COMFORT, + MTS100MODE_SLEEP: PRESET_SLEEP, + MTS100MODE_AWAY: PRESET_AWAY, + MTS100MODE_AUTO: PRESET_AUTO +} +# reverse map +PRESET_TO_MODE_MAP = { + PRESET_CUSTOM: MTS100MODE_CUSTOM, + PRESET_COMFORT: MTS100MODE_COMFORT, + PRESET_SLEEP: MTS100MODE_SLEEP, + PRESET_AWAY: MTS100MODE_AWAY, + PRESET_AUTO: MTS100MODE_AUTO +} +# when HA requests an HVAC mode we'll map it to a 'preset' +HVAC_TO_PRESET_MAP = { + HVAC_MODE_OFF: PRESET_OFF, + HVAC_MODE_HEAT: PRESET_CUSTOM, + HVAC_MODE_AUTO: PRESET_AUTO +} +# when setting target temp we'll set an appropriate payload key +# for the mts100 depending on current 'preset' mode. +# if mts100 is in any of 'off', 'auto' we just set the 'custom' +# target temp but of course the valve will not follow +# this temp since it's mode is not set to follow a manual set +PRESET_TO_TEMPKEY_MAP = { + PRESET_OFF: mc.KEY_CUSTOM, + PRESET_CUSTOM: mc.KEY_CUSTOM, + PRESET_COMFORT: mc.KEY_COMFORT, + PRESET_SLEEP: mc.KEY_ECONOMY, + PRESET_AWAY: mc.KEY_AWAY, + PRESET_AUTO: mc.KEY_CUSTOM +} + +class Mts100Climate(_MerossHubEntity, ClimateEntity): + + PLATFORM = PLATFORM_CLIMATE + + def __init__(self, subdevice: 'MerossSubDevice'): + super().__init__(subdevice, subdevice.id, None) + self._min_temp = None + self._max_temp = None + self._target_temperature = None + self._current_temperature = None + self._preset_mode = None + self._hvac_mode = None + self._hvac_action = None + self._mts100_mode = None + self._mts100_onoff = None + self._mts100_heating = None + + + def update_modes(self) -> None: + if self._mts100_onoff: + self._hvac_mode = HVAC_MODE_AUTO if self._mts100_mode == MTS100MODE_AUTO else HVAC_MODE_HEAT + self._hvac_action = CURRENT_HVAC_HEAT if self._mts100_heating else CURRENT_HVAC_IDLE + self._preset_mode = MODE_TO_PRESET_MAP.get(self._mts100_mode) + else: + self._hvac_mode = HVAC_MODE_OFF + self._hvac_action = CURRENT_HVAC_OFF + self._preset_mode = PRESET_OFF + + self._state = self._hvac_mode if self.subdevice.online else None + + if self.hass and self.enabled: + self.async_write_ha_state() + + + @property + def supported_features(self) -> int: + return SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + + @property + def temperature_unit(self) -> str: + return TEMP_CELSIUS + + @property + def min_temp(self) -> float: + return self._min_temp + + @property + def max_temp(self) -> float: + return self._max_temp + + @property + def hvac_modes(self) -> list[str]: + return [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_AUTO] + + @property + def hvac_mode(self) -> str: + return self._hvac_mode + + @property + def hvac_action(self) -> str | None: + return self._hvac_action + + @property + def current_temperature(self) -> float | None: + return self._current_temperature + + @property + def target_temperature(self) -> float | None: + return self._target_temperature + + @property + def target_temperature_step(self) -> float | None: + return 0.5 + + @property + def preset_mode(self) -> str | None: + return self._preset_mode + + @property + def preset_modes(self) -> list[str] | None: + return [PRESET_OFF, PRESET_CUSTOM, PRESET_COMFORT, + PRESET_SLEEP, PRESET_AWAY, PRESET_AUTO] + + + async def async_set_temperature(self, **kwargs) -> None: + t = kwargs.get('temperature') + key = PRESET_TO_TEMPKEY_MAP[self._preset_mode or PRESET_CUSTOM] + + def _ack_callback(): + self._target_temperature = t + self.update_modes() + + self._device.request( + mc.NS_APPLIANCE_HUB_MTS100_TEMPERATURE, + mc.METHOD_SET, + {mc.KEY_TEMPERATURE: [{mc.KEY_ID: self.subdevice.id, key: t * 10}]}, + _ack_callback + ) + + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + await self.async_set_preset_mode(HVAC_TO_PRESET_MAP.get(hvac_mode)) + + + async def async_set_preset_mode(self, preset_mode: str) -> None: + if preset_mode == PRESET_OFF: + await self.async_turn_off() + else: + mode = PRESET_TO_MODE_MAP.get(preset_mode) + if mode is not None: + + def _ack_callback(): + self._mts100_mode = mode + self.update_modes() + + self._device.request( + mc.NS_APPLIANCE_HUB_MTS100_MODE, + mc.METHOD_SET, + {mc.KEY_MODE: [{mc.KEY_ID: self.subdevice.id, mc.KEY_STATE: mode}]}, + _ack_callback + ) + + if not self._mts100_onoff: + await self.async_turn_on() + + + async def async_turn_on(self) -> None: + def _ack_callback(): + self._mts100_onoff = 1 + self.update_modes() + + self._device.request( + mc.NS_APPLIANCE_HUB_TOGGLEX, + mc.METHOD_SET, + {mc.KEY_TOGGLEX: [{mc.KEY_ID: self.subdevice.id, mc.KEY_ONOFF: 1}]}, + _ack_callback + ) + + + async def async_turn_off(self) -> None: + def _ack_callback(): + self._mts100_onoff = 0 + self.update_modes() + + self._device.request( + mc.NS_APPLIANCE_HUB_TOGGLEX, + mc.METHOD_SET, + {mc.KEY_TOGGLEX: [{mc.KEY_ID: self.subdevice.id, mc.KEY_ONOFF: 0}]}, + _ack_callback + ) diff --git a/custom_components/meross_lan/config_flow.py b/custom_components/meross_lan/config_flow.py index e5b2bfa0..5b3f13c9 100644 --- a/custom_components/meross_lan/config_flow.py +++ b/custom_components/meross_lan/config_flow.py @@ -1,28 +1,51 @@ """Config flow for Meross IoT local LAN integration.""" +from homeassistant.components.mqtt import DATA_MQTT import voluptuous as vol from typing import OrderedDict, Optional import json -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .merossclient import MerossHttpClient, MerossDeviceDescriptor, const as mc, get_productnametype + +from .logger import LOGGER from .const import ( DOMAIN, - CONF_DEVICE_ID, CONF_KEY, CONF_DISCOVERY_PAYLOAD, CONF_DEVICE_TYPE, - MANUFACTURER + CONF_HOST, CONF_DEVICE_ID, CONF_KEY, + CONF_PAYLOAD, CONF_DEVICE_TYPE, + CONF_PROTOCOL, CONF_PROTOCOL_OPTIONS, + CONF_POLLING_PERIOD, CONF_POLLING_PERIOD_DEFAULT, ) +def _mqtt_is_loaded(hass) -> bool: + return hass.data.get(DATA_MQTT) is not None + + +async def _http_discovery(host: str, key: str, hass) -> dict: + client = MerossHttpClient(host, key, async_get_clientsession(hass), LOGGER) + payload = (await client.async_request(mc.NS_APPLIANCE_SYSTEM_ALL)).get(mc.KEY_PAYLOAD) + payload.update((await client.async_request(mc.NS_APPLIANCE_SYSTEM_ABILITY)).get(mc.KEY_PAYLOAD)) + return { + CONF_HOST: host, + CONF_PAYLOAD: payload, + CONF_KEY: key + } + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Meross IoT local LAN.""" - _discovery_info = None - _device_id = None - _device_type = None + _discovery_info: dict = None + _device_id: str = None + _host: str = None + _key: str = None VERSION = 1 - # TODO pick one of the available connection classes in homeassistant/config_entries.py - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL @staticmethod def async_get_options_flow(config_entry): @@ -31,81 +54,110 @@ def async_get_options_flow(config_entry): async def async_step_user(self, user_input=None): - await self.async_set_unique_id(DOMAIN) - self._abort_if_unique_id_configured() + errors = {} + if user_input is None: + # we could get here from user flow start in UI + # or following dhcp discovery + if self._host is None: + # check we already configured the hub .. + if (DOMAIN not in self._async_current_ids()) and _mqtt_is_loaded(self.hass): + return await self.async_step_hub() + else: + self._host = user_input[CONF_HOST] + self._key = user_input.get(CONF_KEY) + try: + discovery_info = await _http_discovery(self._host, self._key, self.hass) + return await self.async_step_discovery(discovery_info) + except Exception as e: + LOGGER.debug("Error (%s) connecting to meross device (host:%s)", str(e), self._host) + errors["base"] = "cannot_connect" + + config_schema = { + vol.Required(CONF_HOST, description={"suggested_value": self._host}): str, + vol.Optional(CONF_KEY, description={"suggested_value": self._key}): str, + } + return self.async_show_form(step_id="user", data_schema=vol.Schema(config_schema), errors=errors) - config_schema = OrderedDict() - config_schema[vol.Optional(CONF_KEY)] = str - return self.async_show_form(step_id="hub", data_schema=vol.Schema(config_schema)) + async def async_step_discovery(self, discovery_info: DiscoveryInfoType): + await self._async_set_info(discovery_info) + return await self.async_step_device() - async def async_step_hub(self, user_input=None): - #right now this is only used to setup MQTT Hub feature to allow discovery - """ - if user_input == None: - await self.async_set_unique_id(DOMAIN) - self._abort_if_unique_id_configured() - - config_schema = OrderedDict() - config_schema[vol.Optional(CONF_KEY)] = str - return self.async_show_form(step_id="hub", data_schema=vol.Schema(config_schema)) - """ - return self.async_create_entry(title="MQTT Hub", data=user_input) + async def async_step_dhcp(self, discovery_info: DiscoveryInfoType): + """Handle a flow initialized by DHCP discovery.""" + LOGGER.debug("received dhcp discovery: %s", json.dumps(discovery_info)) + self._host = discovery_info.get('ip') - async def async_step_discovery(self, info): - self._device_id = info[CONF_DEVICE_ID] - await self.async_set_unique_id(self._device_id) - self._abort_if_unique_id_configured() + # check we already dont have the device registered + api = self.hass.data.get(DOMAIN) + if api is not None: + if api.has_device(self._host, discovery_info.get('macaddress')): + return self.async_abort(reason='already_configured') + self._key = api.key - self._discovery_info = info - self._device_type = info.get(CONF_DISCOVERY_PAYLOAD, {}).get("all", {}).get("system", {}).get("hardware", {}).get("type", MANUFACTURER) + try: + # try device identification so the user/UI has a good context to start with + _discovery_info = await _http_discovery(self._host, self._key, self.hass) + await self._async_set_info(_discovery_info) + # now just let the user edit/accept the host address even if identification was fine + except Exception as e: + LOGGER.debug("Error (%s) connecting to meross device (host:%s)", str(e), self._host) + # forgive and continue if we cant discover the device...let the user work it out - self.context["title_placeholders"] = { - CONF_DEVICE_TYPE: self._device_type, - CONF_DEVICE_ID: self._device_id - } - #config_schema = OrderedDict() - #config_schema[vol.Optional(CONF_KEY, description={ "suggested_value" : info.get(CONF_KEY) })] = str + return await self.async_step_user() - #return self.async_show_form(step_id="device", data_schema=vol.Schema(config_schema)) - return self.async_show_form(step_id="device") async def async_step_device(self, user_input=None): data = self._discovery_info - #device_id = data.get(CONF_DEVICE_ID) - discoverypayload = data.get(CONF_DISCOVERY_PAYLOAD, {}) - #all = discoverypayload.get("all", {}) - #device_type = all.get("system", {}).get("hardware", {}).get("type", MANUFACTURER) if user_input is None: - config_schema = OrderedDict() - #config_schema[vol.Optional(CONF_KEY, description={ "suggested_value" : data.get(CONF_KEY) })] = str + config_schema = {} return self.async_show_form( step_id="device", data_schema=vol.Schema(config_schema), - description_placeholders={ - "device_type": self._device_type, - "device_id": self._device_id, - "payload": json.dumps(discoverypayload) - } + description_placeholders=self._placeholders ) - # not configuring key here since it should be right from discovery ;) - #data[CONF_KEY] = user_input.get(CONF_KEY) - return self.async_create_entry(title=self._device_type + " " + self._device_id, data=data) + return self.async_create_entry(title=self._descriptor.type + " " + self._device_id, data=data) + async def async_step_hub(self, user_input=None): + #right now this is only used to setup MQTT Hub feature to allow discovery and mqtt message sub/pub + if user_input == None: + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + config_schema = { vol.Optional(CONF_KEY): str } + return self.async_show_form(step_id="hub", data_schema=vol.Schema(config_schema)) + + return self.async_create_entry(title="MQTT Hub", data=user_input) + + + async def _async_set_info(self, discovery_info: DiscoveryInfoType) -> None: + self._discovery_info = discovery_info + self._descriptor = MerossDeviceDescriptor(discovery_info.get(CONF_PAYLOAD, {})) + self._device_id = self._descriptor.uuid + await self.async_set_unique_id(self._device_id) + self._abort_if_unique_id_configured() + + if CONF_DEVICE_ID not in discovery_info:#this is coming from manual user entry or dhcp discovery + discovery_info[CONF_DEVICE_ID] = self._device_id + + self._placeholders = { + CONF_DEVICE_TYPE: get_productnametype(self._descriptor.type), + CONF_DEVICE_ID: self._device_id, + CONF_PAYLOAD: ""#json.dumps(data.get(CONF_PAYLOAD, {})) + } + + self.context["title_placeholders"] = self._placeholders + return + -""" -Manage device options configuration -This code is actually disabled since I prefer to not add too many configuration tweaks at the moment. -The initial implementation was about letting the user choose if they wanted specific sensors for power values or not -Actually, I think the default solution of removing attributes (from switches) and adding specific sensors is 'plain right': -As an HA user you can just disable unwanted entities or remove them from recorder if they pollute your history -""" class OptionsFlowHandler(config_entries.OptionsFlow): + """ + Manage device options configuration + """ def __init__(self, config_entry): self._config_entry = config_entry @@ -126,60 +178,56 @@ async def async_step_hub(self, user_input=None): return self.async_create_entry(title="", data=None) config_schema = OrderedDict() - config_schema[vol.Optional(CONF_KEY, description={ "suggested_value" : self._config_entry.data.get(CONF_KEY) } )] = str + config_schema[ + vol.Optional( + CONF_KEY, + description={ "suggested_value" : self._config_entry.data.get(CONF_KEY) } + ) + ] = str return self.async_show_form(step_id="hub", data_schema=vol.Schema(config_schema)) + async def async_step_device(self, user_input=None): data = self._config_entry.data - discoverypayload = data.get(CONF_DISCOVERY_PAYLOAD, {}) - #ability = discoverypayload.get("ability", {}) - all = discoverypayload.get("all", {}) - device_id = data.get(CONF_DEVICE_ID) - device_type = all.get("system", {}).get("hardware", {}).get("type", MANUFACTURER) if user_input is not None: data = dict(data) data[CONF_KEY] = user_input.get(CONF_KEY) - """ - device_id = user_input[CONF_DEVICE_ID] - - all = json.loads(user_input["all"]) - data = { - CONF_DEVICE_ID: device_id, - CONF_KEY: user_input[CONF_KEY], - CONF_DISCOVERY_PAYLOAD: { - "all": all, - "ability": json.loads(user_input["ability"]) - }, - } - device_name = all.get("system", {}).get("hardware", {}).get("type", "Meross") + " " + device_id - """ + data[CONF_PROTOCOL] = user_input.get(CONF_PROTOCOL) + data[CONF_POLLING_PERIOD] = user_input.get(CONF_POLLING_PERIOD) self.hass.config_entries.async_update_entry(self._config_entry, data=data) return self.async_create_entry(title=None, data=None) - """ - data = self._config_entry.data or {} - discoverypayload = data.get(CONF_DISCOVERY_PAYLOAD, {}) - ability = discoverypayload.get("ability", {}) - all = discoverypayload.get("all", {}) - - device_id = data.get(CONF_DEVICE_ID) - device_name = all.get("system", {}).get("hardware", {}).get("type", "Meross") + " " + device_id - """ - config_schema = OrderedDict() - #config_schema[vol.Required(CONF_DEVICE_ID, default=device_id)] = vol.All(str, vol.Length(min=32, max=32)) - config_schema[vol.Optional(CONF_KEY, description={ "suggested_value" : data.get(CONF_KEY) } )] = str - #config_schema[vol.Optional("ability", default=json.dumps(ability, indent=4))] = str - #config_schema[vol.Optional("all", default=json.dumps(all, indent = 4))] = str - + config_schema[ + vol.Optional( + CONF_KEY, + description={"suggested_value": data.get(CONF_KEY)} + ) + ] = str + config_schema[ + vol.Optional( + CONF_PROTOCOL, + description={"suggested_value": data.get(CONF_PROTOCOL)} + ) + ] = vol.In(CONF_PROTOCOL_OPTIONS) + config_schema[ + vol.Optional( + CONF_POLLING_PERIOD, + default=CONF_POLLING_PERIOD_DEFAULT, + description={"suggested_value": data.get(CONF_POLLING_PERIOD)} + ) + ] = cv.positive_int + + descriptor = MerossDeviceDescriptor(data.get(CONF_PAYLOAD, {})) return self.async_show_form( step_id="device", data_schema=vol.Schema(config_schema), description_placeholders={ - "device_type": device_type, - "device_id": device_id, - "payload": json.dumps(discoverypayload) + CONF_DEVICE_TYPE: get_productnametype(descriptor.type), + CONF_DEVICE_ID: data.get(CONF_DEVICE_ID), + CONF_HOST: data.get(CONF_HOST) or "MQTT", + CONF_PAYLOAD: ""#json.dumps(data.get(CONF_PAYLOAD, {})) } ) diff --git a/custom_components/meross_lan/const.py b/custom_components/meross_lan/const.py index 8c99dc9a..ff5c06f4 100644 --- a/custom_components/meross_lan/const.py +++ b/custom_components/meross_lan/const.py @@ -1,59 +1,51 @@ """Constants for the Meross IoT local LAN integration.""" +from homeassistant import const as hac +from .merossclient import const as mc + DOMAIN = "meross_lan" -#PLATFORMS = ["switch", "sensor", "light", "cover"] -SERVICE_MQTT_PUBLISH = "mqtt_publish" -CONF_DEVICE_ID = "device_id" -CONF_KEY = "key" -CONF_DISCOVERY_PAYLOAD = "payload" +PLATFORM_SWITCH = 'switch' +PLATFORM_SENSOR = 'sensor' +PLATFORM_BINARY_SENSOR = 'binary_sensor' +PLATFORM_LIGHT = 'light' +PLATFORM_COVER = 'cover' +PLATFORM_CLIMATE = 'climate' + +SERVICE_REQUEST = "request" + +# ConfigEntry keys +CONF_DEVICE_ID = hac.CONF_DEVICE_ID +CONF_KEY = 'key' +CONF_PAYLOAD = hac.CONF_PAYLOAD CONF_DEVICE_TYPE = "device_type" +CONF_HOST = hac.CONF_HOST +CONF_PROTOCOL = hac.CONF_PROTOCOL # protocol used to communicate with device +CONF_OPTION_AUTO = 'auto' +CONF_OPTION_MQTT = 'mqtt' +CONF_OPTION_HTTP = 'http' +CONF_PROTOCOL_OPTIONS = ( + CONF_OPTION_AUTO, # best-effort: tries whatever to connect + CONF_OPTION_MQTT, + CONF_OPTION_HTTP +) +CONF_POLLING_PERIOD = 'polling_period' # general device state polling or whatever +CONF_POLLING_PERIOD_MIN = 5 +CONF_POLLING_PERIOD_DEFAULT = 30 +CONF_TIME_ZONE = hac.CONF_TIME_ZONE # if set in config we'll force time & zone for devices +CONF_TIMESTAMP = mc.KEY_TIMESTAMP # this is a 'fake' conf param we'll add to config_entry when we want to force flush to storage DISCOVERY_TOPIC = "/appliance/+/publish" -COMMAND_TOPIC = "/appliance/{}/subscribe" - -METHOD_PUSH = "PUSH" -METHOD_GET = "GET" -METHOD_GETACK = "GETACK" -METHOD_SET = "SET" -METHOD_SETACK = "SETACK" -METHOD_ERROR = "ERROR" - -NS_APPLIANCE_SYSTEM_ALL = "Appliance.System.All" -NS_APPLIANCE_SYSTEM_ABILITY = "Appliance.System.Ability" -NS_APPLIANCE_SYSTEM_CLOCK = "Appliance.System.Clock" -NS_APPLIANCE_SYSTEM_REPORT = "Appliance.System.Report" -NS_APPLIANCE_SYSTEM_ONLINE = "Appliance.System.Online" -NS_APPLIANCE_SYSTEM_DEBUG = "Appliance.System.Debug" -NS_APPLIANCE_CONFIG_TRACE = "Appliance.Config.Trace" -NS_APPLIANCE_CONFIG_WIFILIST = "Appliance.Config.WifiList" -NS_APPLIANCE_CONTROL_TOGGLE = "Appliance.Control.Toggle" -NS_APPLIANCE_CONTROL_TOGGLEX = "Appliance.Control.ToggleX" -NS_APPLIANCE_CONTROL_TRIGGER = "Appliance.Control.Trigger" -NS_APPLIANCE_CONTROL_TRIGGERX = "Appliance.Control.TriggerX" -NS_APPLIANCE_CONTROL_CONSUMPTIONCONFIG = "Appliance.Control.ConsumptionConfig" -NS_APPLIANCE_CONTROL_CONSUMPTIONX = "Appliance.Control.ConsumptionX" -NS_APPLIANCE_CONTROL_ELECTRICITY = "Appliance.Control.Electricity" -# Light Abilities -NS_APPLIANCE_CONTROL_LIGHT = "Appliance.Control.Light" -# Humidifier abilities -NS_APPLIANCE_SYSTEM_DND = "Appliance.System.DNDMode" -NS_APPLIANCE_CONTROL_SPRAY = "Appliance.Control.Spray" -# Garage door opener -NS_APPLIANCE_GARAGEDOOR_STATE = "Appliance.GarageDoor.State" -# Roller shutter -NS_APPLIANCE_ROLLERSHUTTER_STATE = 'Appliance.RollerShutter.State' -NS_APPLIANCE_ROLLERSHUTTER_POSITION = 'Appliance.RollerShutter.Position' +REQUEST_TOPIC = "/appliance/{}/subscribe" +RESPONSE_TOPIC = "/appliance/{}/publish" """ general working/configuration parameters (waiting to be moved to CONF_ENTRY) """ PARAM_UNAVAILABILITY_TIMEOUT = 20 # number of seconds since last inquiry to consider the device unavailable -PARAM_ENERGY_UPDATE_PERIOD = 60 # read energy consumption only every ... second -PARAM_UPDATE_POLLING_PERIOD = 30 # periodic state polling or whatever +PARAM_HEARTBEAT_PERIOD = 295 # whatever the connection state periodically inquire the device is there +PARAM_ENERGY_UPDATE_PERIOD = 55 # read energy consumption only every ... second +PARAM_HUBBATTERY_UPDATE_PERIOD = 3595 # read battery levels only every ... second +PARAM_HUBSENSOR_UPDATE_PERIOD = 55 #PARAM_STALE_DEVICE_REMOVE_TIMEOUT = 60 # disable config_entry when device is offline for more than... -PARAM_HEARTBEAT_PERIOD = 300 # whatever the connection state periodically inquire the device is there -""" - GP constant strings -""" -MANUFACTURER = "Meross" \ No newline at end of file + diff --git a/custom_components/meross_lan/cover.py b/custom_components/meross_lan/cover.py index 9e276ccf..de0fcb91 100644 --- a/custom_components/meross_lan/cover.py +++ b/custom_components/meross_lan/cover.py @@ -1,8 +1,5 @@ -from typing import Any, Callable, Dict, List, Optional -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.components.cover import ( CoverEntity, DEVICE_CLASS_GARAGE, DEVICE_CLASS_SHUTTER, @@ -11,34 +8,27 @@ STATE_OPEN, STATE_OPENING, STATE_CLOSED, STATE_CLOSING ) +from .merossclient import const as mc +from .meross_device import MerossDevice +from .meross_entity import _MerossEntity, platform_setup_entry, platform_unload_entry from .const import ( - DOMAIN, - CONF_DEVICE_ID, - METHOD_SET, METHOD_GET, - NS_APPLIANCE_GARAGEDOOR_STATE, - NS_APPLIANCE_ROLLERSHUTTER_STATE, NS_APPLIANCE_ROLLERSHUTTER_POSITION, - NS_APPLIANCE_SYSTEM_ALL + PLATFORM_COVER, ) -from .meross_entity import _MerossEntity -from .logger import LOGGER -async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry, async_add_devices): - device_id = config_entry.data[CONF_DEVICE_ID] - device = hass.data[DOMAIN].devices[device_id] - async_add_devices([entity for entity in device.entities.values() if isinstance(entity, MerossLanGarage) or isinstance(entity, MerossLanRollerShutter)]) - LOGGER.debug("async_setup_entry device_id = %s - platform = cover", device_id) - return +async def async_setup_entry(hass: object, config_entry: object, async_add_devices): + platform_setup_entry(hass, config_entry, async_add_devices, PLATFORM_COVER) async def async_unload_entry(hass: object, config_entry: object) -> bool: - LOGGER.debug("async_unload_entry device_id = %s - platform = cover", config_entry.data[CONF_DEVICE_ID]) - return True + return platform_unload_entry(hass, config_entry, PLATFORM_COVER) class MerossLanGarage(_MerossEntity, CoverEntity): - def __init__(self, meross_device: object, channel: int): - super().__init__(meross_device, channel, DEVICE_CLASS_GARAGE) - meross_device.has_covers = True - self._payload = {"state": {"open": 0, "channel": channel, "uuid": meross_device.device_id } } + + PLATFORM = PLATFORM_COVER + + def __init__(self, device: 'MerossDevice', id: object): + super().__init__(device, id, DEVICE_CLASS_GARAGE) + self._payload = {mc.KEY_STATE: {mc.KEY_OPEN: 0, mc.KEY_CHANNEL: id, mc.KEY_UUID: device.device_id } } @property @@ -64,20 +54,20 @@ def is_closed(self): async def async_open_cover(self, **kwargs) -> None: self._set_state(STATE_OPENING) - self._payload["state"]["open"] = 1 - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_GARAGEDOOR_STATE, - method=METHOD_SET, + self._payload[mc.KEY_STATE][mc.KEY_OPEN] = 1 + self._device.request( + namespace=mc.NS_APPLIANCE_GARAGEDOOR_STATE, + method=mc.METHOD_SET, payload=self._payload) return async def async_close_cover(self, **kwargs) -> None: self._set_state(STATE_CLOSING) - self._payload["state"]["open"] = 0 - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_GARAGEDOOR_STATE, - method=METHOD_SET, + self._payload[mc.KEY_STATE][mc.KEY_OPEN] = 0 + self._device.request( + namespace=mc.NS_APPLIANCE_GARAGEDOOR_STATE, + method=mc.METHOD_SET, payload=self._payload) return @@ -89,11 +79,14 @@ def _set_open(self, open) -> None: class MerossLanRollerShutter(_MerossEntity, CoverEntity): - def __init__(self, meross_device: object, channel: int): - super().__init__(meross_device, channel, DEVICE_CLASS_SHUTTER) - meross_device.has_covers = True + + PLATFORM = PLATFORM_COVER + + def __init__(self, device: MerossDevice, id: object): + super().__init__(device, id, DEVICE_CLASS_SHUTTER) + self._payload = {mc.KEY_POSITION: {mc.KEY_POSITION: 0, mc.KEY_CHANNEL: id } } self._position = None - self._payload = {"position": {"position": 0, "channel": channel } } + @property def supported_features(self): @@ -121,20 +114,20 @@ def current_cover_position(self): async def async_open_cover(self, **kwargs) -> None: self._set_state(STATE_OPENING) - self._payload["position"]["position"] = 100 - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_ROLLERSHUTTER_POSITION, - method=METHOD_SET, + self._payload[mc.KEY_POSITION][mc.KEY_POSITION] = 100 + self._device.request( + namespace=mc.NS_APPLIANCE_ROLLERSHUTTER_POSITION, + method=mc.METHOD_SET, payload=self._payload) return async def async_close_cover(self, **kwargs) -> None: self._set_state(STATE_CLOSING) - self._payload["position"]["position"] = 0 - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_ROLLERSHUTTER_POSITION, - method=METHOD_SET, + self._payload[mc.KEY_POSITION][mc.KEY_POSITION] = 0 + self._device.request( + namespace=mc.NS_APPLIANCE_ROLLERSHUTTER_POSITION, + method=mc.METHOD_SET, payload=self._payload) return @@ -144,10 +137,10 @@ async def async_set_cover_position(self, **kwargs): newpos = kwargs[ATTR_POSITION] if self._position is not None: self._set_state(STATE_CLOSING if newpos < self._position else STATE_OPENING) - self._payload["position"]["position"] = newpos - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_ROLLERSHUTTER_POSITION, - method=METHOD_SET, + self._payload[mc.KEY_POSITION][mc.KEY_POSITION] = newpos + self._device.request( + namespace=mc.NS_APPLIANCE_ROLLERSHUTTER_POSITION, + method=mc.METHOD_SET, payload=self._payload) return @@ -155,10 +148,10 @@ async def async_set_cover_position(self, **kwargs): async def async_stop_cover(self, **kwargs): #self._set_state(STATE_CLOSING) - self._payload["position"]["position"] = -1 - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_ROLLERSHUTTER_POSITION, - method=METHOD_SET, + self._payload[mc.KEY_POSITION][mc.KEY_POSITION] = -1 + self._device.request( + namespace=mc.NS_APPLIANCE_ROLLERSHUTTER_POSITION, + method=mc.METHOD_SET, payload=self._payload) return diff --git a/custom_components/meross_lan/light.py b/custom_components/meross_lan/light.py index 232f5981..a201160d 100644 --- a/custom_components/meross_lan/light.py +++ b/custom_components/meross_lan/light.py @@ -1,7 +1,5 @@ -from typing import Any, Callable, Dict, List, Optional, Union, Tuple +from typing import Union, Tuple -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.components.light import ( LightEntity, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_WHITE_VALUE, @@ -16,14 +14,12 @@ from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF -from .meross_entity import _MerossEntity +from .merossclient import const as mc +from .meross_device import MerossDevice +from .meross_entity import _MerossToggle, platform_setup_entry, platform_unload_entry from .const import ( - DOMAIN, - CONF_DEVICE_ID, - NS_APPLIANCE_CONTROL_LIGHT, NS_APPLIANCE_CONTROL_TOGGLEX, - METHOD_SET, METHOD_GET, + PLATFORM_LIGHT, ) -from .logger import LOGGER CAPACITY_RGB = 1 CAPACITY_TEMPERATURE = 2 @@ -32,20 +28,14 @@ CAPACITY_TEMPERATURE_LUMINANCE = 6 -async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry, async_add_devices): - device_id = config_entry.data[CONF_DEVICE_ID] - device = hass.data[DOMAIN].devices[device_id] - async_add_devices([entity for entity in device.entities.values() if isinstance(entity, MerossLanLight)]) - LOGGER.debug("async_setup_entry device_id = %s - platform = light", device_id) - return +async def async_setup_entry(hass: object, config_entry: object, async_add_devices): + platform_setup_entry(hass, config_entry, async_add_devices, PLATFORM_LIGHT) async def async_unload_entry(hass: object, config_entry: object) -> bool: - LOGGER.debug("async_unload_entry device_id = %s - platform = light", config_entry.data[CONF_DEVICE_ID]) - return True + return platform_unload_entry(hass, config_entry, PLATFORM_LIGHT) - -def rgb_to_int(rgb: Union[tuple, dict, int]) -> int: # pylint: disable=unsubscriptable-object +def _rgb_to_int(rgb: Union[tuple, dict, int]) -> int: # pylint: disable=unsubscriptable-object if isinstance(rgb, int): return rgb elif isinstance(rgb, tuple): @@ -58,16 +48,30 @@ def rgb_to_int(rgb: Union[tuple, dict, int]) -> int: # pylint: disable=unsubscr raise ValueError("Invalid value for RGB!") return (red << 16) + (green << 8) + blue -def int_to_rgb(rgb: int) -> Tuple[int, int, int]: +def _int_to_rgb(rgb: int) -> Tuple[int, int, int]: return (rgb & 16711680) >> 16, (rgb & 65280) >> 8, (rgb & 255) +def _sat_1_100(value) -> int: + if value > 100: + return 100 + elif value < 1: + return 1 + else: + return int(value) + + +class MerossLanLight(_MerossToggle, LightEntity): -class MerossLanLight(_MerossEntity, LightEntity): - def __init__(self, meross_device: object, channel: int): - super().__init__(meross_device, channel, None) + PLATFORM = PLATFORM_LIGHT + + def __init__(self, device: MerossDevice, id: object): + # suppose we use 'togglex' to switch the light + super().__init__( + device, id, None, + mc.NS_APPLIANCE_CONTROL_TOGGLEX, mc.KEY_TOGGLEX) """ self._light = { - "onoff": 0, + #"onoff": 0, "capacity": CAPACITY_LUMINANCE, "channel": channel, #"rgb": 16753920, @@ -77,16 +81,19 @@ def __init__(self, meross_device: object, channel: int): "gradual": 0 } """ - self._light = {} + self._light = dict() + self._color_temp = None + self._hs_color = None + self._brightness = None - self._capacity = meross_device.ability.get(NS_APPLIANCE_CONTROL_LIGHT, {}).get("capacity", CAPACITY_LUMINANCE) + self._capacity = device.descriptor.ability.get( + mc.NS_APPLIANCE_CONTROL_LIGHT, {}).get( + mc.KEY_CAPACITY, CAPACITY_LUMINANCE) self._supported_features = (SUPPORT_COLOR if self._capacity & CAPACITY_RGB else 0)\ | (SUPPORT_COLOR_TEMP if self._capacity & CAPACITY_TEMPERATURE else 0)\ | (SUPPORT_BRIGHTNESS if self._capacity & CAPACITY_LUMINANCE else 0) - meross_device.has_lights = True - @property def supported_features(self): @@ -95,26 +102,17 @@ def supported_features(self): @property def brightness(self): - """Return the brightness of this light between 0..255.""" - luminance = self._light.get("luminance") - return None if luminance is None else luminance * 255 // 100 + return self._brightness @property def hs_color(self): - """Return the hue and saturation color value [float, float].""" - rgb = self._light.get("rgb") - if rgb is not None: - r, g, b = int_to_rgb(rgb) - return color_RGB_to_hs(r, g, b) - return None + return self._hs_color @property def color_temp(self): - """Return the CT color value in mireds.""" - temp = self._light.get("temperature") - return None if temp is None else ((100 - temp) / 100) * (self.max_mireds - self.min_mireds) + self.min_mireds + return self._color_temp @property @@ -135,82 +133,89 @@ def effect(self): return None - @property - def is_on(self) -> bool: - return self._state == STATE_ON - - async def async_turn_on(self, **kwargs) -> None: capacity = 0 # Color is taken from either of these 2 values, but not both. if ATTR_HS_COLOR in kwargs: h, s = kwargs[ATTR_HS_COLOR] - self._light["rgb"] = rgb_to_int(color_hs_to_RGB(h, s)) - self._light.pop("temperature", None) + self._light[mc.KEY_RGB] = _rgb_to_int(color_hs_to_RGB(h, s)) + self._light.pop(mc.KEY_TEMPERATURE, None) capacity |= CAPACITY_RGB elif ATTR_COLOR_TEMP in kwargs: + # map mireds: min_mireds -> 100 - max_mireds -> 1 mired = kwargs[ATTR_COLOR_TEMP] norm_value = (mired - self.min_mireds) / (self.max_mireds - self.min_mireds) - temperature = 100 - (norm_value * 100) - self._light["temperature"] = int(temperature) - self._light.pop("rgb", None) + temperature = 100 - (norm_value * 99) + self._light[mc.KEY_TEMPERATURE] = _sat_1_100(temperature) # meross wants temp between 1-100 + self._light.pop(mc.KEY_RGB, None) capacity |= CAPACITY_TEMPERATURE if self._capacity & CAPACITY_LUMINANCE: capacity |= CAPACITY_LUMINANCE # Brightness must always be set, so take previous luminance if not explicitly set now. if ATTR_BRIGHTNESS in kwargs: - self._light["luminance"] = kwargs[ATTR_BRIGHTNESS] * 100 // 255 + self._light[mc.KEY_LUMINANCE] = _sat_1_100(kwargs[ATTR_BRIGHTNESS] * 100 // 255) - self._light["capacity"] = capacity + self._light[mc.KEY_CAPACITY] = capacity - if NS_APPLIANCE_CONTROL_TOGGLEX in self._meross_device.ability: + if self._light.get(mc.KEY_ONOFF) is None: # since lights could be repeatedtly 'async_turn_on' when changing attributes # we avoid flooding the device with unnecessary messages if not self.is_on: - self._meross_device.togglex_set(channel = self._channel, ison = 1) + await super().async_turn_on(**kwargs) + else: + self._light[mc.KEY_ONOFF] = 1 - # only set the onoff field if the device sent it before - if self._light.get("onoff") is not None: - self._light["onoff"] = 1 + self._device.request( + namespace=mc.NS_APPLIANCE_CONTROL_LIGHT, + method=mc.METHOD_SET, + payload={mc.KEY_LIGHT: self._light}) - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_CONTROL_LIGHT, - method=METHOD_SET, - payload={"light": self._light}) - return + async def async_turn_off(self, **kwargs) -> None: + if self._light.get(mc.KEY_ONOFF) is None: + # we suppose we have to 'toggle(x)' + await super().async_turn_off(**kwargs) + else: + self._light[mc.KEY_ONOFF] = 0 + self._device.request( + namespace=mc.NS_APPLIANCE_CONTROL_LIGHT, + method=mc.METHOD_SET, + payload={mc.KEY_LIGHT: self._light}) - async def async_turn_off(self, **kwargs) -> None: - if NS_APPLIANCE_CONTROL_TOGGLEX in self._meross_device.ability: - self._meross_device.togglex_set(channel = self._channel, ison = 0) - # only set the onoff field if the device sent it before - if self._light.get("onoff") is not None: - self._light["onoff"] = 0 - self._meross_device.mqtt_publish( - namespace=NS_APPLIANCE_CONTROL_LIGHT, - method=METHOD_SET, - payload={"light": self._light}) + def _set_light(self, light: dict) -> None: + if self._light != light: + self._light = light - return + capacity = light.get(mc.KEY_CAPACITY, 0) + if capacity & CAPACITY_LUMINANCE: + self._brightness = light.get(mc.KEY_LUMINANCE, 0) * 255 // 100 + else: + self._brightness = None - def _set_onoff(self, onoff) -> None: - self._set_state(STATE_ON if onoff else STATE_OFF) - return + if capacity & CAPACITY_TEMPERATURE: + self._color_temp = ((100 - light.get(mc.KEY_TEMPERATURE, 0)) / 99) * \ + (self.max_mireds - self.min_mireds) + self.min_mireds + else: + self._color_temp = None + if capacity & CAPACITY_RGB: + r, g, b = _int_to_rgb(light.get(mc.KEY_RGB, 0)) + self._hs_color = color_RGB_to_hs(r, g, b) + else: + self._hs_color = None + + onoff = light.get(mc.KEY_ONOFF) + if onoff is not None: + self._state = STATE_ON if onoff else STATE_OFF + + if self.hass and self.enabled: + self.async_write_ha_state() - def _set_light(self, light: dict) -> None: - self._light = light - onoff = light.get("onoff") - if onoff is not None: - self._state = STATE_ON if onoff else STATE_OFF - if self.hass and self.enabled: - self.async_write_ha_state() - return diff --git a/custom_components/meross_lan/logger.py b/custom_components/meross_lan/logger.py index 130a1370..97df3962 100644 --- a/custom_components/meross_lan/logger.py +++ b/custom_components/meross_lan/logger.py @@ -9,7 +9,7 @@ _trap_time = 0 _trap_level = 0 -def LOGGER_trap(level, msg, *args): +def LOGGER_trap(level, timeout, msg, *args): """ avoid repeating the same last log message until something changes or timeout expires used mainly when discovering new devices @@ -23,7 +23,7 @@ def LOGGER_trap(level, msg, *args): if (_trap_level == level) \ and (_trap_msg == msg) \ and (_trap_args == args) \ - and ((tm - _trap_time) < 300): # 5 minutes timeout + and ((tm - _trap_time) < timeout): return LOGGER.log(level, msg, *args) diff --git a/custom_components/meross_lan/manifest.json b/custom_components/meross_lan/manifest.json index 6f407d14..6ae9ef4a 100644 --- a/custom_components/meross_lan/manifest.json +++ b/custom_components/meross_lan/manifest.json @@ -2,15 +2,13 @@ "domain": "meross_lan", "name": "Meross LAN", "config_flow": true, - "iot_class": "local_push", + "iot_class": "local_polling", "documentation": "https://github.com/krahabb/meross_lan", "issue_tracker": "https://github.com/krahabb/meross_lan/issues", "requirements": [], - "ssdp": [], - "zeroconf": [], - "homekit": {}, - "mqtt": [], - "dependencies": ["mqtt"], + "dhcp": [{"hostname": "*", "macaddress": "48E1E9*"}], + "dependencies": [], + "after_dependencies": ["mqtt", "dhcp"], "codeowners": ["@krahabb"], - "version": "0.0.4" + "version": "0.0.5" } diff --git a/custom_components/meross_lan/meross_device.py b/custom_components/meross_lan/meross_device.py index b2c035ea..60c65d7a 100644 --- a/custom_components/meross_lan/meross_device.py +++ b/custom_components/meross_lan/meross_device.py @@ -1,152 +1,130 @@ -from typing import Any, Callable, Dict, List, Optional, Union - -from time import time, strftime, localtime -import json - +from enum import Enum +import math +from typing import Callable, Dict +from time import time +from logging import WARNING, DEBUG +from aiohttp.client_exceptions import ClientConnectionError, ClientConnectorError from homeassistant.helpers.event import async_call_later -from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigEntries, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.const import ( - DEVICE_CLASS_POWER, POWER_WATT, - DEVICE_CLASS_CURRENT, ELECTRICAL_CURRENT_AMPERE, - DEVICE_CLASS_VOLTAGE, VOLT, - DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR + CONF_HOST ) from .logger import LOGGER, LOGGER_trap -from logging import WARNING, DEBUG -from .switch import MerossLanSwitch -from .sensor import MerossLanSensor -from .light import MerossLanLight -from .cover import MerossLanGarage, MerossLanRollerShutter + from .const import ( - CONF_KEY, CONF_DISCOVERY_PAYLOAD, - METHOD_GET, METHOD_SET, - NS_APPLIANCE_CONTROL_TOGGLE, NS_APPLIANCE_CONTROL_TOGGLEX, - NS_APPLIANCE_CONTROL_LIGHT, NS_APPLIANCE_GARAGEDOOR_STATE, - NS_APPLIANCE_ROLLERSHUTTER_POSITION, NS_APPLIANCE_ROLLERSHUTTER_STATE, - NS_APPLIANCE_CONTROL_ELECTRICITY, NS_APPLIANCE_CONTROL_CONSUMPTIONX, - NS_APPLIANCE_SYSTEM_ALL, NS_APPLIANCE_SYSTEM_REPORT, - PARAM_UNAVAILABILITY_TIMEOUT, PARAM_ENERGY_UPDATE_PERIOD, PARAM_UPDATE_POLLING_PERIOD, PARAM_HEARTBEAT_PERIOD + CONF_DEVICE_ID, CONF_KEY, CONF_PAYLOAD, CONF_POLLING_PERIOD, CONF_POLLING_PERIOD_DEFAULT, CONF_POLLING_PERIOD_MIN, CONF_PROTOCOL, + CONF_OPTION_AUTO, CONF_OPTION_HTTP, CONF_OPTION_MQTT, CONF_TIMESTAMP, CONF_TIME_ZONE, + PARAM_UNAVAILABILITY_TIMEOUT, PARAM_HEARTBEAT_PERIOD ) +from .merossclient import KeyType, MerossDeviceDescriptor, MerossHttpClient, const as mc # mEROSS cONST + + +class Protocol(Enum): + """ + Describes the protocol selection behaviour in order to connect to devices + """ + AUTO = 0 # 'best effort' behaviour + MQTT = 1 + HTTP = 2 -class MerossDevice: - def __init__(self, api: object, device_id: str, entry: ConfigEntry): +MAP_CONF_PROTOCOL = { + CONF_OPTION_AUTO: Protocol.AUTO, + CONF_OPTION_MQTT: Protocol.MQTT, + CONF_OPTION_HTTP: Protocol.HTTP +} - LOGGER.debug("MerossDevice(%s) init", device_id) +class MerossDevice: + + def __init__( + self, + api: object, + descriptor: MerossDeviceDescriptor, + entry: ConfigEntry + ): + self.device_id = entry.data.get(CONF_DEVICE_ID) + LOGGER.debug("MerossDevice(%s) init", self.device_id) self.api = api + self.descriptor = descriptor self.entry_id = entry.entry_id - self.device_id = device_id - self.key = entry.data.get(CONF_KEY) # could be 'None' : if so defaults to "" but allows key reply trick - self.replykey = self.key - self.entities: dict[any, _MerossEntity] = {} # pylint: disable=undefined-variable - self.has_sensors = False - self.has_lights = False - self.has_switches = False - self.has_covers = False - self._sensor_power = None - self._sensor_current = None - self._sensor_voltage = None - self._sensor_energy = None + self.replykey = None self._online = False + self.polling_period = CONF_POLLING_PERIOD_DEFAULT + self.lastpoll = 0 self.lastrequest = 0 self.lastupdate = 0 - self.lastupdate_consumption = 0 - - discoverypayload = entry.data.get(CONF_DISCOVERY_PAYLOAD, {}) - - self.all = discoverypayload.get("all", {}) - self.ability = discoverypayload.get("ability", {}) - - try: - # use a mix of heuristic to detect device features - p_type = self.all.get("system", {}).get("hardware", {}).get("type", "") - - p_digest = self.all.get("digest") - if p_digest: - light = p_digest.get("light") - if isinstance(light, List): - for l in light: - MerossLanLight(self, l.get("channel")) - elif isinstance(light, Dict): - MerossLanLight(self, light.get("channel", 0)) - - garagedoor = p_digest.get("garageDoor") - if isinstance(garagedoor, List): - for g in garagedoor: - MerossLanGarage(self, g.get("channel")) - - # atm we're not sure we can detect this in 'digest' payload - if "mrs" in p_type.lower(): - MerossLanRollerShutter(self, 0) - - # at any rate: setup switches whenever we find 'togglex' - # or whenever we cannot setup anything from digest - togglex = p_digest.get("togglex") - if isinstance(togglex, List): - for t in togglex: - channel = t.get("channel") - if channel not in self.entities: - MerossLanSwitch(self, channel, self.togglex_set, self.togglex_get) - elif isinstance(togglex, Dict): - channel = t.get("channel") - if channel not in self.entities: - MerossLanSwitch(self, channel, self.togglex_set, self.togglex_get) - - #endif p_digest - - # older firmwares (MSS110 with 1.1.28) look like dont really have 'digest' - # but have 'control' - p_control = self.all.get("control") if p_digest is None else None - if p_control: - p_toggle = p_control.get("toggle") - if isinstance(p_toggle, Dict): - MerossLanSwitch(self, p_toggle.get("channel", 0), self.toggle_set, self.toggle_get) - - #fallback for switches: in case we couldnt get from NS_APPLIANCE_SYSTEM_ALL - if not self.entities: - if NS_APPLIANCE_CONTROL_TOGGLEX in self.ability: - MerossLanSwitch(self, 0, self.togglex_set, self.togglex_get) - elif NS_APPLIANCE_CONTROL_TOGGLE in self.ability: - MerossLanSwitch(self, 0, self.toggle_set, self.toggle_get) - - if NS_APPLIANCE_CONTROL_ELECTRICITY in self.ability: - self._sensor_power = MerossLanSensor(self, DEVICE_CLASS_POWER, POWER_WATT) - self._sensor_current = MerossLanSensor(self, DEVICE_CLASS_CURRENT, ELECTRICAL_CURRENT_AMPERE) - self._sensor_voltage = MerossLanSensor(self, DEVICE_CLASS_VOLTAGE, VOLT) - - if NS_APPLIANCE_CONTROL_CONSUMPTIONX in self.ability: - self._sensor_energy = MerossLanSensor(self, DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR) - - self.mqtt_publish(NS_APPLIANCE_SYSTEM_ALL, METHOD_GET) + self.lastmqtt = 0 + """ + self.entities: dict() + is a collection of all of the instanced entities + they're generally build here during __init__ and will be registered + in platforms(s) async_setup_entry with HA + """ + self.entities: Dict[any, '_MerossEntity'] = dict() # pylint: disable=undefined-variable + """ + self.platforms: dict() + when we build an entity we also add the relative platform name here + so that the async_setup_entry for the integration will be able to forward + the setup to the appropriate platform. + The item value here will be set to the async_add_entities callback + during the corresponfing platform async_setup_entry so to be able + to dynamically add more entities should they 'pop-up' (Hub only?) + """ + self.platforms: Dict[str, Callable] = {} + """ + misc callbacks + """ + self.unsub_entry_update_listener: Callable = None + self.unsub_updatecoordinator_listener: Callable = None - except Exception as e: - LOGGER.debug("MerossDevice(%s) init exception:(%s)", device_id, str(e)) + self._set_config_entry(entry.data) + """ + warning: would the response be processed after this object is fully init? + It should if I get all of this async stuff right + also: !! IMPORTANT !! don't send any other message during init process + else the responses could overlap and 'fuck' a bit the offline -> online transition + causing that code to request a new NS_APPLIANCE_SYSTEM_ALL + """ + self.request(mc.NS_APPLIANCE_SYSTEM_ALL) - return def __del__(self): LOGGER.debug("MerossDevice(%s) destroy", self.device_id) return + @property def online(self) -> bool: if self._online: #evaluate device MQTT availability by checking lastrequest got answered in less than 20 seconds if (self.lastupdate > self.lastrequest) or ((time() - self.lastrequest) < PARAM_UNAVAILABILITY_TIMEOUT): return True - #else - LOGGER.debug("MerossDevice(%s) going offline!", self.device_id) - self._online = False - for entity in self.entities.values(): - entity._set_unavailable() + + # when we 'fall' offline while on MQTT eventually retrigger HTTP. + # the reverse is not needed since we switch HTTP -> MQTT right-away + # when HTTP fails (see async_http_request) + if (self.curr_protocol is Protocol.MQTT) and (self.conf_protocol is Protocol.AUTO): + self._switch_protocol(Protocol.HTTP) + return True + + self._set_offline() + return False - def parsepayload(self, namespace: str, method: str, payload: dict, replykey: Union[dict, Optional[str]]) -> None: # pylint: disable=unsubscriptable-object + + def receive( + self, + namespace: str, + method: str, + payload: dict, + replykey: KeyType + ) -> bool: """ every time we receive a response we save it's 'replykey': that would be the same as our self.key (which it is compared against in 'get_replykey') @@ -157,172 +135,240 @@ def parsepayload(self, namespace: str, method: str, payload: dict, replykey: Uni Update: this key trick actually doesnt work on MQTT (but works on HTTP) """ self.replykey = replykey - if replykey != self.key: - LOGGER_trap(WARNING, "Meross device key error for device_id: %s", self.device_id) + if self.key and (replykey != self.key): + LOGGER_trap(WARNING, 14400, "Meross device key error for device_id: %s", self.device_id) self.lastupdate = time() if not self._online: - LOGGER.debug("MerossDevice(%s) back online!", self.device_id) - self._online = True - if namespace != NS_APPLIANCE_SYSTEM_ALL: - self.mqtt_publish(NS_APPLIANCE_SYSTEM_ALL, METHOD_GET) - for entity in self.entities.values(): - entity._set_available() - - # this parsing is going to get 'bolder' as soon as we add more and more messages to parse - # this is not optimal since, based on device/hardware we should be able to really restrict - # this checks..right now let's go with it and order this check list by the likelyhood - # of receiving a particular message (the first should be the more frequent overall and so on...) - # This frequency is based on my guess of the actual devices connected to this code: - # Most of all should be recent switches - # this is naturally valid as per overall users statistic since if you have a particular device - # that may be unlucky and always parse down to the last item - if namespace == NS_APPLIANCE_CONTROL_TOGGLEX: - togglex = payload.get("togglex") - if isinstance(togglex, List): - for t in togglex: - self.entities[t.get("channel")]._set_onoff(t.get("onoff")) - elif isinstance(togglex, Dict): - self.entities[togglex.get("channel")]._set_onoff(togglex.get("onoff")) - """ - # quick refresh power readings after we toggled - if NS_APPLIANCE_CONTROL_ELECTRICITY in self.ability: - def callme(now): - self._mqtt_publish(NS_APPLIANCE_CONTROL_ELECTRICITY, METHOD_GET) - return - # by the look of it meross plugs are not very responsive in updating power readings - # most of the times even with 5 secs delay they dont get it right.... - async_call_later(self.hass, delay = 2, action = callme) - """ - - elif namespace == NS_APPLIANCE_CONTROL_ELECTRICITY: - electricity = payload.get("electricity") - power_w = electricity.get("power") / 1000 - voltage_v = electricity.get("voltage") / 10 - current_a = electricity.get("current") / 1000 - if self._sensor_power: - self._sensor_power._set_state(power_w) - if self._sensor_current: - self._sensor_current._set_state(current_a) - if self._sensor_voltage: - self._sensor_voltage._set_state(voltage_v) - - elif namespace == NS_APPLIANCE_CONTROL_CONSUMPTIONX: - if self._sensor_energy: - self.lastupdate_consumption = self.lastupdate - daylabel = strftime("%Y-%m-%d", localtime()) - for d in payload.get("consumptionx"): - if d.get("date") == daylabel: - energy_wh = d.get("value") - self._sensor_energy._set_state(energy_wh) - - elif namespace == NS_APPLIANCE_CONTROL_LIGHT: - light = payload.get("light") - if isinstance(light, Dict): - self.entities[light.get("channel")]._set_light(light) - - elif namespace == NS_APPLIANCE_GARAGEDOOR_STATE: - garagedoor = payload.get("state") - for g in garagedoor: - self.entities[g.get("channel")]._set_open(g.get("open")) - - elif namespace == NS_APPLIANCE_ROLLERSHUTTER_STATE: - state = payload.get("state") - for s in state: - self.entities[s.get("channel")]._set_rollerstate(s.get("state")) - - elif namespace == NS_APPLIANCE_ROLLERSHUTTER_POSITION: - position = payload.get("position") - for p in position: - self.entities[p.get("channel")]._set_rollerposition(p.get("position")) - - elif namespace == NS_APPLIANCE_SYSTEM_ALL: - self.all = payload.get("all", {}) - p_digest = self.all.get("digest") - if p_digest: - p_togglex = p_digest.get("togglex") - if isinstance(p_togglex, List): - for t in p_togglex: - self.entities[t.get("channel")]._set_onoff(t.get("onoff")) - elif isinstance(p_togglex, Dict): - self.entities[p_togglex.get("channel", 0)]._set_onoff(p_togglex.get("onoff")) - p_light = p_digest.get("light") - if isinstance(p_light, Dict): - self.entities[p_light.get("channel", 0)]._set_light(p_light) - p_garagedoor = p_digest.get("garageDoor") - if isinstance(p_garagedoor, List): - for g in p_garagedoor: - self.entities[g.get("channel")]._set_open(g.get("open")) - return - # older firmwares (MSS110 with 1.1.28) look like dont really have 'digest' - p_control = self.all.get("control") - if p_control: - p_toggle = p_control.get("toggle") - if isinstance(p_toggle, Dict): - self.entities[p_toggle.get("channel", 0)]._set_onoff(p_toggle.get("onoff")) - - elif namespace == NS_APPLIANCE_CONTROL_TOGGLE: - p_toggle = payload.get("toggle") - if isinstance(p_toggle, Dict): - self.entities[p_toggle.get("channel", 0)]._set_onoff(p_toggle.get("onoff")) + if namespace != mc.NS_APPLIANCE_SYSTEM_ALL: + self.request(mc.NS_APPLIANCE_SYSTEM_ALL) + self._set_online() + + if namespace == mc.NS_APPLIANCE_CONTROL_TOGGLEX: + self._parse_togglex(payload) + return True + + if namespace == mc.NS_APPLIANCE_SYSTEM_ALL: + if self._update_descriptor(payload): + self._save_config_entry(payload) + return True + + return False - return + def mqtt_receive( + self, + namespace: str, + method: str, + payload: dict, + replykey: KeyType + ) -> None: + self.lastmqtt = time() + if (self.pref_protocol is Protocol.MQTT) and (self.curr_protocol is Protocol.HTTP): + self._switch_protocol(Protocol.MQTT) + self.receive(namespace, method, payload, replykey) + + + async def async_http_request(self, namespace: str, method: str, payload: dict = {}, callback: Callable = None): + try: + _httpclient:MerossHttpClient = getattr(self, '_httpclient', None) + if _httpclient is None: + _httpclient = MerossHttpClient(self.descriptor.ipAddress, self.key, async_get_clientsession(self.api.hass), LOGGER) + self._httpclient = _httpclient + else: + _httpclient.set_host_key(self.descriptor.ipAddress, self.key) + + response = await _httpclient.async_request(namespace, method, payload) + r_header = response[mc.KEY_HEADER] + r_namespace = r_header[mc.KEY_NAMESPACE] + r_method = r_header[mc.KEY_METHOD] + if (callback is not None) and (r_method == mc.METHOD_SETACK): + #we're actually only using this for SET->SETACK command confirmation + callback() + # passing self.key to shut off MerossDevice replykey behaviour + # since we're already managing replykey in http client + self.receive(r_namespace, r_method, response[mc.KEY_PAYLOAD], self.key) + except ClientConnectionError as e: + LOGGER.info("MerossDevice(%s) client connection error in async_http_request: %s", self.device_id, str(e)) + if self._online: + if (self.conf_protocol is Protocol.AUTO) and ((time() - self.lastmqtt) < PARAM_UNAVAILABILITY_TIMEOUT): + self._switch_protocol(Protocol.MQTT) + self.api.mqtt_publish( + self.device_id, + namespace, + method, + payload, + self.key or self.replykey + ) + else: + self._set_offline() + except Exception as e: + LOGGER.warning("MerossDevice(%s) error in async_http_request: %s", self.device_id, str(e)) - def toggle_set(self, channel: int, ison: int): - return self.mqtt_publish( - NS_APPLIANCE_CONTROL_TOGGLE, - METHOD_SET, - {"toggle": {"channel": channel, "onoff": ison}} - ) - - def toggle_get(self, channel: int): - return self.mqtt_publish( - NS_APPLIANCE_CONTROL_TOGGLE, - METHOD_GET, - {"toggle": {"channel": channel}} - ) - - def togglex_set(self, channel: int, ison: int): - return self.mqtt_publish( - NS_APPLIANCE_CONTROL_TOGGLEX, - METHOD_SET, - {"togglex": {"channel": channel, "onoff": ison}} - ) - - def togglex_get(self, channel: int): - return self.mqtt_publish( - NS_APPLIANCE_CONTROL_TOGGLEX, - METHOD_GET, - {"togglex": {"channel": channel}} - ) - - - def mqtt_publish(self, namespace: str, method: str, payload: dict = {}): + + def request(self, namespace: str, method: str = mc.METHOD_GET, payload: dict = {}, callback: Callable = None): + """ + route the request through MQTT or HTTP to the physical device. + callback will be called on successful replies and actually implemented + only when HTTPing SET requests. On MQTT we rely on async PUSH and SETACK to manage + confirmation/status updates + """ self.lastrequest = time() - return self.api.mqtt_publish(self.device_id, namespace, method, payload, key=self.replykey if self.key is None else self.key) + if self.curr_protocol is Protocol.HTTP: + self.api.hass.async_create_task( + self.async_http_request(namespace, method, payload, callback) + ) + else: # self.curr_protocol is Protocol.MQTT: + self.api.mqtt_publish( + self.device_id, + namespace, + method, + payload, + self.key or self.replykey + ) + + + def _set_offline(self) -> None: + LOGGER.debug("MerossDevice(%s) going offline!", self.device_id) + self._online = False + for entity in self.entities.values(): + entity._set_unavailable() + + + def _set_online(self) -> None: + """ + When coming back online allow for a refresh + also in inheriteds + """ + LOGGER.debug("MerossDevice(%s) back online!", self.device_id) + self._online = True + self.updatecoordinator_listener() + + + def _switch_protocol(self, protocol: Protocol) -> None: + LOGGER.info("MerossDevice(%s) switching protocol to %s", self.device_id, protocol.name) + self.curr_protocol = protocol + + + def _parse_togglex(self, payload: dict) -> None: + togglex = payload.get(mc.KEY_TOGGLEX) + if isinstance(togglex, list): + for t in togglex: + self.entities[t.get(mc.KEY_CHANNEL)]._set_onoff(t.get(mc.KEY_ONOFF)) + elif isinstance(togglex, dict): + self.entities[togglex.get(mc.KEY_CHANNEL)]._set_onoff(togglex.get(mc.KEY_ONOFF)) + + + def _update_descriptor(self, payload: dict) -> bool: + """ + called internally when we receive an NS_SYSTEM_ALL + i.e. global device setup/status + we usually don't expect a 'structural' change in the device here + except maybe for Hub(s) which we're going to investigate later + Return True if we want to persist the payload to the ConfigEntry + """ + descr = self.descriptor + oldaddr = descr.ipAddress + descr.update(payload) + + if self.time_zone and (descr.timezone != self.time_zone): + self.request( + mc.NS_APPLIANCE_SYSTEM_TIME, + mc.METHOD_SET, + payload={mc.KEY_TIME: {mc.KEY_TIMEZONE: self.time_zone}} + ) + + p_digest = descr.digest + if p_digest: + self._parse_togglex(p_digest) + + #persist changes to configentry only when relevant properties change + return oldaddr != descr.ipAddress + def _save_config_entry(self, payload: dict) -> None: + try: + entries:ConfigEntries = self.api.hass.config_entries + entry:ConfigEntry = entries.async_get_entry(self.entry_id) + if entry is not None: + data = dict(entry.data) # deepcopy? not needed: see CONF_TIMESTAMP + data[CONF_PAYLOAD].update(payload) + data[CONF_TIMESTAMP] = time() # force ConfigEntry update.. + entries.async_update_entry(entry, data=data) + except Exception as e: + LOGGER.warning("MerossDevice(%s) error while updating ConfigEntry (%s)", self.device_id, str(e)) + + + def _set_config_entry(self, data: dict) -> None: + """ + common properties read from ConfigEntry on __init__ or when a configentry updates + """ + self.key = data.get(CONF_KEY) + self.conf_protocol = MAP_CONF_PROTOCOL.get(data.get(CONF_PROTOCOL), Protocol.AUTO) + if self.conf_protocol == Protocol.AUTO: + self.pref_protocol = Protocol.HTTP if data.get(CONF_HOST) else Protocol.MQTT + else: + self.pref_protocol = self.conf_protocol + """ + When using Protocol.AUTO we try to use our 'preferred' (pref_protocol) + and eventually fallback (curr_protocol) until some good news allow us + to retry pref_protocol + """ + self.curr_protocol = self.pref_protocol + self.polling_period = data.get(CONF_POLLING_PERIOD, CONF_POLLING_PERIOD_DEFAULT) + if self.polling_period < CONF_POLLING_PERIOD_MIN: + self.polling_period < CONF_POLLING_PERIOD_MIN + + self.time_zone = data.get(CONF_TIME_ZONE) # TODO: add to config_flow options + @callback - def updatecoordinator_listener(self) -> None: + async def entry_update_listener(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + # we're not changing device_id or other 'identifying' stuff + self._set_config_entry(config_entry.data) + self.api.update_polling_period() + _httpclient:MerossHttpClient = getattr(self, '_httpclient', None) + if _httpclient is not None: + _httpclient.set_host_key(self.descriptor.ipAddress, self.key) + + #await hass.config_entries.async_reload(config_entry.entry_id) + + @callback + def updatecoordinator_listener(self) -> bool: now = time() - # this is a bit rude: we'll keep sending 'heartbeats' to check if the device is still there - # update(1): disabled because old firmware doesnt support GET NS_APPLIANCE_SYSTEM_REPORT - # I could change the request to a supported one but all this heartbeat looks lame to mee atm - # update(2): looks like any firmware doesnt support GET NS_APPLIANCE_SYSTEM_REPORT - # we're replacing with a well known message and, we're increasing the period + """ + this is a bit rude: we'll keep sending 'heartbeats' + to check if the device is still there + !!this is actually not happening when we connect through HTTP!! + unless the device went offline so we started skipping polling updates + """ if (now - self.lastrequest) > PARAM_HEARTBEAT_PERIOD: - self.mqtt_publish(NS_APPLIANCE_SYSTEM_ALL, METHOD_GET) - return + self.request(mc.NS_APPLIANCE_SYSTEM_ALL) + return False # prevent any other poll action... - if not (self.online): - return + if self.online: - if NS_APPLIANCE_CONTROL_ELECTRICITY in self.ability: - self.mqtt_publish(NS_APPLIANCE_CONTROL_ELECTRICITY, METHOD_GET) + if (now - self.lastpoll) < self.polling_period: + return False - if self._sensor_energy and self._sensor_energy.enabled: - if ((now - self.lastupdate_consumption) > PARAM_ENERGY_UPDATE_PERIOD): - self.mqtt_publish(NS_APPLIANCE_CONTROL_CONSUMPTIONX, METHOD_GET) + self.lastpoll = math.floor(now) - return + # on MQTT we already have PUSHES... + if (self.curr_protocol == Protocol.HTTP) and (self.lastmqtt < self.lastrequest): + ability = self.descriptor.ability + if mc.NS_APPLIANCE_CONTROL_TOGGLEX in ability: + self.request(mc.NS_APPLIANCE_CONTROL_TOGGLEX, payload={ mc.KEY_TOGGLEX : [] }) + elif mc.NS_APPLIANCE_CONTROL_TOGGLE in ability: + self.request(mc.NS_APPLIANCE_CONTROL_TOGGLE, payload={ mc.KEY_TOGGLE : {} }) + if mc.NS_APPLIANCE_CONTROL_LIGHT in ability: + self.request(mc.NS_APPLIANCE_CONTROL_LIGHT, payload={ mc.KEY_LIGHT : {} }) + + return True # tell inheriting to continue processing + + # when we 'stall' offline while on MQTT eventually retrigger HTTP + # the reverse is not needed since we switch HTTP -> MQTT right-away + # when HTTP fails (see async_http_request) + if (self.curr_protocol is Protocol.MQTT) and (self.conf_protocol is Protocol.AUTO): + self._switch_protocol(Protocol.HTTP) + self.request(mc.NS_APPLIANCE_SYSTEM_ALL) + + return False \ No newline at end of file diff --git a/custom_components/meross_lan/meross_device_bulb.py b/custom_components/meross_lan/meross_device_bulb.py new file mode 100644 index 00000000..08f4b9ac --- /dev/null +++ b/custom_components/meross_lan/meross_device_bulb.py @@ -0,0 +1,61 @@ + + +from typing import Optional, Union + +from .merossclient import KeyType, const as mc # mEROSS cONST +from .meross_device import MerossDevice +from .light import MerossLanLight +from .logger import LOGGER + +class MerossDeviceBulb(MerossDevice): + + def __init__(self, api, descriptor, entry) -> None: + super().__init__(api, descriptor, entry) + + try: + # we expect a well structured digest here since + # we're sure 'light' key is there by __init__ device factory + p_digest = self.descriptor.digest + p_light = p_digest[mc.KEY_LIGHT] + if isinstance(p_light, list): + for l in p_light: + MerossLanLight(self, l.get(mc.KEY_CHANNEL, 0)) + elif isinstance(p_light, dict): + MerossLanLight(self, p_light.get(mc.KEY_CHANNEL, 0)) + + except Exception as e: + LOGGER.warning("MerossDeviceBulb(%s) init exception:(%s)", self.device_id, str(e)) + + + def receive( + self, + namespace: str, + method: str, + payload: dict, + replykey: KeyType + ) -> bool: + + if super().receive(namespace, method, payload, replykey): + return True + + if namespace == mc.NS_APPLIANCE_CONTROL_LIGHT: + self._parse_light(payload) + return True + + return False + + + def _update_descriptor(self, payload: dict) -> bool: + update = super()._update_descriptor(payload) + + p_digest = self.descriptor.digest + if p_digest: + self._parse_light(p_digest) + + return update + + + def _parse_light(self, payload: dict) -> None: + p_light = payload.get(mc.KEY_LIGHT) + if isinstance(p_light, dict): + self.entities[p_light.get(mc.KEY_CHANNEL)]._set_light(p_light) diff --git a/custom_components/meross_lan/meross_device_hub.py b/custom_components/meross_lan/meross_device_hub.py new file mode 100644 index 00000000..ee5b8e6e --- /dev/null +++ b/custom_components/meross_lan/meross_device_hub.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +from time import time +from typing import Callable, Dict + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_HUMIDITY, +) +from homeassistant.core import callback +from homeassistant.components.binary_sensor import DEVICE_CLASS_WINDOW + +from .merossclient import KeyType, const as mc # mEROSS cONST +from .meross_device import MerossDevice, Protocol +from .sensor import MerossLanHubSensor +from .climate import Mts100Climate +from .binary_sensor import MerossLanHubBinarySensor +from .logger import LOGGER +from .const import ( + PARAM_HUBBATTERY_UPDATE_PERIOD, + PARAM_HUBSENSOR_UPDATE_PERIOD, + PLATFORM_BINARY_SENSOR, + PLATFORM_CLIMATE, + PLATFORM_SENSOR +) + + +WELL_KNOWN_TYPE_MAP: Dict[str, Callable] = dict({ +}) +""" +{ + mc.TYPE_MS100: MS100SubDevice, + mc.TYPE_MTS100: MTS100SubDevice, + ... +} +""" + +def _get_subdevice_type(p_digest: dict) -> str: + """ + parses the subdevice payload in 'digest' to look for a well-known type + or extract the type itself: + """ + for p_key, p_value in p_digest.items(): + if isinstance(p_value, dict): + return p_key + return None + + +def _get_temp_normal(value: int | None, default) -> float | None: + if isinstance(value, int): + return value / 10 + return default + + + +class MerossDeviceHub(MerossDevice): + + def __init__(self, api, descriptor, entry) -> None: + super().__init__(api, descriptor, entry) + self.subdevices: Dict[any, MerossSubDevice] = {} + self._lastupdate_battery = 0 + self._lastupdate_sensor = 0 + self._lastupdate_mts100 = 0 + """ + invoke platform(s) async_setup_entry + in order to be able to eventually add entities when they 'pop up' + in the hub (see also self.async_add_sensors) + """ + self.platforms[PLATFORM_SENSOR] = None + self.platforms[PLATFORM_BINARY_SENSOR] = None + self.platforms[PLATFORM_CLIMATE] = None + + try: + # we expect a well structured digest here since + # we're sure 'hub' key is there by __init__ device factory + p_digest = self.descriptor.digest + p_hub = p_digest[mc.KEY_HUB] + p_subdevices = p_hub[mc.KEY_SUBDEVICE] + for p_subdevice in p_subdevices: + type = _get_subdevice_type(p_subdevice) + if type is None: + continue # bugged/incomplete configuration payload..wait for some good updates + deviceclass = WELL_KNOWN_TYPE_MAP.get(type) + if deviceclass is None: + # build something anyway... + MerossSubDevice(self, p_subdevice, type) + else: + deviceclass(self, p_subdevice) + + + except Exception as e: + LOGGER.warning("MerossDeviceHub(%s) init exception:(%s)", self.device_id, str(e)) + + + def receive( + self, + namespace: str, + method: str, + payload: dict, + replykey: KeyType + ) -> bool: + + if super().receive(namespace, method, payload, replykey): + return True + + if namespace == mc.NS_APPLIANCE_HUB_SENSOR_ALL: + self._lastupdate_sensor = self.lastupdate + self._parse_subdevice(payload, mc.KEY_ALL) + return True + + if namespace == mc.NS_APPLIANCE_HUB_SENSOR_TEMPHUM: + self._lastupdate_sensor = self.lastupdate + self._parse_subdevice(payload, mc.KEY_TEMPHUM) + return True + + if namespace == mc.NS_APPLIANCE_HUB_MTS100_ALL: + self._lastupdate_mts100 = self.lastupdate + self._parse_subdevice(payload, mc.KEY_ALL) + return True + + if namespace == mc.NS_APPLIANCE_HUB_MTS100_MODE: + self._parse_subdevice(payload, mc.KEY_MODE) + return True + + if namespace == mc.NS_APPLIANCE_HUB_MTS100_TEMPERATURE: + self._parse_subdevice(payload, mc.KEY_TEMPERATURE) + return True + + if namespace == mc.NS_APPLIANCE_HUB_TOGGLEX: + self._parse_subdevice(payload, mc.KEY_TOGGLEX) + return True + + if namespace == mc.NS_APPLIANCE_HUB_BATTERY: + self._lastupdate_battery = self.lastupdate + self._parse_subdevice(payload, mc.KEY_BATTERY) + return True + + if namespace == mc.NS_APPLIANCE_HUB_ONLINE: + self._parse_subdevice(payload, mc.KEY_ONLINE) + return True + + if namespace == mc.NS_APPLIANCE_DIGEST_HUB: + self._parse_digest_hub(payload.get(mc.KEY_HUB)) + return True + + return False + + + def _parse_subdevice(self, payload: dict, key: str) -> None: + p_subdevices = payload.get(key) + if isinstance(p_subdevices, list): + for p_subdevice in p_subdevices: + p_id = p_subdevice.get(mc.KEY_ID) + subdevice = self.subdevices.get(p_id) + if subdevice is None:# force a rescan since we discovered a new subdevice + self.request(mc.NS_APPLIANCE_SYSTEM_ALL) + else: + method = getattr(subdevice, f"_parse_{key}", None) + if method is not None: + method(p_subdevice) + + + def _parse_digest_hub(self, p_hub: dict) -> bool: + update = False + + p_subdevices = p_hub.get(mc.KEY_SUBDEVICE) + if isinstance(p_subdevices, list): + for p_digest in p_subdevices: + p_id = p_digest.get(mc.KEY_ID) + subdevice = self.subdevices.get(p_id) + if subdevice is None: + update = True + type = _get_subdevice_type(p_digest) + if type is None: + # the hub could report incomplete info anytime so beware! + continue + deviceclass = WELL_KNOWN_TYPE_MAP.get(type) + if deviceclass is None: + # build something anyway... + subdevice = MerossSubDevice(self, p_digest, type) + else: + subdevice = deviceclass(self, p_digest) + subdevice.update_digest(p_digest) + + return update + + + def _update_descriptor(self, payload: dict) -> bool: + update = super()._update_descriptor(payload) + p_digest = self.descriptor.digest + if p_digest: + update |= self._parse_digest_hub(p_digest.get(mc.KEY_HUB, {})) + return update + + + @callback + def updatecoordinator_listener(self) -> bool: + + if super().updatecoordinator_listener(): + tm = time() + + if (self.curr_protocol == Protocol.HTTP) and (self.lastmqtt < self.lastrequest): + if ((tm - self._lastupdate_sensor) >= PARAM_HUBSENSOR_UPDATE_PERIOD): + self.request(mc.NS_APPLIANCE_HUB_SENSOR_ALL, payload={ mc.KEY_ALL: [] }) + if ((tm - self._lastupdate_mts100) >= PARAM_HUBSENSOR_UPDATE_PERIOD): + self.request(mc.NS_APPLIANCE_HUB_MTS100_ALL, payload={ mc.KEY_ALL: [] }) + else: + """ + on MQTT we just ask for updates when something pops online + relying on push updates for any other changes + """ + if self._lastupdate_sensor == 0: + self.request(mc.NS_APPLIANCE_HUB_SENSOR_ALL, payload={ mc.KEY_ALL: [] }) + if self._lastupdate_mts100 == 0: + self.request(mc.NS_APPLIANCE_HUB_MTS100_ALL, payload={ mc.KEY_ALL: [] }) + + if ((tm - self._lastupdate_battery) >= PARAM_HUBBATTERY_UPDATE_PERIOD): + self.request(mc.NS_APPLIANCE_HUB_BATTERY, payload={ mc.KEY_BATTERY: [] }) + + return True + + return False + + + +class MerossSubDevice: + + def __init__(self, hub: MerossDeviceHub, p_digest: dict, type: str) -> None: + self.hub = hub + self.type = type + self.id = p_digest.get(mc.KEY_ID) + self.p_digest = p_digest + self._online = False + hub.subdevices[self.id] = self + self.sensor_battery = MerossLanHubSensor(self, DEVICE_CLASS_BATTERY) + + + @property + def online(self) -> bool: + return self._online + + + def _setonline(self, status) -> None: + if status == mc.STATUS_ONLINE: + if self._online is False: + self._online = True + """ + here we should request updates for all entities but + there could be some 'light' race conditions + since when the device (hub) comes online it requests itself + a full update and this could be the case. + If instead this online status change is due to the single + subdevice coming online then we'll just wait for the next + polling cycle by setting the battery update trigger.. + sensors are instead being updated in this call stack + """ + self.hub._lastupdate_battery = 0 + self.hub._lastupdate_sensor = 0 + self.hub._lastupdate_mts100 = 0 + else: + if self._online is True: + self._online = False + for entity in self.hub.entities.values(): + if entity.subdevice is self: + entity._set_unavailable() + + + def update_digest(self, p_digest: dict) -> None: + self.p_digest = p_digest + self._setonline(p_digest.get(mc.KEY_STATUS)) + + + def _parse_all(self, p_all: dict) -> None: + """ + Generally speaking this payload has a couple of well-known keys + plus a set of sensor values like (MS100 example): + { + "id": "..." + "online: "..." + "temperature": { + "latest": value + ... + } + "humidity": { + "latest": value + ... + } + } + so we just extract generic sensors where we find 'latest' + Luckily enough the key names in Meross will behave consistently in HA + at least for 'temperature' and 'humidity' (so far..) also, we divide + the value by 10 since that's a correct eurhystic for them (so far..) + """ + self.p_subdevice = p_all # warning: digest here could be a generic 'sensor' payload + self._setonline(p_all.get(mc.KEY_ONLINE, {}).get(mc.KEY_STATUS)) + + if self._online: + for p_key, p_value in p_all.items(): + if isinstance(p_value, dict): + p_latest = p_value.get(mc.KEY_LATEST) + if isinstance(p_latest, int): + sensorattr = f"sensor_{p_key}" + sensor:MerossLanHubSensor = getattr(self, sensorattr, None) + if not sensor: + sensor = MerossLanHubSensor(self, p_key) + setattr(self, sensorattr, sensor) + sensor._set_state(p_latest / 10) + + + def _parse_battery(self, p_battery: dict) -> None: + if self._online: + self.sensor_battery._set_state(p_battery.get(mc.KEY_VALUE)) + + + def _parse_online(self, p_online: dict) -> None: + self._setonline(p_online.get(mc.KEY_STATUS)) + + + +class MS100SubDevice(MerossSubDevice): + + def __init__(self, hub: MerossDeviceHub, p_digest: dict) -> None: + super().__init__(hub, p_digest, mc.TYPE_MS100) + self.sensor_temperature = MerossLanHubSensor(self, DEVICE_CLASS_TEMPERATURE) + self.sensor_humidity = MerossLanHubSensor(self, DEVICE_CLASS_HUMIDITY) + + def update_digest(self, p_digest: dict) -> None: + super().update_digest(p_digest) + if self._online: + p_ms100 = p_digest.get(mc.TYPE_MS100) + if p_ms100 is not None: + # beware! it happens some keys are missing sometimes!!! + value = p_ms100.get(mc.KEY_LATESTTEMPERATURE) + if isinstance(value, int): + self.sensor_temperature._set_state(value / 10) + value = p_ms100.get(mc.KEY_LATESTHUMIDITY) + if isinstance(value, int): + self.sensor_humidity._set_state(value / 10) + + def _parse_tempHum(self, p_temphum: dict) -> None: + value = p_temphum.get(mc.KEY_LATESTTEMPERATURE) + if isinstance(value, int): + self.sensor_temperature._set_state(value / 10) + value = p_temphum.get(mc.KEY_LATESTHUMIDITY) + if isinstance(value, int): + self.sensor_humidity._set_state(value / 10) + + +WELL_KNOWN_TYPE_MAP[mc.TYPE_MS100] = MS100SubDevice + + + +class MTS100SubDevice(MerossSubDevice): + + def __init__(self, hub: MerossDeviceHub, p_digest: dict, type: str = mc.TYPE_MTS100) -> None: + super().__init__(hub, p_digest, type) + self.climate = Mts100Climate(self) + self.binary_sensor_window = MerossLanHubBinarySensor(self, DEVICE_CLASS_WINDOW) + + def _parse_all(self, p_all: dict) -> None: + super()._parse_all(p_all) + + climate = self.climate + p_mode = p_all.get(mc.KEY_MODE) + if p_mode is not None: + climate._mts100_mode = p_mode.get(mc.KEY_STATE) + p_temperature = p_all.get(mc.KEY_TEMPERATURE) + if isinstance(p_temperature, dict): + climate._current_temperature = _get_temp_normal(p_temperature.get(mc.KEY_ROOM), climate._current_temperature) + climate._target_temperature = _get_temp_normal(p_temperature.get(mc.KEY_CURRENTSET), climate._target_temperature) + climate._min_temp = _get_temp_normal(p_temperature.get(mc.KEY_MIN), climate._min_temp) + climate._max_temp = _get_temp_normal(p_temperature.get(mc.KEY_MAX), climate._max_temp) + climate._mts100_heating = p_temperature.get(mc.KEY_HEATING) + + p_togglex = p_all.get(mc.KEY_TOGGLEX) + if p_togglex is not None: + climate._mts100_onoff = p_togglex.get(mc.KEY_ONOFF) + + climate.update_modes() + + p_openwindow = p_temperature.get(mc.KEY_OPENWINDOW) + if p_openwindow is not None: + self.binary_sensor_window._set_onoff(p_openwindow) + + + def _parse_mode(self, p_mode: dict) -> None: + climate = self.climate + climate._mts100_mode = p_mode.get(mc.KEY_STATE) + climate.update_modes() + + + def _parse_temperature(self, p_temperature: dict) -> None: + climate = self.climate + climate._current_temperature = _get_temp_normal(p_temperature.get(mc.KEY_ROOM), climate._current_temperature) + climate._target_temperature = _get_temp_normal(p_temperature.get(mc.KEY_CURRENTSET), climate._target_temperature) + climate._min_temp = _get_temp_normal(p_temperature.get(mc.KEY_MIN), climate._min_temp) + climate._max_temp = _get_temp_normal(p_temperature.get(mc.KEY_MAX), climate._max_temp) + climate._mts100_heating = p_temperature.get(mc.KEY_HEATING, climate._mts100_heating) + climate.update_modes() + + + def _parse_togglex(self, p_togglex: dict) -> None: + climate = self.climate + climate._mts100_onoff = p_togglex.get(mc.KEY_ONOFF) + climate.update_modes() + + +WELL_KNOWN_TYPE_MAP[mc.TYPE_MTS100] = MTS100SubDevice + + + +class MTS100V3SubDevice(MTS100SubDevice): + + def __init__(self, hub: MerossDeviceHub, p_digest: dict) -> None: + super().__init__(hub, p_digest, mc.TYPE_MTS100V3) + +WELL_KNOWN_TYPE_MAP[mc.TYPE_MTS100V3] = MTS100V3SubDevice \ No newline at end of file diff --git a/custom_components/meross_lan/meross_device_switch.py b/custom_components/meross_lan/meross_device_switch.py new file mode 100644 index 00000000..5e6aabb6 --- /dev/null +++ b/custom_components/meross_lan/meross_device_switch.py @@ -0,0 +1,201 @@ + + +from time import localtime, strftime, time + + +from homeassistant.core import callback +from homeassistant.const import ( + DEVICE_CLASS_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + DEVICE_CLASS_ENERGY, +) + +from .merossclient import KeyType, const as mc # mEROSS cONST +from .meross_device import MerossDevice +from .logger import LOGGER, LOGGER_trap +from .sensor import MerossLanSensor +from .switch import MerossLanSwitch +from .cover import MerossLanGarage, MerossLanRollerShutter +from .const import PARAM_ENERGY_UPDATE_PERIOD + + +class MerossDeviceSwitch(MerossDevice): + + def __init__(self, api, descriptor, entry): + super().__init__(api, descriptor, entry) + self._lastupdate_consumption = 0 + self._sensor_power = None + self._sensor_current = None + self._sensor_voltage = None + self._sensor_energy = None + + try: + # use a mix of heuristic to detect device features + p_digest = self.descriptor.digest + if p_digest: + + garagedoor = p_digest.get(mc.KEY_GARAGEDOOR) + if isinstance(garagedoor, list): + for g in garagedoor: + MerossLanGarage(self, g.get(mc.KEY_CHANNEL)) + + # atm we're not sure we can detect this in 'digest' payload + if "mrs" in self.descriptor.type.lower(): + MerossLanRollerShutter(self, 0) + + # at any rate: setup switches whenever we find 'togglex' + # or whenever we cannot setup anything from digest + togglex = p_digest.get(mc.KEY_TOGGLEX) + if isinstance(togglex, list): + for t in togglex: + channel = t.get(mc.KEY_CHANNEL) + if channel not in self.entities: + MerossLanSwitch( + self, + channel, + mc.NS_APPLIANCE_CONTROL_TOGGLEX, + mc.KEY_TOGGLEX) + elif isinstance(togglex, dict): + channel = togglex.get(mc.KEY_CHANNEL) + if channel not in self.entities: + MerossLanSwitch( + self, + channel, + mc.NS_APPLIANCE_CONTROL_TOGGLEX, + mc.KEY_TOGGLEX) + + #endif p_digest + + # older firmwares (MSS110 with 1.1.28) look like dont really have 'digest' + # but have 'control' + p_control = self.descriptor.all.get(mc.KEY_CONTROL) if p_digest is None else None + if p_control: + p_toggle = p_control.get(mc.KEY_TOGGLE) + if isinstance(p_toggle, dict): + MerossLanSwitch( + self, + p_toggle.get(mc.KEY_CHANNEL, 0), + mc.NS_APPLIANCE_CONTROL_TOGGLE, + mc.KEY_TOGGLE) + + #fallback for switches: in case we couldnt get from NS_APPLIANCE_SYSTEM_ALL + if not self.entities: + if mc.NS_APPLIANCE_CONTROL_TOGGLEX in self.descriptor.ability: + MerossLanSwitch( + self, + 0, + mc.NS_APPLIANCE_CONTROL_TOGGLEX, + mc.KEY_TOGGLEX) + elif mc.NS_APPLIANCE_CONTROL_TOGGLE in self.descriptor.ability: + MerossLanSwitch( + self, + 0, + mc.NS_APPLIANCE_CONTROL_TOGGLE, + mc.KEY_TOGGLE) + + if mc.NS_APPLIANCE_CONTROL_ELECTRICITY in self.descriptor.ability: + self._sensor_power = MerossLanSensor(self, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER) + self._sensor_current = MerossLanSensor(self, DEVICE_CLASS_CURRENT, DEVICE_CLASS_CURRENT) + self._sensor_voltage = MerossLanSensor(self, DEVICE_CLASS_VOLTAGE, DEVICE_CLASS_VOLTAGE) + + if mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX in self.descriptor.ability: + self._sensor_energy = MerossLanSensor(self, DEVICE_CLASS_ENERGY, DEVICE_CLASS_ENERGY) + + except Exception as e: + LOGGER.warning("MerossDeviceSwitch(%s) init exception:(%s)", self.device_id, str(e)) + + + def receive( + self, + namespace: str, + method: str, + payload: dict, + replykey: KeyType + ) -> bool: + + if super().receive(namespace, method, payload, replykey): + return True + + if namespace == mc.NS_APPLIANCE_CONTROL_TOGGLE: + p_toggle = payload.get(mc.KEY_TOGGLE) + if isinstance(p_toggle, dict): + self.entities[p_toggle.get(mc.KEY_CHANNEL, 0)]._set_onoff(p_toggle.get(mc.KEY_ONOFF)) + return True + + if namespace == mc.NS_APPLIANCE_GARAGEDOOR_STATE: + garagedoor = payload.get(mc.KEY_STATE) + for g in garagedoor: + self.entities[g.get(mc.KEY_CHANNEL)]._set_open(g.get(mc.KEY_OPEN)) + return True + + if namespace == mc.NS_APPLIANCE_ROLLERSHUTTER_STATE: + state = payload.get(mc.KEY_STATE) + for s in state: + self.entities[s.get(mc.KEY_CHANNEL)]._set_rollerstate(s.get(mc.KEY_STATE)) + return True + + if namespace == mc.NS_APPLIANCE_ROLLERSHUTTER_POSITION: + position = payload.get(mc.KEY_POSITION) + for p in position: + self.entities[p.get(mc.KEY_CHANNEL)]._set_rollerposition(p.get(mc.KEY_POSITION)) + return True + + if namespace == mc.NS_APPLIANCE_CONTROL_ELECTRICITY: + electricity = payload.get(mc.KEY_ELECTRICITY) + self._sensor_power._set_state(electricity.get(mc.KEY_POWER) / 1000) + self._sensor_current._set_state(electricity.get(mc.KEY_CURRENT) / 1000) + self._sensor_voltage._set_state(electricity.get(mc.KEY_VOLTAGE) / 10) + return True + + if namespace == mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX: + self._lastupdate_consumption = self.lastupdate + daylabel = strftime("%Y-%m-%d", localtime()) + for d in payload.get(mc.KEY_CONSUMPTIONX): + if d.get(mc.KEY_DATE) == daylabel: + self._sensor_energy._set_state(d.get(mc.KEY_VALUE)) + break + else: + self._sensor_energy._set_state(0) + return True + + return False + + + def _update_descriptor(self, payload: dict) -> bool: + update = super()._update_descriptor(payload) + + p_digest = self.descriptor.digest + if p_digest: + p_garagedoor = p_digest.get(mc.KEY_GARAGEDOOR) + if isinstance(p_garagedoor, list): + for g in p_garagedoor: + self.entities[g.get(mc.KEY_CHANNEL)]._set_open(g.get(mc.KEY_OPEN)) + else: + # older firmwares (MSS110 with 1.1.28) look like dont really have 'digest' + p_control = self.descriptor.all.get(mc.KEY_CONTROL) + if p_control: + p_toggle = p_control.get(mc.KEY_TOGGLE) + if isinstance(p_toggle, dict): + self.entities[p_toggle.get(mc.KEY_CHANNEL, 0)]._set_onoff(p_toggle.get(mc.KEY_ONOFF)) + + return update + + + @callback + def updatecoordinator_listener(self) -> bool: + + if super().updatecoordinator_listener(): + + if ((self._sensor_power is not None) and self._sensor_power.enabled) or \ + ((self._sensor_voltage is not None) and self._sensor_voltage.enabled) or \ + ((self._sensor_current is not None) and self._sensor_current.enabled) : + self.request(mc.NS_APPLIANCE_CONTROL_ELECTRICITY) + if (self._sensor_energy is not None) and self._sensor_energy.enabled: + if ((time() - self._lastupdate_consumption) > PARAM_ENERGY_UPDATE_PERIOD): + self.request(mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX) + + return True + + return False + diff --git a/custom_components/meross_lan/meross_entity.py b/custom_components/meross_lan/meross_entity.py index ed7ece9f..77a97ed5 100644 --- a/custom_components/meross_lan/meross_entity.py +++ b/custom_components/meross_lan/meross_entity.py @@ -4,24 +4,51 @@ actual HA custom platform entities will be derived like this: MerossLanSwitch(_MerossToggle, SwitchEntity) """ -from typing import Any, Callable, Dict, List, Optional - - -from homeassistant.helpers.typing import HomeAssistantType, StateType -from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF - +from __future__ import annotations + +from homeassistant.helpers.typing import StateType +from homeassistant.helpers import device_registry as dr +from homeassistant.const import ( + STATE_UNKNOWN, STATE_ON, STATE_OFF, + DEVICE_CLASS_POWER, POWER_WATT, + DEVICE_CLASS_CURRENT, ELECTRICAL_CURRENT_AMPERE, + DEVICE_CLASS_VOLTAGE, VOLT, + DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR, + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + DEVICE_CLASS_HUMIDITY, PERCENTAGE, + DEVICE_CLASS_BATTERY +) + +from .merossclient import const as mc, get_productnameuuid +from .meross_device import MerossDevice from .logger import LOGGER -from .const import DOMAIN, CONF_DEVICE_ID, NS_APPLIANCE_CONTROL_ELECTRICITY, METHOD_GET +from .const import CONF_DEVICE_ID, DOMAIN + +CLASS_TO_UNIT_MAP = { + DEVICE_CLASS_POWER: POWER_WATT, + DEVICE_CLASS_CURRENT: ELECTRICAL_CURRENT_AMPERE, + DEVICE_CLASS_VOLTAGE: VOLT, + DEVICE_CLASS_ENERGY: ENERGY_WATT_HOUR, + DEVICE_CLASS_TEMPERATURE: TEMP_CELSIUS, + DEVICE_CLASS_HUMIDITY: PERCENTAGE, + DEVICE_CLASS_BATTERY: PERCENTAGE +} # pylint: disable=no-member class _MerossEntity: - def __init__(self, meross_device: object, channel: Optional[int], device_class: str): # pylint: disable=unsubscriptable-object - self._meross_device = meross_device - self._channel = channel + + PLATFORM: str + + def __init__(self, device: 'MerossDevice', id: object, device_class: str): # pylint: disable=unsubscriptable-object + self._device = device + self._id = id self._device_class = device_class self._state = None - meross_device.entities[channel if channel is not None else device_class] = self + device.entities[id] = self + async_add_devices = device.platforms.setdefault(self.PLATFORM) + if async_add_devices is not None: + async_add_devices([self]) def __del__(self): LOGGER.debug("MerossEntity(%s) destroy", self.unique_id) @@ -40,25 +67,34 @@ def async_write_ha_state(self): @property def unique_id(self): - return f"{self._meross_device.device_id}_{self._channel}" + return f"{self._device.device_id}_{self._id}" + + @property + def name(self) -> str: + return f"{self._device.descriptor.productname} - {self._device_class}" if self._device_class else self._device.descriptor.productname - # To link this entity to the device, this property must return an - # identifiers value matching that used in the cover, but no other information such - # as name. If name is returned, this entity will then also become a device in the - # HA UI. @property def device_info(self): + _id = self._device.device_id + _desc = self._device.descriptor + _type = _desc.type return { - "identifiers": { - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, self._meross_device.device_id) + "identifiers": {(DOMAIN, _id)}, + "connections": {(dr.CONNECTION_NETWORK_MAC, _desc.macAddress)}, + "manufacturer": mc.MANUFACTURER, + "name": _desc.productname, + "model": _desc.productmodel, + "sw_version": _desc.firmware.get(mc.KEY_VERSION) } - } @property - def device_class(self): + def device_class(self) -> str | None: return self._device_class + @property + def unit_of_measurement(self) -> str | None: + return CLASS_TO_UNIT_MAP.get(self._device_class) + @property def should_poll(self) -> bool: return False @@ -91,34 +127,99 @@ def _set_state(self, state: str) -> None: self.async_write_ha_state() return - def _set_available(self) -> None: - return - def _set_unavailable(self) -> None: self._set_state(None) return + """ + even though these are toggle/binary_sensor properties + we provide a base-implement-all + """ + @property + def is_on(self) -> bool: + return self._state == STATE_ON + + def _set_onoff(self, onoff) -> None: + self._set_state(STATE_ON if onoff else STATE_OFF) + return + + + class _MerossToggle(_MerossEntity): - def __init__(self, meross_device: object, channel: Optional[int], device_class: str, m_toggle_set, m_toggle_get): # pylint: disable=unsubscriptable-object - super().__init__(meross_device, channel, device_class) - self._m_toggle_set = m_toggle_set - self._m_toggle_get = m_toggle_get + + def __init__(self, device: 'MerossDevice', id: object, device_class: str, toggle_ns: str, toggle_key: str): + super().__init__(device, id, device_class) + self._toggle_ns = toggle_ns + self._toggle_key = toggle_key async def async_turn_on(self, **kwargs) -> None: - return self._m_toggle_set(self._channel, 1) + def _ack_callback(): + self._set_state(STATE_ON) + + self._device.request( + self._toggle_ns, + mc.METHOD_SET, + {self._toggle_key: {mc.KEY_CHANNEL: self._id, mc.KEY_ONOFF: 1}}, + _ack_callback + ) async def async_turn_off(self, **kwargs) -> None: - return self._m_toggle_set(self._channel, 0) + def _ack_callback(): + self._set_state(STATE_OFF) + + self._device.request( + self._toggle_ns, + mc.METHOD_SET, + {self._toggle_key: {mc.KEY_CHANNEL: self._id, mc.KEY_ONOFF: 0}}, + _ack_callback + ) + +class _MerossHubEntity(_MerossEntity): + + def __init__(self, subdevice: 'MerossSubDevice', id: object, device_class: str): + super().__init__( + subdevice.hub, + id, + device_class) + self.subdevice = subdevice + @property - def is_on(self) -> bool: - return self._state == STATE_ON + def name(self) -> str: + name = get_productnameuuid(self.subdevice.type, self.subdevice.id) + return f"{name} - {self._device_class}" if self._device_class else name + @property + def device_info(self): + _id = self.subdevice.id + _type = self.subdevice.type + return { + "via_device": (DOMAIN, self._device.device_id), + "identifiers": {(DOMAIN, _id)}, + "manufacturer": mc.MANUFACTURER, + "name": get_productnameuuid(_type, _id), + "model": _type + } - def _set_onoff(self, onoff) -> None: - self._set_state(STATE_ON if onoff else STATE_OFF) - return + +""" + helper functions to 'commonize' platform setup/unload +""" +def platform_setup_entry(hass: object, config_entry: object, async_add_devices, platform: str): + device_id = config_entry.data[CONF_DEVICE_ID] + device: MerossDevice = hass.data[DOMAIN].devices[device_id] + device.platforms[platform] = async_add_devices + async_add_devices([entity for entity in device.entities.values() if entity.PLATFORM is platform]) + LOGGER.debug("async_setup_entry device_id = %s - platform = %s", device_id, platform) + return + +def platform_unload_entry(hass: object, config_entry: object, platform: str) -> bool: + device_id = config_entry.data[CONF_DEVICE_ID] + device: MerossDevice = hass.data[DOMAIN].devices[device_id] + device.platforms[platform] = None + LOGGER.debug("async_unload_entry device_id = %s - platform = %s", device_id, platform) + return True diff --git a/custom_components/meross_lan/merossclient/__init__.py b/custom_components/meross_lan/merossclient/__init__.py new file mode 100644 index 00000000..56fa8dad --- /dev/null +++ b/custom_components/meross_lan/merossclient/__init__.py @@ -0,0 +1,210 @@ +"""An Http API Client to interact with meross devices""" +from email import header +import logging +from types import MappingProxyType +from typing import List, MappingView, Optional, Dict, Any, Callable, Union +from enum import Enum +from uuid import uuid4 +from hashlib import md5 +from time import time +from json import ( + dumps as json_dumps, + loads as json_loads, +) + +import aiohttp +from yarl import URL +import socket +import asyncio +import async_timeout + +from . import const as mc + +KeyType = Union[dict, Optional[str]] # pylint: disable=unsubscriptable-object + + +def build_payload(namespace:str, method:str, payload:dict = {}, key:KeyType = None, _from:str = None)-> dict: + if isinstance(key, dict): + key[mc.KEY_NAMESPACE] = namespace + key[mc.KEY_METHOD] = method + key[mc.KEY_PAYLOADVERSION] = 1 + key[mc.KEY_FROM] = _from + return { + mc.KEY_HEADER: key, + mc.KEY_PAYLOAD: payload + } + else: + messageid = uuid4().hex + timestamp = int(time()) + return { + mc.KEY_HEADER: { + mc.KEY_MESSAGEID: messageid, + mc.KEY_NAMESPACE: namespace, + mc.KEY_METHOD: method, + mc.KEY_PAYLOADVERSION: 1, + mc.KEY_FROM: _from, + #mc.KEY_FROM: "/app/0-0/subscribe", + #"from": "/appliance/9109182170548290882048e1e9522946/publish", + mc.KEY_TIMESTAMP: timestamp, + mc.KEY_TIMESTAMPMS: 0, + mc.KEY_SIGN: md5((messageid + (key or "") + str(timestamp)).encode('utf-8')).hexdigest() + }, + mc.KEY_PAYLOAD: payload + } + + + +def get_replykey(header: dict, key:KeyType = None) -> KeyType: + """ + checks header signature against key: + if ok return sign itsef else return the full header { "messageId", "timestamp", "sign", ...} + in order to be able to use it in a reply scheme + **UPDATE 28-03-2021** + the 'reply scheme' hack doesnt work on mqtt but works on http: this code will be left since it works if the key is correct + anyway and could be reused in a future attempt + """ + if isinstance(key, dict): + # no way! we're already keying as replykey workflow + return header + + sign = md5((header[mc.KEY_MESSAGEID] + (key or "") + str(header[mc.KEY_TIMESTAMP])).encode('utf-8')).hexdigest() + if sign == header[mc.KEY_SIGN]: + return key + + return header + + +def get_productname(type: str) -> str: + for _type, _name in mc.TYPE_NAME_MAP.items(): + if type.startswith(_type): + return _name + return type + + +def get_productnameuuid(type: str, uuid: str) -> str: + return f"{get_productname(type)} ({uuid})" + + +def get_productnametype(type: str) -> str: + name = get_productname(type) + return f"{name} ({type})" if name is not type else type + + +class MerossDeviceDescriptor: + """ + Utility class to extract various info from Appliance.System.All + device descriptor + """ + def __init__(self, payload: dict): + self.all = dict() + self.ability = dict() + self.update(payload) + + @property + def uuid(self) -> str: + return self.hardware.get(mc.KEY_UUID) + + @property + def macAddress(self) -> str: + return self.hardware.get(mc.KEY_MACADDRESS, '48:e1:e9:XX:XX:XX') + + @property + def ipAddress(self) -> str: + return self.firmware.get(mc.KEY_INNERIP) + + @property + def timezone(self) -> str: + return self.system.get(mc.KEY_TIME, {}).get(mc.KEY_TIMEZONE) + + @property + def productname(self) -> str: + return get_productnameuuid(self.type, self.uuid) + + @property + def productmodel(self) -> str: + return f"{self.type} {self.hardware.get(mc.KEY_VERSION, '')}" + + + def update(self, payload: dict): + """ + reset the cached pointers + """ + self.all = payload.get(mc.KEY_ALL, self.all) + self.system = self.all.get(mc.KEY_SYSTEM, {}) + self.hardware = self.system.get(mc.KEY_HARDWARE, {}) + self.firmware = self.system.get(mc.KEY_FIRMWARE, {}) + self.digest = self.all.get(mc.KEY_DIGEST) + self.ability = payload.get(mc.KEY_ABILITY, self.ability) + self.type = self.hardware.get(mc.KEY_TYPE, mc.MANUFACTURER)# cache because using often + +class MerossHttpClient: + + DEFAULT_TIMEOUT = 5 + + def __init__(self, + host: str, + key: str = None, + session: aiohttp.client.ClientSession = None, + logger: logging.Logger = None + ): + self._host = host + self._requesturl = URL(f"http://{host}/config") + self.key = key + self.replykey = None + self._session = session or aiohttp.ClientSession() + self._logger = logger or logging.getLogger(__name__) + + + def set_host_key(self, host: str, key: str) -> None: + if host != self._host: + self._host = host + self._requesturl = URL(f"http://{host}/config") + self.key = key + + + async def async_request( + self, + namespace: str, + method: str = mc.METHOD_GET, + payload: dict = {}, + timeout=DEFAULT_TIMEOUT + ) -> dict: + + self._logger.debug("MerossHttpClient(%s): HTTP POST method:(%s) namespace:(%s)", self._host, method, namespace) + + request: dict = build_payload(namespace, method, payload, self.key or self.replykey) + response: dict = await self.async_raw_request(request, timeout) + + if response.get(mc.KEY_PAYLOAD, {}).get(mc.KEY_ERROR, {}).get(mc.KEY_CODE) == 5001: + #sign error... hack and fool + self._logger.debug( + "Key error on %s (%s:%s) -> retrying with key-reply hack", + self._host, method, namespace) + req_header = request[mc.KEY_HEADER] + resp_header = response[mc.KEY_HEADER] + req_header[mc.KEY_MESSAGEID] = resp_header[mc.KEY_MESSAGEID] + req_header[mc.KEY_TIMESTAMP] = resp_header[mc.KEY_TIMESTAMP] + req_header[mc.KEY_SIGN] = resp_header[mc.KEY_SIGN] + response = await self.async_raw_request(request, timeout) + + return response + + async def async_raw_request(self, payload: dict, timeout=DEFAULT_TIMEOUT) -> dict: + + try: + with async_timeout.timeout(timeout): + response = await self._session.post( + url=self._requesturl, + data=json_dumps(payload) + ) + response.raise_for_status() + + text_body = await response.text() + self._logger.debug("MerossHttpClient(%s): HTTP Response (%s)", self._host, text_body) + json_body:dict = json_loads(text_body) + self.replykey = get_replykey(json_body.get(mc.KEY_HEADER), self.key) + except Exception as e: + self._logger.warning("MerossHttpClient(%s): HTTP Exception (%s)", self._host, str(e) or type(e).__name__) + raise + + return json_body diff --git a/custom_components/meross_lan/api.http b/custom_components/meross_lan/merossclient/api.http similarity index 64% rename from custom_components/meross_lan/api.http rename to custom_components/meross_lan/merossclient/api.http index 8b2093c8..653523b6 100644 --- a/custom_components/meross_lan/api.http +++ b/custom_components/meross_lan/merossclient/api.http @@ -2,7 +2,7 @@ ### device api: exchange the same message payload as per the mqtt protocol ### "sign" = md5(messageId + key + timestamp) -POST http://192.168.10.21/config HTTP/1.1 +POST http://192.168.10.13/config HTTP/1.1 Content-Type: application/json { @@ -10,13 +10,13 @@ Content-Type: application/json "from":"", "messageId":"", "method":"GET", - "namespace":"Appliance.System.All", + "namespace":"Appliance.Hub.Mts100.All", "payloadVersion":1, - "sign":"cfcd208495d565ef66e7dff9f98764da", - "timestamp": 0 + "sign":"b2f7be905c2e36511b5207dc6b313d02", + "timestamp": 1622314429 }, "payload":{ - + "all": [] } } @@ -48,7 +48,7 @@ Content-Type: application/json } } ### -POST http://192.168.10.21/config HTTP/1.1 +POST http://192.168.10.14/config HTTP/1.1 Content-Type: application/json { @@ -58,14 +58,12 @@ Content-Type: application/json "method": "GET", "payloadVersion": 1, "from": "", - "timestamp": 1617116059, + "timestamp": 0, "timestampMs": 60, - "sign": "e18d3d767751bc75832bafd3f7023fcd" + "sign": "cfcd208495d565ef66e7dff9f98764da" }, "payload": { - "togglex": { - "channel": 0 - } + "togglex": [ { "channel": 1}] } } ### @@ -94,4 +92,40 @@ Content-Type: application/json } } } +### +POST http://192.168.10.14/config HTTP/1.1 +Content-Type: application/json + +{ + "header": { + "messageId":"", + "namespace":"Appliance.Control.ToggleX", + "method":"GET", + "from":"", + "payloadVersion":1, + "timestamp": 0, + "timestampMs": 60, + "sign":"cfcd208495d565ef66e7dff9f98764da" + }, + "payload": { + "togglex" : [] + } +} + +### +POST http://192.168.10.14/config HTTP/1.1 +Content-Type: application/json +{ + "header":{ + "from":"", + "messageId":"", + "method":"GET", + "namespace":"Appliance.System.All", + "payloadVersion":1, + "sign":"cfcd208495d565ef66e7dff9f98764da", + "timestamp": 0 + }, + "payload":{ + } +} diff --git a/custom_components/meross_lan/merossclient/const.py b/custom_components/meross_lan/merossclient/const.py new file mode 100644 index 00000000..e2fc9743 --- /dev/null +++ b/custom_components/meross_lan/merossclient/const.py @@ -0,0 +1,159 @@ + +from typing import OrderedDict + + +METHOD_PUSH = "PUSH" +METHOD_GET = "GET" +METHOD_GETACK = "GETACK" +METHOD_SET = "SET" +METHOD_SETACK = "SETACK" +METHOD_ERROR = "ERROR" + +NS_APPLIANCE_SYSTEM_ALL = "Appliance.System.All" +NS_APPLIANCE_SYSTEM_ABILITY = "Appliance.System.Ability" +NS_APPLIANCE_SYSTEM_CLOCK = "Appliance.System.Clock" +NS_APPLIANCE_SYSTEM_REPORT = "Appliance.System.Report" +NS_APPLIANCE_SYSTEM_ONLINE = "Appliance.System.Online" +NS_APPLIANCE_SYSTEM_DEBUG = "Appliance.System.Debug" +NS_APPLIANCE_SYSTEM_TIME = "Appliance.System.Time" +NS_APPLIANCE_CONFIG_TRACE = "Appliance.Config.Trace" +NS_APPLIANCE_CONFIG_WIFILIST = "Appliance.Config.WifiList" +NS_APPLIANCE_CONTROL_TOGGLE = "Appliance.Control.Toggle" +NS_APPLIANCE_CONTROL_TOGGLEX = "Appliance.Control.ToggleX" +NS_APPLIANCE_CONTROL_TRIGGER = "Appliance.Control.Trigger" +NS_APPLIANCE_CONTROL_TRIGGERX = "Appliance.Control.TriggerX" +NS_APPLIANCE_CONTROL_CONSUMPTIONCONFIG = "Appliance.Control.ConsumptionConfig" +NS_APPLIANCE_CONTROL_CONSUMPTIONX = "Appliance.Control.ConsumptionX" +NS_APPLIANCE_CONTROL_ELECTRICITY = "Appliance.Control.Electricity" +# Light Abilities +NS_APPLIANCE_CONTROL_LIGHT = "Appliance.Control.Light" +# Humidifier abilities +NS_APPLIANCE_SYSTEM_DND = "Appliance.System.DNDMode" +NS_APPLIANCE_CONTROL_SPRAY = "Appliance.Control.Spray" +# Garage door opener +NS_APPLIANCE_GARAGEDOOR_STATE = "Appliance.GarageDoor.State" +# Roller shutter +NS_APPLIANCE_ROLLERSHUTTER_STATE = 'Appliance.RollerShutter.State' +NS_APPLIANCE_ROLLERSHUTTER_POSITION = 'Appliance.RollerShutter.Position' +# Hub +NS_APPLIANCE_DIGEST_HUB = 'Appliance.Digest.Hub' +NS_APPLIANCE_HUB_SUBDEVICELIST = 'Appliance.Hub.SubdeviceList' +NS_APPLIANCE_HUB_EXCEPTION = 'Appliance.Hub.Exception' +NS_APPLIANCE_HUB_BATTERY = 'Appliance.Hub.Battery' +NS_APPLIANCE_HUB_TOGGLEX = 'Appliance.Hub.ToggleX' +NS_APPLIANCE_HUB_ONLINE = 'Appliance.Hub.Online' +# +NS_APPLIANCE_HUB_SENSOR_ALL = 'Appliance.Hub.Sensor.All' +NS_APPLIANCE_HUB_SENSOR_TEMPHUM = 'Appliance.Hub.Sensor.TempHum' +NS_APPLIANCE_HUB_SENSOR_ALERT = 'Appliance.Hub.Sensor.Alert' +# MTS100 +NS_APPLIANCE_HUB_MTS100_ALL = 'Appliance.Hub.Mts100.All' +NS_APPLIANCE_HUB_MTS100_TEMPERATURE = 'Appliance.Hub.Mts100.Temperature' +NS_APPLIANCE_HUB_MTS100_MODE = 'Appliance.Hub.Mts100.Mode' + + +# misc keys for json payloads +KEY_HEADER = 'header' +KEY_MESSAGEID = 'messageId' +KEY_NAMESPACE = 'namespace' +KEY_METHOD = 'method' +KEY_PAYLOADVERSION = 'payloadVersion' +KEY_FROM = 'from' +KEY_TIMESTAMP = 'timestamp' +KEY_TIMESTAMPMS = 'timestampMs' +KEY_SIGN = 'sign' +KEY_PAYLOAD = 'payload' +KEY_ERROR = 'error' +KEY_CODE = 'code' +KEY_ALL = 'all' +KEY_SYSTEM = 'system' +KEY_HARDWARE = 'hardware' +KEY_TYPE = 'type' +KEY_VERSION = 'version' +KEY_UUID = 'uuid' +KEY_MACADDRESS = 'macAddress' +KEY_FIRMWARE = 'firmware' +KEY_INNERIP = 'innerIp' +KEY_CONTROL = 'control' +KEY_DIGEST = 'digest' +KEY_ABILITY = 'ability' +KEY_ONLINE = 'online' +KEY_TIME = 'time' +KEY_TIMEZONE = 'timezone' +KEY_STATUS = 'status' +KEY_CHANNEL = 'channel' +KEY_TOGGLE = 'toggle' +KEY_TOGGLEX = 'togglex' +KEY_ONOFF = 'onoff' +KEY_LIGHT = 'light' +KEY_CAPACITY = 'capacity' +KEY_RGB = 'rgb' +KEY_LUMINANCE = 'luminance' +KEY_TEMPERATURE = 'temperature' +KEY_HUB = 'hub' +KEY_BATTERY = 'battery' +KEY_VALUE = 'value' +KEY_HUBID = 'hubId' +KEY_SUBDEVICE = 'subdevice' +KEY_ID = 'id' +KEY_LATEST = 'latest' +KEY_TEMPHUM = 'tempHum' +KEY_LATESTTEMPERATURE = 'latestTemperature' +KEY_LATESTHUMIDITY = 'latestHumidity' +KEY_ELECTRICITY = 'electricity' +KEY_POWER = 'power' +KEY_CURRENT = 'current' +KEY_VOLTAGE = 'voltage' +KEY_CONSUMPTIONX = 'consumptionx' +KEY_DATE = 'date' +KEY_GARAGEDOOR = 'garageDoor' +KEY_STATE = 'state' +KEY_POSITION = 'position' +KEY_OPEN = 'open' +KEY_MODE = 'mode' +KEY_ROOM = 'room' +KEY_CURRENTSET = 'currentSet' +KEY_MIN = 'min' +KEY_MAX = 'max' +KEY_CUSTOM = 'custom' +KEY_COMFORT = 'comfort' +KEY_ECONOMY = 'economy' +KEY_HEATING = 'heating' +KEY_AWAY = 'away' +KEY_OPENWINDOW = 'openWindow' + +# online status +STATUS_UNKNOWN = -1 +STATUS_NOTONLINE = 0 +STATUS_ONLINE = 1 +STATUS_OFFLINE = 2 +STATUS_UPGRADING = 3 + +# well known device types +TYPE_UNKNOWN = 'unknown' +TYPE_MSH300 = 'msh300' # WiFi Hub +TYPE_MS100 = 'ms100' # Smart temp/humidity sensor over Hub +TYPE_MTS100 = 'mts100' +TYPE_MTS100V3 = 'mts100v3' +TYPE_MSS310 = 'mss310' # smart plug with energy meter +TYPE_MSL100 = 'msl100' # smart bulb +TYPE_MSL120 = 'msl120' # smart bulb with color/temp + +# common device type classes +CLASS_MSH = 'msh' +CLASS_MSS = 'mss' +CLASS_MSL = 'msl' +CLASS_MTS = 'mts' +TYPE_NAME_MAP = OrderedDict() +TYPE_NAME_MAP[TYPE_MSL120] = "Smart RGB Bulb" +TYPE_NAME_MAP[TYPE_MSL100] = "Smart Bulb" +TYPE_NAME_MAP[CLASS_MSL] = "Smart Light" +TYPE_NAME_MAP[CLASS_MSH] = "Smart Hub" +TYPE_NAME_MAP[TYPE_MSS310] = "Smart Plug" +TYPE_NAME_MAP[CLASS_MSS] = "Smart Switch" +TYPE_NAME_MAP[CLASS_MTS] = "Smart Thermostat" +TYPE_NAME_MAP[TYPE_MS100] = "Smart Temp/Humidity Sensor" +""" + GP constant strings +""" +MANUFACTURER = "Meross" \ No newline at end of file diff --git a/custom_components/meross_lan/sensor.py b/custom_components/meross_lan/sensor.py index 50994771..3694055c 100644 --- a/custom_components/meross_lan/sensor.py +++ b/custom_components/meross_lan/sensor.py @@ -1,34 +1,30 @@ -from typing import Any, Callable, Dict, List, Optional from homeassistant.helpers.entity import Entity -from .const import DOMAIN, CONF_DEVICE_ID -from .meross_entity import _MerossEntity -from .logger import LOGGER +from .meross_entity import _MerossEntity, _MerossHubEntity, platform_setup_entry, platform_unload_entry +from .const import PLATFORM_SENSOR + async def async_setup_entry(hass: object, config_entry: object, async_add_devices): - device_id = config_entry.data[CONF_DEVICE_ID] - device = hass.data[DOMAIN].devices[device_id] - async_add_devices([entity for entity in device.entities.values() if isinstance(entity, MerossLanSensor)]) - LOGGER.debug("async_setup_entry device_id = %s - platform = sensor", device_id) + platform_setup_entry(hass, config_entry, async_add_devices, PLATFORM_SENSOR) return async def async_unload_entry(hass: object, config_entry: object) -> bool: - LOGGER.debug("async_unload_entry device_id = %s - platform = sensor", config_entry.data[CONF_DEVICE_ID]) - return True + return platform_unload_entry(hass, config_entry, PLATFORM_SENSOR) + class MerossLanSensor(_MerossEntity, Entity): - def __init__(self, meross_device: object, device_class: str, unit_of_measurement: str): - super().__init__(meross_device, None, device_class) - self._unit_of_measurement = unit_of_measurement - meross_device.has_sensors = True - @property - def unique_id(self) -> Optional[str]: - """Return a unique id identifying the entity.""" - return f"{self._meross_device.device_id}_{self.device_class}" + PLATFORM = PLATFORM_SENSOR + + def __init__(self, device: 'MerossDevice', id: object, device_class: str): + super().__init__(device, id, device_class) + + + +class MerossLanHubSensor(_MerossHubEntity, Entity): - @property - def unit_of_measurement(self) -> Optional[str]: - return self._unit_of_measurement + PLATFORM = PLATFORM_SENSOR + def __init__(self, subdevice: 'MerossSubDevice', device_class: str): + super().__init__(subdevice, f"{subdevice.id}_{device_class}", device_class) diff --git a/custom_components/meross_lan/services.yaml b/custom_components/meross_lan/services.yaml index 28eb9799..7e585cf9 100644 --- a/custom_components/meross_lan/services.yaml +++ b/custom_components/meross_lan/services.yaml @@ -1,11 +1,11 @@ # meross_lan services configuration # Service ID -mqtt_publish: +request: # Service name as shown in UI - name: MQTT Publish + name: Request # Description of the service - description: Publish an mqtt message formatted according to Meross MQTT protocol + description: Sends either an MQTT message or HTTP request formatted according to Meross protocol # If the service accepts entity IDs, target allows the user to specify entities by entity, device, or area. If `target` is specified, `entity_id` should not be defined in the `fields` map. By default it shows only targets matching entities from the same domain as the service, but if further customization is required, target supports the entity, device, and area selectors (https://www.home-assistant.io/docs/blueprint/selectors/). Entity selector parameters will automatically be applied to device and area, and device selector parameters will automatically be applied to area. #target: # Different fields that your service accepts @@ -14,18 +14,26 @@ mqtt_publish: # Field name as shown in UI name: Device identifier # Description of the field - description: The UUID of the meross target device + description: The UUID of the meross target device (needed for MQTT - optional for HTTP) # Whether or not field is required - required: true + required: false # Advanced options are only shown when the advanced mode is enabled for the user advanced: false # Example value that can be passed for this field example: "9109182170548290882048e1e9XXXXXX" # The default value - #default: "high" + #default: "" # Selector (https://www.home-assistant.io/docs/blueprint/selectors/) to control the input UI for this field selector: text: + host: + name: Host address + description: The address of the meross target device (unneeded for MQTT - optional for HTTP) + required: false + advanced: false + example: "192.168.1.1" + selector: + text: method: name: Method description: The method to set in the message @@ -50,8 +58,11 @@ mqtt_publish: options: - "Appliance.System.All" - "Appliance.System.Ability" + - "Appliance.System.Clock" - "Appliance.System.Online" - "Appliance.System.Debug" + - "Appliance.System.Time" + - "Appliance.System.DNDMode" - "Appliance.Config.Trace" - "Appliance.Config.WifiList" - "Appliance.Control.Toggle" @@ -62,11 +73,22 @@ mqtt_publish: - "Appliance.Control.ConsumptionConfig" - "Appliance.Control.Electricity" - "Appliance.Control.Light" - - "Appliance.System.DNDMode" - "Appliance.Control.Spray" - "Appliance.GarageDoor.State" - "Appliance.RollerShutter.State" - "Appliance.RollerShutter.Position" + - "Appliance.Digest.Hub" + - "Appliance.Hub.SubdeviceList" + - "Appliance.Hub.Exception" + - "Appliance.Hub.Battery" + - "Appliance.Hub.ToggleX" + - "Appliance.Hub.Online" + - "Appliance.Hub.Sensor.All" + - "Appliance.Hub.Sensor.TempHum" + - "Appliance.Hub.Sensor.Alert" + - "Appliance.Hub.Mts100.All" + - "Appliance.Hub.Mts100.Temperature" + - "Appliance.Hub.Mts100.Mode" key: name: Key description: The key used to encrypt message signatures diff --git a/custom_components/meross_lan/strings.json b/custom_components/meross_lan/strings.json index a04ebded..551d0e34 100644 --- a/custom_components/meross_lan/strings.json +++ b/custom_components/meross_lan/strings.json @@ -7,6 +7,14 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, "step": { + "user": { + "title": "Meross LAN", + "description": "Setup meross device", + "data": { + "host": "Device host address", + "key": "Device key" + } + }, "hub": { "title": "Meross LAN MQTT Hub", "description": "Configure global Meross LAN settings", @@ -33,10 +41,11 @@ }, "device": { "title": "Device configuration", - "description": "Type: {device_type}\nUUID: {device_id}\n\n{payload}", + "description": "Type: {device_type}\nUUID: {device_id}\nHost: {host}\n\n{payload}", "data": { "key": "Device key", - "device_id": "UUID of Meross appliance", + "protocol": "Connection protocol", + "polling_period": "Polling period", "all": "Appliance.System.All", "ability": "Appliance.System.Ability" } diff --git a/custom_components/meross_lan/switch.py b/custom_components/meross_lan/switch.py index c6dc3210..da5a3d71 100644 --- a/custom_components/meross_lan/switch.py +++ b/custom_components/meross_lan/switch.py @@ -1,28 +1,22 @@ -from typing import Any, Callable, Dict, List, Optional - -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.components.switch import SwitchEntity, DEVICE_CLASS_OUTLET -from .const import DOMAIN, CONF_DEVICE_ID -from .meross_entity import _MerossToggle -from .logger import LOGGER +from .meross_entity import _MerossToggle, platform_setup_entry, platform_unload_entry +from .const import PLATFORM_SWITCH + -async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry, async_add_devices): - device_id = config_entry.data[CONF_DEVICE_ID] - device = hass.data[DOMAIN].devices[device_id] - async_add_devices([entity for entity in device.entities.values() if isinstance(entity, MerossLanSwitch)]) - LOGGER.debug("async_setup_entry device_id = %s - platform = switch", device_id) - return +async def async_setup_entry(hass: object, config_entry: object, async_add_devices): + platform_setup_entry(hass, config_entry, async_add_devices, PLATFORM_SWITCH) async def async_unload_entry(hass: object, config_entry: object) -> bool: - LOGGER.debug("async_unload_entry device_id = %s - platform = switch", config_entry.data[CONF_DEVICE_ID]) - return True + return platform_unload_entry(hass, config_entry, PLATFORM_SWITCH) + class MerossLanSwitch(_MerossToggle, SwitchEntity): - def __init__(self, meross_device: object, channel: int, m_toggle_set, m_toggle_get): - super().__init__(meross_device, channel, DEVICE_CLASS_OUTLET, m_toggle_set, m_toggle_get) - meross_device.has_switches = True + + PLATFORM = PLATFORM_SWITCH + + def __init__(self, device: 'MerossDevice', id: object, toggle_ns: str, toggle_key: str): + super().__init__(device, id, DEVICE_CLASS_OUTLET, toggle_ns, toggle_key) diff --git a/custom_components/meross_lan/translations/en.json b/custom_components/meross_lan/translations/en.json index 92f45839..6d1dcc21 100644 --- a/custom_components/meross_lan/translations/en.json +++ b/custom_components/meross_lan/translations/en.json @@ -6,7 +6,19 @@ "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, + "error": { + "cannot_connect": "Unable to connect", + "invalid_auth": "Authentication error" + }, "step": { + "user": { + "title": "Meross LAN", + "description": "Setup meross device", + "data": { + "host": "Device host address", + "key": "Device key" + } + }, "hub": { "title": "Meross LAN MQTT Hub", "description": "Configure global Meross LAN settings", @@ -33,10 +45,11 @@ }, "device": { "title": "Device configuration", - "description": "Type: {device_type}\nUUID: {device_id}\n\n{payload}", + "description": "Type: {device_type}\nUUID: {device_id}\nHost: {host}\n\n{payload}", "data": { "key": "Device key", - "device_id": "UUID of Meross appliance", + "protocol": "Connection protocol", + "polling_period": "Polling period", "all": "Appliance.System.All", "ability": "Appliance.System.Ability" } diff --git a/hacs.json b/hacs.json index 5f383a99..a31ba35b 100644 --- a/hacs.json +++ b/hacs.json @@ -1,9 +1,9 @@ { "name": "Meross LAN", "render_readme": true, - "country": ["IT"], - "domains": ["switch", "sensor", "light", "cover"], + "country": ["IT", "UK", "US"], + "domains": ["switch", "sensor", "light", "cover", "climate", "binary_sensor"], "homeassistant": "2020.0.0", - "iot_class": "local_push", + "iot_class": "local_polling", "hacs": "1.6.0" } \ No newline at end of file diff --git a/tests/const.py b/tests/const.py index f78d3caa..86eea396 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,6 +1,6 @@ """Constants for integration_blueprint tests.""" from custom_components.meross_lan.const import ( - CONF_DEVICE_ID, CONF_DISCOVERY_PAYLOAD, CONF_KEY + CONF_DEVICE_ID, CONF_KEY, CONF_PAYLOAD ) # Mock config data to be used across multiple tests @@ -10,5 +10,5 @@ MOCK_DEVICE_CONFIG = { CONF_DEVICE_ID: "9109182170548290880048b1a9522933", CONF_KEY: "test_key", - CONF_DISCOVERY_PAYLOAD: {} + CONF_PAYLOAD: {} } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 9d2de0b8..fa149f46 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -8,7 +8,7 @@ from custom_components.meross_lan.const import ( DOMAIN, CONF_DEVICE_ID, - CONF_DISCOVERY_PAYLOAD + CONF_PAYLOAD ) from .const import (