Skip to main content

ControlForge + Parallax Propeller 2: Hardware Interface Guide

James M. Belcher Founder, JMB Technical Services LLC April 2026 | ControlForge v1.0.533


1. Architecture Overview

ControlForge treats the Propeller 2 as a smart I/O module — not a compilation target. The P2 runs a Spin2 firmware (~35KB) that ControlForge uploads automatically at boot via the ROM bootloader. All hardware control flows through USB serial at 3 Mbaud.

There are two ways to control a P2 from ControlForge:

ModeInterfaceBest For
Binary ProtocolP2_INIT / P2_CMD + convenience functionsProduction — structured commands, schema-validated, CRC-protected
Direct Serial (TAQOZ)SER_OPEN / SER_WRITE_STRRapid prototyping — send Forth words directly to the P2 ROM interpreter

The binary protocol offers two calling styles: the universal P2_CMD function (accepts any command by name) and 37 convenience functions like P2_PIN_WRITE, P2_UART_SETUP, etc. that provide a familiar, typed interface. Both styles route through the same schema-driven binary protocol — the convenience functions are thin wrappers over P2_CMD.

Both modes use IEC 61131-3 Structured Text as the programming language in ControlForge's browser-based IDE.

System Diagram


2. Mode 1: Binary Protocol (P2_CMD)

This is the production interface. ControlForge uploads firmware, establishes a CRC-protected binary link at 3 Mbaud, and provides 44 schema-driven commands through a single P2_CMD function.

2.1 Core Functions

(* Connect to P2 and upload firmware *)
ok := P2_INIT('myp2', '/dev/ttyUSB2');

(* Send any command — two calling conventions *)
result := P2_CMD('myp2', 'pin_write', 'pin', 16, 'value', 1); (* key-value *)
result := P2_CMD('myp2', 'pin_write', '{"pin": 16, "value": 1}'); (* JSON *)

(* Check connection health *)
status := P2_STATUS('myp2');
(* Returns: {"connected":true,"mode":"cyclic","ping_us":245} *)

(* Disconnect *)
P2_CLOSE('myp2');

2.2 Convenience Functions

For users who prefer dedicated function calls over P2_CMD string commands, ControlForge provides 37 convenience functions that map 1:1 to binary protocol commands. They are thin wrappers — internally they call P2_CMD with the correct parameters.

(* These two lines do exactly the same thing: *)
P2_CMD('p2', 'pin_write', 'pin', 16, 'value', 1); (* P2_CMD style *)
P2_PIN_WRITE('p2', 16, 1); (* Convenience style *)

(* Read a pin — convenience function returns the value directly *)
val := P2_PIN_READ('p2', 0); (* Returns: INT — 0 or 1 *)
result := P2_CMD('p2', 'pin_read', 'pin', 0); (* Returns: '{"value": 0}' *)

Key difference: Convenience functions return native types (BOOL, INT, STRING) directly, while P2_CMD always returns a JSON string that you parse. Use whichever style fits your program.

StyleProsBest For
Convenience (P2_PIN_WRITE, etc.)Cleaner syntax, typed returns, easier to readBeginners, simple I/O, quick prototyping
P2_CMDAccess to all 44 commands including future additions, full JSON responsePower users, complex parameters, servo_batch

2.2 Wire Protocol

Every P2_CMD call is packed into a binary frame:

┌──────┬──────┬─────┬─────┬────────┬──────────────┬────────┐
│ 0xA5 │ 0x5A │ SEQ │ CMD │ LEN(2) │ PAYLOAD(0-N) │ CRC(2) │
│ sync │ sync │ 1B │ 1B │ LE │ LE fields │ MODBUS │
└──────┴──────┴─────┴─────┴────────┴──────────────┴────────┘
  • CRC-16/MODBUS over SEQ + CMD + LEN + PAYLOAD
  • Max payload: 1024 bytes
  • All multi-byte values: little-endian
  • Response uses same frame format

You never build frames manually — P2_CMD handles packing/unpacking via the p2_commands.json schema.


3. Command Reference

3.1 System Commands

ping — Heartbeat

P2_CMD('p2', 'ping');

No parameters, no response payload. Verifies the link is alive.

version — Firmware Version

result := P2_CMD('p2', 'version');
(* Returns: {"version": 65537} *)

status — Device Status

result := P2_CMD('p2', 'status');
(* Returns: {"status": 1} — 1=OK, 128=ERROR *)

fw_info — Full Firmware Configuration

result := P2_CMD('p2', 'fw_info');
(* Returns: {"version":65537,"clkfreq":200000000,
"num_din":16,"num_dout":16,"num_ain":4,"num_aout":4} *)

3.2 Digital I/O

pin_mode — Configure Pin Direction

ParamTypeValues
pinu80-63 (P62-P63 reserved for host serial)
modeu80=INPUT (float), 1=OUTPUT_LOW, 2=OUTPUT_HIGH, 3=OPEN_DRAIN, 4=OPEN_SOURCE
(* Set pin 16 as output *)
P2_CMD('p2', 'pin_mode', 'pin', 16, 'mode', 1);

Convenience: P2_PIN_MODE(name, pin, mode) : BOOL

ok := P2_PIN_MODE('p2', 16, 1);

pin_read — Read Digital State

result := P2_CMD('p2', 'pin_read', 'pin', 0);
(* Returns: {"value": 1} or {"value": 0} *)

Convenience: P2_PIN_READ(name, pin) : INT — returns 0 or 1 directly

val := P2_PIN_READ('p2', 0);

P2 Note: Pins read 5V signals as FALSE. Use 3.3V logic or external level shifting.

pin_write — Set Digital Output

P2_CMD('p2', 'pin_write', 'pin', 16, 'value', 1);

Convenience: P2_PIN_WRITE(name, pin, value) : BOOL

P2_PIN_WRITE('p2', 16, 1);

pin_toggle — Toggle Digital Output

Convenience only: P2_PIN_TOGGLE(name, pin) : BOOL — reads the current state and writes the inverse. No single P2_CMD equivalent (requires two commands internally).

P2_PIN_TOGGLE('p2', 16);

Example: Digital I/O Scan Loop

PROGRAM POU_DigitalIO
VAR
sensor_in : BOOL;
result : STRING;
END_VAR

(* Read sensor on pin 0 *)
result := P2_CMD('p2', 'pin_read', 'pin', 0);

(* Drive output on pin 16 based on input *)
IF sensor_in THEN
P2_CMD('p2', 'pin_write', 'pin', 16, 'value', 1);
ELSE
P2_CMD('p2', 'pin_write', 'pin', 16, 'value', 0);
END_IF;
END_PROGRAM

Same example using convenience functions:

PROGRAM POU_DigitalIO
VAR
sensor_in : INT;
END_VAR

sensor_in := P2_PIN_READ('p2', 0);

IF sensor_in = 1 THEN
P2_PIN_WRITE('p2', 16, 1);
ELSE
P2_PIN_WRITE('p2', 16, 0);
END_IF;
END_PROGRAM

3.3 Smart Pin (Raw Access)

For advanced P2 users who want direct smart pin register control. These map directly to pinstart(), rdpin(), wypin(), pinfloat()+pinclear().

smartpin_start — Configure Smart Pin

ParamTypeDescription
pinu8Pin number
modeu32Smart pin mode register (P_OE, P_PWM_SAWTOOTH, etc.)
xu32X register (base period/frequency)
yu32Y register (initial value)
(* Start NCO frequency output on pin 10 *)
P2_CMD('p2', 'smartpin_start', 'pin', 10,
'mode', 16#00004C58, (* P_NCO_FREQ | P_OE *)
'x', 10,
'y', 858993459); (* 1kHz at 200MHz: freq * 2^32 / clkfreq *)

Convenience: P2_SMARTPIN_START(name, pin, mode, x, y) : BOOL

P2_SMARTPIN_START('p2', 10, 16#00004C58, 10, 858993459);

Critical: Smart pin X register packing varies by mode. For PWM/servo modes, X.word[0] = clocks per microsecond, X.word[1] = period in microseconds. See JonnyMac's OBEX objects (jm_servo.spin2, jm_pwm.spin2) for correct patterns. The higher-level pwm_setup and servo_move commands handle this packing for you.

smartpin_read / smartpin_write / smartpin_stop

raw := P2_CMD('p2', 'smartpin_read', 'pin', 10);
(* Returns: {"value": 12345} *)

P2_CMD('p2', 'smartpin_write', 'pin', 10, 'value', 500);

P2_CMD('p2', 'smartpin_stop', 'pin', 10);

Convenience functions:

FunctionSignatureReturns
P2_SMARTPIN_READ(name, pin)INT — raw 32-bit value
P2_SMARTPIN_WRITE(name, pin, value)BOOL
P2_SMARTPIN_STOP(name, pin)BOOL
raw := P2_SMARTPIN_READ('p2', 10);
P2_SMARTPIN_WRITE('p2', 10, 500);
P2_SMARTPIN_STOP('p2', 10);

3.4 UART

Up to 16 channels via smart pin async serial. A dedicated PASM2 RX cog polls all active channels at ~0.4 us per scan cycle with 256-byte ring buffers per channel.

uart_setup — Open Channel

ParamTypeDescription
chu8Channel 0-15
tx_pinu8Transmit pin
rx_pinu8Receive pin
baudu32Baud rate
(* DF Mini MP3 player on UART ch0 *)
P2_CMD('p2', 'uart_setup', 'ch', 0, 'tx_pin', 30, 'rx_pin', 29, 'baud', 9600);

Convenience: P2_UART_SETUP(name, ch, txPin, rxPin, baud) : BOOL

P2_UART_SETUP('p2', 0, 30, 29, 9600);

uart_tx — Send Data

Data is hex-encoded. 48656C6C6F = "Hello".

(* Send DF Mini play command: 7E FF 06 03 00 00 01 EF *)
P2_CMD('p2', 'uart_tx', 'ch', 0, 'data', '7EFF060300000001EF');

Convenience: P2_UART_SEND(name, ch, hexData) : INT — returns bytes sent

count := P2_UART_SEND('p2', 0, '7EFF060300000001EF');

uart_rx — Receive Data

(* Read up to 32 bytes with 100ms timeout *)
result := P2_CMD('p2', 'uart_rx', 'ch', 0, 'max_len', 32, 'timeout_ms', 100);
(* Returns: {"count": 5, "data": "7EFF060000..."} *)

Convenience: P2_UART_RECV(name, ch, maxLen, timeoutMs) : STRING — returns hex data directly

data := P2_UART_RECV('p2', 0, 32, 100);

Timing Note: UART RX is a blocking acyclic command. The 2-second acyclic timeout accommodates slow devices. For high-throughput serial, the PASM2 RX cog buffers incoming data between polls.

uart_txrx — Send Then Receive

(* Loopback test: send and receive in one frame *)
result := P2_CMD('p2', 'uart_txrx', 'ch', 0, 'timeout_ms', 50, 'data', '48656C6C6F');

Convenience: P2_UART_TXRX(name, ch, hexData, timeoutMs) : STRING — returns hex response

resp := P2_UART_TXRX('p2', 0, '48656C6C6F', 50);

uart_stop — Close Channel

P2_CMD('p2', 'uart_stop', 'ch', 0);

Convenience: P2_UART_STOP(name, ch) : BOOL

P2_UART_STOP('p2', 0);

3.5 I2C

Up to 8 buses. Uses jm_i2c.spin2 from the Parallax OBEX for reliable bit-bang I2C.

i2c_setup — Open Bus

ParamTypeDescription
chu8Channel 0-7
sclu8Clock pin
sdau8Data pin
speed_khzu16Clock speed (100 or 400 typical)
(* I2C bus on pins 10/11 at 400kHz *)
P2_CMD('p2', 'i2c_setup', 'ch', 0, 'scl', 10, 'sda', 11, 'speed_khz', 400);

Convenience: P2_I2C_SETUP(name, ch, scl, sda, speedKHz) : BOOL

P2_I2C_SETUP('p2', 0, 10, 11, 400);

i2c_xfer — Read/Write Transfer

ParamTypeDescription
chu8Channel
addru87-bit device address
flagsu8Bit 0 = no stop (repeated START)
write_lenu8Bytes to write
read_lenu8Bytes to read
write_databytesHex-encoded write data
(* Read 2 bytes from temperature sensor at 0x48 register 0x00 *)
result := P2_CMD('p2', 'i2c_xfer', 'ch', 0, 'addr', 72,
'flags', 0, 'write_len', 1, 'read_len', 2, 'write_data', '00');
(* Returns: {"ack": 1, "read_data": "0C80"} — ack=1 means device responded *)

(* Write command byte 0xAE to OLED at 0x3C *)
P2_CMD('p2', 'i2c_xfer', 'ch', 0, 'addr', 60,
'flags', 0, 'write_len', 1, 'read_len', 0, 'write_data', 'AE');

(* I2C scan — probe address, check ack *)
result := P2_CMD('p2', 'i2c_xfer', 'ch', 0, 'addr', 60,
'flags', 0, 'write_len', 0, 'read_len', 0, 'write_data', '');
(* ack=1 means device present, ack=0 means no response *)

Convenience functions simplify i2c_xfer into purpose-specific calls (flags and lengths are handled automatically):

FunctionSignatureReturns
P2_I2C_WRITE(name, ch, addr, hexData)BOOL — write_len auto-calculated from data
P2_I2C_WRITE_BYTE(name, ch, addr, byteValue)BOOL — writes a single byte
P2_I2C_READ(name, ch, addr, readLen)STRING — hex read_data
P2_I2C_WRITE_READ(name, ch, addr, hexWriteData, readLen)STRING — hex read_data
(* Write command byte 0xAE to OLED at 0x3C *)
P2_I2C_WRITE('p2', 0, 60, 'AE');
P2_I2C_WRITE_BYTE('p2', 0, 60, 16#AE); (* same thing, integer arg *)

(* Read 2 bytes from temp sensor at 0x48, register 0x00 *)
data := P2_I2C_WRITE_READ('p2', 0, 72, '00', 2); (* Returns: '0C80' *)

(* Pure read — 4 bytes from address 0x50 *)
data := P2_I2C_READ('p2', 0, 80, 4);

i2c_stop — Close Bus

P2_CMD('p2', 'i2c_stop', 'ch', 0);

Convenience: P2_I2C_STOP(name, ch) : BOOL

P2_I2C_STOP('p2', 0);

3.6 SPI

Up to 8 channels. Uses smart pin synchronous serial for MOSI/MISO with NCO clock generation.

spi_setup — Open Channel

ParamTypeDescription
chu8Channel 0-7
clku8Clock pin
mosiu8Master Out pin (255 = unused)
misou8Master In pin (255 = unused)
csu8Chip Select pin (255 = manual)
speed_khzu16Clock speed
modeu8SPI mode 0-3
P2_CMD('p2', 'spi_setup', 'ch', 0,
'clk', 40, 'mosi', 41, 'miso', 42, 'cs', 43,
'speed_khz', 1000, 'mode', 0);

Convenience: P2_SPI_SETUP(name, ch, clk, mosi, miso, cs, speedKHz, mode) : BOOL

P2_SPI_SETUP('p2', 0, 40, 41, 42, 43, 1000, 0);

spi_xfer — Transfer Data

Flag BitMeaning
0 (0x01)Assert CS
1 (0x02)Deassert CS
2 (0x04)Read (return rx_data)
(* Full duplex: assert CS, transfer, deassert CS, read response *)
result := P2_CMD('p2', 'spi_xfer', 'ch', 0, 'flags', 7, 'tx_data', 'FF00');
(* Returns: {"rx_data": "a5b7"} *)

(* Write-only (no read flag): flags = 3 *)
P2_CMD('p2', 'spi_xfer', 'ch', 0, 'flags', 3, 'tx_data', 'DEADBEEF');

Convenience: P2_SPI_XFER(name, ch, flags, hexTxData) : STRING — returns hex rx_data

rx := P2_SPI_XFER('p2', 0, 7, 'FF00'); (* Returns: 'a5b7' *)
P2_SPI_XFER('p2', 0, 3, 'DEADBEEF'); (* Write-only *)

spi_stop

P2_CMD('p2', 'spi_stop', 'ch', 0);

Convenience: P2_SPI_STOP(name, ch) : BOOL

P2_SPI_STOP('p2', 0);

3.7 ADC / DAC

adc_setup — Configure Analog Input

14-bit ADC with auto-calibration (GIO/VIO reference). Returns calibrated millivolts.

GainMultiplierUse Case
01xGeneral purpose (0-3.3V)
13.16x
210xSmall signals
331.6x
4100xMillivolt-level signals
P2_CMD('p2', 'adc_setup', 'pin', 44, 'gain', 0);

Convenience: P2_ADC_SETUP(name, pin, gain) : BOOL

P2_ADC_SETUP('p2', 44, 0);

adc_read — Read Millivolts

result := P2_CMD('p2', 'adc_read', 'pin', 44);
(* Returns: {"millivolts": 1650} — signed i32, can be negative *)

Convenience: P2_ADC_READ(name, pin) : INT — returns millivolts directly

mv := P2_ADC_READ('p2', 44); (* Returns: 1650 *)

dac_setup / dac_write — Analog Output

16-bit DAC (PWM dithered, 990 ohm, 0-3.3V).

P2_CMD('p2', 'dac_setup', 'pin', 45);
P2_CMD('p2', 'dac_write', 'pin', 45, 'value', 32768); (* ~1.65V *)

Convenience: P2_DAC_SETUP(name, pin) : BOOL / P2_DAC_WRITE(name, pin, value) : BOOL

P2_DAC_SETUP('p2', 45);
P2_DAC_WRITE('p2', 45, 32768);

Example: Analog Read Loop

PROGRAM POU_AnalogMonitor
VAR
mv : STRING;
voltage : REAL;
END_VAR

mv := P2_CMD('p2', 'adc_read', 'pin', 44);
(* Parse millivolts from JSON, scale to engineering units *)
(* voltage := JSON_GET_INT(mv, 'millivolts') / 1000.0; *)
END_PROGRAM

Same example using convenience functions:

PROGRAM POU_AnalogMonitor
VAR
mv : INT;
voltage : REAL;
END_VAR

mv := P2_ADC_READ('p2', 44);
voltage := INT_TO_REAL(mv) / 1000.0;
END_PROGRAM

3.8 PWM

General-purpose PWM with configurable frequency and 16-bit duty resolution.

(* 1 kHz PWM at 50% duty on pin 16 *)
P2_CMD('p2', 'pwm_setup', 'pin', 16, 'freq', 1000);
P2_CMD('p2', 'pwm_duty', 'pin', 16, 'duty', 32768); (* 50% = 32768/65535 *)

(* Dim to 25% *)
P2_CMD('p2', 'pwm_duty', 'pin', 16, 'duty', 16384);

(* Stop *)
P2_CMD('p2', 'pwm_stop', 'pin', 16);

Convenience: P2_PWM_SETUP(name, pin, freqHz) : BOOL / P2_PWM_DUTY(name, pin, duty) : BOOL / P2_PWM_STOP(name, pin) : BOOL

P2_PWM_SETUP('p2', 16, 1000);
P2_PWM_DUTY('p2', 16, 32768);
P2_PWM_DUTY('p2', 16, 16384);
P2_PWM_STOP('p2', 16);

Under the hood: Uses P_OE | P_PWM_SAWTOOTH with X register packed per JonnyMac pattern: x.word[0] = (clkfreq/freq)/units, x.word[1] = units. pwm_setup handles this packing for you.


3.9 Servo Control

Dedicated servo cog runs at 50 Hz, reading target positions from hub RAM and smoothly interpolating via wypin. Up to 10 simultaneous channels.

servo_move — Single Servo

ParamTypeDescription
pinu8Servo signal pin
dutyu16Pulse width in microseconds (500-2600 typical)
speedi16Interpolation: 0=instant, positive=divisor (5=default, 20=slow), negative=-deg/sec
(* Move to center, instant *)
P2_CMD('p2', 'servo_move', 'pin', 0, 'duty', 1500, 'speed', 0);

(* Move to 2000us with smooth easing (divisor 5) *)
P2_CMD('p2', 'servo_move', 'pin', 0, 'duty', 2000, 'speed', 5);

(* Move at constant 90 deg/sec *)
P2_CMD('p2', 'servo_move', 'pin', 0, 'duty', 500, 'speed', -90);

Convenience: P2_SERVO_MOVE(name, pin, duty, speed) : BOOL

P2_SERVO_MOVE('p2', 0, 1500, 0); (* center, instant *)
P2_SERVO_MOVE('p2', 0, 2000, 5); (* ease to 2000us *)
P2_SERVO_MOVE('p2', 0, 500, -90); (* 90 deg/sec *)

Servo Cog Interpolation: The firmware's servo cog runs independently at 50 Hz. Each cycle it computes delta = (target - current) / divisor + 1 and updates wypin. This produces smooth exponential easing — the servo decelerates as it approaches the target. Your ST code only sets the target; the cog handles the motion.

servo_batch — Synchronized Multi-Servo Update

Updates multiple servos in a single atomic command. Requires JSON calling convention for the array parameter.

(* Move 4 servos simultaneously *)
P2_CMD('p2', 'servo_batch',
'{"count": 4, "entries": [
{"pin": 0, "duty": 1500},
{"pin": 2, "duty": 1200},
{"pin": 4, "duty": 1800},
{"pin": 6, "duty": 1500}
]}');
(* Returns: {"updated": 4} *)

Example: 8-Servo Robot (Megabite Dog Demo)

PROGRAM POU_Servo
VAR
state : INT := 0;
END_VAR

CASE state OF
0: (* Initialize — set all servos to neutral *)
P2_CMD('p2', 'servo_batch',
'{"count": 8, "entries": [
{"pin": 0, "duty": 1500},
{"pin": 2, "duty": 1500},
{"pin": 4, "duty": 1500},
{"pin": 6, "duty": 1500},
{"pin": 41, "duty": 1500},
{"pin": 43, "duty": 1500},
{"pin": 45, "duty": 1500},
{"pin": 47, "duty": 1500}
]}');
state := 1;

1: (* Running — update servos from control logic *)
P2_CMD('p2', 'servo_move', 'pin', 0, 'duty', 1200, 'speed', 5);
P2_CMD('p2', 'servo_move', 'pin', 47, 'duty', 1800, 'speed', 5);
END_CASE;
END_PROGRAM

3.10 Quadrature Encoder

Uses the P2's built-in P_QUADRATURE smart pin mode.

(* Setup encoder on pins 8 (A) and 9 (B) *)
P2_CMD('p2', 'enc_setup', 'pinA', 8, 'pinB', 9);

(* Read position — signed 32-bit, tracks direction *)
result := P2_CMD('p2', 'enc_read', 'pinA', 8);
(* Returns: {"count": -42} *)

(* Zero the counter *)
P2_CMD('p2', 'enc_reset', 'pinA', 8);

Convenience: P2_ENC_SETUP(name, pinA, pinB) : BOOL / P2_ENC_READ(name, pinA) : INT / P2_ENC_RESET(name, pinA) : BOOL

P2_ENC_SETUP('p2', 8, 9);
pos := P2_ENC_READ('p2', 8); (* Returns: -42 *)
P2_ENC_RESET('p2', 8);

3.11 Frequency Counter

(* Measure frequency on pin 10 with 100ms gate *)
P2_CMD('p2', 'freq_setup', 'pin', 10, 'gate_ms', 100);

result := P2_CMD('p2', 'freq_read', 'pin', 10);
(* Returns: {"hz": 1000, "duty": 500} — 1kHz at 50% duty *)

Convenience: P2_FREQ_SETUP(name, pin, gateMs) : BOOL / P2_FREQ_READ(name, pin) : INT — returns Hz

P2_FREQ_SETUP('p2', 10, 100);
hz := P2_FREQ_READ('p2', 10); (* Returns: 1000 *)

Known Issue: The frequency counter has a bus fight when DIR=1 conflicts with an external signal and rdpin resets the counter. A firmware redesign is planned.


3.12 OLED Display (SSD1306)

Firmware-native text rendering — the P2 renders a built-in 5x7 ASCII font directly. 21 characters x 8 rows on a 128x64 OLED.

(* I2C bus must be set up first *)
P2_CMD('p2', 'i2c_setup', 'ch', 0, 'scl', 10, 'sda', 11, 'speed_khz', 400);

(* Initialize OLED on I2C ch0 at address 0x3C *)
P2_CMD('p2', 'oled_init', 'ch', 0, 'addr', 60);
P2_CMD('p2', 'oled_clear', 'ch', 0);

(* Print text — rows 0-7 *)
P2_CMD('p2', 'oled_print', 'row', 0, 'text', 'ControlForge v1.0.533');
P2_CMD('p2', 'oled_print', 'row', 2, 'text', 'Scan: 2ms');
P2_CMD('p2', 'oled_print', 'row', 4, 'text', 'Status: RUNNING');

Convenience: P2_OLED_INIT(name, ch, addr) : BOOL / P2_OLED_CLEAR(name, ch) : BOOL / P2_OLED_PRINT(name, row, text) : BOOL

P2_I2C_SETUP('p2', 0, 10, 11, 400);

P2_OLED_INIT('p2', 0, 60);
P2_OLED_CLEAR('p2', 0);

P2_OLED_PRINT('p2', 0, 'ControlForge v1.0.533');
P2_OLED_PRINT('p2', 2, 'Scan: 2ms');
P2_OLED_PRINT('p2', 4, 'Status: RUNNING');

3.13 Eye Display (Animated OLED)

Pixel-level animated eye rendering on SSD1306 OLEDs. A dedicated P2 cog handles smooth pupil interpolation and framebuffer rendering independently of the host.

eye_start — Initialize Eye

(* Left eye: SDA=11, SCL=10, addr 0x3C *)
P2_CMD('p2', 'eye_start', 'sda', 11, 'scl', 10, 'addr', 60);

(* Right eye: SDA=25, SCL=24, addr 0x3C — separate I2C bus *)
P2_CMD('p2', 'eye_start', 'sda', 25, 'scl', 24, 'addr', 60);

Two OLEDs at same address: Use separate I2C buses (different SDA/SCL pins). The eye cog manages each independently.

eye_move — Pupil Position

(* Look center: x=64, y=32 on 128x64 display *)
P2_CMD('p2', 'eye_move', 'eye', 0, 'x', 64, 'y', 32);

(* Look right *)
P2_CMD('p2', 'eye_move', 'eye', 0, 'x', 90, 'y', 32);

(* Look up-left *)
P2_CMD('p2', 'eye_move', 'eye', 0, 'x', 40, 'y', 20);

eye_pupil — Pupil Size

P2_CMD('p2', 'eye_pupil', 'eye', 0, 'radius', 8); (* small *)
P2_CMD('p2', 'eye_pupil', 'eye', 0, 'radius', 14); (* large *)

eye_lid / eye_bottom_lid — Eyelid Position

(* Blink *)
P2_CMD('p2', 'eye_lid', 'eye', 0, 'position', 64); (* closed *)
P2_CMD('p2', 'eye_lid', 'eye', 0, 'position', 0); (* open *)

(* Squint — partial close from bottom *)
P2_CMD('p2', 'eye_bottom_lid', 'eye', 0, 'position', 20);

eye_ring — Iris Ring

P2_CMD('p2', 'eye_ring', 'eye', 0, 'radius', 23); (* default *)
P2_CMD('p2', 'eye_ring', 'eye', 0, 'radius', 30); (* wide iris *)

Example: Idle Eye Behavior

PROGRAM POU_Eyes
VAR
scan_count : DINT := 0;
look_interval : DINT := 25; (* ~2.5 sec at 100ms scan *)
blink_interval : DINT := 50; (* ~5 sec *)
eye_x : INT := 64;
eye_y : INT := 32;
END_VAR

scan_count := scan_count + 1;

(* Random look-around *)
IF (scan_count MOD look_interval) = 0 THEN
eye_x := 44 + (scan_count MOD 40); (* 44-84 range *)
eye_y := 22 + (scan_count MOD 20); (* 22-42 range *)
P2_CMD('p2', 'eye_move', 'eye', 0, 'x', eye_x, 'y', eye_y);
P2_CMD('p2', 'eye_move', 'eye', 1, 'x', eye_x, 'y', eye_y);
END_IF;

(* Periodic blink *)
IF (scan_count MOD blink_interval) = 0 THEN
P2_CMD('p2', 'eye_lid', 'eye', 0, 'position', 64);
P2_CMD('p2', 'eye_lid', 'eye', 1, 'position', 64);
END_IF;
IF (scan_count MOD blink_interval) = 2 THEN
P2_CMD('p2', 'eye_lid', 'eye', 0, 'position', 0);
P2_CMD('p2', 'eye_lid', 'eye', 1, 'position', 0);
END_IF;
END_PROGRAM

4. Mode 2: Direct Serial (TAQOZ Forth)

For rapid prototyping or leveraging the existing TAQOZ ecosystem, you can bypass the binary protocol entirely and talk to the P2's ROM-based Forth interpreter over raw serial.

4.1 Serial Port Functions

FunctionDescription
SERIAL_FIND(search)Find port by vendor/product name. Returns port path.
SER_OPEN(port, baud)Open serial connection. Returns handle.
SER_WRITE_STR(handle, text)Send text string
SER_READ_STR(handle)Read available text
SER_READ_LINE(handle)Read until CR/LF
SER_WRITE(handle, hex)Send raw binary (hex-encoded)
SER_READ(handle, count)Read N bytes (hex-encoded)
SER_FLUSH(handle)Flush buffers
SER_SET_DTR(handle, val)Control DTR line
SER_SET_RTS(handle, val)Control RTS line
SER_CLOSE(handle)Close connection

4.2 Entering TAQOZ

The P2 ROM contains TAQOZ Forth. To enter it from serial:

PROGRAM POU_TaqozInit
VAR
port : STRING;
handle : STRING;
resp : STRING;
state : INT := 0;
END_VAR

CASE state OF
0: (* Find Parallax device *)
port := SERIAL_FIND('Parallax');
IF LEN(port) > 0 THEN
state := 1;
END_IF;

1: (* Open at 115200 *)
handle := SER_OPEN(port, 115200);
IF handle <> '' THEN
state := 2;
END_IF;

2: (* Enter TAQOZ: send > ESC CR *)
SER_WRITE_STR(handle, CONCAT('> ', CHR(27), CHR(13)));
state := 3;

3: (* Drain response *)
resp := SER_READ_STR(handle, 256, 100); (* max 256 bytes, 100 ms timeout *)
state := 4;

4: (* Enter TAQOZ again (reliable entry) *)
SER_WRITE_STR(handle, CONCAT('> ', CHR(27), CHR(13)));
state := 5;

5: (* Drain — now in TAQOZ# prompt *)
resp := SER_READ_STR(handle, 256, 100); (* max 256 bytes, 100 ms timeout *)
state := 10;

10: (* Ready — send Forth commands *)
(* ... *)
END_CASE;
END_PROGRAM

4.3 Controlling Hardware with Forth Words

Once in TAQOZ, you send Forth words as plain text:

(* Blink pin 0: set HIGH, wait 500us, set LOW *)
SER_WRITE_STR(handle, CONCAT('0 HIGH 500 us 0 LOW', CHR(13)));

(* PWM output *)
SER_WRITE_STR(handle, CONCAT('1000 16 HZ', CHR(13))); (* 1kHz on pin 16 *)

(* Read pin state *)
SER_WRITE_STR(handle, CONCAT('0 PIN@ .', CHR(13))); (* prints 0 or 1 *)
resp := SER_READ_STR(handle, 256, 100); (* max 256 bytes, 100 ms timeout *)

(* Define a new Forth word *)
SER_WRITE_STR(handle, CONCAT(': BLINK 0 HIGH 500 ms 0 LOW 500 ms ;', CHR(13)));

(* Run it *)
SER_WRITE_STR(handle, CONCAT('BLINK', CHR(13)));

4.4 Servo Control via TAQOZ

The TAQOZ test project demonstrates direct servo pulse generation:

(* Generate servo pulse: HIGH for pulse_us microseconds, then LOW *)
pulse_us := 500 + (angle * 2100) / 180; (* 500-2600us range *)
cmd := CONCAT('0 HIGH ', INT_TO_STRING(pulse_us), ' us 0 LOW', CHR(13));
SER_WRITE_STR(handle, cmd);

This runs every scan cycle, producing a software-timed servo signal. For production use, the binary protocol's servo_move command with its dedicated interpolation cog is more precise.

4.5 TAQOZ Quick Reference (P2 Hardware Words)

CategoryWords
Pin ControlHIGH LOW FLOAT PIN@
Smart PinWRPIN WXPIN WYPIN RDPIN RQPIN AKPIN WAITPIN WRACK
PWM/FreqPWM SAW NCO HZ KHZ MHZ MUTE BLINK BIT
PulsePULSE PULSES HILO DUTY
SerialBAUD TXD RXD TXDAT
StackDUP OVER SWAP ROT DROP + - * / AND OR XOR NOT
ControlIF ELSE THEN BEGIN UNTIL AGAIN DO LOOP FOR NEXT
MemoryC@ W@ @ C! W! ! +!
Display. PRINT .DEC .HEX .BIN EMIT CR SPACE CLS
Timingms us CNT@ LAP .LAP .ms
CogsCOG COGID COGINIT COGSTOP NEWCOG
SystemREBOOT RESET HEX DEC BIN WORDS CLKHZ
Defining: name ... ; VAR FORGET
SPI FlashSFPINS SFWE SFWRPG SFERASE BACKUP RESTORE
SD CardMOUNT DIR FOPEN FLOAD FGET FREAD FWRITE

432 total TAQOZ words available in the P2 ROM. Use WORDS to list them all from the TAQOZ prompt.


5. Cyclic Exchange (Background I/O)

Independent of the acyclic commands above, the binary protocol firmware runs a periodic cyclic exchange at configurable scan rate (default 2ms). This provides deterministic digital and analog I/O without per-scan command overhead.

How It Works

Every 2ms (configurable):
Go runtime builds output frame:
Digital[8 bytes] + Analog[N × 4 bytes]

USB Serial (3 Mbaud)

P2 firmware applies outputs to pins, reads inputs:
Digital[8 bytes] + Analog[N × 4 bytes] + Status[1]

Go runtime stores inputs via atomic (lock-free)

ST code reads latest values (zero-latency)
  • 64 digital I/O (8 bytes, bit-packed)
  • 4 analog inputs + 4 analog outputs (configurable, 32-bit each)
  • Lock-free: Uses sync/atomic.Value — no mutex on the hot path
  • Diagnostics: Exchange count, CRC errors, timeouts, min/max/avg latency
  • Loss detection: 10 consecutive errors = disconnected

Bandwidth

At 3 Mbaud with default payload (24 bytes out + 25 bytes in + 16 bytes framing):

ConfigurationMax Exchange RateNotes
8 DI + 8 DO~10 kHzMinimum payload
64 mixed I/O + 4 analog~1.1 kHzDefault config
Full 1024-byte payload~140 HzMaximum payload

6. Timing Tiers

TierWhere It RunsLatencyUse Case
P2 cog (PASM2)On-chip, dedicated cog~10 ns/instructionServo interpolation, safety watchdog, UART RX buffering
P2 cog (Spin2)On-chip, Spin2 bytecode3-10 us/wordCommand dispatch, I2C/SPI transactions
ControlForge scanHost CPU, ST interpreter1-50 msPID loops, state machines, sequencing
ControlForge bossCluster coordination10-100 msHMI, logging, multi-device orchestration

The P2 handles time-critical operations locally (servo position updates at 50 Hz, UART RX at ~2.5 MHz equivalent poll rate) while ControlForge handles the logic, sequencing, and coordination.


7. Hardware Notes for P2 Users

Pin Constraints

  • P62-P63: Reserved for host serial TX/RX. Cannot be reconfigured.
  • P56-P63: On-board LEDs on P2-EVAL. Available for general I/O but visual feedback is useful for debugging.
  • P2-EVAL isolated pin groups: Not all pins share the same power group. Verify ground reference when connecting external devices.

Smart Pin X Register Packing

This is the single biggest source of bugs when writing raw smart pin code:

ModeX.word[0]X.word[1]
Servo (PWM_SAWTOOTH)clkfreq / 1_000_000 (clocks/us)20_000 (period in us)
General PWM(clkfreq / freq) / unitsunits
DC Motor(clkfreq / (kHz*1000)) / 10001000

Always check JonnyMac's OBEX objects (jm_servo.spin2, jm_pwm.spin2) before implementing custom smart pin modes. AI training data has incorrect P2 register formats. The OBEX is the gold standard.

Serial Gotchas

  • Linux HUPCL: Linux serial close sends hangup signal, resetting the P2. Disable with stty -hupcl /dev/ttyUSB2 or set CLOCAL in termios. ControlForge handles this automatically.
  • 3.3V logic: P2 pins read 5V as FALSE. Use 3.3V logic levels or external level shifters.
  • FTDI latency: Default USB latency timer is 16ms. ControlForge sets it to 1ms automatically for responsive cyclic exchange.

Firmware Cog Behavior

  • Cogs are launched at firmware init only. Attempting to cogspin from a command handler fails silently. All dedicated cogs (servo, UART RX, eye rendering) start at boot and idle until configured.
  • Eye cog re-initialization: When new eye displays are registered, the eye cog detects the count change and re-runs OLED init. Track last_init_count vs eye_count.

Appendix A: Complete Command Quick Reference

CommandOpcodeRequestResponse
ping0x01
version0x03version:u32
status0x05status:u8
fw_info0x30version:u32, clkfreq:u32, num_din:u8, num_dout:u8, num_ain:u8, num_aout:u8
pin_mode0x20pin:u8, mode:u8ok:u8
pin_read0x21pin:u8value:u8
pin_write0x22pin:u8, value:u8ok:u8
smartpin_start0x23pin:u8, mode:u32, x:u32, y:u32ok:u8
smartpin_read0x24pin:u8value:u32
smartpin_write0x25pin:u8, value:u32ok:u8
smartpin_stop0x26pin:u8ok:u8
uart_setup0x40ch:u8, tx_pin:u8, rx_pin:u8, baud:u32ok:u8
uart_tx0x41ch:u8, data:bytescount:u16
uart_rx0x42ch:u8, max_len:u8, timeout_ms:u16count:u16, data:bytes
uart_stop0x43ch:u8ok:u8
uart_txrx0x44ch:u8, timeout_ms:u16, data:bytescount:u16, data:bytes
i2c_setup0x50ch:u8, scl:u8, sda:u8, speed_khz:u16ok:u8
i2c_xfer0x51ch:u8, addr:u8, flags:u8, write_len:u8, read_len:u8, write_data:bytesack:u8, read_data:bytes
i2c_stop0x52ch:u8ok:u8
spi_setup0x60ch:u8, clk:u8, mosi:u8, miso:u8, cs:u8, speed_khz:u16, mode:u8ok:u8
spi_xfer0x61ch:u8, flags:u8, tx_data:bytesrx_data:bytes
spi_stop0x62ch:u8ok:u8
oled_init0x70ch:u8, addr:u8ok:u8
oled_clear0x71ch:u8ok:u8
oled_print0x72row:u8, text:stringok:u8
eye_start0x74sda:u8, scl:u8, addr:u8ok:u8
eye_move0x75eye:u8, x:u8, y:u8ok:u8
eye_pupil0x76eye:u8, radius:u8ok:u8
eye_lid0x78eye:u8, position:u8ok:u8
eye_bottom_lid0x79eye:u8, position:u8ok:u8
eye_ring0x7Aeye:u8, radius:u8ok:u8
adc_setup0x80pin:u8, gain:u8ok:u8
adc_read0x81pin:u8millivolts:i32
dac_setup0x82pin:u8ok:u8
dac_write0x83pin:u8, value:u16ok:u8
pwm_setup0x84pin:u8, freq:u32ok:u8
pwm_duty0x85pin:u8, duty:u16ok:u8
pwm_stop0x86pin:u8ok:u8
enc_setup0x87pinA:u8, pinB:u8ok:u8
enc_read0x88pinA:u8count:i32
enc_reset0x89pinA:u8ok:u8
freq_setup0x8Apin:u8, gate_ms:u16ok:u8
freq_read0x8Bpin:u8hz:u32, duty:u32
servo_batch0x8Ccount:u8, entries:[{pin:u8, duty:u16}]updated:u8
servo_move0x8Dpin:u8, duty:u16, speed:i16ok:u8

Appendix B: Convenience Function Quick Reference

All convenience functions are thin wrappers over P2_CMD. They take the device name as the first argument and return native types instead of JSON strings.

GPIO

FunctionSignatureReturns
P2_PIN_MODE(name, pin, mode)BOOL
P2_PIN_READ(name, pin)INT (0 or 1)
P2_PIN_WRITE(name, pin, value)BOOL
P2_PIN_TOGGLE(name, pin)BOOL (reads then inverts)

Smart Pins

FunctionSignatureReturns
P2_SMARTPIN_START(name, pin, mode, x, y)BOOL
P2_SMARTPIN_READ(name, pin)INT (raw 32-bit)
P2_SMARTPIN_WRITE(name, pin, value)BOOL
P2_SMARTPIN_STOP(name, pin)BOOL

UART

FunctionSignatureReturns
P2_UART_SETUP(name, ch, txPin, rxPin, baud)BOOL
P2_UART_SEND(name, ch, hexData)INT (bytes sent)
P2_UART_RECV(name, ch, maxLen, timeoutMs)STRING (hex data)
P2_UART_TXRX(name, ch, hexData, timeoutMs)STRING (hex response)
P2_UART_STOP(name, ch)BOOL

I2C

FunctionSignatureReturns
P2_I2C_SETUP(name, ch, scl, sda, speedKHz)BOOL
P2_I2C_WRITE(name, ch, addr, hexData)BOOL (write_len auto-calculated)
P2_I2C_WRITE_BYTE(name, ch, addr, byteValue)BOOL
P2_I2C_READ(name, ch, addr, readLen)STRING (hex read_data)
P2_I2C_WRITE_READ(name, ch, addr, hexWriteData, readLen)STRING (hex read_data)
P2_I2C_STOP(name, ch)BOOL

SPI

FunctionSignatureReturns
P2_SPI_SETUP(name, ch, clk, mosi, miso, cs, speedKHz, mode)BOOL
P2_SPI_XFER(name, ch, flags, hexTxData)STRING (hex rx_data)
P2_SPI_STOP(name, ch)BOOL

ADC / DAC

FunctionSignatureReturns
P2_ADC_SETUP(name, pin, gain)BOOL
P2_ADC_READ(name, pin)INT (millivolts, signed)
P2_DAC_SETUP(name, pin)BOOL
P2_DAC_WRITE(name, pin, value)BOOL (value 0-65535)

PWM

FunctionSignatureReturns
P2_PWM_SETUP(name, pin, freqHz)BOOL
P2_PWM_DUTY(name, pin, duty)BOOL (duty 0-65535)
P2_PWM_STOP(name, pin)BOOL

Encoder

FunctionSignatureReturns
P2_ENC_SETUP(name, pinA, pinB)BOOL
P2_ENC_READ(name, pinA)INT (signed 32-bit count)
P2_ENC_RESET(name, pinA)BOOL

Frequency Counter

FunctionSignatureReturns
P2_FREQ_SETUP(name, pin, gateMs)BOOL
P2_FREQ_READ(name, pin)INT (Hz)

Servo

FunctionSignatureReturns
P2_SERVO_MOVE(name, pin, duty, speed)BOOL (duty in us, speed: 0=instant, +N=divisor, -N=deg/sec)

Note: servo_batch is P2_CMD-only due to its array parameter. Use P2_SERVO_MOVE for single servos, P2_CMD with JSON for synchronized multi-servo updates.

OLED Display

FunctionSignatureReturns
P2_OLED_INIT(name, ch, addr)BOOL
P2_OLED_CLEAR(name, ch)BOOL
P2_OLED_PRINT(name, row, text)BOOL

Note: Eye display commands (eye_start, eye_move, eye_pupil, eye_lid, eye_bottom_lid, eye_ring) and servo_batch are available through P2_CMD only. Convenience functions cover 38 hardware I/O operations.


ControlForge v1.0.533 | Firmware: cyclic_io.spin2 (flexspin) | P2 Rev G @ 200 MHz Schema source of truth: go-p2/firmware/p2_commands.json (44 commands)

© 2026 JMB Technical Services LLC. All rights reserved. Back to All Guides