Skip to main content

Using a Microcontroller Host with CircuitPython

A microcontroller can act as the host of a C0-microSD or a C0-microSD+ in place of a development workstation. The microcontroller can drive the module using SD-over-SPI. CircuitPython versions of the signaloid_utilities and signaloid packages provide the same round trip that the other recipes use. Each recipe on this page shows one step of a CircuitPython code.py, starting from bringing up the SPI bus to parsing a distributional result.

The compute modules support SD-over-SPI, the same protocol that the existing SD-card libraries for microcontrollers use. However, existing SD-card libraries for microcontrollers are built to store files. Their usual entry points, such as storage.mount() in CircuitPython, SD.begin() in the Arduino SD library, and esp_vfs_fat_sdspi_mount() in ESP-IDF, expect a FAT file system on the card and fail when they do not find one. A compute module presents itself as a raw, unformatted block device, so those entry points do not work with it. Signaloid therefore provides an SD-over-SPI interface for CircuitPython in its signaloid_utilities package, and this guide uses that interface. Third-party libraries that offer raw block reads and writes, such as the readblocks() and writeblocks() methods of sdcardio.SDCard in CircuitPython may also work with a compute module.

The device application on the module does not change. Any device application that works with a typical host works with a microcontroller host. The Sending Inputs to the Module and Returning Results to the Host recipes apply unchanged.

Before you start​

You need the following:

  • A microcontroller board that runs CircuitPython and has a free SPI bus. The M5Stack Cardputer is an example. Its built-in microSD slot is already wired for SD-over-SPI.
  • A Signaloid compute module wired to that SPI bus as described in the Design-In guide of the module.
  • A Signaloid compute module with your device application already flashed. The flashing toolkits run on a typical workstation only, so flash the module there and then insert it into the microcontroller host.

Copy the libraries to the board​

The microcontroller needs two packages in the lib folder of its CIRCUITPY drive:

  • signaloid_utilities, the CircuitPython host interface, from the src/circuitpython/ folder of the Signaloid-Compute-Module-Utilities repository.
  • signaloid, the distributional data package, from the src/ folder of the signaloid-python repository. You only need this package if your host parses distributions.

The src/circuitpython/signaloid_utilities/ folder contains symbolic links to other files in the repository. The CIRCUITPY drive does not support symbolic links, so copy the packages with the links resolved, as the -L option of cp does in the following commands.

cp -RL Signaloid-Compute-Module-Utilities/src/circuitpython/signaloid_utilities /Volumes/CIRCUITPY/lib/
cp -RL signaloid-python/src/signaloid /Volumes/CIRCUITPY/lib/

Replace /Volumes/CIRCUITPY with the mount point of the drive on your computer. On Linux, the mount point is usually /media/<user>/CIRCUITPY or /run/media/<user>/CIRCUITPY.

Bring up the SPI bus​

Create the SPI bus on the pins that you wired to the module and configure it for SPI mode 0 at the data clock rate.

import board
import busio

spi = busio.SPI(clock=board.SCK, MOSI=board.MOSI, MISO=board.MISO)
while not spi.try_lock():
pass
spi.configure(baudrate=5_000_000, phase=0, polarity=0)
spi.unlock()

Replace the pin names with the ones your board uses. The Cardputer, for example, names them board.SD_SCK, board.SD_MOSI, board.SD_MISO, and board.SD_CS. Signaloid has tested a data clock of 5 MHz.

You do not lower the clock for card identification yourself. When you construct the interface in the next step, the interface identifies the card at 400 kHz and then restores the clock rate that you configured here.

Connect to the module​

Construct the interface object for your module. Pass the SPI bus, the chip-select pin, and a transaction timeout (counted in retries) to the constructor as the spi, cs_pin, and timeout arguments. The constructor initializes the module into SPI mode before it returns. The interface drives the chip-select pin itself, so do not create a digitalio.DigitalInOut object for that pin elsewhere in your program.

from signaloid_utilities.c0microsd.interface import C0microSDSignaloidSoCInterfaceSDSPI

compute_module = C0microSDSignaloidSoCInterfaceSDSPI(spi, board.CS, timeout=1000)

compute_module.get_status()
if compute_module.configuration != "soc":
raise RuntimeError("Switch the C0-microSD to Signaloid SoC mode.")

The get_status() method reads the configuration status of the module and sets configuration to "bootloader" or "soc". The method raises RuntimeError if the configuration status does not match a C0-microSD. A device application runs only in Signaloid SoC mode, so the snippet raises an error if the module is in any other mode.

Run the round trip​

Write the input buffer, run the command, and read back only the bytes you need. The calculate_command() method reads the whole output window by default, which is 4 KiB on the C0-microSD and the full 64 KiB MMIO buffer on the C0-microSD+. That read takes 8 block transfers on the C0-microSD and 128 on the C0-microSD+. Be careful because if your microcontroller has less than 64 KiB of free memory, then the 64 KiB buffer might exceed it. To avoid reading the entire buffer, you can skip the MISO read when calling the calculate_command() method, and call read_output_buffer() with the size of your result instead.

import struct

compute_module.write_input_buffer(struct.pack("<2f", 4.5, 5.5))
compute_module.calculate_command(1, poll_sleep_time=0.001, skip_MISO_read=True, verbose=False)
(particle_value,) = struct.unpack("<f", compute_module.read_output_buffer(4))

The example sends two floats as in Send Floats and Build a Parametric Distribution, and reads one float as in Return the Particle Value as a Plain Float. The interface transfers whole 512-byte blocks, so a 4-byte read costs one block transfer.

When the device application returns a result of variable length behind a 4-byte length header, read the header first and then read the header and the payload together.

(payload_size,) = struct.unpack("<I", compute_module.read_output_buffer(4))
payload = compute_module.read_output_buffer(4 + payload_size)[4:]

Parse a distributional result​

The signaloid package detects CircuitPython at import time and uses the ulab version of NumPy that CircuitPython includes. Parse a result in Ux Binary Data format with the same call as on a development host.

from signaloid.distributional.distributional import DistributionalValue

dist = DistributionalValue.parse(payload)
print(dist.particle_value)

The parse() method returns None if the payload is not a valid Ux Binary Data value. For the device-side half, see Return the Full Distribution as Ux Binary Data.

A parsed distribution holds every Dirac delta of the result in microcontroller memory. If your board runs out of memory, you can choose to return statistics or samples from the device application instead of the full distribution. See Return On-Module Statistics as Plain Floats.

Limits on a microcontroller host​

  • Memory. Size every read and write for the free memory of your microcontroller based system, not for the buffer capacities of the compute module. Call gc.mem_free() to check the free memory while you develop.
  • Start-up on the C0-microSD. A C0-microSD that is not configured for Signaloid SoC mode loads its custom user bitstream if no host initializes it within 3 seconds of power-up. See Modes and Custom Bitstream.
  • Power loss on the C0-microSD+. If your board removes power from the module, for example to save energy, the module powers up again with the core in reset. Run core-start again before the next command. See Power Cycles and State.
  • Shared SPI bus. The CircuitPython interface in signaloid_utilities locks the SPI bus for each transfer to the module, and waits up to 5 seconds for any other code that holds the lock to release it. If your program uses the same bus for another device, unlock the bus after each transfer to that device.

Complete example​

The Signaloid-Compute-Module-Demo-CardPuter repository contains a complete CircuitPython host application for the M5Stack Cardputer. The application uses the steps on this page to run the Calculator device application on the module, and plots the result distribution on the Cardputer display.

Next steps​