Python is probably my favourite language, so I was excited some years ago when a project appeared on Kickstarter to develop a Python runtime for microcontrollers, and an associated microcontroller board.
However, writing Python for a microcontroller does have some constraints that aren’t really a factor when writing Python for other environments. Having maybe only 100KB of RAM to work with, keeping code size as low as possible is essential.
When I wrote a package to support the TI tmp102 temperature sensor, I initially included all the required functionality in a single importable class. It used 15KB of RAM after import, which does leave space for other code, but since some of the functionality is mutually exclusive I knew I could probably do better.
This post is about what I ended up with and how it works.
The core functionality of the package can be leveraged by importing the
Tmp102 class and creating an instance. This leaves the sensor in its default configuration, in which it performs a reading 4 times per second and makes the most recent available to your code on request. The details of initialising the object are explained in the documentation if you actually want to use the module, so I won’t go into them again here.
from machine import I2C from tmp102 import Tmp102 bus = I2C(1) sensor = Tmp102(bus, 0x48) print(sensor.temperature)
That’s all well and good, but what if you want to make use of some of the more advanced features of the sensor, such as controlling the rate at which it takes readings (the “conversion rate”)? Such features are structured as importable modules which add the required functionality into the
Tmp102 class. The
CONVERSION_RATE_1HZ constant in the example below, as well as other relevant code, are added to the class when the
conversionrate module is imported.
from tmp102 import Tmp102 import tmp102.conversionrate sensor = Tmp102( bus, 0x48, conversion_rate=Tmp102.CONVERSION_RATE_1HZ )
If you don’t need to change the conversion rate in your project then the code to do so is never loaded. If you do need this or other features, all the functionality is still exposed through a single easy to use class.
The package is structured like this:
tmp102 +-- __init__.py +-- _tmp102.py +-- alert.py +-- conversionrate.py +-- convertors.py +-- extendedmode.py +-- oneshot.py +-- shutdown.py
Tmp102 class is defined in
_tmp102.py, along with some private functions and constants.
REGISTER_TEMP = 0 REGISTER_CONFIG = 1 EXTENDED_MODE_BIT = 0x10 def _set_bit(b, mask): return b | mask def _clear_bit(b, mask): return b & ~mask def _set_bit_for_boolean(b, mask, val): if val: return _set_bit(b, mask) else: return _clear_bit(b, mask) class Tmp102(object): def __init__(self, bus, address, temperature_convertor=None, **kwargs): self.bus = bus self.address = address self.temperature_convertor = temperature_convertor # The register defaults to the temperature. self._last_write_register = REGISTER_TEMP self._extended_mode = False . . .
To hide the private stuff from users of the package, the
__init__.py imports the
Tmp102 class and then removes the
_tmp102 module from the namespace.
from tmp102._tmp102 import Tmp102 del _tmp102
The interesting stuff happens in the feature sub-modules. Each feature module defines an
_extend_class function which modifies the
def _extend_class(): # Modify Tmp102 here - Check the next code block! pass _extend_class() del _extend_class
Let’s take a look at the
oneshot module, which adds functionality to the
Tmp102 class to allow the sensor to be polled as necessary instead of constantly performing readings - very useful if you want to save power.
def _extend_class(): from tmp102._tmp102 import Tmp102 from tmp102._tmp102 import _set_bit_for_boolean import tmp102.shutdown SHUTDOWN_BIT = 0x01 ONE_SHOT_BIT = 0x80 def initiate_conversion(self): """ Initiate a one-shot conversion. """ current_config = self._get_config() if not current_config & SHUTDOWN_BIT: raise RuntimeError("Device must be shut down to initiate one-shot conversion") new_config = bytearray(current_config) new_config = _set_bit_for_boolean( new_config, ONE_SHOT_BIT, True ) self._set_config(new_config) Tmp102.initiate_conversion = initiate_conversion def _conversion_ready(self): current_config = self._get_config() return (current_config & ONE_SHOT_BIT) == ONE_SHOT_BIT Tmp102.conversion_ready = property(_conversion_ready)
So what’s going on here? First, the
Tmp102 class and any required functions are imported. Since it was imported in the package’s
__init__ the class is already defined. Importing the private functions and constants in a function like this keeps them out of the global namespace.
from tmp102._tmp102 import Tmp102 from tmp102._tmp102 import _set_bit_for_boolean
oneshot module depends on the functionality from the
shutdown module, so it is imported next.
Next, a couple of constants are defined. Through the magic of closure, these will only be available to the methods defined in this module.
SHUTDOWN_BIT = 0x01 ONE_SHOT_BIT = 0x80
The rest of the function defines a method and a property which are added to the class by simply assigning them to attributes. These will be available to any instances of the class, exactly as if they were included in the class definition.
def initiate_conversion(self): """ Initiate a one-shot conversion. """ current_config = self._get_config() if not current_config & SHUTDOWN_BIT: raise RuntimeError("Device must be shut down to initiate one-shot conversion") new_config = bytearray(current_config) new_config = _set_bit_for_boolean( new_config, ONE_SHOT_BIT, True ) self._set_config(new_config) Tmp102.initiate_conversion = initiate_conversion def _conversion_ready(self): current_config = self._get_config() return (current_config & ONE_SHOT_BIT) == ONE_SHOT_BIT Tmp102.conversion_ready = property(_conversion_ready)
The other feature modules follow the same pattern.
Importing the base
Tmp102 class uses about 3.53KB of RAM - quite a saving if that is all you need. The feature modules vary between 0.8KB and 4KB, or thereabouts. Importing them all uses 13.44KB, but it is unlikely that they would all be required in any given application.
I thought of this approach as “monkey-patching” for a long time - the last refuge of the desperate and the damned - but I’m not sure that it is really, because the modifications are all being made internally to the package. It is definitely outside the norm for Python, but it achieved the goal of reducing RAM usage while maintaining a clean API.