Exemple #1
0
class MerossCloudClient(object):
    _DEFAULT_PORT = 2001

    def __init__(
            self,
            cloud_credentials,  # type: MerossCloudCreds
            push_message_callback=None,  # type: callable
            auto_reconnect=True,  # type: bool
            **kwords):

        self.connection_status = ConnectionStatusManager()
        self._cloud_creds = cloud_credentials
        self._auto_reconnect = auto_reconnect
        self._pending_response_messages = dict()
        self._pending_responses_lock = lock_factory.build_rlock()
        self._push_message_callback = push_message_callback
        self._subscription_count = AtomicCounter(0)

        if "domain" in kwords:
            self._domain = kwords['domain']
        else:
            self._domain = "iot.meross.com"

        # Lookup port and certificate for MQTT server
        self._port = kwords.get('port', MerossCloudClient._DEFAULT_PORT)
        self._ca_cert = kwords.get('ca_cert', None)

        self._generate_client_and_app_id()

        # Password is calculated as the MD5 of USERID concatenated with KEY
        md5_hash = md5()
        clearpwd = "%s%s" % (self._cloud_creds.user_id, self._cloud_creds.key)
        md5_hash.update(clearpwd.encode("utf8"))
        hashed_password = md5_hash.hexdigest()

        # Start the mqtt client
        self._mqtt_client = mqtt.Client(
            client_id=self._client_id, protocol=mqtt.MQTTv311
        )  # ex. app-id -> app:08d4c9f99da40203ebc798a76512ec14
        self._mqtt_client.on_connect = self._on_connect
        self._mqtt_client.on_message = self._on_message
        self._mqtt_client.on_disconnect = self._on_disconnect
        self._mqtt_client.on_subscribe = self._on_subscribe

        # Avoid login if user_id is None
        if self._cloud_creds.user_id is not None:
            self._mqtt_client.username_pw_set(
                username=self._cloud_creds.user_id, password=hashed_password)
        self._mqtt_client.tls_set(ca_certs=self._ca_cert,
                                  certfile=None,
                                  keyfile=None,
                                  cert_reqs=ssl.CERT_REQUIRED,
                                  tls_version=ssl.PROTOCOL_TLS,
                                  ciphers=None)

    def close(self):
        l.info("Closing the MQTT connection...")
        self._mqtt_client.disconnect()
        l.debug("Waiting for the client to disconnect...")
        self.connection_status.wait_for_status(ClientStatus.CONNECTION_DROPPED)

        # Starts a new thread that handles mqtt protocol and calls us back via callbacks
        l.debug("Stopping the MQTT looper.")
        self._mqtt_client.loop_stop(True)

        l.info("Client has been fully disconnected.")

    def connect(self):
        """
        Starts the connection to the MQTT broker
        :return:
        """
        l.info("Initializing the MQTT connection...")
        self._mqtt_client.connect(self._domain, self._port, keepalive=30)
        self.connection_status.update_status(ClientStatus.CONNECTING)

        # Starts a new thread that handles mqtt protocol and calls us back via callbacks
        l.debug("(Re)Starting the MQTT looper.")
        self._mqtt_client.loop_stop(True)
        self._mqtt_client.loop_start()

        l.debug("Waiting for the client to connect...")
        self.connection_status.wait_for_status(ClientStatus.SUBSCRIBED)
        l.info(
            "Client connected to MQTT broker and subscribed to relevant topics."
        )

    # ------------------------------------------------------------------------------------------------
    # MQTT Handlers
    # ------------------------------------------------------------------------------------------------
    def _on_disconnect(self, client, userdata, rc):
        l.info("Disconnection detected. Reason: %s" % str(rc))

        # When the mqtt connection is dropped, we need to reset the subscription counter.
        self._subscription_count = AtomicCounter(0)
        self.connection_status.update_status(ClientStatus.CONNECTION_DROPPED)

        # If the client disconnected explicitly, the mqtt library handles thred stop autonomously
        if rc == mqtt.MQTT_ERR_SUCCESS:
            pass
        else:
            # Otherwise, if the disconnection was not intentional, we probably had a connection drop.
            # In this case, we only stop the loop thread if auto_reconnect is not set. In fact, the loop will
            # handle reconnection autonomously on connection drops.
            if not self._auto_reconnect:
                l.info("Stopping mqtt loop on connection drop")
                client.loop_stop(True)
            else:
                l.warning(
                    "Client has been disconnected, however auto_reconnect flag is set. "
                    "Won't stop the looping thread, as it will retry to connect."
                )

    def _on_unsubscribe(self):
        l.debug("Unsubscribed from topic")
        self._subscription_count.dec()

    def _on_subscribe(self, client, userdata, mid, granted_qos):
        l.debug("Succesfully subscribed to topic. Subscription count: %d" %
                self._subscription_count.get())
        if self._subscription_count.inc() == 2:
            self.connection_status.update_status(ClientStatus.SUBSCRIBED)

    def _on_connect(self, client, userdata, rc, other):
        l.debug("Connected with result code %s" % str(rc))
        self.connection_status.update_status(ClientStatus.CONNECTED)

        self._client_response_topic = "/app/%s-%s/subscribe" % (
            self._cloud_creds.user_id, self._app_id)
        self._user_topic = "/app/%s/subscribe" % self._cloud_creds.user_id

        # Subscribe to the relevant topics
        l.debug("Subscribing to topics...")
        client.subscribe(self._user_topic)
        client.subscribe(self._client_response_topic)

    def _on_message(self, client, userdata, msg):
        """
        This handler is called when a message is received from the MQTT broker, on the subscribed topics.
        The current implementation checks the validity of the message itself, by verifying its signature.

        :param client: is the MQTT client reference, useful to respond back
        :param userdata: metadata about the received data
        :param msg: message that was received
        :return: nothing, it simply handles the message accordingly.
        """
        networkl.debug(msg.topic + " --> " + str(msg.payload))

        try:
            message = json.loads(str(msg.payload, "utf8"))
            header = message['header']

            message_hash = md5()
            strtohash = "%s%s%s" % (header['messageId'], self._cloud_creds.key,
                                    header['timestamp'])
            message_hash.update(strtohash.encode("utf8"))
            expected_signature = message_hash.hexdigest().lower()

            if header['sign'] != expected_signature:
                raise InvalidSignatureException(
                    'The signature did not match!',
                    expected_signature=expected_signature,
                    provided_signature=header['sign'],
                    data=message)

            # Check if there is any thread waiting for this message or if there is a callback that we need to invoke.
            # If so, do it here.
            handle = None
            with self._pending_responses_lock:
                msg_id = header['messageId']
                handle = self._pending_response_messages.get(msg_id)

            from_myself = False
            if handle is not None:
                # There was a handle for this message-id. It means it is a response message to some
                # request performed by the library itself.
                from_myself = True
                try:
                    l.debug("Calling handle event handler for message %s" %
                            msg_id)
                    # Call the handler
                    handle.notify_message_received(error=None,
                                                   response=message)
                    l.debug("Done handler for message %s" % msg_id)

                    # Remove the message from the pending queue
                    with self._pending_responses_lock:
                        del self._pending_response_messages[msg_id]
                except:
                    l.exception(
                        "Error occurred while invoking message handler")

            # Let's also catch all the "PUSH" notifications and dispatch them to the push_notification_callback.
            if self._push_message_callback is not None and header[
                    'method'] == "PUSH" and 'namespace' in header:
                self._push_message_callback(message, from_myself=from_myself)

        except InvalidSignatureException as e:
            l.error(
                "Invalid signature received. Expecting: %s, received %s. Message: %s"
                % (e.expected_signature, e.provided_signature,
                   json.dumps(e.data)))

        except Exception:
            l.exception("Failed to process message.")

    # ------------------------------------------------------------------------------------------------
    # Protocol Handlers
    # ------------------------------------------------------------------------------------------------
    def execute_cmd(self,
                    dst_dev_uuid,
                    method,
                    namespace,
                    payload,
                    callback=None,
                    timeout=SHORT_TIMEOUT):
        # If the underlying mqttclient is not connected, let's fast-fail.
        # Otherwise, if it's connected but not yet subscribed to relevant topics, it's still OK to
        # queue messages: the client will dispatch them as soon it subscribes to the relevant topics.
        if not self._mqtt_client.is_connected():
            l.error("The underlying mqtt client is not connected.")
            raise ConnectionError()

        start = time.time()
        # Build the mqtt message we will send to the broker
        message, message_id = self._build_mqtt_message(method, namespace,
                                                       payload)

        # Register the waiting handler for that message
        handle = PendingMessageResponse(message_id=message_id,
                                        callback=callback)
        with self._pending_responses_lock:
            self._pending_response_messages[message_id] = handle

        # Send the message to the broker
        l.debug("Executing message-id %s, %s on %s command for device %s" %
                (message_id, method, namespace, dst_dev_uuid))

        self._mqtt_client.publish(
            topic=build_client_request_topic(dst_dev_uuid), payload=message)

        # If the caller has specified a callback, we don't need to actively wait for the message ACK. So we can
        # immediately return.
        if callback is not None:
            return None

        # Otherwise, we need to wait until the message is received.
        l.debug("Waiting for response to message-id %s" % message_id)
        success, resp = handle.wait_for_response(timeout=timeout)
        if not success:
            raise CommandTimeoutException(
                "A timeout occurred while waiting for the ACK: %d" % timeout)

        elapsed = time.time() - start

        l.debug("Message-id: %s, command %s-%s command for device %s took %s" %
                (message_id, method, namespace, dst_dev_uuid, str(elapsed)))
        return resp['payload']

    # ------------------------------------------------------------------------------------------------
    # Protocol utilities
    # ------------------------------------------------------------------------------------------------
    def _build_mqtt_message(self, method, namespace, payload):
        """
        Sends a message to the Meross MQTT broker, respecting the protocol payload.
        :param method:
        :param namespace:
        :param payload:
        :return:
        """

        # Generate a random 16 byte string
        randomstring = ''.join(
            random.SystemRandom().choice(string.ascii_uppercase +
                                         string.digits) for _ in range(16))

        # Hash it as md5
        md5_hash = md5()
        md5_hash.update(randomstring.encode('utf8'))
        messageId = md5_hash.hexdigest().lower()
        timestamp = int(round(time.time()))

        # Hash the messageId, the key and the timestamp
        md5_hash = md5()
        strtohash = "%s%s%s" % (messageId, self._cloud_creds.key, timestamp)
        md5_hash.update(strtohash.encode("utf8"))
        signature = md5_hash.hexdigest().lower()

        data = {
            "header": {
                "from": self._client_response_topic,
                "messageId":
                messageId,  # Example: "122e3e47835fefcd8aaf22d13ce21859"
                "method": method,  # Example: "GET",
                "namespace": namespace,  # Example: "Appliance.System.All",
                "payloadVersion": 1,
                "sign":
                signature,  # Example: "b4236ac6fb399e70c3d61e98fcb68b74",
                "timestamp": timestamp,
                "triggerSrc": "Android"
            },
            "payload": payload
        }
        strdata = json.dumps(data)
        return strdata.encode("utf-8"), messageId

    def _generate_client_and_app_id(self):
        md5_hash = md5()
        rnd_uuid = UUID.uuid4()
        md5_hash.update(("%s%s" % ("API", rnd_uuid)).encode("utf8"))
        self._app_id = md5_hash.hexdigest()
        self._client_id = 'app:%s' % md5_hash.hexdigest()
Exemple #2
0
class GenericPlug:
    _status_lock = None
    _client_status = None

    _token = None
    _key = None
    _user_id = None
    _domain = None
    _port = 2001
    _channels = []

    _uuid = None
    _client_id = None
    _app_id = None

    # Device info
    _name = None
    _type = None
    _hwversion = None
    _fwversion = None

    # Topic name where the client should publish to its commands. Every client should have a dedicated one.
    _client_request_topic = None

    # Topic name in which the client retrieves its own responses from server.
    _client_response_topic = None

    # Topic where important notifications are pushed (needed if any other client is dealing with the same device)
    _user_topic = None

    # Paho mqtt client object
    _mqtt_client = None

    # Waiting condition used to wait for command ACKs
    _waiting_message_ack_queue = None
    _waiting_subscribers_queue = None
    _waiting_message_id = None

    _ack_response = None

    # Block for at most 10 seconds.
    _command_timeout = 10

    _error = None

    # Cached list of abilities
    _abilities = None

    # Dictionary {channel->status}
    _state = None

    def __init__(self, token, key, user_id, **kwords):

        self._status_lock = RLock()

        self._waiting_message_ack_queue = Condition()
        self._waiting_subscribers_queue = Condition()
        self._subscription_count = AtomicCounter(0)

        self._set_status(ClientStatus.INITIALIZED)

        self._token = token,
        self._key = key
        self._user_id = user_id
        self._uuid = kwords['uuid']
        if "domain" in kwords:
            self._domain = kwords['domain']
        else:
            self._domain = "eu-iot.meross.com"
        if "channels" in kwords:
            self._channels = kwords['channels']

        # Informations about device
        if "devName" in kwords:
            self._name = kwords['devName']
        if "deviceType" in kwords:
            self._type = kwords['deviceType']
        if "fmwareVersion" in kwords:
            self._fwversion = kwords['fmwareVersion']
        if "hdwareVersion" in kwords:
            self._hwversion = kwords['hdwareVersion']

        # Lookup port and certificate for MQTT server
        self._port = kwords.get('port', GenericPlug._port)
        self._ca_cert = kwords.get('ca_cert', None)

        self._generate_client_and_app_id()

        # Password is calculated as the MD5 of USERID concatenated with KEY
        md5_hash = md5()
        clearpwd = "%s%s" % (self._user_id, self._key)
        md5_hash.update(clearpwd.encode("utf8"))
        hashed_password = md5_hash.hexdigest()

        # Start the mqtt client
        self._mqtt_client = mqtt.Client(
            client_id=self._client_id, protocol=mqtt.MQTTv311
        )  # ex. app-id -> app:08d4c9f99da40203ebc798a76512ec14
        self._mqtt_client.on_connect = self._on_connect
        self._mqtt_client.on_message = self._on_message
        self._mqtt_client.on_disconnect = self._on_disconnect
        self._mqtt_client.on_subscribe = self._on_subscribe
        self._mqtt_client.on_log = self._on_log
        # Avoid login if user_id is None
        if self._user_id is not None:
            self._mqtt_client.username_pw_set(username=self._user_id,
                                              password=hashed_password)
        self._mqtt_client.tls_set(ca_certs=self._ca_cert,
                                  certfile=None,
                                  keyfile=None,
                                  cert_reqs=ssl.CERT_REQUIRED,
                                  tls_version=ssl.PROTOCOL_TLS,
                                  ciphers=None)

        self._mqtt_client.connect(self._domain, self._port, keepalive=30)
        self._set_status(ClientStatus.CONNECTING)

        # Starts a new thread that handles mqtt protocol and calls us back via callbacks
        self._mqtt_client.loop_start()

        with self._waiting_subscribers_queue:
            self._waiting_subscribers_queue.wait()
            if self._client_status != ClientStatus.SUBSCRIBED:
                # An error has occurred
                raise Exception(self._error)

        self.get_status()

    # Private methods used by the base class in order to handle basic protocol communication
    # --------------------------------------------------------------------------------------
    def _on_disconnect(self, client, userdata, rc):
        l.info("Disconnection detected. Reason: %s" % str(rc))

        # We should clean all the data structures.
        with self._status_lock:
            self._subscription_count = AtomicCounter(0)
            self._error = "Connection dropped by the server"
            self._set_status(ClientStatus.CONNECTION_DROPPED)

        with self._waiting_subscribers_queue:
            self._waiting_subscribers_queue.notify_all()

        with self._waiting_message_ack_queue:
            self._waiting_message_ack_queue.notify_all()

        if rc == mqtt.MQTT_ERR_SUCCESS:
            pass
        else:
            # TODO: Should we reconnect by calling again the client.loop_start() ?
            client.loop_stop()

    def _on_unsubscribe(self):
        l.debug("Unsubscribed from topic")
        self._subscription_count.dec()

    def _on_subscribe(self, client, userdata, mid, granted_qos):
        l.debug("Succesfully subscribed!")
        if self._subscription_count.inc() == 2:
            with self._waiting_subscribers_queue:
                self._set_status(ClientStatus.SUBSCRIBED)
                self._waiting_subscribers_queue.notify_all()

    def _on_connect(self, client, userdata, rc, other):
        l.debug("Connected with result code %s" % str(rc))
        self._set_status(ClientStatus.SUBSCRIBED)

        self._set_status(ClientStatus.CONNECTED)

        self._client_request_topic = "/appliance/%s/subscribe" % self._uuid
        self._client_response_topic = "/app/%s-%s/subscribe" % (self._user_id,
                                                                self._app_id)
        self._user_topic = "/app/%s/subscribe" % self._user_id

        # Subscribe to the relevant topics
        l.debug("Subscribing to topics...")
        client.subscribe(self._user_topic)
        client.subscribe(self._client_response_topic)

    # The callback for when a PUBLISH message is received from the server.
    # --------------------------------------------------------------------
    def _on_message(self, client, userdata, msg):
        l.debug(msg.topic + " --> " + str(msg.payload))

        try:
            message = json.loads(str(msg.payload, "utf8"))
            header = message['header']

            message_hash = md5()
            strtohash = "%s%s%s" % (header['messageId'], self._key,
                                    header['timestamp'])
            message_hash.update(strtohash.encode("utf8"))
            expected_signature = message_hash.hexdigest().lower()

            if (header['sign'] != expected_signature):
                raise MQTTException('The signature did not match!')

            # If the message is the RESP for some previous action, process return the control to the "stopped" method.
            if header['messageId'] == self._waiting_message_id:
                with self._waiting_message_ack_queue:
                    self._ack_response = message
                    self._waiting_message_ack_queue.notify()

            # Otherwise process it accordingly
            if self._message_from_self(message):
                if header['method'] == "PUSH" and 'namespace' in header:
                    self._handle_namespace_payload(header['namespace'],
                                                   message['payload'])
                else:
                    l.debug("UNKNOWN msg received by %s" % self._uuid)
            else:
                # do nothing because the message was from a different device
                pass
        except Exception as e:
            l.error("%s failed to process message because: %s" %
                    (self._uuid, e))

    def _on_log(self, client, userdata, level, buf):
        # print("Data: %s - Buff: %s" % (userdata, buf))
        pass

    def _generate_client_and_app_id(self):
        md5_hash = md5()
        md5_hash.update(("%s%s" % ("API", self._uuid)).encode("utf8"))
        self._app_id = md5_hash.hexdigest()
        self._client_id = 'app:%s' % md5_hash.hexdigest()

    def _mqtt_message(self, method, namespace, payload):
        # Generate a random 16 byte string
        randomstring = ''.join(
            random.SystemRandom().choice(string.ascii_uppercase +
                                         string.digits) for _ in range(16))

        # Hash it as md5
        md5_hash = md5()
        md5_hash.update(randomstring.encode('utf8'))
        messageId = md5_hash.hexdigest().lower()
        timestamp = int(round(time.time()))

        # Hash the messageId, the key and the timestamp
        md5_hash = md5()
        strtohash = "%s%s%s" % (messageId, self._key, timestamp)
        md5_hash.update(strtohash.encode("utf8"))
        signature = md5_hash.hexdigest().lower()

        data = {
            "header": {
                "from": self._client_response_topic,
                "messageId":
                messageId,  # Example: "122e3e47835fefcd8aaf22d13ce21859"
                "method": method,  # Example: "GET",
                "namespace": namespace,  # Example: "Appliance.System.All",
                "payloadVersion": 1,
                "sign":
                signature,  # Example: "b4236ac6fb399e70c3d61e98fcb68b74",
                "timestamp": timestamp
            },
            "payload": payload
        }
        strdata = json.dumps(data)
        l.debug("--> %s" % strdata)
        self._mqtt_client.publish(topic=self._client_request_topic,
                                  payload=strdata.encode("utf-8"))
        return messageId

    def _wait_for_status(self, status):
        ok = False
        while not ok:
            if not self._status_lock.acquire(True, self._command_timeout):
                raise TimeoutError()
            ok = status == self._client_status
            self._status_lock.release()

    def _set_status(self, status):
        with self._status_lock:
            self._client_status = status

    def _execute_cmd(self, method, namespace, payload, timeout=SHORT_TIMEOUT):
        # Before executing any command, we need to be subscribed to the MQTT topics where to listen for ACKS.
        with self._waiting_subscribers_queue:
            while self._client_status != ClientStatus.SUBSCRIBED:
                self._waiting_subscribers_queue.wait()

            # Execute the command and retrieve the message-id
            self._waiting_message_id = self._mqtt_message(
                method, namespace, payload)

            # Wait synchronously until we get the ACK.
            with self._waiting_message_ack_queue:
                if not self._waiting_message_ack_queue.wait(timeout=timeout):
                    # Timeout expired.
                    raise CommandTimeoutException()

            return self._ack_response['payload']

    def _message_from_self(self, message):
        try:
            return 'from' in message['header'] and message['header'][
                'from'].split('/')[2] == self._uuid
        except:
            return False

    def _get_consumptionx(self):
        return self._execute_cmd("GET", CONSUMPTIONX, {})

    def _get_electricity(self):
        return self._execute_cmd("GET", ELECTRICITY, {})

    def _toggle(self, status):
        payload = {"channel": 0, "toggle": {"onoff": status}}
        return self._execute_cmd("SET", TOGGLE, payload)

    def _togglex(self, channel, status):
        payload = {'togglex': {"onoff": status, "channel": channel}}
        return self._execute_cmd("SET", TOGGLEX, payload)

    def _channel_control_impl(self, channel, status):
        if TOGGLE in self.get_abilities():
            return self._toggle(status)
        elif TOGGLEX in self.get_abilities():
            return self._togglex(channel, status)
        else:
            raise Exception(
                "The current device does not support neither TOGGLE nor TOGGLEX."
            )

    def _handle_namespace_payload(self, namespace, payload):
        with self._status_lock:
            if namespace == TOGGLE:
                self._state[0] = payload['toggle']['onoff'] == 1

            elif namespace == TOGGLEX:
                if isinstance(payload['togglex'], list):
                    for c in payload['togglex']:
                        channel_index = c['channel']
                        self._state[channel_index] = c['onoff'] == 1
                elif isinstance(payload['togglex'], dict):
                    channel_index = payload['togglex']['channel']
                    self._state[channel_index] = payload['togglex'][
                        'onoff'] == 1
            else:
                raise Exception("Unknown/Unsupported namespace/command: %s" %
                                namespace)

    def _get_status_impl(self):
        res = {}
        data = self.get_sys_data()['all']
        if 'digest' in data:
            for c in data['digest']['togglex']:
                res[c['channel']] = c['onoff'] == 1
        elif 'control' in data:
            res[0] = data['control']['toggle']['onoff'] == 1
        return res

    def _get_channel_id(self, channel):
        # Otherwise, if the passed channel looks like the channel spec, lookup its array indexindex
        if channel in self._channels:
            return self._channels.index(channel)

        # if a channel name is given, lookup the channel id from the name
        if isinstance(channel, str):
            for i, c in enumerate(self.get_channels()):
                if c['devName'] == channel:
                    return c['channel']

        # If an integer is given assume that is the channel ID
        elif isinstance(channel, int):
            return channel

        # In other cases return an error
        raise Exception("Invalid channel specified.")

    def __str__(self):
        basic_info = "%s (%s, %d channels, HW %s, FW %s): " % (
            self._name, self._type, len(
                self._channels), self._hwversion, self._fwversion)

        for i, c in enumerate(self._channels):
            channel_type = c[
                'type'] if 'type' in c else "Master" if c == {} else "Unknown"
            channel_state = "On" if self.get_status(i) else "Off"
            channel_desc = "%s=%s" % (channel_type, channel_state)
            basic_info += channel_desc + ", "

        return basic_info

    def supports_consumption_reading(self):
        return CONSUMPTIONX in self.get_abilities()

    def supports_electricity_reading(self):
        return ELECTRICITY in self.get_abilities()

    def get_power_consumption(self):
        if CONSUMPTIONX in self.get_abilities():
            return self._get_consumptionx()
        else:
            # Not supported!
            return None

    def get_electricity(self):
        if ELECTRICITY in self.get_abilities():
            return self._get_electricity()
        else:
            # Not supported!
            return None

    def device_id(self):
        return self._uuid

    def get_sys_data(self):
        return self._execute_cmd("GET", "Appliance.System.All", {})

    def get_channels(self):
        return self._channels

    def get_wifi_list(self):
        return self._execute_cmd("GET",
                                 "Appliance.Config.WifiList", {},
                                 timeout=LONG_TIMEOUT)

    def get_trace(self):
        return self._execute_cmd("GET", "Appliance.Config.Trace", {})

    def get_debug(self):
        return self._execute_cmd("GET", "Appliance.System.Debug", {})

    def get_abilities(self):
        # TODO: Make this cached value expire after a bit...
        if self._abilities is None:
            self._abilities = self._execute_cmd("GET",
                                                "Appliance.System.Ability",
                                                {})['ability']
        return self._abilities

    def get_report(self):
        return self._execute_cmd("GET", "Appliance.System.Report", {})

    def get_channel_status(self, channel):
        c = self._get_channel_id(channel)
        return self.get_status(c)

    def turn_on_channel(self, channel):
        c = self._get_channel_id(channel)
        return self._channel_control_impl(c, 1)

    def turn_off_channel(self, channel):
        c = self._get_channel_id(channel)
        return self._channel_control_impl(c, 0)

    def turn_on(self, channel=0):
        c = self._get_channel_id(channel)
        return self._channel_control_impl(c, 1)

    def turn_off(self, channel=0):
        c = self._get_channel_id(channel)
        return self._channel_control_impl(c, 0)

    def get_status(self, channel=0):
        # In order to optimize the network traffic, we don't call the get_status() api at every request.
        # On the contrary, we only call it the first time. Then, the rest of the API will silently listen
        # for state changes and will automatically update the self._state structure listening for
        # messages of the device.
        # Such approach, however, has a side effect. If we call TOGGLE/TOGGLEX and immediately after we call
        # get_status(), the reported status will be still the old one. This is a race condition because the
        # "status" RESPONSE will be delivered some time after the TOGGLE REQUEST. It's not a big issue for now,
        # and synchronizing the two things would be inefficient and probably not very useful.
        # Just remember to wait some time before testing the status of the item after a toggle.
        with self._status_lock:
            c = self._get_channel_id(channel)
            if self._state is None:
                self._state = self._get_status_impl()
            return self._state[c]

    def get_usb_channel_index(self):
        # Look for the usb channel
        for i, c in enumerate(self.get_channels()):
            if 'type' in c and c['type'] == 'USB':
                return i
        return None

    def enable_usb(self):
        c = self.get_usb_channel_index()
        if c is None:
            return
        else:
            return self.turn_on_channel(c)

    def disable_usb(self):
        c = self.get_usb_channel_index()
        if c is None:
            return
        else:
            return self.turn_off_channel(c)

    def get_usb_status(self):
        c = self.get_usb_channel_index()
        if c is None:
            return
        else:
            return self.get_channel_status(c)