460 lines
14 KiB
Markdown
460 lines
14 KiB
Markdown
# 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).
|