Extending CircuitPython with Native Modules - Part 3
This is part 3 of a series of articles on extending CircuitPython with native modules for CircuitPython Day 2026 . In part 1 , we set up our environment and built a module with an enum in C which could be called from CircuitPython. Last time, in the second article , we built a module-level method which takes the operator and applies it to two numbers. In this final post, we will bundle everything inside a class which can carry some unique state with it.
Our goal from the first article was to build something that could be used like this.
>>> from mikesexample import Calculator, Operator
>>> calc = Calculator()
>>> calc.calculate(5, Operator.ADD, 4)
9
Today's main task will be to take our
calculate()
method from last time
and move it into a class.
We'll also keep track of the last calculation's result and make it available through an
ans()
method like the Ans button you see on many physical calculators.
Calculator
shared-bindings/mikesexample/Calculator.h
Our
Calculator
class is closer to the Python side of things than the C side, so it's best to put its code in with the other shared-bindings code, not the shared-module area.
The class itself is rather simple, with just a single field to hold the answer from the previous calculation.
Our type definition is therefore simple too, extend the base object type and add our single field.
#pragma once
#include "py/obj.h"
typedef struct {
mp_obj_base_t base;
float last_answer;
} mikesexample_calculator_obj_t;
extern const mp_obj_type_t mikesexample_calculator_type;
shared-bindings/mikesexample/Calculator.c
We can now start our class implementation "proper".
For the time being, we'll leave the
calculate()
method where it is and focus on building our objects and implementing the
ans()
method.
Most of this is fairly standard CircuitPython boilerplate.
If you've seen how we implemented the
calculate()
method last time, the
ans()
method should be familiar.
The big difference here is the object's
self
reference is passed in as the first parameter, giving us access to the
last_answer
field.
#include "Calculator.h"
#include "py/runtime.h"
//| class Calculator:
//| """Calculate the result of a mathematical operation."""
//|
//| def __init__(
//| self,
//| ) -> None:
//| """Create the Calculator object that determines the result."""
//|
//|
static mp_obj_t mikesexample_calculator_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *args) {
mp_arg_check_num(n_args, n_kw, 0, 0, false);
mikesexample_calculator_obj_t *self = mp_obj_malloc(mikesexample_calculator_obj_t, &mikesexample_calculator_type);
self->last_answer = 0.0f;
return MP_OBJ_FROM_PTR(self);
}
//| def ans(self) -> float:
//| """Returns the last computed answer."""
//| ...
//|
static mp_obj_t mikesexample_calculator_ans(mp_obj_t self_in) {
mikesexample_calculator_obj_t *self = MP_OBJ_TO_PTR(self_in);
return mp_obj_new_float(self->last_answer);
}
static MP_DEFINE_CONST_FUN_OBJ_1(mikesexample_calculator_ans_obj, mikesexample_calculator_ans);
static const mp_rom_map_elem_t mikesexample_calculator_locals_dict_table[] = {
{ MP_ROM_QSTR(MP_QSTR_ans), MP_ROM_PTR(&mikesexample_calculator_ans_obj) },
};
static MP_DEFINE_CONST_DICT(mikesexample_calculator_locals_dict, mikesexample_calculator_locals_dict_table);
MP_DEFINE_CONST_OBJ_TYPE(
mikesexample_calculator_type,
MP_QSTR_Calculator,
MP_TYPE_FLAG_NONE,
make_new, mikesexample_calculator_make_new,
locals_dict, &mikesexample_calculator_locals_dict
);
With that done, it's time to bring the
calculate
implementation over to our
Calculator
class.
We need to import the references to the
Operator
enum and the
calculate()
shared-module.
@@ -1,4 +1,6 @@
#include "Calculator.h"
+#include "shared-bindings/mikesexample/Operator.h"
+#include "shared-module/mikesexample/calculate.h"
#include "py/runtime.h"
We can then bring over the
calculate()
method from the old shared-bindings file.
One change we need to make is to pass in the
self
reference as the first parameter.
This results in us having to change a
[...]_OBJ_3
declaration into
[...]_OBJ_VAR_BETWEEN
as CircuitPython only has the syntactic-sugar versions for methods with up to three parameters.
@@ -18,6 +20,25 @@
return MP_OBJ_FROM_PTR(self);
}
+//| def calculate(self, a: float, op: Operator, b: float) -> float:
+//| """Computes "a OP b", stores it as the last answer, and returns it."""
+//| ...
+//|
+//|
+static mp_obj_t mikesexample_calculator_calculate(size_t n_args, const mp_obj_t *args) {
+ mikesexample_calculator_obj_t *self = MP_OBJ_TO_PTR(args[0]);
+
+ float a = mp_obj_get_float(args[1]);
+ mikesexample_operator_t op = cp_enum_value(&mikesexample_operator_type, args[2], MP_QSTR_op);
+ float b = mp_obj_get_float(args[3]);
+
+ self->last_answer = shared_module_mikesexample_calculate(a, op, b);
+
+ return mp_obj_new_float(self->last_answer);
+}
+
+static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mikesexample_calculator_calculate_obj, 4, 4, mikesexample_calculator_calculate);
+
//| def ans(self) -> float:
//| """Returns the last computed answer."""
//| ...
We then have to make our binding aware of our method so that it can be called from Python.
@@ -31,6 +52,7 @@
static const mp_rom_map_elem_t mikesexample_calculator_locals_dict_table[] = {
+ { MP_ROM_QSTR(MP_QSTR_calculate), MP_ROM_PTR(&mikesexample_calculator_calculate_obj) },
{ MP_ROM_QSTR(MP_QSTR_ans), MP_ROM_PTR(&mikesexample_calculator_ans_obj) },
};
shared-bindings/mikesexample/__init__.c
The module declaration can be changed now, bringing in the
Calculator
class, rather than the
calculate
method.
@@ -2,7 +2,7 @@
#include "py/runtime.h"
#include "shared-bindings/mikesexample/Operator.h"
-#include "shared-bindings/mikesexample/calculate.h"
+#include "shared-bindings/mikesexample/Calculator.h"
//| """Support for mathematical operations
//|
Similarly, we need to tell Python about the new class and remove the old module-level method.
@@ -12,7 +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) },
+ { MP_ROM_QSTR(MP_QSTR_Calculator), MP_ROM_PTR(&mikesexample_calculator_type) },
};
static MP_DEFINE_CONST_DICT(mikesexample_module_globals, mikesexample_module_globals_table);
py/circuitpy_defns.mk
The last update is to the build file, ensuring our new class is compiled, and the reference to the old module-level method is removed.
@@ -655,7 +655,7 @@ $(filter $(SRC_PATTERNS), \
microcontroller/RunMode.c \
mikesexample/__init__.c \
mikesexample/Operator.c \
- mikesexample/calculate.c \
+ mikesexample/Calculator.c \
msgpack/__init__.c \
msgpack/ExtType.c \
paralleldisplaybus/__init__.c \
Building and Testing
The project can be built as before, and once flashed, our new class can be used according to our original design spec.
>>> from mikesexample import Calculator, Operator
>>> calc = Calculator()
>>> calc.calculate(5, Operator.ADD, 4)
9.0
>>> calc.calculate(calc.ans(), Operator.ADD, 4)
13.0
>>> calc.calculate(calc.ans(), Operator.ADD, 4)
17.0
>>> calc.calculate(calc.ans(), Operator.ADD, 4)
21.0
To prove our objects' states are independent, we can create multiple instances and show that they don't share
ans()
values.
>>> from mikesexample import Calculator, Operator
>>> c1 = Calculator()
>>> c2 = Calculator()
>>> c1.calculate(3, Operator.ADD, 4)
7.0
>>> c2.calculate(c1.ans(), Operator.ADD, 4)
11.0
>>> (c1.ans(), c2.ans())
(7.0, 11.0)
Conclusion
If you've made it this far, well done! If you want to have a poke about the code yourself, I've uploaded my completed example to a branch on my own fork on GitHub , and the diffs are here , one per blog post. This was really just a learning exercise for me, with these posts written as a log and aide-mémoire for future-Mike; if anyone else gets any benefit from them, then great!
If you're inspired by this to write your own module, I'd love to hear about it. Please leave a comment or send me a PM on socials.
2026-08-21