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
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
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}")
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
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
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
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
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 elif
s.
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
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
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
Ends up as:
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:
def do_something(a: int, b: unsigned_int) -> None:
pass
The above code will be converted to:
void do_something(int a, unsigned int b)
{}
Notice 2 things:
-
None
is an equivalent ofvoid
- types like
unsigned int
,unsigned long
etc. are not supported by default in Python, so PyBedded adds such support by defining new aliases like:
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. if
or 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
becomes that:
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:
# 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:
# 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:
SENSOR_PIN: int = 4
sensor_value: int = 0
is converted to:
#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
:
IFDEF("SOME_FLAG")
sensor_value += 1
ENDIF()
C++ code equivalent:
#ifdef SOME_FLAG
sensor_value += 1;
#endif
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)
The generated C++ code:
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
delay(1000);
}
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)
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);
}
}
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)
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);
}
}
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 defaultTrue
, omits compilation step if set toFalse
-
upload
- by defaultTrue
, omits board flashing if set toFalse
-
clean_up
- by defaultTrue
, omits Arduino project folder removal if set toFalse
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)