Oberon RTK

EcsControlBase (v0–v7)

Exploratory: eight test programs, each implementing a slice of the ECS-for-control concepts.

Overview

EcsControlBase is a small program used to evaluate whether – and how – an Entity Component System (ECS) architecture fits a real-time control system written in Oberon with Astrobe. Rather than one program, it is a family of eight variants, v0 through v7, each derived from an earlier one by changing a single aspect. Read in order they form an evolution; but they are variants, not stages – each isolates one facet of the design, and several are peers rather than rungs of a ladder (v6 and v7, below, are the clearest case). The evolution even branches: v6 derives from v2 and v7 from v4, so it is a tree, not a line.

Each variant lives in its own directory with its own copy of every module. That duplication is deliberate: each variant is a controlled experiment, and a self-contained copy makes the one changed aspect legible in a direct diff against its predecessor.

Each variant is presented here by the concepts it implements. The concepts document supplies the vocabulary – the store, the layering, the adaptation modes, the seam, the data-shape rule – and each variant realises a defined slice of it in working code. The technical elements (a ring buffer, a message pool, an interrupt handler) appear as what they are: implementations of the concepts, not subjects of their own. The lessons drawn from building the variants are collected in the lessons document.

Scope

The programs implement the concepts as the concepts document describes them – the store, stateless Systems on a tick, the layering, the adaptation modes, the seam. Within that frame they are faithful: all state lives in Components, Systems hold none, and the report's crossing follows the rules.

One part of the concepts document is described but not yet mechanised here. The concepts document presents the producer and consumer roles as machine-readable declarations – the basis for deriving the run order, detecting dependency cycles, and rejecting a second producer, all before the first tick. In these programs the roles are recorded as annotations in each variant's Components module – comments, not declarations – and the schedule is set by the programmer, in the order the Systems are registered. The checks the concepts document assigns to construction are here performed by discipline and review. Nothing in the programs contradicts the concept; the mechanism is simply not built yet.

The same holds at the larger scale: the programs are one store under one scheduler – partitioning, interface stores, and coordination remain at the concept level – and recovery, the motivation of the whole exploration, is not yet attempted.

These gaps are the intended next steps, not omissions. The outlook document starts from exactly them – machine-readable attributions and the derived schedule first among them – and the work it sketches will lead to extended evaluation programs and a follow-up to the concepts document.

What the Program Does

The program blinks a set of LEDs, each at its own rate, and reports their state to a terminal. Two details make it more than a blinker.

The report carries measured state, not commanded state: StateSystem reads the actual pin levels back through the HAL and reports what the hardware is doing – not what BlinkSystem last told it to do.

That choice also decouples the two halves completely. No Component connects BlinkSystem to StateSystem; they meet only in the hardware. Actuation (blink) and telemetry (report) are independent Systems over the same store – delete one, and the other keeps working.

The program is deliberately open loop: nothing feeds the measurement back into the actuation. What it exercises is the mechanics – actuate through the HAL, sense through the HAL, carry a report across the adaptation layer – with the report's crossing as the experimental variable. Almost every variant changes only how the report crosses from the store to the UART; the control part stays fixed.

Structure

In the concepts document's terms:

  • Entities – the LEDs and the terminal. Each LED groups its control-domain Components (on/off state, blink configuration) and its pin bindings; the terminal groups the report, the Components of its crossing, and the UART binding.

  • SystemsBlinkSystem (actuation: drives the pins from the blink configurations), StateSystem (sensing and formatting: reads the pins, produces the report), and the output-side adaptation unit, DrainSystem – a System in the cooperative variants; in the asynchronous ones its role passes, in whole or in part, to an interrupt handler, which is not a System.

  • Components – all state, in the three kinds of the concepts document: control-domain Components (LED states, blink configurations, the report), bindings (pins, the UART device), and adaptation Components – the working state of the report's crossing, which is where the variants differ.

The layering is constant across all variants; only the report's crossing changes:

control logic    BlinkSystem          StateSystem
                      |                ^        |
            immediate |      immediate |        |  the report crossing
                      |                |        |  (adaptation -- varies, v0..v7)
                      v                |        v
HAL              GPIO write        GPIO read   UART
hardware           LED pins                    TX --> terminal

Both GPIO interactions are immediate in the concepts document's sense – one access, complete at once – so BlinkSystem and StateSystem reach the HAL directly, with no adaptation in between. The report is not immediate: it leaves for the terminal over many ticks, carries progress state, and therefore takes the adaptation layer – in one of its modes, which is exactly the space the variants walk.

Each variant's world creation follows the concepts document's World Creation and Steady State: the standard start-up, then build and value. Acquire has no work in these programs – no measured Component persists across ticks – and the hardware's initial agreement with the store rests on the reset defaults rather than on an explicit propagate. Then the tick starts.

The Evolution at a Glance

Variant Concept demonstrated Implementation
v0 the minimal world: store, Systems, one synchronous adaptation unit one LED; single-buffer crossing, copy-out drain
v1 one System over many Component instances: the Entity set scales, the code does not four LEDs, per-LED rates; Component instances become arrays
v2 producer and consumer decoupled: an SPSC channel replaces the handshake; clip, never wait single buffer -> byte ring, first-class and injected
v3 the data-shape rule, message form: framing and atomicity byte ring -> message ring (queue + pool)
v4 progress machinery moved below the store boundary; the hand-off stays visible queue + pool relocated into a module of its own, still cooperative
v5 the asynchronous seam, hybrid mode drain becomes a scheduled set-up step plus a TX interrupt handler
v6 (from v2) the asynchronous seam, pure mode: async structure follows data shape interrupt character pump, software-interrupt kick
v7 (from v4) the interaction made one-shot: data shaped to fit the crossing report constrained to the TX FIFO; one-shot cooperative drain

The Variants

v0 – The Minimal World

v0 realises the concepts document's core with the least machinery: a store holding all state, stateless Systems run on a tick, and one interaction that cannot be done in a single step – the report output – handled by a synchronous adaptation unit, whose progress accordingly lives in the store as adaptation Components.

One LED. StateSystem formats the report into a single buffer; DrainSystem copies it out whole and feeds the UART across the ticks, the two coordinating through a small completion handshake held in the store. The single-buffer crossing is the simplest thing that works – and every later variant is a response to a specific pressure it cannot handle.

StateSystem --> single buffer + handshake --> DrainSystem --> UART
                (adaptation Components)       synchronous, copy-out

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • TxData (hand-over: the report buffer and its sequence)
    • TxBufWriter
  • adaptation:
    • TxCursor (copy-out cursor and completion handshake)
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding

v1 – One System, Many Entities

As described in the concepts document, one System is attributed a set of Component types, and runs over all their instances, blind to the Entities they belong to. v1 changes exactly one thing against v0: one LED Entity becomes four, each with its own blink period, and accordingly there are four Component instances of each type belonging to an LED.

The Component instances become arrays; BlinkSystem and StateSystem iterate; the report lists four measured states. One Component type is new: StatusSource, naming the LEDs the report covers – with a single LED there was nothing to select. The crossing is untouched – same buffer, same drain. Scaling the Entity set is a pure data change: more instances, the same Systems, now iterating, but no new mechanism.

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • TxData (hand-over)
    • TxBufWriter
    • StatusSource
  • adaptation:
    • TxCursor
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding

v2 – Producer and Consumer Decoupled

In v0 and v1, the crossing couples the two Systems: the completion handshake lets StateSystem refill the buffer only once the drain reports done, so the producer runs with regard to the consumer's state, over a feedback edge in the store. v2 dissolves that coupling: producer and consumer meet in a channel – one producer, one consumer, no handshake – and each side acts on the channel's status alone. StateSystem appends when there is room and clips when there is not: output dropped, never a wait, never corrupted logic. The drain streams out whatever is there as the UART accepts it. Either System may run at any time, in any order; the logic stays right regardless.

The single buffer becomes a byte ring – a lock-free single-producer/single-consumer (SPSC) construct. Its two indices do the status management for free, fill level and drain progress in one – so v0/v1's cursor, sequence numbers, and done flag disappear along with the handshake.

StateSystem --> byte ring (adaptation Component) --> DrainSystem --> UART
                                                     synchronous, streaming

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • TxBufWriter
    • StatusSource
  • adaptation:
    • TxData (lock-free SPSC ring)
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding

The ring is lock-free by design – one producer, one consumer, each owning its own index – but v2 never tests that design: under cooperative scheduling the two ends cannot run concurrently. v6 tests it, when the consumer becomes an interrupt handler and the design alone carries the correctness – one core is one observer; see v5's note on barriers. This is the channel shape the concepts document needs at the asynchronous seam, met first in cooperative form.

v3 – The Message Form

Variant v2 implements a stream-based producer-to-consumer crossing for variable-length data of the same type (characters). v3 implements a message-based crossing that is best suited for fixed-length data of mixed type (such as a record). Text remains the data, to keep in line with this series of variants, and v3 even keeps the variable-length feature, if only to demonstrate the mismatch, which forces a return to explicit drain-side per-message progress accounting (as in v0/v1 – though without the handshake: v2's decoupling is kept). Variant v7 then implements fixed-length message data to demonstrate the difference.

The producer prints into a text buffer that is the message's payload, and sends the message to the consumer via a message queue. A message pool provides a set of available message instances. The producer acquires a message from the pool, sends it to the consumer, which puts the message back into the pool when done. While either the producer or the consumer makes use of a message, they "own" it, ie. only they have a reference to it.

Both message queue and pool are part of the store, as is the per-message progress accounting of DrainSystem.

The last of these is a point in its own right: with the progress accounting in the store, the drain mechanics are part of the observable state. For a blinker's report to a terminal, that buys little. But read the message as, say, a telemetry dump from a spacecraft, and the transmit status is precisely what recovery needs – how far the dump got, where to restart mid-stream after a failure. Whether a crossing's progress belongs to the observable state is a design decision, made per interaction; v3 shows the form that keeps it there, v4 the form that encapsulates it away.

The byte ring of v2 becomes a message ring – a queue of message objects, each carrying one formatted report. The message pool is implemented as a message ring as well, with reversed producer-consumer roles. All properties of the ring buffer as outlined for variant v2 also apply here.

StateSystem --> queue + pool of messages --> DrainSystem --> UART
                (adaptation Components)      synchronous, per message

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • StatusSource
  • adaptation:
    • TextMsgQueue (hand-over: the queue of filled messages)
    • TextMsgPool (the return path: processed messages back to the producer)
    • TxCursor (the drain's per-message send progress)
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding

TxBufWriter is gone: formatting now targets each message's own device, acquired with the message.

v4 – Machinery Below the Store

Variant v3 held the messaging infrastructure in the store. Variant v4 places it outside the store, using a queueing port: the queue and pool move out of Components into a module of their own, behind an Emit/Receive interface, and only a reference to the hand-over point remains a Component. v4 still operates synchronously (cooperatively), but prepares the ground for the asynchronous path in v5 – relocation is orthogonal to asynchrony, so v5 can change only the receiver. Encapsulated, the machinery is also receiver-agnostic: v5 and v7 reuse it unchanged.

The drain-side accounting is reduced to keeping a reference to the message in progress, since the text data is read character by character directly from the message's text buffer. Two consequences ride along: the transmit status v3 kept observable leaves the store – the design decision stated at v3, here taken the other way – and a message stays out of the pool for the whole transmission, where v3's copy-out returned it at once.

StateSystem --> Emit ] queue + pool, below the store [ Receive --> DrainSystem --> UART
                                                                   synchronous

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • StatusSource
  • adaptation:
    • OutputPort (hand-over: the port into the crossing)
    • TxCursor (the message the drain currently holds)
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding
  • below the store:
    • queue + pool, inside the port module

v5 – The Asynchronous Seam, Hybrid

Variant v4 uses synchronous draining; v5 is the first implementation using asynchronous draining via a device interrupt. Asynchronous draining means that the hardware is in the driver's seat, running on "hardware time", not a System running on "tick time".

Variant v5 employs a hybrid synchronous/asynchronous method: a tick-scheduled step does the per-message set-up and primes the first character; the UART transmit interrupt handler then carries the stream, sending the rest as the hardware drains. The split shows in the store: the scheduled half, being a System, keeps its state (the message under transmission) as a Component; the handler's byte-level progress lives below. The ring's marked memory-barrier points stay inactive even here – one core is one observer: the thread and its own interrupt handler share a coherent view, so the SPSC discipline is carried by program order alone. The one active barrier is a completion barrier (DSB) where the handler disarms the transmit interrupt: that register write must have landed before the handler returns, or the interrupt re-enters. The transmit interrupt must be armed and disarmed with care.

The intricacy of v5 comes from streaming a variable-length message through the seam – set-up state has to persist across the interrupt. v6 shows the seam without that burden.

StateSystem --> Emit ] queue + pool [ --> set-up step + TX interrupt handler --> UART
                                          hybrid

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • StatusSource
  • adaptation:
    • OutputPort (hand-over: the port into the crossing)
    • TxCursor (the message under transmission – held by the scheduled half of the hybrid)
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding
  • below the store:
    • queue + pool, inside the port module
    • the handler's byte-level progress

v6 – The Asynchronous Seam, Pure

Variant v6 goes back to the character ring buffer of v2, which is the much better data fit for variable-length data of the same type (a stream of data).

A lock-free SPSC ring serves as intermediary between the producer (StateSystem) and the consumer (the UART interrupt handler). The handler is a trivial character pump: whenever the interrupt fires, it reads any available text data from the ring and writes it to the hardware transmit register. The FIFO is disabled, since the RP2350 does not offer a "FIFO empty" event, but a "transmit done" event with the FIFO off – so the handler runs once per character, each transmitted byte pulling the next. From idle, the producer starts the draining with Texts.FlushOut, which kicks the handler once via a software interrupt.

StateSystem --> byte ring --> TX interrupt handler --> UART
                              asynchronous, pure

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • TxBufWriter
    • StatusSource
  • adaptation:
    • TxData (lock-free SPSC ring)
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding

Placed against v5, v6 isolates the seam from the message model: a byte stream has no per-message set-up, so the handler can do everything. The seam itself is trivial; v5's intricacy was the shape of what crossed it, not the crossing.

v7 – The Interaction Made One-Shot

Variant v7 goes back to the message ring buffer of v4, but limits the amount of output data to the size of the empty UART FIFO. This simulates a fixed-size mixed-type data transfer – read the report as a small, fixed "sensor reading". Now the synchronous drain System can drop any progress accounting across ticks.

As with v4, a queueing port message ring serves as intermediary between producer and consumer, which internally is implemented as a pair of lock-free ring buffers (message queue and pool). The drain System is gated by a test for the empty FIFO and collapses to one shot: receive, write the whole message into the FIFO, return the message – done within a single run, no cross-tick state, no held progress. The FIFO then holds the in-flight characters during transmit, and drains over several ticks.

StateSystem --> Emit ] queue + pool [ Receive --> DrainSystem --> UART TX FIFO
                                                  synchronous, one shot

Components:

  • control domain:
    • LedState
    • BlinkConfig
    • StatusSource
  • adaptation:
    • OutputPort (hand-over: the port into the crossing)
  • bindings:
    • LedBinding
    • LedGpioBinding
    • UartBinding
  • below the store:
    • queue + pool, inside the port module
    • the TX FIFO, holding the in-flight bytes in silicon

The accounting of v3–v5 was a data-shape mismatch – variable text forced through a record-shaped carrier – not a fault of the message form. Given the shape it fits, the machinery all but disappears. v6 and v7 are peers: one right for a stream, one right for an atomic record, neither above the other.

Reading the Evolution

The order v0 -> v7 is a path through the design space, not a ranking. Two readings hold it together:

  • Choose by data shape. The central finding, and the concepts document's data-shape rule made concrete: the carrier should match the data, not a notion of progress. A variable-length byte stream (diagnostic text) wants a byte ring; a fixed structured record (a sensor reading, a command, an event) wants a message ring. v6 (stream, asynchronous) and v7 (record, cooperative) close the evolution as a pair of peers.

  • Isolate one aspect. Each variant changes one thing against a named predecessor, so the cost and benefit of that one change is legible on its own – multiplicity (v1), producer-consumer decoupling (v2), the message form (v3), placement (v4), the seam (v5), and the two data-shape endpoints (v6, v7). The branches (v6 from v2, v7 from v4) exist precisely so that the seam and the data shape can each be varied alone.

What the evolution deliberately does not claim is that v7 is the destination. It is where the message form is shown at its best, which is a fitting place to stop – not a summit the earlier variants were climbing towards.

Implementation Notes

A few notes on what the code shows, and why – the programs are implemented to work, so the concepts could be exercised, not engineered for reuse. Three consequences:

  • They run on the standard RTK start-up framework, unchanged. That also keeps the run-time error trapping and its reporting to the console intact: the console UART stays reserved for errors and diagnostics, and the programs write their report to a different UART.

  • No attempt is made to generalise the modules into a framework. The going-forward candidates – the ring, the port – are shaped by exactly these programs; open framework questions, such as where buffer sizes should live, are deliberately left standing.

  • Engineering polish is out of scope where it does not serve the exploration. One open example: Systems access single-value Components "raw", as exposed record fields, while the structured ones are proper ADTs behind access procedures (the ring, the messages). Whether the store should consistently offer only handles – ADT discipline everywhere – is a real design question, noted here and left open.

  • The store is built from individual POINTER TO RECORD Components, allocated one by one at creation. That is the simplest thing that works, not a settled choice: the store's storage model – pointer structures versus value records gathered in one block, with what that would mean for snapshots and copying – is one of the questions this exploration feeds, and will be revisited going forward on the experience gained.

Formatting above, raw characters below. The report producer formats with the standard Texts procedures, which write through a TextIO.Writer – that is why the buffers carry a writer: it is the adapter that lets the ordinary formatted-output machinery target a memory buffer (the single buffer, a message's payload) as its output device. From the hand-over on down, the crossing and the drain work on raw character data only; formatting is settled before the crossing, and nothing below it knows about text structure.

Repository

lib/v3.2<repo>/examples/v3.2/rpi/pico2/EcsControlBase/EcsControlBase-v0-v7

Last updated: 13 July 2026