DEV Community

Cover image for How to write Arduino Uno code with Python?
pikoTutorial
pikoTutorial

Posted on • Originally published at pikotutorial.com

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

Read also on pikotutorial.com: UDP multicasting with 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.

with ArduinoBoard("/dev/ttyUSB0", Board.UNO):
    # Arduino code
Enter fullscreen mode Exit fullscreen mode

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:

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

    def loop() -> None:
        pass
Enter fullscreen mode Exit fullscreen mode

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:

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}")
Enter fullscreen mode Exit fullscreen mode

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:

def digitalWrite(pin: int, state: int) -> None:
    pass
Enter fullscreen mode Exit fullscreen mode

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:

Python code -> PythonParser -> code model -> CppGenerator -> C++ code
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

if "if" in python_line:
    # process
elif "elif" in python_line:
    # process
Enter fullscreen mode Exit fullscreen mode

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

if "elif" in python_line:
    # process
elif "if" in python_line:
    # process
Enter fullscreen mode Exit fullscreen mode

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.


Read also on Medium: Make C++ a batter place #2: CppFront as an alternative


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:

sensor_value: int = 0
Enter fullscreen mode Exit fullscreen mode

Ends up as:

int sensor_value = 0;
Enter fullscreen mode Exit fullscreen mode

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:

def do_something(a: int, b: unsigned_int) -> None:
    pass
Enter fullscreen mode Exit fullscreen mode

The above code will be converted to:

void do_something(int a, unsigned int b)
{}
Enter fullscreen mode Exit fullscreen mode

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:
unsigned_long = int  
unsigned_int = int
Enter fullscreen mode Exit fullscreen mode

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:

if not Serial and (a == 1 or b == 2):
    pass
Enter fullscreen mode Exit fullscreen mode

becomes that:

if (!Serial && (a == 1 || b == 2))
{}
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

Output:

# 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)
{}
Enter fullscreen mode Exit fullscreen mode

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:

SENSOR_PIN: int = 4
sensor_value: int = 0
Enter fullscreen mode Exit fullscreen mode

is converted to:

#define SENSOR_PIN 4
int sensor_value = 0;
Enter fullscreen mode Exit fullscreen mode

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

IFDEF("SOME_FLAG")
sensor_value += 1
ENDIF()
Enter fullscreen mode Exit fullscreen mode

C++ code equivalent:

#ifdef SOME_FLAG
sensor_value += 1;
#endif
Enter fullscreen mode Exit fullscreen mode

Read also on pikotutorial.com: Hacking Python functions by changing their source code


Examples

Blink

Python code:

# 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)
Enter fullscreen mode Exit fullscreen mode

The generated C++ code:

void setup() {  
    pinMode(LED_BUILTIN, OUTPUT);  
}  
void loop() {  
    digitalWrite(LED_BUILTIN, HIGH);  
    delay(1000);  
    digitalWrite(LED_BUILTIN, LOW);  
    delay(1000);  
}
Enter fullscreen mode Exit fullscreen mode

Blink without delay

Python code:

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)
Enter fullscreen mode Exit fullscreen mode

The generated C++ code:

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);  
    }  
}
Enter fullscreen mode Exit fullscreen mode

Servo sweep

Python code:

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)
Enter fullscreen mode Exit fullscreen mode

The generated C++ code:

#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);  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)