Extending CircuitPython with Native Modules - Part 2

As described last time, for CircuitPython Day 2026, I wanted to get closer to the metal and write a module in C that I could call from CircuitPython. I had hoped this would be a single article, but given the number of steps, it felt better to split it into three parts.

As I wrote in part 1, the approach detailed within these articles relies heavily on Dave Astels' Extending CircuitPython: An Introduction, for which I am truly grateful.

We've already built our Operator enum, and found a place in the build process to slot in our module. Today we'll focus on building the calculator which takes the operator and applies it to two numbers.

calculate()

Although we could get away with putting our calculation code inside the C/Python shared-binding, it's more proper for the pure C code to live in the shared-modules. Therefore, we need to create a couple of new files.

shared-module/mikesexample/calculate.h

The first file we need is our header where we declare our method's signature. The method's name is a bit contrived, but since it's in the hidden part of the module and not within the Python binding, it's not a big deal. It's also another example of following Dave's advice...

This naming convention is the convention used. Just Do It.

#pragma once

#include "shared-bindings/mikesexample/Operator.h"

float shared_module_mikesexample_calculate(float operand_a, mikesexample_operator_t operator, float operand_b);

shared-module/mikesexample/calculate.c

The second file is where we write the implementation for our calculation method.

#include "shared-module/mikesexample/calculate.h"

float shared_module_mikesexample_calculate(float operand_a, mikesexample_operator_t operator, float operand_b) {
    switch (operator) {
        case OPERATOR_ADD:
            return operand_a + operand_b;
        case OPERATOR_SUBTRACT:
            return operand_a - operand_b;
        case OPERATOR_MULTIPLY:
            return operand_a * operand_b;
        case OPERATOR_DIVIDE:
            return operand_a / operand_b;
        default:
            return 0.0f;
    }
}

With the module written, it's time to write the binding that will make it available from within the CircuitPython environment.

shared-bindings/mikesexample/calculate.h

As with the module, we begin with a header, this time declaring our binding's signature.

#pragma once

#include "py/obj.h"

MP_DECLARE_CONST_FUN_OBJ_3(mikesexample_calculate_obj);

shared-bindings/mikesexample/calculate.c

Next, we turn to the most complicated part of this whole blogpost; marshalling Python types into their native counterparts. CircuitPython has a number of helper functions for us, such as mp_obj_get_float and mp_obj_new_float, which move floating-point numbers back-and-forth between their C and Python representations.

In this binding we marshall the Python types into their native counterparts, perform the calculation, before returning the Python boxed result.

#include "calculate.h"

#include "shared-bindings/mikesexample/Operator.h"
#include "shared-module/mikesexample/calculate.h"

//| def calculate(a: float, op: mikesexample.Operator, b: float) -> float:
//|     """Returns the result of the calculation of "a OP b"."""
//|
//|
static mp_obj_t mikesexample_calculate(mp_obj_t a_in, mp_obj_t op_in, mp_obj_t b_in) {
    float a = mp_obj_get_float(a_in);
    mikesexample_operator_t op = cp_enum_value(&mikesexample_operator_type, op_in, MP_QSTR_op);
    float b = mp_obj_get_float(b_in);
    
    float result = shared_module_mikesexample_calculate(a, op, b);
    
    return mp_obj_new_float(result);
}

MP_DEFINE_CONST_FUN_OBJ_3(mikesexample_calculate_obj, mikesexample_calculate);

With our method and its binding now written, we have to include everything within the module for building.

shared-bindings/mikesexample/__init__.c

The first place we look is the __init__ file, which is where we declare all the members of our module to the Python interpreter.

@@ -2,6 +2,7 @@
 #include "py/runtime.h"

 #include "shared-bindings/mikesexample/Operator.h"
+#include "shared-bindings/mikesexample/calculate.h"

 //| """Support for mathematical operations
 //|
@@ -11,6 +12,7 @@
 static const mp_rom_map_elem_t mikesexample_module_globals_table[] = {
     { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_mikesexample) },
     { MP_ROM_QSTR(MP_QSTR_Operator),  MP_ROM_PTR(&mikesexample_operator_type) },
+    { MP_ROM_QSTR(MP_QSTR_calculate), MP_ROM_PTR(&mikesexample_calculate_obj) },
 };

 static MP_DEFINE_CONST_DICT(mikesexample_module_globals, mikesexample_module_globals_table);

py/circuitpy_defns.mk

We then need to include our new files in the build. We first need to add our module's method to shared-module's list of code files.

@@ -801,6 +802,7 @@ SRC_SHARED_MODULE_ALL = \
        memorymonitor/__init__.c \
        memorymonitor/AllocationAlarm.c \
        memorymonitor/AllocationSize.c \
+       mikesexample/calculate.c \
        network/__init__.c \
        msgpack/__init__.c \
        onewireio/__init__.c \

We can then add our calculate binding next to our existing Operator binding.

@@ -655,6 +655,7 @@ $(filter $(SRC_PATTERNS), \
        microcontroller/RunMode.c \
        mikesexample/__init__.c \
        mikesexample/Operator.c \
+       mikesexample/calculate.c \
        msgpack/__init__.c \
        msgpack/ExtType.c \
        paralleldisplaybus/__init__.c \

Building

As before, we can now compile our firmware, including our new method.

$ make -j16 BOARD=adafruit_qtpy_esp32s2
[...]
Creating ESP32-S2 image...
Merged 3 ELF sections.
Successfully created ESP32-S2 image.
[...]
Converted to uf2, output size: 3146752, start address: 0x0
Wrote 3146752 bytes to build-adafruit_qtpy_esp32s2/firmware.uf2

The same trick from part 1 can be used to check that our module is included in the firmware, this time looking inside the modules folder rather than bindings.

$ ls -1 build-adafruit_qtpy_esp32s2/shared-module/ \
| grep mikesexample
mikesexample

Testing

We can now flash this new firmware to our QT Py, connect to its console, and have a play!

>>> import mikesexample
>>> dir(mikesexample)
['__class__', '__name__', 'Operator', '__dict__', 'calculate']
>>> from mikesexample import Operator, calculate
>>> calculate(1,Operator.ADD,2)
3.0
>>> calculate(2,Operator.DIVIDE,3)
0.6666666
>>> calculate(3,Operator.MULTIPLY,4)
12.0
>>> calculate(5,Operator.SUBTRACT,6)
-1.0

Here we can see our module's declarations now include both the old enum and the new method, and calling the method with different operators provides the results we'd expect!

Next time

We'll finish building our mikesexample module, taking the calculate() method and bundling it inside a Calculator class, which can carry some state.

2026-08-20

Leave a comment