BERGSONNE

Scope (live streaming)

Register a variable once and call one function in the loop: core_scope snapshots it, frames it, and streams it over whichever link the Core has. Studio’s Scope panel plots the stream live; so does anything else that speaks the protocol, because the stream describes its own channels.

Overview

#include "core_scope.h"

static int32_t distance_mm;
static float   temp_c;
static int16_t accel[3];

int main(void)
{
    core_init();
    core_scope_watch(distance_mm);        // name and type come from the variable
    core_scope_watch(temp_c);
    core_scope_watch_array(accel, 3);     // channels accel[0], accel[1], accel[2]

    while (1) {
        ... update the variables ...
        core_scope_update();              // snapshot + send; never blocks
    }
}

That is the whole API for most programs. Open the project in Studio, build and flash, and the Scope pane in the firmware inspector plots distance_mm, temp_c and the three accelerometer axes as they change. The DSL’s scope(...) statement is the same thing from Blocks.

Nothing is sent while nobody is listening (no terminal on USB, no subscriber on Bluetooth), so a scope left in production firmware costs one branch per loop pass. When the link cannot keep up, whole samples are dropped rather than blocking the loop, and the host is told how many.

Watching variables

core_scope_watch(var) is a macro: it stringifies the variable’s name for the channel label, takes its address, and picks the wire type from the C type with _Generic. Any integer up to 32 bits, float and bool work; a double or a 64-bit integer is a compile error, because the wire has no 8-byte type. Narrow types travel narrow: an int16_t costs two bytes per sample, which matters over Bluetooth.

The module keeps a pointer and re-reads the variable on every sample, so it has to outlive the loop: a global, a static, or a field of one. A local variable will not do. core_scope_watch_as("name", var) picks a different label, and core_scope_add() is the plain function underneath for anything the macro cannot express. Up to 32 channels; an array counts per element.

core_scope_set_interval_ms(10) throttles a fast loop to one sample every 10 ms. It is a gate, not a timer: a loop that already runs slower streams at its own pace.

Sampling from an interrupt

A main loop samples whenever it happens to get round to it, so the plot carries the loop’s jitter. Data that arrives on a timer or a sensor’s data-ready interrupt should be sampled there. Split the two halves:

core_scope_watch_array(accel, 3);
core_scope_declare_rate_hz(1000);        // a promise: sample() runs at exactly 1 kHz

void my_timer_isr(void)
{
    read_imu(accel);
    core_scope_sample();                 // ISR-safe: copies the values into a ring, nothing else
}

while (1) {
    core_scope_pump();                   // frames and sends from the main loop
}

With a declared rate the stream carries a sample counter instead of a millisecond timestamp per sample. That is smaller (rows of samples share one header), exact above 1 kHz, and a dropped sample shows up on the plot as a gap of exactly the right width. core_scope_declare_rate_millihertz() keeps rates that are not a whole number of hertz exact (an IMU at 416.667 Hz is 416667).

One producer
Call core_scope_sample() from one context only, and not alongside core_scope_update(), which samples too. A declared rate assumes continuous sampling; for capture-then-idle bursts use the timestamped mode.

Cost and production builds

While streaming, a sample is a gather of the watched variables into a ring; the framing and link I/O happen in core_scope_pump(). A 6-channel frame is 35 bytes; at 1 kHz that is 35 kB/s, comfortably inside full-speed USB and too much for Bluetooth, where narrow types and a declared rate bring an IMU stream down to about 13 kB/s. On a Core.ST.L4 the module costs about 3.8 KB of flash and 1.8 KB of RAM when used, and nothing when it is not: the linker drops it.

For a production build, "scope": { "enabled": false } in config.json compiles every call to nothing: no flash, no RAM, and no Studio Link service in the GATT table. core_scope_enable(0) is the run-time pause for a debug button.

The protocol

Every frame is A5 53 type seq len payload crc8. A schema frame carries the channel names and types and is re-sent every second, so a host that attaches mid-run learns the channels from the device within a second; data frames carry only values. A decoder is about a hundred lines in any language, and the reference one, tools/scope_decode.py in the SDK, has no dependencies and prints a capture as CSV. The full byte-level spec with worked example frames is docs/scope-protocol.md.

Cross-architecture support

Plain C over the USB and BLE Core APIs; builds on every Core. Verified on the host against the reference decoder and compile-checked on all four Cores; hardware verification is pending.

·L0M0+L4M4·W5M33H5M33WCH (RISC-V) · Nordic (nRF54) — in development
·L0M0+·L4M4W5M33·H5M33WCH (RISC-V) · Nordic (nRF54) — in development

See the implementation status for the full matrix.

API reference

Lower-level · Tier 1
void core_scope_init(void);
Start the module on the Core's default link.
void core_scope_set_link(const core_scope_link_t * link);
Stream over a different link than the default.
int core_scope_add(const char * name, const volatile void * ptr, core_scope_type_t type);
Register one variable as a channel.
int core_scope_add_array(const char * name, const volatile void * ptr, core_scope_type_t type, uint8_t count);
Register a fixed array as channels `name[0]` .. `name[count-1]`.
void core_scope_set_interval_ms(uint32_t ms);
Limit core_scope_update() to one sample per `ms` milliseconds.
void core_scope_declare_rate_hz(uint32_t hz);
Promise that samples are taken at exactly this rate.
void core_scope_declare_rate_millihertz(uint32_t millihertz);
As core_scope_declare_rate_hz(), in millihertz — for rates that
void core_scope_sample(void);
Snapshot the registered variables. ISR-safe.
void core_scope_pump(void);
Frame the pending samples and hand them to the link. Main loop.
void core_scope_update(void);
core_scope_sample() + core_scope_pump(), honoring the interval
void core_scope_enable(int on);
Pause or resume at run time (a debug button, a BLE command).
int core_scope_active(void);
Is a host listening right now?
uint32_t core_scope_dropped(void);
Samples dropped since boot because the ring was full.

Generated from core_scope.htiles@83c9540.