velxio/docs/wiki/custom-chips-examples.md

460 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Custom Chips — Examples walkthrough
Velxio ships an in-app gallery of 11 example chips. Each one is a complete,
working chip you can study, fork, and modify. They cover every protocol the
runtime supports: GPIO, I2C, SPI, UART, attributes, timers.
The C source for all of them lives at:
- [`test/test_custom_chips/sdk/examples/`](../../test/test_custom_chips/sdk/examples/)
- (Same files copied into the frontend bundle at `frontend/src/components/customChips/examples/` for the gallery UI)
Each example is also covered by automated tests in
[`test/test_custom_chips/test/`](../../test/test_custom_chips/test/) — read
those alongside the C source to see the expected behavior under load.
---
## The galleria
| # | Chip | Tier | Protocol | Lines of C |
|---|---|---|---|---|
| 1 | [Inverter](#1-inverter) | Beginner | GPIO + watch | ~25 |
| 2 | [XOR Gate](#2-xor-gate) | Beginner | GPIO × 2 + watch | ~30 |
| 3 | [Pulse Counter](#3-pulse-counter) | Beginner | GPIO + attributes | ~35 |
| 4 | [CD4094](#4-cd4094-shift-register) | Intermediate | GPIO + state machine | ~70 |
| 5 | [74HC595](#5-74hc595-spi-shift-register) | Intermediate | SPI slave | ~75 |
| 6 | [MCP3008](#6-mcp3008-spi-adc) | Intermediate | SPI + analog | ~85 |
| 7 | [PCF8574](#7-pcf8574-io-expander) | Intermediate | I2C slave + GPIO bidir | ~75 |
| 8 | [24C01 EEPROM](#8-24c01-eeprom) | Intermediate | I2C slave | ~80 |
| 9 | [24LC256 EEPROM](#9-24lc256-eeprom) | Advanced | I2C 16-bit addressing | ~90 |
| 10 | [DS3231 RTC](#10-ds3231-rtc) | Advanced | I2C state + BCD | ~110 |
| 11 | [ROT13 UART](#11-rot13-uart) | Intermediate | UART RX/TX | ~50 |
---
## 1. Inverter
OUT is always the inverse of IN.
**Why study it**: simplest possible chip. Shows the full lifecycle:
allocate state, register pins, watch one input, react.
```c
typedef struct { vx_pin in, out; } chip_state_t;
static void on_in_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
vx_pin_write(s->out, value ? VX_LOW : VX_HIGH);
}
void chip_setup(void) {
chip_state_t *s = malloc(sizeof(chip_state_t));
s->in = vx_pin_register("IN", VX_INPUT);
s->out = vx_pin_register("OUT", VX_OUTPUT);
vx_pin_write(s->out, vx_pin_read(s->in) ? VX_LOW : VX_HIGH);
vx_pin_watch(s->in, VX_EDGE_BOTH, on_in_change, s);
}
```
`chip.json`:
```json
{ "name": "Inverter", "pins": ["IN", "OUT", "GND", "VCC"], "attributes": [] }
```
**Tested by**: [`test/chips/01_inverter.test.js`](../../test/test_custom_chips/test/chips/01_inverter.test.js) (3 tests).
---
## 2. XOR Gate
OUT = A xor B. Demonstrates watching multiple inputs.
```c
typedef struct { vx_pin a, b, out; } chip_state_t;
static void update_output(chip_state_t* s) {
int a = vx_pin_read(s->a);
int b = vx_pin_read(s->b);
vx_pin_write(s->out, (a ^ b) ? VX_HIGH : VX_LOW);
}
static void on_input_change(void *ud, vx_pin pin, int value) {
update_output(ud);
}
void chip_setup(void) {
chip_state_t *s = malloc(sizeof(chip_state_t));
s->a = vx_pin_register("A", VX_INPUT);
s->b = vx_pin_register("B", VX_INPUT);
s->out = vx_pin_register("OUT", VX_OUTPUT);
vx_pin_watch(s->a, VX_EDGE_BOTH, on_input_change, s);
vx_pin_watch(s->b, VX_EDGE_BOTH, on_input_change, s);
update_output(s);
}
```
**Pattern**: when output depends on multiple inputs, write a single
`update_output()` and call it from every input's watch.
---
## 3. Pulse Counter
Counts rising edges on `PULSE`. Toggles `OVF` every N pulses, where N is
user-editable from the UI.
**Why study it**: shows attributes (`vx_attr_register`/`vx_attr_read`) and
how the UI's slider value flows into the chip at runtime.
```c
typedef struct {
vx_pin pulse, ovf, rst;
vx_attr threshold;
uint32_t count;
int ovf_state;
} chip_state_t;
static void on_pulse(void *ud, vx_pin p, int v) {
chip_state_t *s = ud;
s->count++;
uint32_t threshold = (uint32_t)vx_attr_read(s->threshold); // re-read live
if (s->count >= threshold) {
s->count = 0;
s->ovf_state = !s->ovf_state;
vx_pin_write(s->ovf, s->ovf_state);
}
}
static void on_reset(void *ud, vx_pin p, int v) {
chip_state_t *s = ud;
s->count = 0;
s->ovf_state = 0;
vx_pin_write(s->ovf, VX_LOW);
}
void chip_setup(void) {
chip_state_t *s = calloc(1, sizeof(chip_state_t));
s->pulse = vx_pin_register("PULSE", VX_INPUT);
s->ovf = vx_pin_register("OVF", VX_OUTPUT_LOW);
s->rst = vx_pin_register("RST", VX_INPUT_PULLUP);
s->threshold = vx_attr_register("threshold", 4.0);
vx_pin_watch(s->pulse, VX_EDGE_RISING, on_pulse, s);
vx_pin_watch(s->rst, VX_EDGE_FALLING, on_reset, s);
}
```
`chip.json`:
```json
{
"pins": ["PULSE", "OVF", "RST", "GND", "VCC"],
"attributes": [
{ "name": "threshold", "type": "int", "default": 4, "min": 1, "max": 1024 }
]
}
```
The user gets a slider labeled "threshold" with range 1..1024. Move it
during simulation → next pulse picks up the new value.
---
## 4. CD4094 shift register
8-stage shift-and-store bus register. Real-world chip with three control
pins (CLK / DATA / STR), parallel outputs Q1..Q8, and cascade outputs.
**Why study it**: state machine with multiple input watches, edge masks
(`VX_EDGE_RISING` for STR, `VX_EDGE_BOTH` for CLK), and a "no power" guard.
Highlights:
```c
static int has_power(chip_state_t *s) {
return vx_pin_read(s->VDD) && !vx_pin_read(s->VSS);
}
static void on_clk_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
if (!has_power(s)) {
for (int i = 0; i < 8; i++) vx_pin_write(s->Q[i], VX_LOW);
return;
}
if (vx_pin_read(s->CLK)) {
/* rising edge: shift DATA into bit position s->bit */
if (vx_pin_read(s->DATA)) s->reg |= (1 << s->bit);
else s->reg &= ~(1 << s->bit);
s->bit = s->bit > 0 ? s->bit - 1 : 7;
}
}
static void on_strobe_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
for (int i = 0; i < 8; i++) {
vx_pin_write(s->Q[i], (s->reg >> i) & 1 ? VX_HIGH : VX_LOW);
}
}
void chip_setup(void) {
chip_state_t *s = calloc(1, sizeof(chip_state_t));
/* register VDD/VSS/CLK/DATA/STR/OE/QS/QSN and Q1..Q8 */
vx_pin_watch(s->CLK, VX_EDGE_BOTH, on_clk_change, s);
vx_pin_watch(s->STR, VX_EDGE_RISING, on_strobe_change, s);
}
```
**Tested by**: [`test/chips/03_cd4094.test.js`](../../test/test_custom_chips/test/chips/03_cd4094.test.js) (4 tests including a power-gate test).
---
## 5. 74HC595 SPI shift register
8-bit SIPO shift register driven over SPI.
**Why study it**: SPI **without CS** — the chip shifts on every SCK clock
as long as data flows, so it must re-arm `vx_spi_start` after each byte.
```c
static void on_spi_done(void *ud, uint8_t *buffer, uint32_t count) {
chip_state_t *s = ud;
if (count > 0) s->shift_reg = buffer[0];
vx_spi_start(s->spi, s->spi_buf, 1); // re-arm immediately
}
static void on_rclk(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
s->latch_reg = s->shift_reg;
for (int i = 0; i < 8; i++) {
vx_pin_write(s->Q[i], (s->latch_reg >> i) & 1);
}
}
void chip_setup(void) {
chip_state_t *s = calloc(1, sizeof(chip_state_t));
/* register SER/SRCLK/RCLK/SRCLR/OE/QH and Q0..Q7 */
vx_spi_config cfg = {
.sck = s->SRCLK, .mosi = s->SER, .miso = s->QH,
.cs = s->RCLK, .mode = 0, .on_done = on_spi_done, .user_data = s,
};
s->spi = vx_spi_attach(&cfg);
vx_spi_start(s->spi, s->spi_buf, 1); // arm initial transfer
vx_pin_watch(s->RCLK, VX_EDGE_RISING, on_rclk, s);
}
```
**Pattern**: chips with a real CS pin should call `vx_spi_start` /
`vx_spi_stop` from a CS pin watch instead. See MCP3008 below.
---
## 6. MCP3008 SPI ADC
8-channel, 10-bit ADC.
**Why study it**: SPI with CS-driven transactions, two-phase exchange (the
master sends a command, then clocks more bytes to read the result), and
analog reads via `vx_pin_read_analog`.
```c
static void on_cs_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
if (value == VX_LOW) {
s->buf[0] = s->buf[1] = s->buf[2] = 0xff;
vx_spi_start(s->spi, s->buf, 3);
} else {
vx_spi_stop(s->spi);
}
}
static void on_spi_done(void *ud, uint8_t *buffer, uint32_t count) {
chip_state_t *s = ud;
if (count < 3) return;
uint8_t channel = (buffer[1] >> 4) & 0x07;
double voltage = vx_pin_read_analog(s->CH[channel]);
uint16_t result = (voltage / 5.0) * 1023.0 + 0.5;
/* Pre-fill response: byte[0] don't care, byte[1] = upper 2 bits, byte[2] = lower 8 */
s->buf[0] = 0;
s->buf[1] = (result >> 8) & 0x03;
s->buf[2] = result & 0xff;
vx_spi_start(s->spi, s->buf, 3); // arm response phase
}
```
**Pattern**: when a chip needs to send a response, fill the buffer and call
`vx_spi_start` again — the master clocks more bytes which read out the
buffer (and overwrite it with whatever the master happens to send next, which
the chip can ignore).
---
## 7. PCF8574 IO expander
8-bit I2C IO expander. Master writes a byte → 8 GPIO pins reflect that
byte. Master reads → returns the current state of the 8 pins.
```c
static bool on_write(void *ud, uint8_t byte) {
chip_state_t *s = ud;
s->latched = byte;
for (int i = 0; i < 8; i++) {
vx_pin_write(s->P[i], (byte >> i) & 1);
}
return true;
}
static uint8_t on_read(void *ud) {
chip_state_t *s = ud;
uint8_t v = 0;
for (int i = 0; i < 8; i++) {
if (vx_pin_read(s->P[i])) v |= (1 << i);
}
return v;
}
```
**Address**: A0/A1/A2 are read at `chip_setup()` time and form the low 3
bits of the 7-bit address (base 0x20). Wire them HIGH/LOW differently to
put two chips on the same bus at 0x20 and 0x21.
---
## 8. 24C01 EEPROM
128-byte I2C EEPROM with a write pointer that auto-increments.
**Why study it**: classic I2C protocol with an internal state machine
(IDLE → HAS_POINTER) and the standard "first byte after addressing is the
register pointer" pattern.
See the full code in
[API reference → I2C example](./custom-chips-api-reference.md#example-24c01-eeprom).
**Tested by**: [`test/chips/04_eeprom_24c01.test.js`](../../test/test_custom_chips/test/chips/04_eeprom_24c01.test.js) — 4 tests covering basic write/read, auto-increment, wrap at 0x80, and address-pin selection. Also full E2E with a real Arduino sketch using `Wire.h` in [`test/e2e/07_chip_eeprom_avr_e2e.test.js`](../../test/test_custom_chips/test/e2e/07_chip_eeprom_avr_e2e.test.js).
---
## 9. 24LC256 EEPROM
32 KB I2C EEPROM. Same idea as 24C01 but with **two-byte** addressing.
```c
typedef enum { ST_IDLE, ST_HAS_HIGH, ST_HAS_FULL_ADDRESS } ee_state;
static bool i2c_write(void *ud, uint8_t byte) {
chip_state_t *s = ud;
switch (s->state) {
case ST_IDLE:
s->pointer = (byte & 0x7f) << 8; // address high
s->state = ST_HAS_HIGH;
break;
case ST_HAS_HIGH:
s->pointer = (s->pointer & 0xff00) | byte; // address low
s->state = ST_HAS_FULL_ADDRESS;
break;
case ST_HAS_FULL_ADDRESS:
s->mem[s->pointer & (EEPROM_SIZE - 1)] = byte;
s->pointer = (s->pointer + 1) & (EEPROM_SIZE - 1);
break;
}
return true;
}
```
The state machine grows naturally as the protocol gets richer. Same pattern
extends to 24LC512, 24LC1024, and other big EEPROMs.
---
## 10. DS3231 RTC
I2C real-time clock with 19 registers and BCD-encoded values.
**Why study it**: register pointer + multi-register read auto-increment +
encoded values. This is the workhorse pattern for most I2C peripherals
(sensors, displays, motor drivers).
```c
static bool on_write(void *ud, uint8_t byte) {
chip_state_t *s = ud;
if (s->state == ST_IDLE) {
s->pointer = byte % RTC_REG_COUNT; // first byte = register pointer
s->state = ST_HAS_POINTER;
} else {
s->regs[s->pointer] = byte; // subsequent bytes = data
s->pointer = (s->pointer + 1) % RTC_REG_COUNT;
}
return true;
}
static uint8_t on_read(void *ud) {
chip_state_t *s = ud;
uint8_t b = s->regs[s->pointer];
s->pointer = (s->pointer + 1) % RTC_REG_COUNT;
return b;
}
```
**Seed values**: this chip pre-seeds time to 2026-01-15 12:34:56 (Thursday)
inside `chip_setup`. A real RTC would tick — extending this with a 1-second
timer is a great exercise.
---
## 11. ROT13 UART
UART loopback that ROT13-shifts every received byte.
**Why study it**: simplest possible UART chip. Establishes the pattern for
any UART peripheral (GPS modules, BT modems, anything Serial-based).
```c
static uint8_t rot13(uint8_t v) {
if (v >= 'A' && v <= 'Z') return ((v - 'A' + 13) % 26) + 'A';
if (v >= 'a' && v <= 'z') return ((v - 'a' + 13) % 26) + 'a';
return v;
}
static void on_rx(void *ud, uint8_t byte) {
chip_state_t *s = ud;
uint8_t out = rot13(byte);
vx_uart_write(s->uart, &out, 1);
}
void chip_setup(void) {
chip_state_t *s = malloc(sizeof(chip_state_t));
vx_uart_config cfg = {
.rx = vx_pin_register("RX", VX_INPUT),
.tx = vx_pin_register("TX", VX_INPUT_PULLUP),
.baud_rate = 115200,
.on_rx_byte = on_rx,
.on_tx_done = NULL,
.user_data = s,
};
s->uart = vx_uart_attach(&cfg);
}
```
When wired to the Arduino's `Serial`, every `Serial.print('A')` from the
sketch makes the chip echo `'N'` — visible in the Serial Monitor.
---
## How to learn from these
A productive workflow:
1. **Pick the example closest to what you want to build.** EEPROM-like? Start
from 24C01. Logic gate? Start from XOR. Display? See the chip with a
`display` field in `chip.json`.
2. **Open it in the Custom Chip Designer.** Examples tab → click the chip.
3. **Modify it.** Change a constant, add a pin, adjust a callback.
4. **Compile and place it.** Save & Place puts it on the canvas.
5. **Wire it up.** The Arduino sketch in your editor talks to it via the
normal Wire/SPI/Serial APIs.
6. **Run.** The chip console (browser dev tools) shows your `vx_log` /
`printf` output.
If something breaks, see [Build & test → Troubleshooting](./custom-chips-build-and-test.md#troubleshooting).