How to write Arduino Uno code with Python?


Recently I came across a Reddit thread where someone asked:

“I was thinking about using an Arduino, but I have been learning Python and do not want to learn a whole other programming language to do basic things that an Arduino can. (Planning on making a robot, will be using raspberry pi also, but have seen motor controls and such are best done with Arduino).

What experience have you had programming an Arduino with python? How is it and is it difficult?”

There are more threads on the similar topic (like this and that) and when you scroll through the responses on all of these threads, you see people responding with messages like:

“you can’t program an arduino with python”

Or:

“Arduino does not have sufficient resources to run python.”

Or:

“It’s going to be extremely difficult to get any kind of Python script running directly on the Arduino.”

Or:

“You gotta use pyfirmata”

What surprised me was that everyone seemed to be answering a question the original poster didn’t actually ask. The guy who started the thread didn’t say that he wanted to literally run Python on the Arduino and definitely he didn’t want to just control the Arduino from the host (like the answer about pyfirmata suggests). To me, it sounds more like he just wanted to write his Arduino code in Python – and that he wouldn’t really care if the board ended up running an ordinary C++.

I thought that it shouldn’t be that hard to write some reasonable MVP which does exactly that. This gave me the idea for an experimental project: PyBedded. It’s a tool that lets you write Arduino code in Python and then flash it to your board by just running your Python script. You still get the simplicity of Python, but the Arduino ends up running the same C++ firmware as it would normally do.

TL;DR

If you’re here to just use the tool and not read about its details, download the GitHub repository and read the README to learn how to use it.

What this project is NOT

At the beginning, to avoid any disappointments after reading, I’d like to clearly outline what to not expect from this project:

  • as mentioned in the introduction, this is not another Firmata-like project. The original post on Reddit mentioned about the ability to program Arduino which could run as an independent device, so controlling the board with a Python script from host doesn’t fulfill that requirement
  • I won’t try to deploy the actual Python interpreter on the Arduino board or use Micro/CircuitPython which are supported only by some subset of all boards – my assumption is that someone has an old Arduino (like Uno, Nano, Mega2560 etc.) and wants to write a Python code
  • it’s not (only) about the Python to C++ conversion – I want the tool to be “self-contained” meaning, that the user doesn’t need to use tool A to convert the Python code, tool B to compile it, tool C to flash it etc. This tool should actually flash the board just by running the script with Arduino code written in Python

Challenges

Workflow

The main challenge was to define a workflow that would fulfill all the assumptions made in the previous paragraph . In the end, I wanted user to just write an ordinary Python script (with a reasonable amount of additional boilerplate code), run it and have the program running on the Arduino board.

The framework ended up looking as following – user uses an externally defined type ArduinoBoard which provides context manager functionality. The entire code contained within this block will be treated as code which is intended to run on Arduino.

Python
with ArduinoBoard("/dev/ttyUSB0", Board.UNO):
    # Arduino code

The Arduino users are used to a specific main file structure which requires definition of setup and loop functions. Let’s make them mandatory also in PyBedded. Python supports function definition inside the context manager’s block, so the minimum required code looks like this:

Python
with ArduinoBoard("/dev/ttyUSB0", Board.UNO):
    def setup() -> None:
        pass

    def loop() -> None:
        pass

Such structure gives also another benefit – the ease of finding where the Arduino code starts.

I mentioned above, that everything below the context manager block will end up flashed to Arduino, but how to actually find it in the file? I don’t want to rely on hardcoding the line number because the user may add something above it. I also don’t want to rely on searching of substring like “ArduinoBoard(“ because if the name will be changed, I would have to change code extraction logic.

However, because I know that the user always uses context manager, I can use the inspect module to check from what file and which line in that file ArduinoBoard‘s context manager has been called:

Python
import inspect

frame: FrameInfo = inspect.stack()[2]
file_path: str = frame.filename
start_line: int = frame.lineno

LOGGER.debug(f"Python code available in {file_path}, starting at line {start_line}")

Support for Arduino and external libraries

At the beginning one of my concerns was how to provide support for all the Arduino’s core API (stuff like digitalWrite, digitalRead etc.) and all the libraries associated with it (like Servo, Ethernet etc.), but then I realized that actually the only thing that’s needed to write a valid Python code, is the list of corresponding function’s and classes’ definitions. For example, to enable PyBedded user to set digital pin state, the only thing that needs to be provided is:

Python
def digitalWrite(pin: int, state: int) -> None:
    pass

The call of this function will look exactly the same both in Python and C++ what makes the code conversion easier. Appropriate C++ header will be included in the generated code depending on the functions used in the code.

Toolchain

The main tool that will be used underneath will be Arduino CLI. The following commands will be especially useful in this project:

  • arduino-cli lib install – I will use it to in the installation script to install all the currently supported libraries which can be used in the sketch
  • arduino-cli core install – I will use it in the installation script to install all the currently supported boards
  • arduino-cli compile – compilation of the generated C++ code
  • arduino-cli upload – flashing Arduino with the compiled firmware

Design

The design relies almost entirely on the Python’s context manager functionality – as soon as the with ArduinoBoard... block finishes, the following steps are executed:

  • read the script’s path and the line where Arduino code starts
  • extract Python code from the script which is going to be converted to C++
  • create Arduino project (folder and .ino file)
  • compile the code
  • upload the code to the Arduino board
  • remove Arduino project folder and .ino file

The flow is nice and easy, so let’s now look at the Python-to-C++ code conversion.

Python to C++ conversion

There are whole books about writing compilers, code transpilation, parsers, lexers etc., but because this proof of concept is rather focused on the Python framework for Arduino programming, I decided to implement a simple Python to C++ converter with the following structure:

MDX
Python code -> PythonParser -> code model -> CppGenerator -> C++ code

PythonParser takes as an input Python code from the user’s script and converts it to a common, language-agnostic model. In practice, PythonParser will be responsible for recognizing certain language features and creating their models like this:

Python
for python_line in python_code:
    if is_function_definition(python_line):  
        # parsing
        return FunctionDefinitionModel(...)  
    elif is_for_loop(python_line):  
        # parsing  
        return ForLoopModel(...) 
    elif is_if_statement(python_line):  
        # parsing
        return IfStatementModel(...)
    # etc

Then this model is taken as an input by the CppGenerator which runs over all the models and converts these models to the actual C++ code line depending on the type of the each model object:

Python
cpp_code: str = ""

for model in models:
    if isinstance(model, FunctionDefinitionModel):
        cpp_code += generate_function_definition(model)
    elif isinstance(model, ForLoopModel):
        cpp_code += generate_for_loop(model)
    elif isinstance(model, IfStatement):
        cpp_code += generate_if_statement(model)
    # etc

Such logic is simple, but not perfect – it makes it ease to add support for the new features in simple scenarios (you just add a single elif in PythonParser and elif in CppGenerator), but can introduce some unexpected behavior because the parser actually relies on the order of the elifs.

To make it work, the least inclusive conditions must be placed earlier in the elif “ladder” and more inclusive ones must be placed later. For example, in the following code, the second condition will never be triggered because “if” is contained both in “if” and “elif”, so it would never generate any “else if” in the C++ code.

Python
if "if" in python_line:
    # process
elif "elif" in python_line:
    # process

So “elif” (as the less inclusive condition) must be checked before “if” (which is more general condition and matches more cases):

Python
if "elif" in python_line:
    # process
elif "if" in python_line:
    # process

Ok, so let’s now take a look on how to actually write the Arduino code with PyBedded and what C++ code it is being converted to.

Variable definition

Variable definition looks just as they do in Python with one addition – types annotations are no longer optional, but mandatory since C++ requires them. So the following code:

Python
sensor_value: int = 0

Ends up as:

C++
int sensor_value = 0;

In the resulting sketch.

Function definition

Python allows to define new functions almost everywhere, so user places custom functions definitions inside the Arduino context manager’s block (in the same way as setup and loop functions are defined). The syntax is a typical Python’s syntax, but again, type annotations are mandatory:

Python
def do_something(a: int, b: unsigned_int) -> None:
    pass

The above code will be converted to:

C++
void do_something(int a, unsigned int b)
{}

Notice 2 things:

  • None is an equivalent of void
  • types like unsigned int, unsigned longetc. are not supported by default in Python, so PyBedded adds such support by defining new aliases like:
Python
unsigned_long = int  
unsigned_int = int

This way user can use such types and still be able to write a valid Python code.

Control statements

There are control statements whose syntax is very similar in Python and C++ (e.g. ifor while loop) and control statements whose syntax is completely different (e.g. for loops).

In case of the first category, generation boils down mainly to adding parenthesis and converting logic operators to the ones used in C++, so that this:

Python
if not Serial and (a == 1 or b == 2):
    pass

becomes that:

C++
if (!Serial && (a == 1 || b == 2))
{}

In case of the second category, you still write an ordinary Python syntax, but underneath Python-to-C++ converter tries to match the corresponding values to the C++’s syntax. Input:

Python
# 1
for i in range(10):
    pass
# 2
for i in range(2, 10):
    pass
# 3
for i in range(2, 10, 3):
    pass
# 4
for i in range(10, 0, -1):
    pass

Output:

C++
# 1
for (int i=0; i<10; i += 1)
{}
# 2
for (int i=2, i<10; i += 1)
{}
# 3
for (int i=2; i<10; i += 3)
{}
# 4
for (int i=10, i>0; i += -1)
{}

Preprocessor directives

Python is not a compiled programming language, so preprocessor directives like #define or ifdef must be supported by some kind of a convention.

In case of #define, I decided to rely on a naming convention – if a variable is declared using all uppercase letters, then it will end up as a compile-time constant. So the following Python code:

Python
SENSOR_PIN: int = 4
sensor_value: int = 0

is converted to:

C++
#define SENSOR_PIN 4
int sensor_value = 0;

When it comes to compile-time conditions, PyBedded just provides 3 special functions: IFDEF, IFNDEF and ENDIF:

Python
IFDEF("SOME_FLAG")
sensor_value += 1
ENDIF()

C++ code equivalent:

C++
#ifdef SOME_FLAG
sensor_value += 1;
#endif

Examples

Python code:

Python
# create ArduinoBoard object providing the port it is connected to
# and the board's type 
with ArduinoBoard("/dev/ttyUSB0", Board.UNO):  
    def setup() -> None:
        # set pin mode  
        pinMode(LED_BUILTIN, OUTPUT)  
  
    def loop() -> None:
        # set pin state to high
        digitalWrite(LED_BUILTIN, HIGH)
        # wait 1000ms  
        delay(1000)  
        # set pin state to low
        digitalWrite(LED_BUILTIN, LOW)
        # wait 1000ms  
        delay(1000)

The generated C++ code:

C++
void setup() {  
    pinMode(LED_BUILTIN, OUTPUT);  
}  
void loop() {  
    digitalWrite(LED_BUILTIN, HIGH);  
    delay(1000);  
    digitalWrite(LED_BUILTIN, LOW);  
    delay(1000);  
}

Python code:

Python
with ArduinoBoard("/dev/ttyUSB0", Board.UNO):  
    led_pin: int = LED_BUILTIN  
    led_state: int = LOW  
    previous_millis: unsigned_long = 0  
    interval: long = 1000  
  
    def setup() -> None:  
        pinMode(led_pin, OUTPUT)  
  
    def loop() -> None:
        global previous_millis, led_state  
  
        current_millis: unsigned_long = millis()  
  
        if current_millis - previous_millis >= interval:  
            previous_millis = current_millis  
  
            if led_state == LOW:  
                led_state = HIGH  
            else:  
                led_state = LOW  
  
            digitalWrite(led_pin, led_state)

The generated C++ code:

C++
int led_pin = LED_BUILTIN;  
int led_state = LOW;  
unsigned long previous_millis = 0;  
long interval = 1000;  
void setup() {  
    pinMode(led_pin, OUTPUT);  
}  
void loop() {  
    unsigned long current_millis = millis();  
    if (current_millis - previous_millis >= interval) {  
        previous_millis = current_millis;  
        if (led_state == LOW) {  
            led_state = HIGH;  
        }  
        else {  
            led_state = LOW;  
        }  
        digitalWrite(led_pin, led_state);  
    }  
}

Servo sweep

Python code:

Python
with ArduinoBoard("/dev/ttyUSB0", Board.UNO):  
    myservo: Servo = Servo()  
    pos: int = 0  
  
    def setup() -> None:  
        myservo.attach(9)  
  
    def loop() -> None:  
        for pos in range(180):  
            myservo.write(pos)  
            delay(15)  
        for pos in range(180, 0, -1):  
            myservo.write(pos)  
            delay(15)

The generated C++ code:

C++
#include <Servo.h>  
Servo myservo = Servo();  
int pos = 0;  
void setup() {  
    myservo.attach(9);  
}  
void loop() {  
    for (int pos=0; pos<180; pos += 1) {  
        myservo.write(pos);  
        delay(15);  
    }  
    for (int pos=180; pos>=0; pos += -1) {  
        myservo.write(pos);  
        delay(15);  
    }  
}

Debugging options

While working with PyBedded I found it useful to e.g. compile the code, but not upload it to the board or compile and debug the compilation error by analysing the generated C++ code, so ArduinoBoard object accepts couple of additional parameters-flags:

  • compile – by default True, omits compilation step if set to False
  • upload – by default True, omits board flashing if set to False
  • clean_up – by default True, omits Arduino project folder removal if set to False

    Maturity of the project

Currently the project supports all the features used in the examples available in Arduino IDE, except custom classes/structs definition.

Don’t miss next tips!

By joining you agree with our privacy policy. You may unsubscribe at any time.