ev3dev / ev3dev-lang-python

Pure python bindings for ev3dev
MIT License
425 stars 144 forks source link

MQTT update docs and make it easier to use #699

Open dwalton76 opened 4 years ago

dwalton76 commented 4 years ago

see #385 for some background on this

dwalton76 commented 4 years ago

we could install the python3-paho-mqtt debian package by default on ev3dev

EDIT: there is a debian package that I was able to install on my laptop but there is not a debian package available on stretch

robot@ev3dev:~$ apt-cache search python3-paho-mqtt
robot@ev3dev:~$ 
dwalton76 commented 4 years ago

the https://www.ev3dev.org/docs/tutorials/sending-and-receiving-messages-with-mqtt/ docs are really good at walking the user through getting the basics up and running

dwalton76 commented 4 years ago

I am brainstorming on how we can make this easy to use. My first stab at this is to have the publisher TX a block of code to the subscriber and then the subscriber execs that block of code (ignore the glaring security issues with this for now).

I've created Ev3devMqttPublisher and Ev3devMqttSubscriber classes that implement the MQTT connections described in the docs. For now they aren't doing much more than that so I didn't include the code for those here.

It ends up looking like this

Publisher

    ev3 = Ev3devMqttPublisher("localhost")
    ev3.publish("homer", """
from ev3dev2.motor import LargeMotor, OUTPUT_A
from ev3dev2.sensor import INPUT_1
from ev3dev2.sensor.lego import TouchSensor

motor = LargeMotor(OUTPUT_A)
ts = TouchSensor(INPUT_1)

# If the TouchSensor is pressed, run the motor
while True:
    ts.wait_for_pressed()
    motor.run_forever(speed_sp=200)

    ts.wait_for_released()
    motor.stop()
""")

Subscriber

    def on_message(self, userdata, msg):
        log.info("%s RXed userdata '%s' with msg\n%s\n" % (self, userdata, msg.payload.decode()))

        try:
            exec(msg.payload.decode())
        except Exception as e:
            log.exception(e)

This doesn't take much code and it makes it easy for someone to access our full API on a remote ev3dev device. I need to think some about how to tighten down the security issues...about the time someone sends os.unlink("/") that would be really bad.

Thoughts?

WasabiFan commented 4 years ago

I feel like that model is on the right track but has some design issues. By the looks of it, this code is assuming that you'd send a large block of a program and then have it operate independently. But what happens when you need to constantly send it input? E.g. you need to be able to send new steering commands for motors? One option would be to further embed MQTT receiving into the code you send -- you send the initial code and then it in turn uses APIs to receive other input. But personally I think that's a) ideosyncratic and b) confusing.

Personally, I think it might make sense to somewhat reinvent RPyC in a simpler form. We could make proxy objects (i.e., objects which look like a Motor or TouchSensor or MoveTank, except with some extra constructor parameters) which internally do nothing but send a "function call" or "property read" message to the remote device. The remote device calls that function or reads that property on a real Motor or TouchSensor or MoveTank and reports back the results. Perhaps the API would look like this:

from ev3dev2.motor import MoveTank, OUTPUT_A, OUTPUT_B
from ev3dev2.remote import RemoteProxy

# RemoteProxy would take the full importable path of the given class and the given constructor parameters and send them to the remote device. The remote device constructs one of those objects with the given parameters and reports the results. The remote device would probably cache the object in a map somewhere and return a unique ID for it.
drive = RemoteProxy(MoveTank, '192.168.1.15', OUTPUT_A, OUTPUT_B)

# the RemoteProxy implements __getattr__ so that it picks up this call. It forwards the call onto the remote host and waits for a response.
drive.on_for_rotations(50, 75, 10)

This would probably be rather simple to implement, at least in principle. I'd guess the client and server would each be a hundred lines or less. From an API perpective, we could explicitly have individual RemoteMotor, RemoteMoveTank, etc. classes instead of a public RemoteProxy; I don't have a particular preference although I think the one I showed above would be easier to implement. The RemoteProxy could potentially be expanded to have the ability to initiate a call and not block while waiting for a response, to improve latency. This all sounds like quite a fun project! I'd be happy to write out a POC if that would be helpful.

The main difficulty I see here is treating types carefully. We could probably pickle the parameters or similar so we didn't have to deal with it ourselves. Whatever we do, unit classes like SpeedPercent must be supported.

Pros:

Cons:

What are your thoughts?

Other potential solutions, while I'm thinking:

WasabiFan commented 4 years ago

My imagined model above would look something like this as an implementation (real syntax with some details ignored):

# local library
class RemoteHost:
    def __init__(self, hostname):
        # TODO: connect

    def call_function(name, args):
        # TODO: send private unique ID along with the connection

    # internal APIs to use for the connection...

class RemoteProxy:
    def __init__(self, clazz, remote_host, *args):
        # e.g.: ev3dev2.motor.LargeMotor
        class_name = get_importable_class_name(clazz)
        self._remote_obj = remote_host.create_object(class_name, args)

    def __getattr__(self, name):
        def call_handler(*args):
            return self._remote_obj.call_function(name, args)
        return call_handler

# remote script
remote_objects = {}

def handle_create_object_request(class_name, args):
    obj = import_and_construct(class_name, args)
    remote_objects[id(obj)] = obj

def handle_call_function_request(id, name, args):
    obj = remote_objects[id]
    return obj[name](*args)

This is essentially a reimplementation of RPyC, but simplified in a way we can probably support Micropython and adjust for our own use cases. Altogether, I think this would be a pretty good user experience, although it does lack the "fire and forget" mechanics that you might want for high performance.

Major details:

Minor details:

ddemidov commented 4 years ago

I like the 'mount sysfs remotely and use it with native classes' path. This way, the device classes do not have to be changed at all (we already support custom sysfs paths for testing). sshfs is one option, exporting sysfs over nfs (if that works) could be another.

WasabiFan commented 4 years ago

I do agree that the simplicity is very nice. @dwalton76 I think we should test it and get a sense of the performance. My guess, which could be very misguided, is that it some combination of increased latency and caching effects will make synchronized motor starts unusably inconsistent. We should test and see.

dwalton76 commented 4 years ago

If we mount the remote file system we won’t have a way to be notified when the file changes (at least I don’t think we do). Today there are many places where we block until we receive an event from the kernel to let us know some file had been modified. I think we have to figure out some solution for that for any approach where we want to directly write to a remote file system.

dwalton76 commented 4 years ago

But what happens when you need to constantly send it input? E.g. you need to be able to send new steering commands for motors?

You would code it the same way you would today for running locally but you would TX it to the subscriber for execution. When the subscriber execs the block of code in the example it is storing the created LargeMotr object as part of self so the state is still there. Any future code we send to the subscriber just needs to use that object...but that is no different than if you ran that code locally.

dwalton76 commented 4 years ago

If we mount the remote file system we won’t have a way to be notified when the file changes

Looking at this some more, the only place where this is an issue is Motor.wait() where it waits for the state file to be modified. For the scenario of a remote Motor a super simple solution would be for Motor.wait() to check the state file in a loop until it changes...not ideal but it would work.

dwalton76 commented 4 years ago

Regarding the API it could be as simple as specifying the IP of the remote ev3dev device when you create the object

lm = LargeMotor(ev3dev_ip="10.1.1.7")
ts = TouchSensor(ev3dev_ip="10.1.1.17")
WasabiFan commented 4 years ago

Interesting note about the event notifications. Yes, a busy wait would potentially work. I think it would make sense to test the remote mounting approach so we have a benchmark.

You would code it the same way you would today for running locally but you would TX it to the subscriber for execution. [...]

In other words, the usage would look less like the example above and more like an individual series of one-line execute calls, I think.

In implementing this "raw exec" scheme we would then need to consider how values are returned if you want to read a value and retrieve it. And, as you said, it would need some way of managing existing objects so that you can create an object in one exec call and then use it in a later one. I don't know how difficult that would be.

Personally, I think a somewhat integrated API is preferable over raw string execution. I think it would be easier for the client to consume and also easier for us to optimize for particular methods' behaviors if desired. The particulars of the API (having a separate wrapper object vs. trying to integrate it into existing objects) can be figured out of we agree on a general implementation style.

And again, I think it's worth testing the direct mounting approach to see if that works. I tested it on a plain Ubuntu machine I have and was able to interact with sysfs attributes, but I have no idea how it will react on when interacting with ev3dev motors.