Skip to main content

Using the Python Host Interface

The Signaloid-Compute-Module-Utilities repository ships a Python package for the code that runs on the host. It gives your host application a class whose methods read and write the registers and buffers of the C0-microSD, so you exchange data with your device application without writing raw block-device code of your own.

For the offsets these methods reach, see Host Interface and Protocol. For the device-side half of the application implementation, see Using the Hardware Abstraction Layer.

Install the package

The distribution is named signaloid-utilities and the importable package is signaloid_utilities. It requires Python 3.10 or later. See Python Environment.

Install it from the Signaloid-Compute-Module-Utilities repository. The Signaloid-Compute-Module-Application-Template lists it in python-host-application/requirements.txt, so a template-based project installs it with the rest of its dependencies.

python3 -m venv .venv
.venv/bin/pip install -r python-host-application/requirements.txt
note

Host applications need root privileges, because reaching the module means opening a raw block device. Invoke the interpreter of your virtual environment directly under sudo, for example sudo .venv/bin/python3. A plain sudo python3 may resolve to a different interpreter that does not have your installed packages.

Construct the interface

The C0-microSD class lives in the c0microsd subpackage.

from signaloid_utilities.c0microsd.interface import C0microSDSignaloidSoCInterface

compute_module = C0microSDSignaloidSoCInterface("/dev/disk4")

The constructor signature is C0microSDSignaloidSoCInterface(target_device, force_transactions=False). Pass the raw device-node path as target_device, which looks like /dev/sdX on Linux and /dev/diskN or /dev/rdiskN on macOS. Leave force_transactions at its default, because setting it makes the interface press on where it would otherwise stop with an error.

Constructing the interface probes the module. It reads the configuration status words, confirms that the device really is a C0-microSD, and records which configuration is loaded together with its version. The constructor raises RuntimeError when the device is not a C0-microSD, and also when a configuration switch is pending, in which case power-cycle the module and construct the interface again.

The instance carries the offsets of this module as attributes you can read. The register and buffer values come from the major version of the loaded configuration, so the instance always matches the module in front of it. The configuration status offset is fixed across versions, because reading it is how the interface discovers the version in the first place.

AttributeValueMeaning
STATUS_REGISTER_OFFSET0x00000Status register
SOC_CONTROL_REGISTER_OFFSET0x00004SoC control register
COMMAND_REGISTER_OFFSET0x10000Command register
DEVICE_CONFIGURATION_STATUS_OFFSET0x20000Base of the three configuration status words
MOSI_BUFFER_OFFSET0x50000Input buffer, host to device, 4 KiB
MISO_BUFFER_OFFSET0x60000Output buffer, device to host, 4 KiB

These are SD-interface block offsets, which differ from the memory addresses your device application uses for the same registers and buffers. See Host Interface and Protocol → Host-side addressing.

The buffer sizes are available as MOSI_BUFFER_SIZE_BYTES and MISO_BUFFER_SIZE_BYTES, with INPUT_BUFFER_SIZE_BYTES and OUTPUT_BUFFER_SIZE_BYTES as aliases. Size your transfers from those attributes rather than from literals, so your application keeps working if a future configuration changes them.

Check the loaded configuration

The C0-microSD boots into one of two configurations, the Bootloader or the Signaloid SoC, and your device application runs only when the Signaloid SoC configuration is loaded.

compute_module.get_status()
print(compute_module)

get_status() re-reads the configuration status words and refreshes the configuration, configuration_version, and configuration_switching attributes. The configuration attribute holds "bootloader" or "soc", and printing the instance gives a one-line summary of all three.

Note that get_status() reports the loaded configuration of the module rather than the status register of your device application. Read the status register with get_signaloid_soc_status(), described below.

The interface has no methods for starting, stopping, or resetting the core. The device application starts when the module powers up with the Signaloid SoC configuration loaded, and switching configurations is a Bootloader operation followed by a power cycle. See Switch Between Operation Modes.

Move data through the buffers

MethodPurpose
write_input_buffer(buffer)Write operand bytes into the input buffer.
read_output_buffer(size=None)Read result bytes from the output buffer, all of it by default.
read_debug_log_buffer(size=512)Read the device log window at the end of the output buffer.

The first two are aliases for write_signaloid_soc_MOSI_buffer() and read_signaloid_soc_MISO_buffer(), which name the same methods after the buffers themselves. Prefer the aliases, because they match the interfaces of the other Signaloid compute modules.

Each write raises ValueError when the buffer you pass is longer than the 4 KiB region it targets, and so does a read whose size is larger than the buffer.

Python struct is all you need to lay bytes out. The following packs four floats for a device application that reads them from the input buffer.

import struct

operands = struct.pack("<4f", 5.0, 6.0, 4.0, 7.5)
compute_module.write_input_buffer(operands)

The layout you pack here is the layout your device application reads, and the RISC-V core of the module requires every load to be naturally aligned. Place each field on a multiple of its own size, adding padding where a mixed layout would otherwise misalign a later field. See Using the Hardware Abstraction Layer → Memory alignment.

Drive the round trip

MethodPurpose
send_signaloid_soc_command(value)Write a 32-bit command word.
get_signaloid_soc_status()Read the 32-bit status word.
calculate_command(...)Run a whole blocking round trip.

calculate_command() is the method most host applications call. Its signature is calculate_command(command, idle_command=0, poll_sleep_time=0.5, skip_MISO_read=False, verbose=True, timeout_waiting_to_start=0.5). It writes the command, polls the status register until the device reports done, optionally reads the output buffer back, and then drives the device to idle by writing idle_command until the status returns to waiting for command.

The module-level constants mirror the conventional status values of the device-side header, namely SIGNALOID_SOC_STATUS_WAIT_FOR_COMMAND, SIGNALOID_SOC_STATUS_CALCULATING, SIGNALOID_SOC_STATUS_DONE, and SIGNALOID_SOC_STATUS_INVALID_COMMAND, alongside K_CALCULATE_NO_COMMAND for the idle command.

Three behaviors are worth knowing before you rely on this method.

  • The default poll cadence is 0.5 seconds, so a fast computation still takes about that long to report. Pass a smaller poll_sleep_time, as the application template does with 0.001, when latency matters.
  • timeout_waiting_to_start covers only the start of the work. Once the device reports calculating there is no timeout.
  • A failure prints rather than raises. An invalid command, a start timeout, or an unrecognized status makes the method report the problem and return None.

Passing skip_MISO_read=True and then calling read_output_buffer() with an explicit size transfers less data when you only need a few result words, because the default reads the entire 4 KiB buffer.

compute_module.calculate_command(
1,
poll_sleep_time=0.001,
skip_MISO_read=True,
)

mean, variance = struct.unpack("<2f", compute_module.read_output_buffer(8))

Inspect the module

MethodPurpose
get_status()Refresh the loaded configuration, version, and switching flag.
print_bitstream_information(offset)Print the metadata of a bitstream in flash and check its CRC.
get_bitstream_prefix(offset)Read and decode the JSON prefix of a bitstream in flash.
verify_bitstream_crc(offset, crc, prefix_size, size)Check a bitstream region against an expected CRC.

The bitstream helpers take explicit offsets from the Bootloader flash address map, and the C0_microSD_toolkit.py script uses them when it flashes and verifies images, so a typical host application never calls them. For the flash layout, see Modes and Custom Bitstream.

A minimal host application

import struct

from signaloid_utilities.c0microsd.interface import (
C0microSDSignaloidSoCInterface,
SIGNALOID_SOC_STATUS_WAIT_FOR_COMMAND,
)

K_MY_COMMAND_ADDITION = 1

compute_module = C0microSDSignaloidSoCInterface("/dev/disk4")

# Confirm that the Signaloid SoC configuration is loaded.
if compute_module.configuration != "soc":
raise SystemExit("Switch the module to the Signaloid SoC configuration first.")

# Wait for the device application to be ready.
while (
compute_module.get_signaloid_soc_status()
!= SIGNALOID_SOC_STATUS_WAIT_FOR_COMMAND
):
pass

# Send the operands, run the command, and read the results back.
compute_module.write_input_buffer(struct.pack("<4f", 5.0, 6.0, 4.0, 7.5))
compute_module.calculate_command(
K_MY_COMMAND_ADDITION,
poll_sleep_time=0.001,
skip_MISO_read=True,
)
mean, variance = struct.unpack("<2f", compute_module.read_output_buffer(8))

print(f"Mean {mean}, variance {variance}")

# Release the device descriptor.
compute_module.device.close()

The command number has to match the command your device application recognizes. Nothing in the library enforces that agreement, so treat the command numbers as a contract between your two halves.

The interface has no close() method and no context manager. Call compute_module.device.close() when you want the underlying descriptor released deterministically.

Device access model

A register read issued through an ordinary buffered file descriptor is subject to page-cache coherence rules, so a poll loop can observe a stale status word and a command write can sit in the cache instead of reaching the hardware. The package therefore inspects the device node and selects a cache-bypassing strategy for it.

Device nodeMechanism
Linux block node, /dev/sdXOpened with O_DIRECT, which bypasses the page cache
macOS character node, /dev/rdiskNPositioned pread and pwrite on a descriptor held open
macOS block node, /dev/diskNReopened per operation, because macOS invalidates the cache on close

Two consequences are worth knowing.

  • Transfers are aligned for you. The unbuffered strategies require block-aligned access, so a read fetches the covering blocks and returns your slice, and a write fetches them, patches your range, and writes them back. Any offset and length are valid, and bytes next to your range inside the same block survive.
  • Each instance serializes its own access. One lock covers every read and write, including the read-modify-write that a partial write performs, so an instance is safe to share across the threads of a single process. Coordinating two processes on the same device remains your responsibility.

Failures name the device path. A missing node raises FileNotFoundError, denied access raises PermissionError, and any other device-level failure raises OSError.

Read the device log

When your device application is built with logging enabled, read_debug_log_buffer() returns the raw bytes of the log window. The package also ships a ready-made reader, the C0_debug_logger.py script at the root of the Signaloid-Compute-Module-Utilities repository, which polls that window and prints it.

sudo python3 C0_debug_logger.py /dev/disk4

The script defaults to the C0-microSD, so it needs no variant flag. To read the log from inside your own host application instead, see Embed the Log Reader in Your Host Application.

Next steps