Skip to content

Commit ccc21a0

Browse files
committed
events.py: Add ELO class.
1 parent d5edede commit ccc21a0

File tree

4 files changed

+283
-13
lines changed

4 files changed

+283
-13
lines changed

v3/docs/EVENTS.md

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ This document assumes familiarity with `asyncio`. See [official docs](http://doc
2020
5.1 [Use of Delay_ms](./EVENTS.md#51-use-of-delay_ms) A retriggerable delay
2121
5.2 [Long and very long button press](./EVENTS.md#52-long-and-very-long-button-press)
2222
5.3 [Application example](./EVENTS.md#53-application-example)
23-
6. [Drivers](./EVENTS.md#6-drivers) Minimal Event-based drivers
24-
6.1 [ESwitch](./EVENTS.md#61-eswitch) Debounced switch
25-
6.2 [EButton](./EVENTS.md#62-ebutton) Debounced pushbutton with double and long press events
23+
6. [ELO class](./EVENTS.md#6-elo-class) Convert a coroutine or task to an event-like object.
24+
7. [Drivers](./EVENTS.md#7-drivers) Minimal Event-based drivers
25+
7.1 [ESwitch](./EVENTS.md#71-eswitch) Debounced switch
26+
7.2 [EButton](./EVENTS.md#72-ebutton) Debounced pushbutton with double and long press events
2627

2728
[Appendix 1 Polling](./EVENTS.md#100-appendix-1-polling)
2829

@@ -61,6 +62,11 @@ Users only need to know the names of the bound `Event` instances. By contast
6162
there is no standard way to specify callbacks, to define the passing of
6263
callback arguments or to define how to retrieve their return values.
6364

65+
There are other ways to define an API without callbacks, notably the stream
66+
mechanism and the use of asynchronous iterators with `async for`. This doc
67+
discusses the `Event` based approach which is ideal for sporadic occurrences
68+
such as responding to user input.
69+
6470
###### [Contents](./EVENTS.md#0-contents)
6571

6672
# 2. Rationale
@@ -135,6 +141,10 @@ ELO examples are:
135141
| [Delay_ms][2m] | Y | Y | Y | Self-setting |
136142
| [WaitAll](./EVENTS.md#42-waitall) | Y | Y | N | See below |
137143
| [WaitAny](./EVENTS.md#41-waitany) | Y | Y | N | |
144+
| [ELO instances](./EVENTS.md#44-elo-class) | Y | N | N | |
145+
146+
The `ELO` class converts coroutines or `Task` instances to event-like objects,
147+
allowing them to be included in the arguments of event based primitives.
138148

139149
Drivers exposing `Event` instances include:
140150

@@ -316,19 +326,118 @@ async def foo():
316326
else:
317327
# Normal outcome, process readings
318328
```
329+
###### [Contents](./EVENTS.md#0-contents)
330+
331+
# 6. ELO class
332+
333+
This converts a task to an "event-like object", enabling tasks to be included in
334+
`WaitAll` and `WaitAny` arguments. An `ELO` instance is a wrapper for a `Task`
335+
instance and its lifetime is that of its `Task`. The constructor can take a
336+
coroutine or a task as its first argument; in the former case the coro is
337+
converted to a `Task`.
338+
339+
#### Constructor args
340+
341+
1. `coro` This may be a coroutine or a `Task` instance.
342+
2. `*args` Positional args for a coroutine (ignored if a `Task` is passed).
343+
3. `**kwargs` Keyword args for a coroutine (ignored if a `Task` is passed).
344+
345+
If a coro is passed it is immediately converted to a `Task` and scheduled for
346+
execution.
347+
348+
#### Asynchronous method
349+
350+
1. `wait` Pauses until the `Task` is complete or is cancelled. In the latter
351+
case no exception is thrown.
352+
353+
#### Synchronous method
354+
355+
1. `__call__` Returns the instance's `Task`. If the instance's `Task` was
356+
cancelled the `CancelledError` exception is returned. The function call operator
357+
allows a running task to be accessed, e.g. for cancellation. It also enables return values to be
358+
retrieved.
359+
360+
#### Usage example
361+
362+
In most use cases an `ELO` instance is a throw-away object which allows a coro
363+
to participate in an event-based primitive:
364+
```python
365+
evt = asyncio.Event()
366+
async def my_coro(t):
367+
await asyncio.wait(t)
368+
369+
async def foo(): # Puase until the event has been triggered and coro has completed
370+
await WaitAll((evt, ELO(my_coro, 5))).wait() # Note argument passing
371+
```
372+
#### Retrieving results
373+
374+
A task may return a result on completion. This may be accessed by awaiting the
375+
`ELO` instance's `Task`. A reference to the `Task` may be acquired with function
376+
call syntax. The following code fragment illustrates usage. It assumes that
377+
`task` has already been created, and that `my_coro` is a coroutine taking an
378+
integer arg. There is an `EButton` instance `ebutton` and execution pauses until
379+
tasks have run to completion and the button has been pressed.
380+
```python
381+
async def foo():
382+
elos = (ELO(my_coro, 5), ELO(task))
383+
events = (ebutton.press,)
384+
await WaitAll(elos + events).wait()
385+
for e in elos: # Retrieve results from each task
386+
r = await e() # Works even though task has already completed
387+
print(r)
388+
```
389+
This works because it is valid to `await` a task which has already completed.
390+
The `await` returns immediately with the result. If `WaitAny` were used an `ELO`
391+
instance might contain a running task. In this case the line
392+
```python
393+
r = await e()
394+
```
395+
would pause before returning the result.
396+
397+
#### Cancellation
398+
399+
The `Task` in `ELO` instance `elo` may be retrieved by issuing `elo()`. For
400+
example the following will subject an `ELO` instance to a timeout:
401+
```python
402+
async def elo_timeout(elo, t):
403+
await asyncio.sleep(t)
404+
elo().cancel() # Retrieve the Task and cancel it
405+
406+
async def foo():
407+
elo = ELO(my_coro, 5)
408+
asyncio.create_task(elo_timeout(2))
409+
await WaitAll((elo, ebutton.press)).wait() # Until button press and ELO either finished or timed out
410+
```
411+
If the `ELO` task is cancelled, `.wait` terminates; the exception is retained.
412+
Thus `WaitAll` or `WaitAny` behaves as if the task had terminated normally. A
413+
subsequent call to `elo()` will return the exception. In an application
414+
where the task might return a result or be cancelled, the following may be used:
415+
```python
416+
async def foo():
417+
elos = (ELO(my_coro, 5), ELO(task))
418+
events = (ebutton.press,)
419+
await WaitAll(elos + events).wait()
420+
for e in elos: # Check each task
421+
t = e()
422+
if isinstance(t, asyncio.CancelledError):
423+
# Handle exception
424+
else: # Retrieve results
425+
r = await t # Works even though task has already completed
426+
print(r)
427+
```
319428

320429
###### [Contents](./EVENTS.md#0-contents)
321430

322-
# 6. Drivers
431+
# 7. Drivers
323432

324433
The following device drivers provide an `Event` based interface for switches and
325434
pushbuttons.
326435

327-
## 6.1 ESwitch
436+
## 7.1 ESwitch
328437

329438
This is now documented [here](./DRIVERS.md#31-eswitch-class).
330439

331-
## 6.2 EButton
440+
## 7.2 EButton
332441

333442
This is now documented [here](./DRIVERS.md#41-ebutton-class).
334443

v3/primitives/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
async def _g():
1313
pass
14+
15+
1416
type_coro = type(_g())
1517

1618
# If a callback is passed, run it and return.
@@ -22,14 +24,18 @@ def launch(func, tup_args):
2224
res = asyncio.create_task(res)
2325
return res
2426

27+
2528
def set_global_exception():
2629
def _handle_exception(loop, context):
2730
import sys
31+
2832
sys.print_exception(context["exception"])
2933
sys.exit()
34+
3035
loop = asyncio.get_event_loop()
3136
loop.set_exception_handler(_handle_exception)
3237

38+
3339
_attrs = {
3440
"AADC": "aadc",
3541
"Barrier": "barrier",
@@ -44,6 +50,7 @@ def _handle_exception(loop, context):
4450
"Switch": "switch",
4551
"WaitAll": "events",
4652
"WaitAny": "events",
53+
"ELO": "events",
4754
"ESwitch": "events",
4855
"EButton": "events",
4956
"RingbufQueue": "ringbuf_queue",

v3/primitives/events.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# events.py Event based primitives
22

3-
# Copyright (c) 2022 Peter Hinch
3+
# Copyright (c) 2022-2024 Peter Hinch
44
# Released under the MIT License (MIT) - see LICENSE file
55

66
import uasyncio as asyncio
@@ -34,9 +34,10 @@ def event(self):
3434
return self.trig_event
3535

3636
def clear(self):
37-
for evt in (x for x in self.events if hasattr(x, 'clear')):
37+
for evt in (x for x in self.events if hasattr(x, "clear")):
3838
evt.clear()
3939

40+
4041
# An Event-like class that can wait on an iterable of Event-like instances,
4142
# .wait pauses until all passed events have been set.
4243
class WaitAll:
@@ -46,6 +47,7 @@ def __init__(self, events):
4647
async def wait(self):
4748
async def wt(event):
4849
await event.wait()
50+
4951
tasks = (asyncio.create_task(wt(event)) for event in self.events)
5052
try:
5153
await asyncio.gather(*tasks)
@@ -54,15 +56,65 @@ async def wt(event):
5456
task.cancel()
5557

5658
def clear(self):
57-
for evt in (x for x in self.events if hasattr(x, 'clear')):
59+
for evt in (x for x in self.events if hasattr(x, "clear")):
5860
evt.clear()
5961

62+
63+
# Convert to an event-like object: either a running task or a coro with args.
64+
# Motivated by a suggestion from @sandyscott iss #116
65+
class ELO_x:
66+
def __init__(self, coro, *args, **kwargs):
67+
self._coro = coro
68+
self._args = args
69+
self._kwargs = kwargs
70+
self._task = None # Current running task (or exception)
71+
72+
async def wait(self):
73+
cr = self._coro
74+
istask = isinstance(cr, asyncio.Task) # Instantiated with a Task
75+
if istask and isinstance(self._task, asyncio.CancelledError):
76+
return # Previously awaited and was cancelled/timed out
77+
self._task = cr if istask else asyncio.create_task(cr(*self._args, **self._kwargs))
78+
try:
79+
await self._task
80+
except asyncio.CancelledError as e:
81+
self._task = e # Let WaitAll or WaitAny complete
82+
83+
# User can retrieve task/coro results by awaiting .task() (even if task had
84+
# run to completion). If task was cancelled CancelledError is returned.
85+
# If .task() is called before .wait() returns None or result of prior .wait()
86+
# Caller issues isinstance(task, CancelledError)
87+
def task(self):
88+
return self._task
89+
90+
91+
# Convert to an event-like object: either a running task or a coro with args.
92+
# Motivated by a suggestion from @sandyscott iss #116
93+
class ELO:
94+
def __init__(self, coro, *args, **kwargs):
95+
tsk = isinstance(coro, asyncio.Task) # Instantiated with a Task
96+
self._task = coro if tsk else asyncio.create_task(coro(*args, **kwargs))
97+
98+
async def wait(self):
99+
try:
100+
await self._task
101+
except asyncio.CancelledError as e:
102+
self._task = e # Let WaitAll or WaitAny complete
103+
104+
# User can retrieve task/coro results by awaiting elo() (even if task had
105+
# run to completion). If task was cancelled CancelledError is returned.
106+
# If .task() is called before .wait() returns None or result of prior .wait()
107+
# Caller issues isinstance(task, CancelledError)
108+
def __call__(self):
109+
return self._task
110+
111+
60112
# Minimal switch class having an Event based interface
61113
class ESwitch:
62114
debounce_ms = 50
63115

64116
def __init__(self, pin, lopen=1): # Default is n/o switch returned to gnd
65-
self._pin = pin # Should be initialised for input with pullup
117+
self._pin = pin # Should be initialised for input with pullup
66118
self._lopen = lopen # Logic level in "open" state
67119
self.open = asyncio.Event()
68120
self.close = asyncio.Event()
@@ -92,6 +144,7 @@ def deinit(self):
92144
self.open.clear()
93145
self.close.clear()
94146

147+
95148
# Minimal pushbutton class having an Event based interface
96149
class EButton:
97150
debounce_ms = 50 # Attributes can be varied by user
@@ -103,13 +156,14 @@ def __init__(self, pin, suppress=False, sense=None):
103156
self._supp = suppress
104157
self._sense = pin() if sense is None else sense
105158
self._state = self.rawstate() # Initial logical state
106-
self._ltim = Delay_ms(duration = EButton.long_press_ms)
107-
self._dtim = Delay_ms(duration = EButton.double_click_ms)
159+
self._ltim = Delay_ms(duration=EButton.long_press_ms)
160+
self._dtim = Delay_ms(duration=EButton.double_click_ms)
108161
self.press = asyncio.Event() # *** API ***
109162
self.double = asyncio.Event()
110163
self.long = asyncio.Event()
111164
self.release = asyncio.Event() # *** END API ***
112-
self._tasks = [asyncio.create_task(self._poll(EButton.debounce_ms))] # Tasks run forever. Poll contacts
165+
# Tasks run forever. Poll contacts
166+
self._tasks = [asyncio.create_task(self._poll(EButton.debounce_ms))]
113167
self._tasks.append(asyncio.create_task(self._ltf())) # Handle long press
114168
if suppress:
115169
self._tasks.append(asyncio.create_task(self._dtf())) # Double timer

0 commit comments

Comments
 (0)