Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BBSolar Smart Plant Light support, improve device handling for more complex mixins. #414

Open
wants to merge 19 commits into
base: 0.4.X.X
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,6 @@ sniffed_data

# Documentation
docs/_static

# Local configuration for Windows
*.ps1
60 changes: 47 additions & 13 deletions meross_iot/controller/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,27 +232,61 @@ async def update_from_http_state(self, hdevice: HttpDeviceInfo) -> BaseDevice:
# TODO: fire some sort of events to let users see changed data?
return self

async def async_handle_push_notification(self, namespace: Namespace, data: dict) -> bool:
async def async_call_mixin_visitor(self,func,*args,**kwargs):
# Import this within the function scope to prevent circular dependency warnings.
from meross_iot.controller.mixins.utilities import DynamicFilteringMixin
# Call the relevant function for each of the direct base classes. The use of __bases__ instead of __mro__ is intentional;
# if we used MRO, we'd call the function for all potentially-obsoleted mixin parent classes (e.g. calling Toggle when we want ToggleX)
# Calling bases avoids this problems: if the device has a given mixin, it'll be set as a direct parent class and thus get called.
# However, if the device is weird and the mixin inherits from multiple classes (e.g. the BBSolar lights), it'll call the single mixin
# function, which has to deal with it.
returnStatus = None
for clazz in self.__class__.__bases__:
if issubclass(clazz,DynamicFilteringMixin):
try:
visitor = getattr(clazz,func)
mixinStatus = await visitor(self,*args,**kwargs)
# Special case for functions which return bool - Warning, this is profoundly weird
if isinstance(mixinStatus,bool):
if returnStatus == None:
returnStatus = mixinStatus
else:
returnStatus = returnStatus or mixinStatus
_LOGGER.debug(f'Function: {func} called in {clazz} via {visitor}')

except AttributeError as e:
_LOGGER.debug(f'Function: {func} not found in {clazz}: {e}')

return returnStatus

# These functions handle mixins which inherit from one or more other mixins to work correctly. Prior to this change, the async_handle_push_notification
# function would call a parent function, which is supposed to be the next item in the class hierarchy. However, if the class is inherited, it'll
# call the parent mixin, which is bad. Secondly, if the class inherits from more than one mixin, the call to the parent class would have unexpected
# results.
async def async_handle_all_push_notifications(self, namespace: Namespace, data: dict) -> bool:
_LOGGER.debug(f"MerossBaseDevice {self.name} handling notification {namespace}")

# Notify all base classes
retStatus = await self.async_call_mixin_visitor("async_handle_push_notification",namespace,data)
# However, we want to notify any registered event handler
await self._fire_push_notification_event(namespace=namespace, data=data, device_internal_id=self.internal_id)
return False
return retStatus

async def async_handle_update(self, namespace: Namespace, data: dict) -> bool:
async def async_handle_all_updates(self, namespace: Namespace, data: dict) -> bool:
_LOGGER.debug("Handling all updates")
# Catch SYSTEM_ALL case and update the generic device info
if namespace == Namespace.SYSTEM_ALL:
# TODO: we might update name/uuid/other stuff in here...
system = data.get('all', {}).get('system', {})
self._inner_ip = system.get('firmware', {}).get('innerIp')
self._mac_address = system.get('hardware', {}).get('macAddress', None)

# Call the function for all mixins
retStatus = await self.async_call_mixin_visitor("async_handle_update",namespace,data)

await self._fire_push_notification_event(namespace=namespace, data=data, device_internal_id=self.internal_id)
self._last_full_update_ts = time.time() * 1000

# Even though we handle the event, we return False as we did not handle the event in any way
# rather than updating the last_full_update_ts
return False

return retStatus

async def async_update(self,
*args,
Expand All @@ -270,10 +304,11 @@ async def async_update(self,
# device type. For instance, wifi devices use GET System.Appliance.ALL while HUBs use a different one.
# Implementing mixin should never call the super() implementation (as it happens
# with _handle_update) as we want to use only an UPDATE_ALL method.
# Howe ver, we want to keep it within the MerossBaseDevice so that we expose a consistent
# However, we want to keep it within the MerossBaseDevice so that we expose a consistent
# interface.
"""
pass
await self.async_call_mixin_visitor("_async_request_update",*args,**kwargs)


def dismiss(self):
# TODO: Should we do something here?
Expand Down Expand Up @@ -317,12 +352,11 @@ def __repr__(self):
basic_info = f"{self.name} ({self.type}, HW {self.hardware_version}, FW {self.firmware_version}, class: {self.__class__.__name__})"
return basic_info

@staticmethod
def _parse_channels(channel_data: List) -> List[ChannelInfo]:
def _parse_channels(self,channel_data: List) -> List[ChannelInfo]:
res = []
if channel_data is None:
return res

for i, val in enumerate(channel_data):
name = val.get('devName', 'Main channel')
type = val.get('type')
Expand Down
25 changes: 16 additions & 9 deletions meross_iot/controller/mixins/consumption.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
from typing import List, Optional

from meross_iot.model.enums import Namespace
from meross_iot.controller.mixins.utilities import DynamicFilteringMixin

_LOGGER = logging.getLogger(__name__)


_DATE_FORMAT = '%Y-%m-%d'


class ConsumptionXMixin(object):
class ConsumptionMixin(DynamicFilteringMixin):
_execute_command: callable

def __init__(self, device_uuid: str,
manager,
**kwargs):
super().__init__(device_uuid=device_uuid, manager=manager, **kwargs)

@staticmethod
def filter(device_ability : str, device_name : str,**kwargs):
return device_ability == Namespace.CONTROL_CONSUMPTION.value

async def async_get_daily_power_consumption(self,
channel=0,
timeout: Optional[float] = None,
Expand All @@ -31,10 +35,10 @@ async def async_get_daily_power_consumption(self,
"""
# TODO: returning a nice PowerConsumtpionReport object rather than a list of dict?
result = await self._execute_command(method="GET",
namespace=Namespace.CONTROL_CONSUMPTIONX,
namespace=Namespace.CONTROL_CONSUMPTION,
payload={'channel': channel},
timeout=timeout)
data = result.get('consumptionx')
data = result.get('consumption')

# Parse the json data into nice-python native objects
res = [{
Expand All @@ -43,16 +47,19 @@ async def async_get_daily_power_consumption(self,
} for x in data]

return res


class ConsumptionMixin(object):

class ConsumptionXMixin(ConsumptionMixin):
_execute_command: callable

def __init__(self, device_uuid: str,
manager,
**kwargs):
super().__init__(device_uuid=device_uuid, manager=manager, **kwargs)

@staticmethod
def filter(device_ability : str, device_name : str,**kwargs):
return device_ability == Namespace.CONTROL_CONSUMPTIONX.value

async def async_get_daily_power_consumption(self,
channel=0,
timeout: Optional[float] = None,
Expand All @@ -66,10 +73,10 @@ async def async_get_daily_power_consumption(self,
"""
# TODO: returning a nice PowerConsumtpionReport object rather than a list of dict?
result = await self._execute_command(method="GET",
namespace=Namespace.CONTROL_CONSUMPTION,
namespace=Namespace.CONTROL_CONSUMPTIONX,
payload={'channel': channel},
timeout=timeout)
data = result.get('consumption')
data = result.get('consumptionx')

# Parse the json data into nice-python native objects
res = [{
Expand Down
17 changes: 9 additions & 8 deletions meross_iot/controller/mixins/diffuser_light.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import logging
from typing import Optional

from meross_iot.controller.mixins.utilities import DynamicFilteringMixin
from meross_iot.model.enums import Namespace, DiffuserLightMode
from meross_iot.model.typing import RgbTuple
from meross_iot.utilities.conversion import rgb_to_int, int_to_rgb

_LOGGER = logging.getLogger(__name__)


class DiffuserLightMixin(object):
class DiffuserLightMixin(DynamicFilteringMixin):
_execute_command: callable
check_full_update_done: callable

Expand All @@ -19,7 +20,11 @@ def __init__(self, device_uuid: str,

# Dictionary keeping the status for every channel
self._channel_diffuser_light_status = {}


@staticmethod
def filter(device_ability : str, device_name : str,**kwargs):
return device_ability == Namespace.DIFFUSER_LIGHT.value

async def async_handle_push_notification(self, namespace: Namespace, data: dict) -> bool:
locally_handled = False

Expand All @@ -39,10 +44,7 @@ async def async_handle_push_notification(self, namespace: Namespace, data: dict)

locally_handled = True

# Always call the parent handler when done with local specific logic. This gives the opportunity to all
# ancestors to catch all events.
parent_handled = await super().async_handle_push_notification(namespace=namespace, data=data)
return locally_handled or parent_handled
return locally_handled

def get_light_mode(self, channel: int = 0, *args, **kwargs) -> Optional[DiffuserLightMode]:
"""
Expand All @@ -67,8 +69,7 @@ async def async_handle_update(self, namespace: Namespace, data: dict) -> bool:
self._channel_diffuser_light_status[channel] = l
locally_handled = True

super_handled = await super().async_handle_update(namespace=namespace, data=data)
return super_handled or locally_handled
return locally_handled

def get_light_brightness(self, channel: int = 0, *args, **kwargs) -> Optional[int]:
"""
Expand Down
15 changes: 8 additions & 7 deletions meross_iot/controller/mixins/diffuser_spray.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import logging
from typing import Optional

from meross_iot.controller.mixins.utilities import DynamicFilteringMixin
from meross_iot.model.enums import Namespace, DiffuserSprayMode


_LOGGER = logging.getLogger(__name__)


class DiffuserSprayMixin(object):
class DiffuserSprayMixin(DynamicFilteringMixin):
_execute_command: callable
check_full_update_done: callable

Expand All @@ -19,6 +20,10 @@ def __init__(self, device_uuid: str,
# Dictionary keeping the status for every channel
self._channel_diffuser_spray_status = {}

@staticmethod
def filter(device_ability : str, device_name : str,**kwargs):
return device_ability == Namespace.DIFFUSER_SPRAY.value

async def async_handle_push_notification(self, namespace: Namespace, data: dict) -> bool:
locally_handled = False

Expand All @@ -38,10 +43,7 @@ async def async_handle_push_notification(self, namespace: Namespace, data: dict)

locally_handled = True

# Always call the parent handler when done with local specific logic. This gives the opportunity to all
# ancestors to catch all events.
parent_handled = await super().async_handle_push_notification(namespace=namespace, data=data)
return locally_handled or parent_handled
return locally_handled

async def async_handle_update(self, namespace: Namespace, data: dict) -> bool:
_LOGGER.debug(f"Handling {self.__class__.__name__} mixin data update.")
Expand All @@ -53,8 +55,7 @@ async def async_handle_update(self, namespace: Namespace, data: dict) -> bool:
self._channel_diffuser_spray_status[channel] = l
locally_handled = True

super_handled = await super().async_handle_update(namespace=namespace, data=data)
return super_handled or locally_handled
return locally_handled

def get_current_spray_mode(self, channel: int = 0, *args, **kwargs) -> Optional[DiffuserSprayMode]:
"""
Expand Down
6 changes: 5 additions & 1 deletion meross_iot/controller/mixins/dnd.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import logging
from typing import Optional, Dict

from meross_iot.controller.mixins.utilities import DynamicFilteringMixin
from meross_iot.model.enums import Namespace, RollerShutterState, DNDMode

_LOGGER = logging.getLogger(__name__)


class SystemDndMixin:
class SystemDndMixin(DynamicFilteringMixin):
_execute_command: callable
check_full_update_done: callable

@staticmethod
def filter(device_ability : str, device_name : str,**kwargs):
return device_ability == Namespace.SYSTEM_DND_MODE.value
## It looks like the DND mode update/change does not trigger any PUSH notification update.
## This means we won't catch any "DND mode change" via push notifications.

Expand Down
9 changes: 7 additions & 2 deletions meross_iot/controller/mixins/electricity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from datetime import datetime
from typing import Optional

from meross_iot.controller.mixins.utilities import DynamicFilteringMixin
from meross_iot.model.enums import Namespace
from meross_iot.model.plugin.power import PowerInfo

Expand All @@ -11,7 +12,7 @@
_DATE_FORMAT = '%Y-%m-%d'


class ElectricityMixin(object):
class ElectricityMixin(DynamicFilteringMixin):
_execute_command: callable

def __init__(self, device_uuid: str,
Expand All @@ -21,7 +22,11 @@ def __init__(self, device_uuid: str,

# We'll hold a dictionary of lastest samples, one per channel
self.__channel_cached_samples = {}


@staticmethod
def filter(device_ability : str, device_name : str,**kwargs):
return device_ability == Namespace.CONTROL_ELECTRICITY.value

async def async_get_instant_metrics(self,
channel=0,
timeout: Optional[float] = None,
Expand Down
10 changes: 7 additions & 3 deletions meross_iot/controller/mixins/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from Cryptodome.Cipher import AES
from enum import Enum
from typing import Dict

from meross_iot.controller.mixins.utilities import DynamicFilteringMixin
from meross_iot.model.enums import Namespace

_LOGGER = logging.getLogger(__name__)
Expand All @@ -14,7 +14,7 @@ class EncryptionAlg(Enum):
ECDHE256 = 0


class EncryptionSuiteMixin(object):
class EncryptionSuiteMixin(DynamicFilteringMixin):
_execute_command: callable
_DEFAULT_IV="0000000000000000".encode("utf8")
_abilities: Dict[str, dict]
Expand All @@ -29,7 +29,11 @@ def __init__(self, device_uuid: str,
self._encryption_alg = EncryptionAlg.ECDHE256
else:
raise ValueError("Unsupported/undetected encryption method")


@staticmethod
def filter(device_ability : str, device_name : str,**kwargs):
return device_ability == Namespace.SYSTEM_ENCRYPTION.value

def _pad_to_16_bytes(self, data):
block_size = 16
pad_length = block_size - (len(data) % block_size)
Expand Down
Loading