├── .github └── workflows │ └── test.yml ├── .gitignore ├── .python-version ├── LICENSE ├── MANIFEST.in ├── README.rst ├── examples └── basic.py ├── losantmqtt ├── RootCA.crt ├── __init__.py └── device.py ├── setup.py └── tests ├── __init__.py └── losantmqtt_tests.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: ['opened', 'reopened', 'synchronize'] 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | matrix: 14 | python: [ '3.11', '3.10', '3.9', '3.8' ] 15 | runs-on: 'ubuntu-latest' 16 | permissions: 17 | actions: write 18 | contents: read 19 | pull-requests: write 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python }} 27 | - name: Install deps 28 | run: pip install --upgrade --force-reinstall pip 29 | - name: Run tests 30 | run: python setup.py test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | *.egg-info/ 4 | *.egg 5 | *.eggs 6 | *.py[cod] 7 | __pycache__/ 8 | *.so 9 | .DS_Store 10 | *~ 11 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Losant IoT Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include losantmqtt/RootCA.crt 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Losant Python MQTT Client 2 | ========================= 3 | 4 | |test-badge|_ |pypi-badge|_ 5 | 6 | .. |test-badge| image:: https://github.com/Losant/losant-mqtt-python/actions/workflows/test.yml/badge.svg?branch=master 7 | .. _test-badge: https://github.com/Losant/losant-mqtt-python/actions/workflows/test.yml 8 | 9 | .. |pypi-badge| image:: https://badge.fury.io/py/losant-mqtt.svg 10 | .. _pypi-badge: https://badge.fury.io/py/losant-mqtt 11 | 12 | The `Losant `_ MQTT client provides a simple way for 13 | custom things to communicate with the Losant platform over MQTT. You can authenticate 14 | as a device, publish device state, and listen for device commands. 15 | 16 | This client works with both Python 2.7 and 3. It uses the 17 | `Paho MQTT Client `_ under the 18 | covers for the actual MQTT communication. 19 | 20 | Installation 21 | ------------ 22 | 23 | The latest stable version is available in the Python Package Index (PyPi) 24 | and can be installed using 25 | 26 | :: 27 | 28 | pip install losant-mqtt 29 | 30 | 31 | Example 32 | ------- 33 | 34 | Below is a high-level example of using the Losant Python MQTT client to send the value 35 | of a temperature sensor to the Losant platform. 36 | 37 | :: 38 | 39 | import time 40 | from losantmqtt import Device 41 | 42 | # Construct device 43 | device = Device("my-device-id", "my-app-access-key", "my-app-access-secret") 44 | 45 | def on_command(device, command): 46 | print("Command received.") 47 | print(command["name"]) 48 | print(command["payload"]) 49 | 50 | # Listen for commands. 51 | device.add_event_observer("command", on_command) 52 | 53 | # Connect to Losant. 54 | device.connect(blocking=False) 55 | 56 | # Send temperature once every second. 57 | while True: 58 | device.loop() 59 | if device.is_connected(): 60 | temp = call_out_to_your_sensor_here() 61 | device.send_state({"temperature": temp}) 62 | time.sleep(1) 63 | 64 | 65 | API Documentation 66 | ----------------- 67 | 68 | * `Device`_ 69 | * `constructor`_ 70 | * `connect`_ 71 | * `is_connected`_ 72 | * `close`_ 73 | * `send_state`_ 74 | * `loop`_ 75 | * `add_event_observer`_ 76 | * `remove_event_observer`_ 77 | 78 | Device 79 | ****** 80 | 81 | A device represents a single thing or widget that you'd like to connect to the Losant platform. 82 | A single device can contain many different sensors or other attached peripherals. 83 | Devices can either report state or respond to commands. 84 | 85 | A device's state represents a snapshot of the device at some point in time. 86 | If the device has a temperature sensor, it might report state every few seconds 87 | with the temperature. If a device has a button, it might only report state when 88 | the button is pressed. Devices can report state as often as needed by your specific application. 89 | 90 | Commands instruct a device to take a specific action. Commands are defined as a 91 | name and an optional payload. For example, if the device is a scrolling marquee, 92 | the command might be "update text" and the payload would include the text to update. 93 | 94 | constructor 95 | ``````````` 96 | 97 | :: 98 | 99 | Device(device_id, key, secret, secure=True, transport="tcp") 100 | 101 | The ``Device()`` constructor takes the following arguments: 102 | 103 | device_id 104 | The device's ID. Obtained by first registering a device using the Losant platform. 105 | 106 | key 107 | The Losant access key. 108 | 109 | secret 110 | The Losant access secret. 111 | 112 | secure 113 | If the client should connect to Losant over SSL - default is true. 114 | 115 | transport 116 | Allowed values are "tcp" and "websockets". Defaults to "tcp", which is a raw TCP connection over 117 | ports 1883 (insecure) or 8883 (secure). When "websockets" is passed in, connects using MQTT over 118 | WebSockets, which uses either port 80 (insecure) or port 443 (secure). 119 | 120 | Example 121 | ....... 122 | 123 | :: 124 | 125 | from losantmqtt import Device 126 | 127 | device = Device("my-device-id", "my-app-access-key", "my-app-access-secret") 128 | 129 | connect 130 | ``````` 131 | 132 | :: 133 | 134 | connect(blocking=True) 135 | 136 | Connects the device to the Losant platform. Hook the connect event to know when a connection 137 | has been successfully established. Connect takes the following arguments: 138 | 139 | blocking 140 | If the connect method should block or not. True is the default, which means that the connect 141 | call will be a blocking call that will not return until the connection is closed or an error 142 | occurs - all interaction has to be done through the various event callbacks. If blocking is 143 | set to False, the function will only block until the connection is kicked off - after that point 144 | you must run the network loop yourself, by calling the `loop`_ method periodically. 145 | 146 | is_connected 147 | ```````````` 148 | 149 | :: 150 | 151 | is_connected() 152 | 153 | Returns a boolean indicating whether or not the device is currently connected 154 | to the Losant platform. 155 | 156 | close 157 | ````` 158 | 159 | :: 160 | 161 | close() 162 | 163 | Closes the device's connection to the Losant platform. 164 | 165 | send_state 166 | `````````` 167 | 168 | :: 169 | 170 | send_state(state, time_like=None) 171 | 172 | Sends a device state to the Losant platform. In many scenarios, device states will 173 | change rapidly. For example a GPS device will report GPS coordinates once a second or 174 | more. Because of this, sendState is typically the most invoked function. Any state 175 | data sent to Losant is stored and made available in data visualization tools 176 | and workflow triggers. 177 | 178 | state 179 | The state to send as a Dict. 180 | 181 | time_like 182 | When the state occurred - if None or not set, will default to now. 183 | 184 | Example 185 | ....... 186 | 187 | :: 188 | 189 | device.send_state({ "voltage": read_analog_in() }) 190 | 191 | loop 192 | ````` 193 | 194 | :: 195 | 196 | loop(timeout=1) 197 | 198 | Loops the network stack for the connection. Only valid to call when connected in non-blocking mode. 199 | Be sure to call this reasonably frequently when in that model to make sure the underlying 200 | MQTT connection does not get timed out. 201 | 202 | timeout 203 | Max time to block on the socket before continuing - defaults to 1 second. 204 | 205 | add_event_observer 206 | `````````````````` 207 | 208 | :: 209 | 210 | add_event_observer(event_name, observer) 211 | 212 | Adds an observer to listen for an event on this device. 213 | 214 | event_name 215 | The event to listen for. Possible events are: "connect" (the device has connected), 216 | "reconnect" (the device lost its connection and reconnected), 217 | "close" (the device's connection was closed), and 218 | "command" (the device has received a command from Losant). 219 | 220 | observer 221 | Callback method to call when the given event fires. The first argument for all callbacks 222 | will be the device instance. Command callbacks have a second argument - the command 223 | received. 224 | 225 | 226 | Example 227 | ....... 228 | 229 | :: 230 | 231 | def on_command(device, cmd): 232 | print(cmd["time"]) # time of the command 233 | print(cmd["name"]) # name of the command 234 | print(cmd["payload"]) # payload of the command 235 | 236 | device.add_event_observer("command", on_command) 237 | 238 | remove_event_observer 239 | ````````````````````` 240 | 241 | :: 242 | 243 | remove_event_observer(event_name, observer) 244 | 245 | Removes an observer from listening for an event on this device. 246 | 247 | event_name 248 | The event to stop listening for. Same events as `add_event_observer`_. 249 | 250 | observer 251 | Callback method to remove. 252 | 253 | 254 | Copyright (c) 2025 Losant IoT, Inc 255 | 256 | https://www.losant.com 257 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2024 Losant IoT, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import time 26 | import random 27 | from losantmqtt import Device 28 | 29 | # Construct device 30 | device = Device("my-device-id", "my-app-access-key", "my-app-access-secret") 31 | 32 | def on_command(my_device, command): 33 | print("Command received.") 34 | print(command["name"]) 35 | print(command["payload"]) 36 | 37 | # Listen for commands. 38 | device.add_event_observer("command", on_command) 39 | 40 | # Connect to Losant. 41 | device.connect(blocking=False) 42 | 43 | # Send temperature once every second. 44 | while True: 45 | device.loop() 46 | if device.is_connected(): 47 | # Call out to your sensor here 48 | temp = random.random() * 100 49 | device.send_state({"temperature": temp}) 50 | time.sleep(1) 51 | 52 | -------------------------------------------------------------------------------- /losantmqtt/RootCA.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh 3 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 4 | d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH 5 | MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT 6 | MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j 7 | b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG 8 | 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI 9 | 2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx 10 | 1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ 11 | q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz 12 | tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ 13 | vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP 14 | BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV 15 | 5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY 16 | 1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 17 | NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG 18 | Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 19 | 8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe 20 | pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl 21 | MrY= 22 | -----END CERTIFICATE----- 23 | -----BEGIN CERTIFICATE----- 24 | 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 53 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /losantmqtt/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2024 Losant IoT, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | from .device import Device 26 | -------------------------------------------------------------------------------- /losantmqtt/device.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2024 Losant IoT, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | """ 26 | Losant MQTT Device module 27 | 28 | Contains the Device class, used for connecting a 29 | device to the Losant platform. 30 | """ 31 | 32 | import time 33 | import datetime 34 | import calendar 35 | import json 36 | import logging 37 | import pkg_resources 38 | import socket 39 | # pylint: disable=E0401 40 | from paho.mqtt import client as mqtt 41 | 42 | LOGGER = logging.getLogger(__name__) 43 | ROOT_CA_PATH = pkg_resources.resource_filename(__name__, "RootCA.crt") 44 | 45 | class UtcTzinfo(datetime.tzinfo): 46 | """UTC tzinfo from https://docs.python.org/2.7/library/datetime.html#datetime.tzinfo""" 47 | ZERO = datetime.timedelta(0) 48 | 49 | def utcoffset(self, dt): 50 | return UTC.ZERO 51 | 52 | def tzname(self, dt): 53 | return "UTC" 54 | 55 | def dst(self, dt): 56 | return UTC.ZERO 57 | UTC = UtcTzinfo() 58 | 59 | def ext_json_decode(dct): 60 | """Deals with $date and $undefined extended json fields. 61 | Originally from https://github.com/mongodb/mongo-python-driver/blob/master/bson/json_util.py 62 | """ 63 | # pylint: disable=R0912 64 | if "$date" in dct: 65 | dtm = dct["$date"] 66 | # Parse offset 67 | if dtm[-1] == "Z": 68 | dstr = dtm[:-1] 69 | offset = "Z" 70 | elif dtm[-3] == ":": 71 | # (+|-)HH:MM 72 | dstr = dtm[:-6] 73 | offset = dtm[-6:] 74 | elif dtm[-5] in ("+", "-"): 75 | # (+|-)HHMM 76 | dstr = dtm[:-5] 77 | offset = dtm[-5:] 78 | elif dtm[-3] in ("+", "-"): 79 | # (+|-)HH 80 | dstr = dtm[:-3] 81 | offset = dtm[-3:] 82 | else: 83 | dstr = dtm 84 | offset = "" 85 | 86 | aware = datetime.datetime.strptime(dstr, "%Y-%m-%dT%H:%M:%S.%f").replace(tzinfo=UTC) 87 | 88 | if not offset or offset == "Z": 89 | # UTC 90 | return aware 91 | else: 92 | if len(offset) == 6: 93 | hours, minutes = offset[1:].split(":") 94 | secs = (int(hours) * 3600 + int(minutes) * 60) 95 | elif len(offset) == 5: 96 | secs = (int(offset[1:3]) * 3600 + int(offset[3:]) * 60) 97 | elif len(offset) == 3: 98 | secs = int(offset[1:3]) * 3600 99 | if offset[0] == "-": 100 | secs *= -1 101 | return aware - datetime.timedelta(seconds=secs) 102 | if "$undefined" in dct: 103 | return None 104 | return dct 105 | 106 | class Device(object): 107 | """ 108 | Losant MQTT Device class 109 | 110 | Used to communicate as a particular device over MQTT to Losant 111 | and report device state and receive commands. 112 | """ 113 | 114 | mqtt_endpoint = "broker.losant.com" 115 | 116 | def __init__(self, device_id, key, secret, secure=True, transport="tcp"): 117 | self._device_id = device_id 118 | self._key = key 119 | self._secret = secret 120 | self._secure = secure 121 | self._transport = transport 122 | 123 | self._mqtt_client = None 124 | self._observers = {} 125 | self._initial_connect = False 126 | self._looping = False 127 | 128 | def add_event_observer(self, event_name, observer): 129 | """ Add an observer callback to an event. 130 | 131 | Available events are: "connect", "reconnect", "close", and "command". 132 | """ 133 | if event_name in self._observers: 134 | self._observers[event_name].append(observer) 135 | else: 136 | self._observers[event_name] = [observer] 137 | 138 | def remove_event_observer(self, event_name, observer): 139 | """ Remove an observer callback from an event.""" 140 | if event_name in self._observers: 141 | self._observers[event_name].remove(observer) 142 | 143 | def is_connected(self): 144 | """ Returns if the client is currently connected to Losant """ 145 | # pylint: disable=W0212 146 | return self._mqtt_client and self._mqtt_client._state == mqtt.mqtt_cs_connected 147 | 148 | def connect(self, blocking=True): 149 | """ Attempts to establish a connection to Losant. 150 | 151 | Will be blocking or non-blocking depending on the value of 152 | the 'blocking' argument. When non-blocking, the 'loop' function 153 | must be called to perform network activity. 154 | """ 155 | if self._mqtt_client: 156 | return 157 | 158 | self._looping = blocking 159 | self._initial_connect = True 160 | self._mqtt_client = mqtt.Client(self._device_id, transport=self._transport) 161 | self._mqtt_client.username_pw_set(self._key, self._secret) 162 | 163 | port = 80 if self._transport == "websockets" else 1883 164 | if self._secure: 165 | self._mqtt_client.tls_set(ROOT_CA_PATH) 166 | port = 443 if self._transport == "websockets" else 8883 167 | 168 | LOGGER.debug("Connecting to Losant as %s", self._device_id) 169 | self._mqtt_client.on_connect = self._cb_client_connect 170 | self._mqtt_client.on_disconnect = self._cb_client_disconnect 171 | self._mqtt_client.message_callback_add(self._command_topic(), self._cb_client_command) 172 | self._mqtt_client.connect(Device.mqtt_endpoint, port, 15) 173 | if self._looping: 174 | self._mqtt_client.loop_forever() 175 | 176 | def loop(self, timeout=1): 177 | """ Performs network activity when connected in non blocking mode """ 178 | if self._looping: 179 | raise Exception("Connection in blocking mode, don't call loop") 180 | 181 | if self._mqtt_client: 182 | result = self._mqtt_client.loop(timeout) 183 | if result != mqtt.MQTT_ERR_SUCCESS: 184 | LOGGER.debug("Attempting another reconnect for %s...", self._device_id) 185 | self._wrapped_reconnect() 186 | 187 | 188 | def close(self): 189 | """ Closes the connection to Losant """ 190 | if self._mqtt_client: 191 | self._mqtt_client.disconnect() 192 | 193 | def send_state(self, state, time_like=None): 194 | """ Reports the given state to Losant for this device """ 195 | LOGGER.debug("Sending state for %s", self._device_id) 196 | if not self._mqtt_client: 197 | return False 198 | 199 | if isinstance(time_like, datetime.datetime): 200 | # getting utc tuple, and so use timegm 201 | seconds = calendar.timegm(time_like.utctimetuple()) 202 | millis = time_like.microsecond / 1000 203 | time_like = int(seconds * 1000 + millis) 204 | if isinstance(time_like, time.struct_time): 205 | # don't know the timezone, assume it is local and use mktime 206 | time_like = int(time.mktime(time_like) * 1000) 207 | if not time_like: 208 | time_like = int(time.time() * 1000) 209 | 210 | payload = json.dumps({"time": time_like, "data": state}, sort_keys=True) 211 | result = self._mqtt_client.publish(self._state_topic(), payload) 212 | 213 | return mqtt.MQTT_ERR_SUCCESS == result 214 | 215 | 216 | # ============================================================ 217 | # Private functions 218 | # ============================================================ 219 | 220 | def _command_topic(self): 221 | return "losant/{0}/command".format(self._device_id) 222 | 223 | def _state_topic(self): 224 | return "losant/{0}/state".format(self._device_id) 225 | 226 | def _fire_event(self, event_name, data=None): 227 | if not event_name in self._observers: 228 | return 229 | for observer in self._observers[event_name]: 230 | if data is None: 231 | observer(self) 232 | else: 233 | observer(self, data) 234 | 235 | def _cb_client_connect(self, client, userdata, flags, response_code): 236 | if response_code == 0: 237 | self._mqtt_client.subscribe(self._command_topic()) 238 | if self._initial_connect: 239 | self._initial_connect = False 240 | LOGGER.debug("%s successfully connected", self._device_id) 241 | self._fire_event("connect") 242 | else: 243 | LOGGER.debug("%s successfully reconnected", self._device_id) 244 | self._fire_event("reconnect") 245 | return 246 | 247 | LOGGER.debug("%s failed to connect, with mqtt error %s", self._device_id, response_code) 248 | 249 | if response_code in (1, 2, 4, 5): 250 | raise Exception("Invalid Losant credentials - error code {0}".format(response_code)) 251 | else: 252 | LOGGER.debug("%s retrying connection", self._device_id) 253 | self._wrapped_reconnect() 254 | 255 | def _cb_client_disconnect(self, client, userdata, response_code): 256 | if not self._mqtt_client: 257 | return 258 | if response_code == mqtt.MQTT_ERR_SUCCESS: # intentional disconnect 259 | self._mqtt_client = None 260 | LOGGER.debug("Connection closed for %s", self._device_id) 261 | self._fire_event("close") 262 | else: 263 | LOGGER.debug("Connection abnormally ended for %s, reconnecting...", self._device_id) 264 | self._wrapped_reconnect() 265 | 266 | def _cb_client_command(self, client, userdata, msg): 267 | LOGGER.debug("Received command for %s", self._device_id) 268 | payload = msg.payload 269 | if not payload: 270 | return 271 | if hasattr(payload, "decode"): 272 | payload = payload.decode("utf-8") 273 | msg = json.loads(payload, object_hook=ext_json_decode) 274 | self._fire_event("command", msg) 275 | 276 | def _wrapped_reconnect(self): 277 | # no need to trigger a reconnect ourselves if loop_forever is active 278 | if not self._looping: 279 | try: 280 | self._mqtt_client.reconnect() 281 | except socket.error as err: 282 | LOGGER.debug("Reconnect attempt failed for %s due to %s", self._device_id, err) 283 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | 5 | here = path.abspath(path.dirname(__file__)) 6 | 7 | # Get the long description from the README file 8 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name='losant-mqtt', 13 | version='1.2.2', 14 | description='An MQTT client for Losant', 15 | long_description=long_description, 16 | url='https://github.com/Losant/losant-mqtt-python', 17 | author='Losant', 18 | author_email='hello@losant.com', 19 | license='MIT', 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Intended Audience :: Developers', 23 | 'Operating System :: MacOS :: MacOS X', 24 | 'Operating System :: Microsoft :: Windows', 25 | 'Operating System :: POSIX', 26 | 'Topic :: Communications', 27 | 'Topic :: Internet', 28 | 'Topic :: Software Development :: Libraries', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3' 33 | ], 34 | packages=find_packages(exclude=['tests']), 35 | include_package_data=True, 36 | package_data={ 37 | 'losantmqtt': ['RootCA.crt'] 38 | }, 39 | keywords=["MQTT", "Losant", "IoT"], 40 | test_suite='tests', 41 | install_requires=['paho-mqtt>=1.6.1,<2'] 42 | ) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Losant/losant-mqtt-python/39cca932c5cc1e1d451110a374e4212cd4af89e0/tests/__init__.py -------------------------------------------------------------------------------- /tests/losantmqtt_tests.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0111,W0212,R0903,W0201,C0301,E0401 2 | import unittest 3 | import calendar 4 | from losantmqtt import Device 5 | 6 | class MqttMock(object): 7 | 8 | def __init__(self): 9 | self.publish_calls = [] 10 | 11 | def publish(self, topic, payload): 12 | self.publish_calls.append([topic, payload]) 13 | return 0 14 | 15 | class MsgMock(object): 16 | def __init__(self, msg): 17 | self.payload = msg 18 | 19 | class TestDevice(unittest.TestCase): 20 | 21 | def setUp(self): 22 | self.device = Device("device_id", "device_key", "device_secret") 23 | 24 | def test_correct_props(self): 25 | self.assertEqual(self.device._device_id, "device_id") 26 | self.assertEqual(self.device._key, "device_key") 27 | self.assertEqual(self.device._secret, "device_secret") 28 | self.assertEqual(self.device._secure, True) 29 | self.assertEqual(self.device._command_topic(), "losant/device_id/command") 30 | self.assertEqual(self.device._state_topic(), "losant/device_id/state") 31 | 32 | def test_add_remove_observer(self): 33 | self.event_fired = 0 34 | def on_event(device): 35 | self.assertEqual(device, self.device) 36 | self.event_fired += 1 37 | self.device.add_event_observer("test", on_event) 38 | self.device._fire_event("test") 39 | self.assertEqual(self.event_fired, 1) 40 | self.device.remove_event_observer("test", on_event) 41 | self.device._fire_event("test") 42 | self.assertEqual(self.event_fired, 1) 43 | 44 | def test_send_state(self): 45 | self.device._mqtt_client = MqttMock() 46 | calls = self.device._mqtt_client.publish_calls 47 | self.assertEqual(len(calls), 0) 48 | result = self.device.send_state({"one": "two"}, 1234) 49 | self.assertEqual(result, True) 50 | calls = self.device._mqtt_client.publish_calls 51 | self.assertEqual(len(calls), 1) 52 | self.assertEqual(calls[0][0], "losant/device_id/state") 53 | expected_payload = '{"data": {"one": "two"}, "time": 1234}' 54 | self.assertEqual(calls[0][1], expected_payload) 55 | 56 | def test_receive_command(self): 57 | self.cmd_msg = None 58 | def on_command(device, msg): 59 | self.assertEqual(device, self.device) 60 | self.cmd_msg = msg 61 | self.device.add_event_observer("command", on_command) 62 | mock = MsgMock('{"name":"start","payload":{"one":[2,3]},"time":{"$date":"2016-06-01T01:09:51.145Z"}}') 63 | self.device._cb_client_command(None, None, mock) 64 | self.assertEqual(self.cmd_msg["name"], "start") 65 | self.assertEqual(self.cmd_msg["payload"], {"one": [2, 3]}) 66 | self.assertEqual(self.cmd_msg["time"].microsecond, 145000) 67 | self.assertEqual(calendar.timegm(self.cmd_msg["time"].utctimetuple()), 1464743391.0) 68 | --------------------------------------------------------------------------------