diff --git a/README.md b/README.md index a5a05f9a..7e1f56c8 100644 --- a/README.md +++ b/README.md @@ -40,19 +40,37 @@ Manually switch your plug and check if you received any message in your MQTT con 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. +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. +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 ok the component will be able to auto detect the device and nothing need to be done. If your device is not discovered then it is probably my fault since I currently do not have many of them to test +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 ## Supported hardware -At the moment this software has been developed and tested on the Meross MSS310R plug (power meter included). 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 my MSS310Rs (firmware 2.1.4) +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 + +- 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 +- 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. + + +## Service -- [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) +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. +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 ## References diff --git a/custom_components/meross_lan/__init__.py b/custom_components/meross_lan/__init__.py index e07277eb..db95fb66 100644 --- a/custom_components/meross_lan/__init__.py +++ b/custom_components/meross_lan/__init__.py @@ -43,6 +43,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): api = MerossApi(hass) hass.data[DOMAIN] = api await api.async_mqtt_register() + + 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 @@ -92,6 +102,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 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.unsub_updatecoordinator_listener = api.coordinator.async_add_listener(device.updatecoordinator_listener) @@ -117,6 +129,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: device.unsub_entry_update_listener() @@ -125,10 +143,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.unsub_updatecoordinator_listener() device.unsub_updatecoordinator_listener = None - if platforms_unload: - if False == all(await asyncio.gather(*platforms_unload)): - return False - api.devices.pop(device_id) #when removing the last configentry do a complete cleanup @@ -139,6 +153,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if api.unsub_entry_update_listener: api.unsub_entry_update_listener() api.unsub_entry_update_listener = None + if api.unsub_updatecoordinator_listener: + api.unsub_updatecoordinator_listener() + api.unsub_updatecoordinator_listener = None hass.data.pop(DOMAIN) return True @@ -198,18 +215,13 @@ def __init__(self, hass: HomeAssistant): self.key = None self.devices: Dict[str, MerossDevice] = {} self.discovering: Dict[str, {}] = {} + self.unsub_mqtt = None + self.unsub_entry_update_listener = None + self.unsub_updatecoordinator_listener = None async def async_update_data(): """ data fetch and control moved to MerossDevice - - now = time() - devices = list(self.devices.values())# make a static copy since we may be destroying devices - for device in devices: - if not device.online and ((now - device.lastupdate) > PARAM_STALE_DEVICE_REMOVE_TIMEOUT): - await hass.config_entries.async_set_disabled_by(device.entry_id, DOMAIN) - else: - device.triggerupdate() """ return None @@ -228,94 +240,128 @@ async def async_update_data(): async def async_mqtt_register(self): # Listen to a message on MQTT. @callback - async def message_received(msg): - 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") - - device = self.devices.get(device_id) - if device == None: - # lookout for any disabled/ignored entry - for domain_entry in self.hass.config_entries.async_entries(DOMAIN): - if (domain_entry.unique_id == device_id): - # entry already present... - #if domain_entry.disabled_by == DOMAIN: - # we previously disabled this one due to extended anuavailability - #await self.hass.config_entries.async_set_disabled_by(domain_entry.entry_id, None) - # skip discovery anyway - 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) - 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) - return - - replykey = get_replykey(header, self.key) - if replykey != self.key: - LOGGER_trap(WARNING, "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.discovering[device_id] = { "__time": time() } - 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) - discovered["__time"] = time() + 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") + + LOGGER.debug("MerossApi: MQTT RECV device_id:(%s) method:(%s) namespace:(%s)", device_id, method, namespace) + + device = self.devices.get(device_id) + if device == None: + # lookout for any disabled/ignored entry + for domain_entry in self.hass.config_entries.async_entries(DOMAIN): + if (domain_entry.unique_id == device_id): + # entry already present... + #if domain_entry.disabled_by == DOMAIN: + # we previously disabled this one due to extended anuavailability + #await self.hass.config_entries.async_set_disabled_by(domain_entry.entry_id, None) + # skip discovery anyway + 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) 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) + #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) + return + + replykey = get_replykey(header, self.key) + if replykey != self.key: + LOGGER_trap(WARNING, "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.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) discovered["__time"] = time() return - payload.update(discovered[NS_APPLIANCE_SYSTEM_ALL]) - self.discovering.pop(device_id) - await self.hass.config_entries.flow.async_init( - DOMAIN, - context={ "source": SOURCE_DISCOVERY }, - data={ - CONF_DEVICE_ID: device_id, - CONF_DISCOVERY_PAYLOAD: payload, - CONF_KEY: replykey - }, - ) + 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) + discovered["__time"] = time() + return + payload.update(discovered[NS_APPLIANCE_SYSTEM_ALL]) + self.discovering.pop(device_id) + if (len(self.discovering) == 0) and self.unsub_updatecoordinator_listener: + self.unsub_updatecoordinator_listener() + self.unsub_updatecoordinator_listener = None + await self.hass.config_entries.flow.async_init( + DOMAIN, + context={ "source": SOURCE_DISCOVERY }, + data={ + CONF_DEVICE_ID: device_id, + CONF_DISCOVERY_PAYLOAD: payload, + CONF_KEY: replykey + }, + ) + return + #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) + else: + self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ABILITY, METHOD_GET, key=replykey) + discovered["__time"] = time() return - #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) - else: - self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ABILITY, METHOD_GET, key=replykey) - discovered["__time"] = time() - return - - - else: - device.parsepayload(namespace, method, payload, get_replykey(header, device.key)) + + + else: + device.parsepayload(namespace, method, payload, get_replykey(header, device.key)) + + except Exception as e: + LOGGER.debug("MerossApi: mqtt_receive exception:(%s) payload:(%s)", str(e), msg.payload) + return self.unsub_mqtt = await self.hass.components.mqtt.async_subscribe( - DISCOVERY_TOPIC, message_received + DISCOVERY_TOPIC, mqtt_receive ) def mqtt_publish(self, device_id: str, namespace: str, method: str, payload: dict = {}, key: Union[dict, Optional[str]] = None): # pylint: disable=unsubscriptable-object + 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) + @callback async def entry_update_listener(self, hass: HomeAssistant, config_entry: ConfigEntry): - self.key = config_entry.data.get(CONF_KEY) \ No newline at end of file + self.key = config_entry.data.get(CONF_KEY) + return + + + @callback + def updatecoordinator_listener(self) -> None: + """ + called by DataUpdateCoordinator when we have pending discoveries + this callback gets attached/detached dinamically when we have discoveries pending + """ + 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) + else: + self.mqtt_publish(device_id, NS_APPLIANCE_SYSTEM_ABILITY, METHOD_GET, {}, self.key) + discovered["__time"] = now + + return diff --git a/custom_components/meross_lan/const.py b/custom_components/meross_lan/const.py index 8c3c899b..8c99dc9a 100644 --- a/custom_components/meross_lan/const.py +++ b/custom_components/meross_lan/const.py @@ -1,8 +1,8 @@ """Constants for the Meross IoT local LAN integration.""" DOMAIN = "meross_lan" -#PLATFORMS = ["switch", "sensor", "light"] - +#PLATFORMS = ["switch", "sensor", "light", "cover"] +SERVICE_MQTT_PUBLISH = "mqtt_publish" CONF_DEVICE_ID = "device_id" CONF_KEY = "key" @@ -21,15 +21,17 @@ 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_TOGGLEX = "Appliance.Control.ToggleX" 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 @@ -37,14 +39,20 @@ # 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' """ general working/configuration parameters (waiting to be moved to CONF_ENTRY) """ -PARAM_UNAVAILABILITY_TIMEOUT = 10 # number of seconds since last inquiry to consider the device unavailable +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_STALE_DEVICE_REMOVE_TIMEOUT = 60 # disable config_entry when device is offline for more than... +#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 """ diff --git a/custom_components/meross_lan/cover.py b/custom_components/meross_lan/cover.py new file mode 100644 index 00000000..9e276ccf --- /dev/null +++ b/custom_components/meross_lan/cover.py @@ -0,0 +1,183 @@ + +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, + ATTR_POSITION, + SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, + STATE_OPEN, STATE_OPENING, STATE_CLOSED, STATE_CLOSING +) + +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 +) +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_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 + + +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 } } + + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + + @property + def is_opening(self): + return self._state == STATE_OPENING + + + @property + def is_closing(self): + return self._state == STATE_CLOSING + + + @property + def is_closed(self): + return self._state == STATE_CLOSED + + + 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, + 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, + payload=self._payload) + return + + + def _set_open(self, open) -> None: + self._set_state(STATE_OPEN if open else STATE_CLOSED) + return + + + +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 + self._position = None + self._payload = {"position": {"position": 0, "channel": channel } } + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + + + @property + def is_opening(self): + return self._state == STATE_OPENING + + + @property + def is_closing(self): + return self._state == STATE_CLOSING + + + @property + def is_closed(self): + return self._state == STATE_CLOSED + + @property + def current_cover_position(self): + return self._position + + 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, + 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, + payload=self._payload) + return + + + async def async_set_cover_position(self, **kwargs): + if ATTR_POSITION in 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, + payload=self._payload) + + return + + + 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, + payload=self._payload) + return + + def _set_unavailable(self) -> None: + self._position = None + super()._set_unavailable() + return + + def _set_rollerstate(self, state) -> None: + if state == 1: + self._set_state(STATE_CLOSING) + elif state == 2: + self._set_state(STATE_OPENING) + return + + def _set_rollerposition(self, position) -> None: + self._position = position + if position == 0: + self._set_state(STATE_CLOSED) + else: + self._set_state(STATE_OPEN) + return \ No newline at end of file diff --git a/custom_components/meross_lan/light.py b/custom_components/meross_lan/light.py index 90b91666..232f5981 100644 --- a/custom_components/meross_lan/light.py +++ b/custom_components/meross_lan/light.py @@ -45,7 +45,7 @@ async def async_unload_entry(hass: object, config_entry: object) -> bool: -def rgb_to_int(rgb: Union[tuple, dict, int]) -> int: +def rgb_to_int(rgb: Union[tuple, dict, int]) -> int: # pylint: disable=unsubscriptable-object if isinstance(rgb, int): return rgb elif isinstance(rgb, tuple): @@ -65,6 +65,7 @@ def int_to_rgb(rgb: int) -> Tuple[int, int, int]: class MerossLanLight(_MerossEntity, LightEntity): def __init__(self, meross_device: object, channel: int): super().__init__(meross_device, channel, None) + """ self._light = { "onoff": 0, "capacity": CAPACITY_LUMINANCE, @@ -75,7 +76,8 @@ def __init__(self, meross_device: object, channel: int): "transform": 0, "gradual": 0 } - self._payload = {"light": self._light} + """ + self._light = {} self._capacity = meross_device.ability.get(NS_APPLIANCE_CONTROL_LIGHT, {}).get("capacity", CAPACITY_LUMINANCE) @@ -85,11 +87,59 @@ def __init__(self, meross_device: object, channel: int): meross_device.has_lights = True + @property def supported_features(self): return self._supported_features + @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 + + + @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 + + + @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 + + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return None + + + @property + def effect_list(self): + """Return the list of supported effects.""" + return None + + + @property + def effect(self): + """Return the current effect.""" + return None + + + @property + def is_on(self) -> bool: + return self._state == STATE_ON + + async def async_turn_on(self, **kwargs) -> None: capacity = 0 @@ -103,7 +153,7 @@ async def async_turn_on(self, **kwargs) -> None: 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"] = temperature + self._light["temperature"] = int(temperature) self._light.pop("rgb", None) capacity |= CAPACITY_TEMPERATURE @@ -111,99 +161,56 @@ async def async_turn_on(self, **kwargs) -> None: 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["luminance"] = kwargs[ATTR_BRIGHTNESS] * 100 // 255 self._light["capacity"] = capacity - self._internal_send(onoff=1) - return - - - async def async_turn_off(self, **kwargs) -> None: - self._internal_send(onoff = 0) - return - - - def _internal_send(self, onoff: int): - if NS_APPLIANCE_CONTROL_TOGGLEX in self._meross_device.ability: # since lights could be repeatedtly 'async_turn_on' when changing attributes # we avoid flooding the device with unnecessary messages - if (onoff == 0) or (not self.is_on): - self._meross_device.togglex_set(channel = self._channel, ison = onoff) + if not self.is_on: + self._meross_device.togglex_set(channel = self._channel, ison = 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._light["onoff"] = onoff self._meross_device.mqtt_publish( namespace=NS_APPLIANCE_CONTROL_LIGHT, method=METHOD_SET, - payload=self._payload) - - - @property - def is_on(self) -> bool: - return self._state == STATE_ON - - def _set_onoff(self, onoff) -> None: - newstate = STATE_ON if onoff else STATE_OFF - if self._state != newstate: - self._state = newstate - self._light["onoff"] = 1 if onoff else 0 - if self.enabled: - self.async_write_ha_state() - return - - def _set_light(self, light: dict) -> None: - self._light = light - self._payload["light"] = light - self._state = STATE_ON if light.get("onoff") else STATE_OFF - if self.enabled: - self.async_write_ha_state() - return + payload={"light": self._light}) - def _set_available(self) -> None: - #if self.enabled: - # self._m_toggle_get(self._channel) return - @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 - - - @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 + 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}) - @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 - @property - def white_value(self): - """Return the white value of this light between 0..255.""" - return None + def _set_onoff(self, onoff) -> None: + self._set_state(STATE_ON if onoff else STATE_OFF) + return - @property - def effect_list(self): - """Return the list of supported effects.""" - return None + 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 - @property - def effect(self): - """Return the current effect.""" - return None diff --git a/custom_components/meross_lan/manifest.json b/custom_components/meross_lan/manifest.json index 52450f49..266dcfc8 100644 --- a/custom_components/meross_lan/manifest.json +++ b/custom_components/meross_lan/manifest.json @@ -11,5 +11,5 @@ "mqtt": [], "dependencies": ["mqtt"], "codeowners": ["@krahabb"], - "version": "0.0.3" + "version": "0.0.4" } \ No newline at end of file diff --git a/custom_components/meross_lan/meross_device.py b/custom_components/meross_lan/meross_device.py index 8257d202..b2c035ea 100644 --- a/custom_components/meross_lan/meross_device.py +++ b/custom_components/meross_lan/meross_device.py @@ -15,22 +15,29 @@ ) from .logger import LOGGER, LOGGER_trap -from logging import WARNING +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_CONTROL_ELECTRICITY, NS_APPLIANCE_CONTROL_CONSUMPTIONX, NS_APPLIANCE_SYSTEM_ALL, - PARAM_UNAVAILABILITY_TIMEOUT, PARAM_ENERGY_UPDATE_PERIOD + 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 ) class MerossDevice: def __init__(self, api: object, device_id: str, entry: ConfigEntry): + + LOGGER.debug("MerossDevice(%s) init", device_id) + self.api = api self.entry_id = entry.entry_id self.device_id = device_id @@ -40,6 +47,7 @@ def __init__(self, api: object, device_id: str, entry: ConfigEntry): 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 @@ -51,39 +59,59 @@ def __init__(self, api: object, device_id: str, entry: ConfigEntry): discoverypayload = entry.data.get(CONF_DISCOVERY_PAYLOAD, {}) + self.all = discoverypayload.get("all", {}) self.ability = discoverypayload.get("ability", {}) try: - - digest = discoverypayload.get("all", {}).get("digest", {}) - - # let's assume lights are controlled by togglex (optimism) - light = 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)) - - # at any rate: could we have more toggles than lights? (yes: no lights at all) - # but the question is: could we have lights (with togglex) + switches (only togglex) ? - togglex = digest.get("togglex") - if isinstance(togglex, List): - for t in togglex: + # 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) - elif isinstance(togglex, Dict): - channel = t.get("channel") - if channel not in self.entities: - MerossLanSwitch(self, channel, self.togglex_set, self.togglex_get) - elif NS_APPLIANCE_CONTROL_TOGGLEX in self.ability: - #fallback for switches: in case we couldnt get from NS_APPLIANCE_SYSTEM_ALL - if not self.has_lights: + + #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: - #fallback for switches: in case we couldnt get from NS_APPLIANCE_SYSTEM_ALL - if not self.has_lights: + 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: @@ -94,10 +122,11 @@ def __init__(self, api: object, device_id: str, entry: ConfigEntry): if NS_APPLIANCE_CONTROL_CONSUMPTIONX in self.ability: self._sensor_energy = MerossLanSensor(self, DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR) - except: - pass + self.mqtt_publish(NS_APPLIANCE_SYSTEM_ALL, METHOD_GET) + + except Exception as e: + LOGGER.debug("MerossDevice(%s) init exception:(%s)", device_id, str(e)) - LOGGER.debug("MerossDevice(%s) init", self.device_id) return def __del__(self): @@ -107,7 +136,7 @@ def __del__(self): @property def online(self) -> bool: if self._online: - #evaluate device MQTT availability by checking lastrequest got answered in less than 10 seconds + #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 @@ -118,86 +147,124 @@ def online(self) -> bool: return False def parsepayload(self, namespace: str, method: str, payload: dict, replykey: Union[dict, Optional[str]]) -> None: # pylint: disable=unsubscriptable-object - try: - """ - 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') - if it's good else it would be the device message header to be used in - a reply scheme where we're going to 'fool' the device by using its own hashes - if our config allows for that (our self.key is 'None' which means empty key or auto-detect) + """ + 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') + if it's good else it would be the device message header to be used in + a reply scheme where we're going to 'fool' the device by using its own hashes + if our config allows for that (our self.key is 'None' which means empty key or auto-detect) + + 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) + + 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")) """ - self.replykey = replykey - - self.lastupdate = time() - if not self._online: - LOGGER.debug("MerossDevice(%s) back online!", self.device_id) - self._online = True - for entity in self.entities.values(): - entity._set_available() - - if replykey != self.key: - LOGGER_trap(WARNING, "Meross device key error for device_id: %s", self.device_id) - - 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) """ - # 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) - pass - - elif namespace == NS_APPLIANCE_SYSTEM_ALL: - digest = payload.get("all", {}).get("digest", {}) - togglex = digest.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")) - light = digest.get("light") - if isinstance(light, Dict): - self.entities[light.get("channel")]._set_light(light) - - except: - pass + 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")) return @@ -232,18 +299,24 @@ def togglex_get(self, channel: int): def mqtt_publish(self, namespace: str, method: str, payload: dict = {}): - # self.lastrequest should represent the time of the most recent un-responded request - if self.lastupdate >= self.lastrequest: - self.lastrequest = time() + 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) + @callback def updatecoordinator_listener(self) -> None: - if not(self.online): + 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 + if (now - self.lastrequest) > PARAM_HEARTBEAT_PERIOD: self.mqtt_publish(NS_APPLIANCE_SYSTEM_ALL, METHOD_GET) return - now = time() + if not (self.online): + return if NS_APPLIANCE_CONTROL_ELECTRICITY in self.ability: self.mqtt_publish(NS_APPLIANCE_CONTROL_ELECTRICITY, METHOD_GET) diff --git a/custom_components/meross_lan/meross_entity.py b/custom_components/meross_lan/meross_entity.py index 4ba7c6a5..ed7ece9f 100644 --- a/custom_components/meross_lan/meross_entity.py +++ b/custom_components/meross_lan/meross_entity.py @@ -16,7 +16,7 @@ # pylint: disable=no-member class _MerossEntity: - def __init__(self, meross_device: object, channel: Optional[int], device_class: str): + def __init__(self, meross_device: object, channel: Optional[int], device_class: str): # pylint: disable=unsubscriptable-object self._meross_device = meross_device self._channel = channel self._device_class = device_class @@ -82,19 +82,16 @@ async def async_added_to_hass(self) -> None: return async def async_will_remove_from_hass(self) -> None: - self._state = None return def _set_state(self, state: str) -> None: if self._state != state: self._state = state - if self.enabled: + if self.hass and self.enabled: self.async_write_ha_state() return def _set_available(self) -> None: - #if self.enabled: - # self._m_toggle_get(self._channel) return def _set_unavailable(self) -> None: @@ -103,30 +100,25 @@ def _set_unavailable(self) -> None: class _MerossToggle(_MerossEntity): - def __init__(self, meross_device: object, channel: Optional[int], device_class: str, m_toggle_set, m_toggle_get): + 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 - #async def async_added_to_hass(self) -> None: - # self._m_toggle_get(self._channel) - # return async def async_turn_on(self, **kwargs) -> None: return self._m_toggle_set(self._channel, 1) + async def async_turn_off(self, **kwargs) -> None: return self._m_toggle_set(self._channel, 0) + @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 - - def _set_available(self) -> None: - if self.enabled: - self._m_toggle_get(self._channel) - return diff --git a/custom_components/meross_lan/services.yaml b/custom_components/meross_lan/services.yaml new file mode 100644 index 00000000..28eb9799 --- /dev/null +++ b/custom_components/meross_lan/services.yaml @@ -0,0 +1,85 @@ +# meross_lan services configuration + +# Service ID +mqtt_publish: + # Service name as shown in UI + name: MQTT Publish + # Description of the service + description: Publish an mqtt message formatted according to Meross MQTT 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 + fields: + device_id: + # Field name as shown in UI + name: Device identifier + # Description of the field + description: The UUID of the meross target device + # Whether or not field is required + required: true + # 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" + # Selector (https://www.home-assistant.io/docs/blueprint/selectors/) to control the input UI for this field + selector: + text: + method: + name: Method + description: The method to set in the message + required: true + advanced: false + example: "GET" + default: "GET" + selector: + select: + options: + - "SET" + - "GET" + namespace: + name: Namespace + description: The namespace for the request + required: true + advanced: false + example: "Appliance.System.All" + default: "Appliance.System.All" + selector: + select: + options: + - "Appliance.System.All" + - "Appliance.System.Ability" + - "Appliance.System.Online" + - "Appliance.System.Debug" + - "Appliance.Config.Trace" + - "Appliance.Config.WifiList" + - "Appliance.Control.Toggle" + - "Appliance.Control.ToggleX" + - "Appliance.Control.Trigger" + - "Appliance.Control.TriggerX" + - "Appliance.Control.ConsumptionX" + - "Appliance.Control.ConsumptionConfig" + - "Appliance.Control.Electricity" + - "Appliance.Control.Light" + - "Appliance.System.DNDMode" + - "Appliance.Control.Spray" + - "Appliance.GarageDoor.State" + - "Appliance.RollerShutter.State" + - "Appliance.RollerShutter.Position" + key: + name: Key + description: The key used to encrypt message signatures + required: false + advanced: false + selector: + text: + payload: + name: Payload + description: the payload (text/json) to send + required: false + advanced: false + example: '{ "togglex": { "onoff": 0, "channel": 0 } }' + default: "{}" + selector: + text: diff --git a/hacs.json b/hacs.json index f746745b..72050baa 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "Meross LAN", "render_readme": true, "country": ["IT"], - "domains": ["switch", "sensor", "light"], + "domains": ["switch", "sensor", "light", "cover"], "homeassistant": "2020.0.0", "iot_class": "Local Push", "hacs": "1.6.0" diff --git a/tests/const.py b/tests/const.py index 73c31e1d..f78d3caa 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,5 +1,14 @@ """Constants for integration_blueprint tests.""" -from custom_components.meross_lan.const import CONF_DEVICE_ID, CONF_DISCOVERY_PAYLOAD +from custom_components.meross_lan.const import ( + CONF_DEVICE_ID, CONF_DISCOVERY_PAYLOAD, CONF_KEY +) # Mock config data to be used across multiple tests -MOCK_CONFIG = {CONF_DEVICE_ID: "19091821705482908020a8e1e9522906", CONF_DISCOVERY_PAYLOAD: {} } +MOCK_HUB_CONFIG = { + CONF_KEY: "test_key" + } +MOCK_DEVICE_CONFIG = { + CONF_DEVICE_ID: "9109182170548290880048b1a9522933", + CONF_KEY: "test_key", + CONF_DISCOVERY_PAYLOAD: {} + } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index f413d14d..9d2de0b8 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -7,12 +7,14 @@ from custom_components.meross_lan.const import ( DOMAIN, - PLATFORMS, CONF_DEVICE_ID, CONF_DISCOVERY_PAYLOAD ) -from .const import MOCK_CONFIG +from .const import ( + MOCK_HUB_CONFIG, + MOCK_DEVICE_CONFIG +) # This fixture bypasses the actual setup of the integration @@ -35,43 +37,44 @@ def bypass_setup_fixture(): # Note that we use the `bypass_get_data` fixture here because # we want the config flow validation to succeed during the test. async def test_successful_config_flow(hass): - """Test a successful config flow.""" - # Initialize a config flow + + + + #test initial user config-flow (MQTT Hub) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) # Check that the config flow shows the user form as the first step - #assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - #assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "hub" - # If a user were to enter `test_username` for username and `test_password` - # for password, it would result in this function call - #result = await hass.config_entries.flow.async_configure( - # result["flow_id"], user_input=MOCK_CONFIG - #) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_HUB_CONFIG + ) # Check that the config flow is complete and a new entry is created with # the input data assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "MQTT Hub" - #assert result["data"] == MOCK_CONFIG - #assert result["result"] + #assert result["title"] == "MQTT Hub" + assert result["data"] == MOCK_HUB_CONFIG + assert result["result"] + #test discovery config flow result = await hass.config_entries.flow.async_init( - DOMAIN, context = {"source": config_entries.SOURCE_DISCOVERY}, data = MOCK_CONFIG + DOMAIN, context = {"source": config_entries.SOURCE_DISCOVERY}, data = MOCK_DEVICE_CONFIG ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["step_id"] == "device" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG + result["flow_id"], user_input=MOCK_DEVICE_CONFIG ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_CONFIG[CONF_DEVICE_ID] - assert result["data"] == MOCK_CONFIG + assert result["data"] == MOCK_DEVICE_CONFIG """ # In this case, we want to simulate a failure during the config flow. diff --git a/tests/test_init.py b/tests/test_init.py index 91726917..da46f2d7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -6,11 +6,11 @@ from custom_components.meross_lan import ( async_setup_entry, async_unload_entry, - MerossLan + MerossApi ) from custom_components.meross_lan.const import DOMAIN -from .const import MOCK_CONFIG +from .const import MOCK_HUB_CONFIG # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture @@ -21,15 +21,15 @@ async def test_setup_unload_and_reload_entry(hass, bypass_mqtt_subscribe): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_HUB_CONFIG, entry_id="test") # Set up the entry and assert that the values set during setup are where we expect # them to be. Because we have patched the BlueprintDataUpdateCoordinator.async_get_data # call, no code from custom_components/integration_blueprint/api.py actually runs. assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + #assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] assert ( - type(hass.data[DOMAIN]) == MerossLan + type(hass.data[DOMAIN]) == MerossApi ) """ diff --git a/tests/test_switch.py b/tests/test_switch.py index 713bf7c0..7d7a3386 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -8,7 +8,7 @@ #from custom_components.integration_blueprint import async_setup_entry #from custom_components.integration_blueprint.const import DEFAULT_NAME, DOMAIN, SWITCH -from .const import MOCK_CONFIG +from .const import MOCK_DEVICE_CONFIG """ async def test_switch_services(hass):