Skip to main content

Communication with Thymio II robot via the Thymio Device Manager

Project description

tdmclient

Python package to connect to a Thymio II robot via the Thymio Device Manager (TDM), a component of the Thymio Suite. The connection between Python and the TDM is done over TCP to the port number advertised by zeroconf.

Installation

Make sure that Thymio Suite, Python 3, and pip, the package installer for Python, are installed on your computer. You can find instructions at [https://www.thymio.org/program/], [https://www.python.org/downloads/] and [https://pypi.org/project/pip/], respectively.

Then in a terminal window, install tdmclient by typing

sudo python3 -m pip install tdmclient

on macOS or Linux. On Windows, in Windows PowerShell or Windows Terminal, just type

python3 -m pip install tdmclient

Tutorial

Connect a robot to your computer via a USB cable or the RF dongle and launch Thymio Suite. In Thymio Suite, you can click the Aseba Studio icon to check that the Thymio is recognized, and, also optionally, start Aseba Studio (select the robot and click the button "Program with Aseba Studio"). Only one client can control the robot at the same time to change a variable or run a program. If that's what you want to do from Python, either don't start Aseba Studio or unlock the robot by clicking the little lock icon in the tab title near the top left corner of the Aseba Studio window.

Some features of the library can be accessed directly from the command window by typing python3 -m tdmclient.tools.abc arguments, where abc is the name of the tool.

tdmclient.tools.tdmdiscovery

Display the address and port of TDM advertised by zeroconf until control-C is typed:

python3 -m tdmclient.tools.tdmdiscovery

tdmclient.tools.run

Run an Aseba program on the first Thymio II robot and store it into the scratchpad so that it's seen in Aseba Studio:

python3 -m tdmclient.tools.run --scratchpad examples/blink.aseba

Stop the program:

python3 -m tdmclient.tools.run --stop

Display other options:

python3 -m tdmclient.tools.run --help

tdmclient.tools.watch

Display all node changes (variables, events and program in the scratchpad) until control-C is typed:

python3 -m tdmclient.tools.watch

tdmclient.tools.variables

Run the variable browser in a window. The GUI is implemented with TK.

python3 -m tdmclient.tools.variables

At launch, the robot is unlocked, i.e. the variables are just fetched and displayed: Observe is displayed in the status area at the bottom of the window. To be able to change them, activate menu Robot>Control. Then you can click any variable, change its value and type Return to confirm or Esc to cancel.

Interactive Python

This section will describe only the use of ClientAsync, the highest-level way to interact with a robot, with asynchronous methods which behave nicely in a non-blocking way if you need to perform other tasks such as running a user interface. All the tools described above use ClientAsync, except for tdmclient.tools.tdmdiscovery which doesn't communicate with the robots.

First we'll type commands interactively by starting Python 3 without argument. To start Python 3, open a terminal window (Windows Terminal or Command Prompt in Windows, Terminal in macOS or Linux) and type python3. TDM replies should arrive quicker than typing at the keyboard. Next section shows how to interact with the TDM from a program where you wait for replies and use them immediately to run as fast as possible.

Start Python 3, then import the required class. We also import the helper function aw, an alias of the static method ClientAsync.aw which is useful when typing commands interactively.

from tdmclient import ClientAsync, aw

Create a client object:

client = ClientAsync()

If the TDM runs on your local computer, its address and port number will be obtained from zeroconf. You can check their value:

client.tdm_addr
client.tdm_port

The client will connect to the TDM which will send messages to us, such as one to announce the existence of a robot. There are two ways to accept and process them:

  • Call explicitly
    client.process_waiting_messages()
    
    If a robot is connected, you should find its description in an array of nodes in the client object:
    node = client.nodes[0]
    
  • Call an asynchronous function in such a way that its result is waited for. This can be done in a coroutine, a special function which is executed at the same time as other tasks your program must perform, with the await Python keyword; or handled by the helper function aw. Keyword await is valid only in a function, hence we cannot call it directly from the Python prompt. In this section, we'll use aw. Robots are associated to nodes. To get the first node once it's available (i.e. an object which refers to the first or only robot after having received and processed enough messages from the TDM to have this information), type
    node = aw(client.wait_for_node())
    
    Avoiding calling yourself process_waiting_messages() is safer, because other methods like wait_for_node() make sure to wait until the expected reply has been received from the TDM.

The value of node is an object which contains some properties related to the robot and let you communicate with it. The node id is displayed when you just print the node:

node

or

print(node)

It's also available as a string:

node_id_str = node.id_str

The node properties are stored as a dict in node.props. For example node.props["name"] is the robot's name, which you can change:

aw(node.rename("my white Thymio"))

Lock the robot to change variables or run programs (make sure it isn't already used in Thymio Suite):

aw(node.lock())

Compile and load an Aseba program:

program = """
var on = 0  # 0=off, 1=on
timer.period[0] = 500

onevent timer0
    on = 1 - on  # "on = not on" with a syntax Aseba accepts
    leds.top = [32 * on, 32 * on, 0]
"""
r = aw(node.compile(program))

The result r is None if the call is successful, or an error number if it has failed. In interactive mode, we won't store anymore the result code if we don't expect and check errors anyway. But it's usually a good thing to be more careful in programs.

No need to store the actual source code for other clients, or anything at all.

aw(node.set_scratchpad("Hello, Studio!"))

Run the program compiled by compile:

aw(node.run())

Stop it:

aw(node.stop())

Make the robot move forward by setting both variables motor.left.target and motor.right.target:

v = {
    "motor.left.target": [50],
    "motor.right.target": [50],
}
aw(node.set_variables(v))

Make the robot stop:

v = {
    "motor.left.target": [0],
    "motor.right.target": [0],
}
aw(node.set_variables(v))

Unlock the robot:

aw(node.unlock())

Getting variable values is done by observing changes, which requires a function; likewise to receive events. This is easier to do in a Python program file. We'll do it in the next section.

Here is how to send custom events from Python to the robot. The robot must run a program which defines an onevent event handler; but in order to accept a custom event name, we have to declare it first to the TDM, outside the program. We'll define an event to send two values for the speed of the wheels, "speed". Method node.register_events has one argument, an array of tuples where each tuple contains the event name and the size of its data between 0 for none and a maximum of 32. The robot must be locked if it isn't already to accept register_events, compile, run, and send_events.

aw(node.lock())
aw(node.register_events([("speed", 2)]))

Then we can send and run the program. The event data are obtained from variable event.args; in our case only the first two elements are used.

program = """
onevent speed
    motor.left.target = event.args[0]
    motor.right.target = event.args[1]
"""
aw(node.compile(program))
aw(node.run())

Finally, the Python program can send events. Method node.send_events has one argument, a dict where keys correspond to event names and values to event data.

# turn right
aw(node.send_events({"speed": [40, 20]}))
# wait 1 second, or wait yourself before typing the next command
aw(client.sleep(1))
# stop the robot
aw(node.send_events({"speed": [0, 0]}))

Python program

In a program, instead of executing asynchronous methods synchronously with aw or ClientAsync.aw, we put them in an async function and we await for their result. The whole async function is executed with method run_async_program.

Moving forward, waiting for 2 seconds and stopping could be done with the following code. You can store it in a .py file or paste it directly into an interactive Python 3 session, as you prefer; but make sure you don't keep the robot locked, you wouldn't be able to lock it a second time. Quitting and restarting Python is a sure way to start from a clean state.

from tdmclient import ClientAsync

def motors(left, right):
    return {
        "motor.left.target": [left],
        "motor.right.target": [right],
    }

client = ClientAsync()

async def prog():
    node = await client.wait_for_node()
    await node.lock()
    await node.set_variables(motors(50, 50))
    await client.sleep(2)
    await node.set_variables(motors(0, 0))
    await node.unlock()

client.run_async_program(prog)

This can be simplified a little bit with the help of with constructs:

from tdmclient import ClientAsync

def motors(left, right):
    return {
        "motor.left.target": [left],
        "motor.right.target": [right],
    }

with ClientAsync() as client:
    async def prog():
        with await client.lock() as node:
            await node.set_variables(motors(50, 50))
            await client.sleep(2)
            await node.set_variables(motors(0, 0))
    client.run_async_program(prog)

To read variables, the updates must be observed with a function. The following program calculates a motor speed based on the front proximity sensor to move backward when it detects an obstacle. Instead of calling the async method set_variables which expects a result code in a message from the TDM, it just sends a message to change variables with send_set_variables without expecting any reply. The TDM will send a reply anyway, but the client will ignore it without trying to associate it with the request message. sleep() without argument (or with a negative duration) waits forever, until you interrupt it by typing control-C.

from tdmclient import ClientAsync

def motors(left, right):
    return {
        "motor.left.target": [left],
        "motor.right.target": [right],
    }

def on_variables_changed(node, variables):
    try:
        prox = variables["prox.horizontal"]
        prox_front = prox[2]
        speed = -prox_front // 10
        node.send_set_variables(motors(speed, speed))
    except KeyError:
        pass  # prox.horizontal not found

with ClientAsync() as client:
    async def prog():
        with await client.lock() as node:
            await node.watch(variables=True)
            node.add_variables_changed_listener(on_variables_changed)
            await client.sleep()
    client.run_async_program(prog)

Compare with an equivalent Python program running directly on the Thymio:

@onevent
def prox():
    global prox_horizontal, motor_left_target, motor_right_target
    prox_front = prox_horizontal[2]
    speed = -prox_front // 10
    motor_left_target = speed
    motor_right_target = speed

You could save it as a .py file and run it with tdmclient.tools.run as explained above. If you want to do everything yourself, to understand precisely how tdmclient works or because you want to eventually combine processing on the Thymio and on your computer, here is a Python program running on the PC to convert it to Aseba, compile and load it, and run it.

from tdmclient import ClientAsync
from tdmclient.atranspiler import ATranspiler

thymio_program_python = r"""
@onevent
def prox():
    global prox_horizontal, motor_left_target, motor_right_target
    prox_front = prox.horizontal[2]
    speed = -prox_front // 10
    motor_left_target = speed
    motor_right_target = speed
"""

# convert program from Python to Aseba
transpiler = ATranspiler()
transpiler.set_source(thymio_program_python)
transpiler.transpile()
thymio_program_aseba = transpiler.get_output()

with ClientAsync() as client:
    async def prog():
        with await client.lock() as node:
            error = await node.compile(thymio_program_aseba)
            error = await node.run()
    client.run_async_program(prog)

Cached variables

tdmclient offers a simpler way, if slightly slower, to obtain and change Thymio variables. They're accessible as node["variable_name"] or node.v.variable_name, both for getting and setting values, also when variable_name contains dots. Here is an alternative implementation of the remote control version of the program which makes the robot move backward when an obstacle is detected by the front proximity sensor.

from tdmclient import ClientAsync

with ClientAsync() as client:
    async def prog():
        with await client.lock() as node:
            await node.wait_for_variables({"prox.horizontal"})
            while True:
                prox_front = node.v.prox.horizontal[2]
                speed = -prox_front // 10
                node.v.motor.left.target = speed
                node.v.motor.right.target = speed
                node.flush()
                await client.sleep(0.1)
    client.run_async_program(prog)

Scalar variables have an int value. Array variables are iterable, i.e. they can be used in for loops, converted to lists with function list, and used by functions such as max and sum. They can be stored as a whole and retain their link with the robot: getting an element retieves the most current value, and setting an element caches the value so that it will be sent to the robot by the next call to node.flush(). Here is an interactive session which illustrates what can be done.

>>> from tdmclient import ClientAsync
>>> client = ClientAsync()
>>> node = client.aw(client.wait_for_node())
>>> client.aw(node.wait_for_variables({"leds.top"}))
>>> rgb = node.v.leds.top
>>> rgb
Node array variable leds.top[3]
>>> list(rgb)
[0, 0, 0]
>>> client.aw(node.lock_node())
>>> rgb[0] = 32  # red
>>> node.var_to_send
{'leds.top': [32, 0, 0]}
>>> node.flush()  # robot turns red

Python-to-Aseba transpiler

The official programming language of the Thymio is Aseba, a rudimentary event-driven text language. In the current official software environment, it's compiled by the TDM to machine code for a virtual processor, which is itself a program which runs on the Thymio. Virtual processors are common on many platforms; they're often referred as VM (Virtual Machine), and their machine code as bytecode.

Most programming languages for the Thymio eventually involve bytecode running on its VM. They can be divided into two main categories:

  • Programs compiled to bytecode running autonomously in the VM on the microcontroller of the Thymio. In Thymio Suite and its predecessor Aseba Studio, this includes the Aseba language; and VPL, VPL 3, and Blockly, where programs are made of graphical representations of programming constructs and converted to bytecode in two steps, first to Aseba, then from Aseba to bytecode with the standard TDM Aseba compiler.
  • Programs running on the computer and controlling the Thymio remotely. In Thymio Suite and Aseba Studio, this includes Scratch. Python programs which use thymiodirect or tdmclient can also belong to this category. A small Aseba program runs on the Thymio, receives commands and sends back data via events.

Exceptions would include programs which control remotely the Thymio exclusively by fetching and changing variables; and changing the Thymio firmware, the low-level program compiled directly for its microcontroller.

Remote programs can rely on much greater computing power and virtually unlimited data storage capacity. On the other hand, communication is slow, especially with the USB radio dongle. It restricts what can be achieved in feedback loops when commands to actuators are based on sensor measurements.

Alternative compilers belonging to the first category, not needing the approval of Mobsya, are possible and have actually been implemented. While the Thymio VM is tightly coupled to the requirements of the Aseba language, some of the most glaring Aseba language limitations can be circumvented. Ultimately, the principal restrictions are the amount of code and data memory and the execution speed.

Sending arbitrary bytecode to the Thymio cannot be done by the TDM. The TDM accepts only Aseba source code, compiles it itself, and sends the resulting bytecode to the Thymio. So with the TDM, to support alternative languages, we must convert them to Aseba programs. To send bytecode, or assembly code which is a bytecode text representation easier to understand for humans, an alternative would be the Python package thymiodirect.

Converting source code (the original text program representation) from a language to another one is known as transpilation (or transcompilation). This document describes a transpiler which converts programs from Python to Aseba. Its goal is to run Python programs locally on the Thymio, be it for autonomous programs or for control and data acquisition in cooperation with the computer via events. Only a small subset of Python is supported. Most limitations of Aseba are still present.

Features

The transpiler is implemented in class ATranspiler, completely independently of other tdmclient functionality. The input is a complete program in Python; the output is an Aseba program.

Here are the implemented features:

  • Python syntax. The official Python parser is used, hence no surprise should be expected, including with spaces, tabs, parentheses, and comments.
  • Integer and boolean base types. Both are stored as signed 16-bit numbers, without error on overflow.
  • Global variables. Variables are collected from the left-hand side part of plain assignments (assignment to variables without indexing). For arrays, there must exist at least one assignment of a list, directly or indirectly (i.e. a=[1,2];b=a is valid). Size conflicts are flagged as errors.
  • Expressions with scalar arithmetic, comparisons (including chained comparisons), and boolean logic and conditional expressions with short-circuit evaluation. Numbers and booleans can be mixed freely. The following Python operators and functions are supported: infix operators +, -, *, // (integer division), % (converted to modulo instead of remainder, whose sign can differ with negative operands), &, |, ^, <<, >>, ==, !=, >, >=, <, <=, and, or; prefix operators +, -, ~, not; and functions abs and len.
  • Constants False and True.
  • Assignments of scalars to scalar variables or array elements; or lists to whole array variables.
  • Augmented assignments +=, -=, *=, //=, %=, &=, |=, ^=, <<=, >>=.
  • Programming constructs if elif else, while else, for in range else, pass, return. The for loop must use a range generator with 1, 2 or 3 arguments.
  • Functions with scalar arguments, with or without return value (either a scalar value in all return statement; or no return statement or only without value, and call from the top level of expression statements, i.e. not at a place where a value is expected). Variable-length arguments *args and **kwargs, default values and multiple arguments with the same name are forbidden. Variables are local unless declared as global or not assigned to. Thymio predefined variables must also be declared explicitly as global when used in functions. In Python, dots are replaced by underscores; e.g. leds_top in Python corresponds to leds.top in Aseba.
  • Function definitions for event handlers with the @onevent decorator. The function name must match the event name (such as def timer0(): for the first timer event). Arguments are not supported; otherwise variables in event handlers behave like in plain function definitions.
  • Function call emit("name") or emit("name", param1, param2, ...) to emit an event without or with parameters. The first argument must be a literal string, delimited with single or double quotes. Raw strings (prefixed with r) are allowed, f-strings or byte strings are not. Remaining arguments, if any, must be scalar expressions and are passed as event data.
  • In expression statements, in addition to function calls, the ellipsis ... can be used as a synonym of pass.

Perhaps the most noticeable missing features are the non-integer division operator / (Python has operator // for the integer division), and the break and continue statements, also missing in Aseba and difficult to transpile to sane code without goto. High on our to-do list: functions with arguments and return value.

The transpilation is mostly straightforward. Mixing numeric and boolean expressions often requires splitting them into multiple statements and using temporary variables. The for loop is transpiled to an Aseba while loop because in Aseba, for is limited to constant ranges. Comments are lost because the official Python parser used for the first phase ignores them. Since functions are transpiled to subroutines, recursive functions are forbidden.

Example

Blinking top RGB led:

on = False
timer_period[0] = 500

@onevent
def timer0():
    global on, leds_top
    on = not on
    if on:
        leds_top = [32, 32, 0]
    else:
        leds_top = [0, 0, 0]

To transpile this program, assuming it's stored in examples/blink.py:

python3 -m tdmclient.atranspiler examples/blink.py

The result is

var on
var tmp[1]

on = 0
timer.period[0] = 500

onevent timer0
    if on == 0 then
        tmp[0] = 1
    else
        tmp[0] = 0
    end
    on = tmp[0]
    if on != 0 then
        leds.top = [32, 32, 0]
    else
        leds.top = [0, 0, 0]
    end

To run this program:

python3 -m tdmclient.tools.run examples/blink.py

Feature comparison

The table below shows a mapping between Aseba and Python features. Empty cells stand for lack of a direct equivalent. Prefixes const_, numeric_ or bool_ indicate restrictions on what's permitted. Standard Python features which are missing are not transpiled; they cause an error.

Aseba Python
infix + - * / infix + - * // %
infix % (remainder) infix % (modulo)
infix << >> ` & ^`
prefix - ~ not prefix - ~ not
prefix +
== != < <= > >= == != < <= > >=
a < b < c (chained comparisons)
and or (without shortcut) and or (with shortcut)
val1 if test else val2
var v no declarations
var a[size]
var a[] = [...] a = [...]
v = numeric_expr v = any_expr
v[index_expr] v[index_expr]
v[constant_range]
if bool_expr then if any_expr:
elseif bool_expr then elif any_expr:
else else:
end indenting
when bool_expr do
while bool_expr do while any_expr:
for v in 0 : const_b - 1 do for v in range(expr_a, expr_b):
for v in const_a : const_b - 1 do for v in range(expr_a, expr_b):
for v in const_a : const_b -/+ 1 step const_s do for v in range(expr_a, expr_b, expr_s):
sub fun def fun():
all variables are global global g
assigned variables are local by default
def fun(arg1, arg2, ...):
return return
return expr
callsub fun fun()
fun(expr1, expr2, ...)
fun(...) in expression
onevent name @onevent def name():
all variables are global global g
assigned variables are local by default
emit name emit("name")
emit name [expr1, expr2, ...] emit("name", expr1, expr2, ...)
call natfun expr1, expr2, ... nf_natfun(expr1, expr2, ...) (see below)
natfun(expr1, ...) in expressions

In Python, the names of native functions have underscores instead of dots. Many native functions can be called with the syntax of a plain function call, with a name prefixed with nf_ and the same arguments as in Aseba. In the table below, uppercase letters stand for arrays, lowercase letters for scalar values, A, B, a and b for inputs, R and r for result, and P for both input and result.

Aseba Python
call math.copy(R, A) nf_math_copy(R, A)
call math.fill(R, a) nf_math_fill(R, a)
call math.addscalar(R, A, b) nf_math_addscalar(R, A, b)
call math.add(R, A, B) nf_math_add(R, A, B)
call math.sub(R, A, B) nf_math_sub(R, A, B)
call math.mul(R, A, B) nf_math_mul(R, A, B)
call math.div(R, A, B) nf_math_div(R, A, B)
call math.min(R, A, B) nf_math_min(R, A, B)
call math.max(R, A, B) nf_math_max(R, A, B)
call math.clamp(R, A, B, C) nf_math_clamp(R, A, B, C)
call math.rand(R) nf_math_rand(R)
call math.sort(P) nf_math_sort(P)
call math.muldiv(R, A, B, C) nf_math_muldiv(R, A, B, C)
call math.atan2(R, A, B) nf_math_atan2(R, A, B)
call math.sin(R, A) nf_math_sin(R, A)
call math.cos(R, A) nf_math_cos(R, A)
call math.rot2(R, A, b) nf_math_rot2(R, A, b)
call math.sqrt(R, A) nf_math_sqrt(R, A)

A few of them have a name without the nf_ prefix, scalar arguments and a single scalar result. They can be used in an assignment or in expressions.

Aseba native function Python function call
math.min math_min(a, b)
math.max math_max(a, b)
math.clamp math_clamp(a, b, c)
math.rand math_rand()
math.muldiv math_muldiv(a, b, c)
math.atan2 math_atan2(a, b)
math.sin math_sin(a)
math.cos math_cos(a)
math.sqrt math_sqrt(a)

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tdmclient-0.1.2.tar.gz (62.4 kB view hashes)

Uploaded Source

Built Distribution

tdmclient-0.1.2-py3-none-any.whl (59.5 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page