Open raveslave opened 1 year ago
So the issue is that fundamentally, Belay is just a TON of syntactic-sugar around micropython's REPL. Currently, it's (relatively) easy to parse responses, since we always know that a response to a Belay Task is the return value. If we add in an asyncronous-esque type solution, we will need a more advanced system of associating responses with their appropriate task. Further still, all targets would need to support some form of threading to execute the task in the background while leaving the primary thread available for the REPL. Not all tasks would require this, but it'd be very hard to implement a reasonable solution.
I'm all ears for ideas, but I don't see a good way of accomplishing it. Perhaps it'd be best to brainstorm solutions to make the polling implementation easier/more natural.
some ideas:
additional options:
A hard requirement I have for Belay is that it has to "just work" with micropython/circuitpython. Optional enhancements could depend on firmware-specific elements, I suppose (but I'd rather avoid it).
Thinking on your first bullet point, what if we add the functionality where host-side Belay performs the polling for the user. Each automatic call is still blocking (I'd have to add thread-safety to Belay), but happens in the background to the user. Add a new optional argument period: float = 0.0
to @Device.task
. The default value, 0, would mean that this feature is disabled.
class MyDevice(Device):
@Device.setup(autoinit=True)
def setup():
state = {
"button A": False,
}
button_a = Pin(25, Pin.IN, Pin.PULL_UP)
@Device.task(period=0.1) # Execute task every 0.1 seconds
def poll_buttons() -> dict: # Methods with a defined period MUST always return a dict.
state["button A"] = button_a.value()
return state
device = MyDevice()
state = poll_buttons() # Blocks on initial read.
# Afterwards, automatically starts a thread that will execute ``poll_buttons`` every 0.1 seconds.
# The returned mutable dictionary will be updated in-place.
while True:
print(f"Button A: {state['button A']}")
What this solves:
What this doesn't solve:
@Device.thread
(or similar) that would read sensors in the background, update the global state, and then have the @Device.task
just return the global state as quickly as possible.All in all, I'm not sure that this solution provides enough value, but let me know what you think. It would be ever so slightly more efficient than:
while True:
state = device.poll_buttons()
print(f"Button A: {state['button A']}")
agree that belay should be without the need for patches on the micropython-side. ...unless there would be interest from the official mpy team to add something like a side-channel to the REPL.
the sketch above removes some boiler-plate code for the user which is nice, but behind the scenes its still polling the mpy-target from the host periodically.
thinking if there are any 'hacks' that can be done to trigger it from the target. i.e. a gpio setup with rising-edge irq, this is the signal we want to trickle up to belay so that an explicit read can be called only when neccesary. could a magic-string be printed from the target that is captured separate from device.task return values and print-outs?
We could introduce a new response code here. Currently there is:
_BELAYR
(R for Return) where the characters that follow are interpretted as an expression/task response._BELAYS
(S for StopIteration) which is used be generators to propagate a StopIteration
signal.Currently the serial port is only being read after being fed a command, we'd have to make it run continuously in another thread. I suppose we could make it invoke a user-provided callback, but I worry it gets to be fairly complicated both internally and externally.
Still thinking...
for incoming data, can't the serial-port use 'select' and just wait there until the target sends something upstream?
AFAIK select
doesn't work with Windows, so we'd have to resort to using the separate thread just for reading (it's not the worst thing in the world).
Thinking out loud of a workflow:
Call a Belay Task (or Setup) that sets up some sort of interrupt to execute a user-provided deviceside-callback-function.
Whenever that interrupt triggers on-device, it will schedule the user interrupt code via micropython.schedule
so that it's less constrained.
The return value of the user code from (2) will return some value. Belay will serialize & send it to host (e.g. print('_BELAYI{"my result": "foo"}')
).
Host Belay will parse this (in the reading thread), and... do what with it? Lets say the user provides a hostside-callback-function, do we spawn another thread to execute it?
True regarding windows & select, but I guess that could be solved in python with a wrap for win32
re 1-2-3 above, agree that something like that would be perfect. On the host side, can't that be all async!? i.e. the CDC will just wait for new data, once it's something coming from the USB phy upstream. shouldn't be a need for threads
A bonus-feature that would be nice to support as well
question:
async as in using python's builtin asyncio? I'd rather avoid it as it would creep its way into everything, making the codebase harder to maintain, and harder to use (especially for newcomers).
As for callbacks, I feel like it could get super messy and the user would have to reorganize their program to be all callback-oriented. Not saying we shouldn't go this route, just need to think about it.
As for printing from a hardware timer without smashing the REPL, I think scheduling a print via micropython.schedule
would work. The current issue you're probably seeing is that an existing print statement may be interrupted with your hardware timer, resulting in a corrupted print message. I think by scheduling it, micropython will internally wait until the complete print
statement is done, but i have not tested.
ok, agree re async, can be added on top for those liking it
about callbacks and/or an 'upstream interrupt', I think most micropy devs are used to dealing with irq's in code running on the target device. so if belay offers a way to register a handler that will get called 'immediately' when the target sends it would be great. this can be extended to multiple callbacks later on, but for most it might just be one, saying that its time to read (rather than having a _while 1: check_if_my_data_isready()
not familiar how micropython.schedule works behind the scenes, but I assume that with a select/wait-state like scenario, in its most basic form, a button event, would promote faster than having a constant poll-loop even with a low or no sleep!?
hey, noted some activity so waking up this discussion as well. have you had some time to sleep on it? 😃
I think for many use-cases, a polling loop is sufficient (admittedly, a little inelegant). Directly reading a button in a belay.task
may miss the actual button push, so it'd be better to have a on-device hardware interrupt set a flag, and then have the belay.task
polling to read/reset the flag. Same goes for ADC conversions and the like.
problem is when running from a raspberry which still has a bit sensitive/unstable usb support in the kernel, or put it like this, if you mix USB FS + USB HS devices on a hub, then polling cont. might cause split-nak's and in turn will slow down other things. so being able to trigger a 'please readme signal' like _BELAYI would be more than enough. rest can be done application side
hmmm, I don't have a good understanding of the lower levels of USB. All micropython devices just use a usb-serial adapter (at some level), and then Belay itself polls the serial port for data. So if data is being pushed into that serial buffer from device without much USB traffic disruption, then these interrupts could improve performance. Is this the case?
yes! and since the CDC is async by design, the link would be virtually silent until the micropy device has something to report upstream to the host
(once this works, a cool feature would of course be to support a callback mechanism to @device.task's so they can return when they're done, lets say there is a slow i2c read or a sensor that needs some time to acquire its data)
Are we expecting for the micropython interpreter to be executing the task in the foreground? E.g:
@device.task
def read_my_sensor() -> float:
return my_i2c.read() # Lets say this is a slow sensor reading that takes 1 second.
data = read_my_sensor() # This blocks for about 1 second
print(f"The temperature is {data} degrees")
What we could do is something like:
@device.task(blocking=False)
def read_my_sensor():
return my_i2c.read() # Lets say this is a slow sensor reading that takes 1 second.
promise = read_my_sensor() # This immediately returns
while not promise.is_ready(): # Manually blocking until the results are ready
time.sleep(0.1)
print(f"The temperature is {data.result} degrees")
This wouldn't require any additional work on the micropython part, it would just be breaking up the response parsing on the host-side.
An issue that would arise is the following:
promise1 = read_my_sensor() # nonblocking call
promise2 = read_my_sensor() # The device is currently processing the first call.
# Do we block for 1 second until promise1 is complete?
# Should Belay create a queue system so this second call immediately returns?
If we want to do that, we could also add
def foo(read_my_sensor_return_value):
pass
@device.task(callback=foo)
def read_my_sensor():
return my_i2c.read() # Lets say this is a slow sensor reading that takes 1 second.
read_my_sensor() # Doesn't return anything; immediately returns
# ``foo`` will be called when read_my_sensor completes
my wish is that the micropython device could be the one initiating the data transfer upstream to the host. mainly to avoid the need for constant polling, but also to reduce the latency a bit (usb fs is 1ms, so doing same via pyboard->pyserial would get a bit busy)
waking up this one. with latest micropython there is now the dynamic USB support. so perhaps it would be the right way of achieving a full duplex belay with higher bandwidth.
handshake over REPL to see on target if dyn usb is there, add a 2nd custom CDC, switch to a new IN & OUT endpoint for the belay commands downstream and one for async upstream events
My schedule is a bit packed, but I'll do some cursory investigation. I am by no means a USB expert, so I might be a little slow learning 😅 .
It would be good to post as many resources here about the new feature. Examples/Tutorials would be much appreciated!
https://docs.micropython.org/en/v1.23.0/library/machine.USBDevice.html#machine.USBDevice
take a look at this example https://github.com/micropython/micropython-lib/blob/master/micropython/usb/examples/device/cdc_repl_example.py#L33
this would setup a clean endpoint, call it a serial-pipe between the host and the micropython device meaning any transport layer / framing could be used.
honestly not sure what would be the "(micro)python way" of solving this pattern with minimal dependencies. some might use cbor or messagepack, but what I like with belay is that there is no need to implement glue-logic or additional protocols/command for each new thing you might want to send or retrieve. that said, it would be nice to offer both the normal function call > immediate return value, but adding async responses for things that might take some time to fetch, like a ADC conversion on the device side. perhaps a pub/sub pattern is best there as it can be used to send immediate events from things like a button or a log message.
I haven't fully grokked all of the approaches explored above and if progress is happening on one of them, maybe alternatives aren't really needed anymore. But FWIW, here's an approach that doesn't appear to have come up yet – use a generator in a device task as a mechanism for issuing a call from host to device that blocks until input is received, rather than polling from the host.
Maybe this wouldn't work the way I imagine – I'm not 100% clear on the threading model on the micropython side and specifically how IRQ functions interact with the REPL. But I was thinking it could work something like ... an IRQ sets a flag and a polling loop waits for the flag and then triggers something and resets the flag, so this would be similar to a previous suggestion, but the key would be to put that polling loop device-side (run by Belay within the REPL) rather than host-side, and the function would yield a value as the mechanism to trigger the host when the polling finds activity. I expect that would significantly reduce the communications overhead of repeated polling calls from the host.
Of course if it works, I think a Belay user could in principle implement all of this on their own on top of Belay, but it seems like Belay could hide a lot of the complexity, both host side and device side.
This would also mean the host would need to be able to interrupt its blocking call when it wants to initiate another task on the device, but I imagine that could be solved with threading in the host-side code? Additionally it would mean that while executing a synchronous task, interrupts on the device side wouldn't be sent up to the host until the synchronous task completes so the polling loop can resume, but for a lot of purposes, that might be fine.
have you thought about ways to implement something to allow 2-way async communication? i.e. setting up a callback for a hardware interrupt generated from a "board gpio"/button could also be useful scenarios when you read a sensor that emits its value when its ADC conversion is done, so instead of having a poll loop on the host, it would be nice if a value or fifo can be dispatched on the next (upstream) usb packet transfer => event to belay