BrianPugh / belay

Belay is a python library that enables the rapid development of projects that interact with hardware via a micropython-compatible board.
Apache License 2.0
237 stars 13 forks source link

API for interrupts / callbacks #132

Open raveslave opened 1 year ago

raveslave commented 1 year ago

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

BrianPugh commented 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.

raveslave commented 1 year ago

some ideas:

additional options:

BrianPugh commented 1 year ago

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:

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']}")
raveslave commented 1 year ago

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?

BrianPugh commented 1 year ago

We could introduce a new response code here. Currently there is:

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...

raveslave commented 1 year ago

for incoming data, can't the serial-port use 'select' and just wait there until the target sends something upstream?

BrianPugh commented 1 year ago

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:

  1. Call a Belay Task (or Setup) that sets up some sort of interrupt to execute a user-provided deviceside-callback-function.

  2. Whenever that interrupt triggers on-device, it will schedule the user interrupt code via micropython.schedule so that it's less constrained.

  3. 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"}')).

  4. 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?

raveslave commented 1 year ago

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:

BrianPugh commented 1 year ago

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.

raveslave commented 1 year ago

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!?

raveslave commented 11 months ago

hey, noted some activity so waking up this discussion as well. have you had some time to sleep on it? 😃

BrianPugh commented 11 months ago

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.

raveslave commented 11 months ago

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

BrianPugh commented 11 months ago

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?

raveslave commented 11 months ago

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)

BrianPugh commented 11 months ago

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?
BrianPugh commented 11 months ago

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
raveslave commented 10 months ago

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)

raveslave commented 3 months ago

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

BrianPugh commented 3 months ago

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

raveslave commented 3 months ago

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.

neilfred commented 1 month ago

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.