Skip to content

Commit e380caa

Browse files
authoredMay 23, 2024··
Merge pull request #80 from arduino/sync_mode
ucloud: Add support for a sync mode.
2 parents 30f8598 + de53378 commit e380caa

File tree

4 files changed

+171
-125
lines changed

4 files changed

+171
-125
lines changed
 

‎examples/example.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def user_task(client):
4444
# Parse command line args.
4545
parser = argparse.ArgumentParser(description="arduino_iot_cloud.py")
4646
parser.add_argument("-d", "--debug", action="store_true", help="Enable debugging messages")
47+
parser.add_argument("-s", "--sync", action="store_true", help="Run in synchronous mode")
4748
args = parser.parse_args()
4849

4950
# Assume the host has an active Internet connection.
@@ -60,7 +61,7 @@ def user_task(client):
6061
# Create a client object to connect to the Arduino IoT cloud.
6162
# The most basic authentication method uses a username and password. The username is the device
6263
# ID, and the password is the secret key obtained from the IoT cloud when provisioning a device.
63-
client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY)
64+
client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY, sync_mode=args.sync)
6465

6566
# Alternatively, the client also supports key and certificate-based authentication. To use this
6667
# mode, set "keyfile" and "certfile", and the CA certificate (if any) in "ssl_params".
@@ -73,6 +74,7 @@ def user_task(client):
7374
# "keyfile": KEY_PATH, "certfile": CERT_PATH, "cafile": CA_PATH,
7475
# "verify_mode": ssl.CERT_REQUIRED, "server_hostname" : "iot.arduino.cc"
7576
# },
77+
# sync_mode=args.sync,
7678
# )
7779

7880
# Register cloud objects.
@@ -107,5 +109,11 @@ def user_task(client):
107109
# to client.register().
108110
client.register(Task("user_task", on_run=user_task, interval=1.0))
109111

110-
# Start the Arduino IoT cloud client.
112+
# Start the Arduino IoT cloud client. In synchronous mode, this function returns immediately
113+
# after connecting to the cloud.
111114
client.start()
115+
116+
# In sync mode, start returns after connecting, and the client must be polled periodically.
117+
while True:
118+
client.update()
119+
time.sleep(0.100)

‎examples/micropython_basic.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def wifi_connect():
6666
logging.basicConfig(
6767
datefmt="%H:%M:%S",
6868
format="%(asctime)s.%(msecs)03d %(message)s",
69-
level=logging.INFO,
69+
level=logging.DEBUG,
7070
)
7171

7272
# NOTE: Add networking code here or in boot.py
@@ -75,7 +75,7 @@ def wifi_connect():
7575
# Create a client object to connect to the Arduino IoT cloud.
7676
# The most basic authentication method uses a username and password. The username is the device
7777
# ID, and the password is the secret key obtained from the IoT cloud when provisioning a device.
78-
client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY)
78+
client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY, sync_mode=False)
7979

8080
# Alternatively, the client also supports key and certificate-based authentication. To use this
8181
# mode, set "keyfile" and "certfile", and the CA certificate (if any) in "ssl_params".
@@ -86,6 +86,7 @@ def wifi_connect():
8686
# "keyfile": KEY_PATH, "certfile": CERT_PATH, "cadata": CADATA,
8787
# "verify_mode": ssl.CERT_REQUIRED, "server_hostname" : "iot.arduino.cc"
8888
# },
89+
# sync_mode=False,
8990
# )
9091

9192
# Register cloud objects.
@@ -133,5 +134,11 @@ def wifi_connect():
133134
except (ImportError, AttributeError):
134135
pass
135136

136-
# Start the Arduino IoT cloud client.
137+
# Start the Arduino IoT cloud client. In synchronous mode, this function returns immediately
138+
# after connecting to the cloud.
137139
client.start()
140+
141+
# In sync mode, start returns after connecting, and the client must be polled periodically.
142+
while True:
143+
client.update()
144+
time.sleep(0.100)

‎src/arduino_iot_cloud/__init__.py

+11-40
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
# License, v. 2.0. If a copy of the MPL was not distributed with this
55
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
66

7-
import asyncio
87
import binascii
98
from .ucloud import ArduinoCloudClient # noqa
109
from .ucloud import ArduinoCloudObject
10+
from .ucloud import ArduinoCloudObject as Task # noqa
1111
from .ucloud import timestamp
1212

1313

@@ -30,33 +30,6 @@
3030
b"8d6444ffe82217304ff2b89aafca8ecf"
3131
)
3232

33-
async def coro(): # noqa
34-
pass
35-
36-
37-
def is_async(obj):
38-
if hasattr(asyncio, "iscoroutinefunction"):
39-
return asyncio.iscoroutinefunction(obj)
40-
else:
41-
return isinstance(obj, type(coro))
42-
43-
44-
class Task(ArduinoCloudObject):
45-
def __init__(self, name, **kwargs):
46-
kwargs.update({("runnable", True)}) # Force task creation.
47-
self.on_run = kwargs.pop("on_run", None)
48-
if not callable(self.on_run):
49-
raise TypeError("Expected a callable object")
50-
super().__init__(name, **kwargs)
51-
52-
async def run(self, aiot):
53-
if is_async(self.on_run):
54-
await self.on_run(aiot)
55-
else:
56-
while True:
57-
self.on_run(aiot)
58-
await asyncio.sleep(self.interval)
59-
6033

6134
class Location(ArduinoCloudObject):
6235
def __init__(self, name, **kwargs):
@@ -80,24 +53,22 @@ def __init__(self, name, **kwargs):
8053

8154
class Schedule(ArduinoCloudObject):
8255
def __init__(self, name, **kwargs):
83-
kwargs.update({("runnable", True)}) # Force task creation.
56+
kwargs.update({("on_run", self.on_run)})
8457
self.on_active = kwargs.pop("on_active", None)
8558
# Uncomment to allow the schedule to change in runtime.
8659
# kwargs["on_write"] = kwargs.get("on_write", lambda aiot, value: None)
8760
self.active = False
8861
super().__init__(name, keys={"frm", "to", "len", "msk"}, **kwargs)
8962

90-
async def run(self, aiot):
91-
while True:
92-
if self.initialized:
93-
ts = timestamp() + aiot.get("tz_offset", 0)
94-
if ts > self.frm and ts < (self.frm + self.len):
95-
if not self.active and self.on_active is not None:
96-
self.on_active(aiot, self.value)
97-
self.active = True
98-
else:
99-
self.active = False
100-
await asyncio.sleep(self.interval)
63+
def on_run(self, aiot):
64+
if self.initialized:
65+
ts = timestamp() + aiot.get("tz_offset", 0)
66+
if ts > self.frm and ts < (self.frm + self.len):
67+
if not self.active and self.on_active is not None:
68+
self.on_active(aiot, self.value)
69+
self.active = True
70+
else:
71+
self.active = False
10172

10273

10374
class Television(ArduinoCloudObject):

‎src/arduino_iot_cloud/ucloud.py

+140-80
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ def timestamp():
3535
return int(time.time())
3636

3737

38+
def timestamp_ms():
39+
return time.time_ns()//1000000
40+
41+
3842
def log_level_enabled(level):
3943
return logging.getLogger().isEnabledFor(level)
4044

@@ -43,8 +47,9 @@ class ArduinoCloudObject(SenmlRecord):
4347
def __init__(self, name, **kwargs):
4448
self.on_read = kwargs.pop("on_read", None)
4549
self.on_write = kwargs.pop("on_write", None)
50+
self.on_run = kwargs.pop("on_run", None)
4651
self.interval = kwargs.pop("interval", 1.0)
47-
self._runnable = kwargs.pop("runnable", False)
52+
self.backoff = kwargs.pop("backoff", None)
4853
value = kwargs.pop("value", None)
4954
if keys := kwargs.pop("keys", {}):
5055
value = { # Create a complex object (with sub-records).
@@ -54,6 +59,8 @@ def __init__(self, name, **kwargs):
5459
self._updated = False
5560
self.on_write_scheduled = False
5661
self.timestamp = timestamp()
62+
self.last_run = timestamp_ms()
63+
self.runnable = any((self.on_run, self.on_read, self.on_write))
5764
callback = kwargs.pop("callback", self.senml_callback)
5865
for key in kwargs: # kwargs should be empty by now, unless a wrong attr was used.
5966
raise TypeError(f"'{self.__class__.__name__}' got an unexpected keyword argument '{key}'")
@@ -84,10 +91,6 @@ def initialized(self):
8491
return all(r.initialized for r in self.value.values())
8592
return self.value is not None
8693

87-
@property
88-
def runnable(self):
89-
return self.on_read is not None or self.on_write is not None or self._runnable
90-
9194
@SenmlRecord.value.setter
9295
def value(self, value):
9396
if value is not None:
@@ -152,12 +155,19 @@ def senml_callback(self, record, **kwargs):
152155

153156
async def run(self, client):
154157
while True:
155-
if self.on_read is not None:
156-
self.value = self.on_read(client)
157-
if self.on_write is not None and self.on_write_scheduled:
158-
self.on_write_scheduled = False
159-
self.on_write(client, self if isinstance(self.value, dict) else self.value)
158+
self.run_sync(client)
160159
await asyncio.sleep(self.interval)
160+
if self.backoff is not None:
161+
self.interval = min(self.interval * self.backoff, 5.0)
162+
163+
def run_sync(self, client):
164+
if self.on_run is not None:
165+
self.on_run(client)
166+
if self.on_read is not None:
167+
self.value = self.on_read(client)
168+
if self.on_write is not None and self.on_write_scheduled:
169+
self.on_write_scheduled = False
170+
self.on_write(client, self if isinstance(self.value, dict) else self.value)
161171

162172

163173
class ArduinoCloudClient:
@@ -171,17 +181,20 @@ def __init__(
171181
port=None,
172182
keepalive=10,
173183
ntp_server="pool.ntp.org",
174-
ntp_timeout=3
184+
ntp_timeout=3,
185+
sync_mode=False
175186
):
176187
self.tasks = {}
177188
self.records = {}
178189
self.thing_id = None
179190
self.keepalive = keepalive
180191
self.last_ping = timestamp()
192+
self.last_run = timestamp()
181193
self.senmlpack = SenmlPack("", self.senml_generic_callback)
182-
self.started = False
183194
self.ntp_server = ntp_server
184195
self.ntp_timeout = ntp_timeout
196+
self.async_mode = not sync_mode
197+
self.connected = False
185198

186199
if "pin" in ssl_params:
187200
try:
@@ -213,7 +226,7 @@ def __init__(
213226

214227
# Create MQTT client.
215228
self.mqtt = MQTTClient(
216-
device_id, server, port, ssl_params, username, password, keepalive, self.mqtt_callback
229+
device_id, server, port, ssl_params, username, password, keepalive, self.mqtt_callback
217230
)
218231

219232
# Add internal objects initialized by the cloud.
@@ -252,11 +265,12 @@ def update_systime(self, server=None, timeout=None):
252265
def create_task(self, name, coro, *args, **kwargs):
253266
if callable(coro):
254267
coro = coro(*args)
255-
if self.started:
268+
try:
269+
asyncio.get_event_loop()
256270
self.tasks[name] = asyncio.create_task(coro)
257271
if log_level_enabled(logging.INFO):
258272
logging.info(f"task: {name} created.")
259-
else:
273+
except Exception:
260274
# Defer task creation until there's a running event loop.
261275
self.tasks[name] = coro
262276

@@ -272,14 +286,14 @@ def register(self, aiotobj, coro=None, **kwargs):
272286
# Register the ArduinoCloudObject
273287
self.records[aiotobj.name] = aiotobj
274288

275-
# Create a task for this object if it has any callbacks.
276-
if aiotobj.runnable:
277-
self.create_task(aiotobj.name, aiotobj.run, self)
278-
279289
# Check if object needs to be initialized from the cloud.
280290
if not aiotobj.initialized and "r:m" not in self.records:
281291
self.register("r:m", value="getLastValues")
282292

293+
# Create a task for this object if it has any callbacks.
294+
if self.async_mode and aiotobj.runnable:
295+
self.create_task(aiotobj.name, aiotobj.run, self)
296+
283297
def senml_generic_callback(self, record, **kwargs):
284298
# This callback catches all unknown/umatched sub/records that were not part of the pack.
285299
rname, sname = record.name.split(":") if ":" in record.name else [record.name, None]
@@ -303,76 +317,75 @@ def mqtt_callback(self, topic, message):
303317
self.senmlpack.from_cbor(message)
304318
self.senmlpack.clear()
305319

306-
async def discovery_task(self, interval=0.100):
307-
self.mqtt.subscribe(self.device_topic, qos=1)
308-
while self.thing_id is None:
309-
self.mqtt.check_msg()
310-
if self.records.get("thing_id").value is not None:
311-
self.thing_id = self.records.pop("thing_id").value
312-
if not self.thing_id: # Empty thing ID should not happen.
313-
raise (Exception("Device is not linked to a Thing ID."))
314-
315-
self.topic_out = self.create_topic("e", "o")
316-
self.mqtt.subscribe(self.create_topic("e", "i"))
317-
318-
if lastval_record := self.records.pop("r:m", None):
319-
lastval_record.add_to_pack(self.senmlpack)
320-
self.mqtt.subscribe(self.create_topic("shadow", "i"), qos=1)
321-
self.mqtt.publish(self.create_topic("shadow", "o"), self.senmlpack.to_cbor(), qos=1)
322-
logging.info("Device configured via discovery protocol.")
323-
await asyncio.sleep(interval)
324-
raise DoneException()
325-
326-
async def conn_task(self, interval=1.0, backoff=1.2):
320+
def ts_expired(self, record, ts):
321+
return (ts - record.last_run) > int(record.interval * 1000)
322+
323+
def poll_connect(self, aiot=None):
327324
logging.info("Connecting to Arduino IoT cloud...")
328-
while True:
329-
try:
330-
self.mqtt.connect()
331-
break
332-
except Exception as e:
333-
if log_level_enabled(logging.WARNING):
334-
logging.warning(f"Connection failed {e}, retrying after {interval}s")
335-
await asyncio.sleep(interval)
336-
interval = min(interval * backoff, 4.0)
325+
try:
326+
self.mqtt.connect()
327+
except Exception as e:
328+
if log_level_enabled(logging.WARNING):
329+
logging.warning(f"Connection failed {e}, retrying...")
330+
return False
337331

338332
if self.thing_id is None:
339-
self.create_task("discovery", self.discovery_task)
333+
self.mqtt.subscribe(self.device_topic, qos=1)
340334
else:
341335
self.mqtt.subscribe(self.create_topic("e", "i"))
342-
self.create_task("mqtt_task", self.mqtt_task)
343-
raise DoneException()
344336

345-
async def mqtt_task(self, interval=0.100):
346-
while True:
347-
self.mqtt.check_msg()
348-
if self.thing_id is not None:
349-
self.senmlpack.clear()
350-
for record in self.records.values():
351-
if record.updated:
352-
record.add_to_pack(self.senmlpack, push=True)
353-
if len(self.senmlpack._data):
354-
logging.debug("Pushing records to Arduino IoT cloud:")
355-
if log_level_enabled(logging.DEBUG):
356-
for record in self.senmlpack._data:
357-
logging.debug(f" ==> record: {record.name} value: {str(record.value)[:48]}...")
358-
self.mqtt.publish(self.topic_out, self.senmlpack.to_cbor(), qos=1)
359-
self.last_ping = timestamp()
360-
elif self.keepalive and (timestamp() - self.last_ping) > self.keepalive:
361-
self.mqtt.ping()
362-
self.last_ping = timestamp()
363-
logging.debug("No records to push, sent a ping request.")
364-
await asyncio.sleep(interval)
365-
raise DoneException()
366-
367-
async def run(self):
368-
self.started = True
337+
if self.async_mode:
338+
if self.thing_id is None:
339+
self.register("discovery", on_run=self.poll_discovery, interval=0.100)
340+
self.register("mqtt_task", on_run=self.poll_mqtt, interval=0.100)
341+
raise DoneException()
342+
return True
343+
344+
def poll_discovery(self, aiot=None):
345+
self.mqtt.check_msg()
346+
if self.records.get("thing_id").value is not None:
347+
self.thing_id = self.records.pop("thing_id").value
348+
if not self.thing_id: # Empty thing ID should not happen.
349+
raise Exception("Device is not linked to a Thing ID.")
350+
351+
self.topic_out = self.create_topic("e", "o")
352+
self.mqtt.subscribe(self.create_topic("e", "i"))
353+
354+
if lastval_record := self.records.pop("r:m", None):
355+
lastval_record.add_to_pack(self.senmlpack)
356+
self.mqtt.subscribe(self.create_topic("shadow", "i"), qos=1)
357+
self.mqtt.publish(self.create_topic("shadow", "o"), self.senmlpack.to_cbor(), qos=1)
358+
logging.info("Device configured via discovery protocol.")
359+
if self.async_mode:
360+
raise DoneException()
361+
362+
def poll_mqtt(self, aiot=None):
363+
self.mqtt.check_msg()
364+
if self.thing_id is not None:
365+
self.senmlpack.clear()
366+
for record in self.records.values():
367+
if record.updated:
368+
record.add_to_pack(self.senmlpack, push=True)
369+
if len(self.senmlpack._data):
370+
logging.debug("Pushing records to Arduino IoT cloud:")
371+
if log_level_enabled(logging.DEBUG):
372+
for record in self.senmlpack._data:
373+
logging.debug(f" ==> record: {record.name} value: {str(record.value)[:48]}...")
374+
self.mqtt.publish(self.topic_out, self.senmlpack.to_cbor(), qos=1)
375+
self.last_ping = timestamp()
376+
elif self.keepalive and (timestamp() - self.last_ping) > self.keepalive:
377+
self.mqtt.ping()
378+
self.last_ping = timestamp()
379+
logging.debug("No records to push, sent a ping request.")
380+
381+
async def run(self, interval, backoff):
369382
# Creates tasks from coros here manually before calling
370383
# gather, so we can keep track of tasks in self.tasks dict.
371384
for name, coro in self.tasks.items():
372385
self.create_task(name, coro)
373386

374387
# Create connection task.
375-
self.create_task("conn_task", self.conn_task)
388+
self.register("connection_task", on_run=self.poll_connect, interval=interval, backoff=backoff)
376389

377390
while True:
378391
task_except = None
@@ -394,10 +407,57 @@ async def run(self):
394407
elif task_except is not None and log_level_enabled(logging.ERROR):
395408
logging.error(f"task: {name} raised exception: {str(task_except)}.")
396409
if name == "mqtt_task":
397-
self.create_task("conn_task", self.conn_task)
410+
self.register(
411+
"connection_task",
412+
on_run=self.poll_connect,
413+
interval=interval,
414+
backoff=backoff
415+
)
398416
break # Break after the first task is removed.
399417
except (CancelledError, InvalidStateError):
400418
pass
401419

402-
def start(self):
403-
asyncio.run(self.run())
420+
def start(self, interval=1.0, backoff=1.2):
421+
if self.async_mode:
422+
asyncio.run(self.run(interval, backoff))
423+
else:
424+
# Synchronous mode.
425+
while not self.poll_connect():
426+
time.sleep(interval)
427+
interval = min(interval * backoff, 5.0)
428+
429+
while self.thing_id is None:
430+
self.poll_discovery()
431+
time.sleep(0.100)
432+
433+
self.connected = True
434+
435+
def update(self):
436+
if self.async_mode:
437+
raise RuntimeError("This function can't be called in asyncio mode.")
438+
439+
if not self.connected:
440+
try:
441+
self.start()
442+
self.connected = True
443+
except Exception as e:
444+
raise e
445+
446+
try:
447+
ts = timestamp_ms()
448+
for record in self.records.values():
449+
if record.runnable and self.ts_expired(record, ts):
450+
record.run_sync(self)
451+
record.last_run = ts
452+
except Exception as e:
453+
self.records.pop(record.name)
454+
if log_level_enabled(logging.ERROR):
455+
logging.error(f"task: {record.name} raised exception: {str(e)}.")
456+
457+
try:
458+
self.poll_mqtt()
459+
except Exception as e:
460+
self.connected = False
461+
if log_level_enabled(logging.WARNING):
462+
logging.warning(f"Connection lost {e}")
463+
raise e

0 commit comments

Comments
 (0)
Please sign in to comment.