Skip to main content

Hardware: GPIO, ADC, PWM, I2C, SPI

Kog treats hardware state the same way it treats UI state: a pin is a signal. An interrupt edge or an ADC sample writes the signal natively; only the parts of your UI that read it update. No polling loops in JS, ever.

The easy layer: hooks

import { useButton, useDigitalWrite, useAnalogRead } from '@kog/hardware';

function Panel() {
const [led, setLed] = useDigitalWrite(2); // useState-style: [value, setValue]
const { pressed } = useButton(0, {
activeLow: true, // pull-up wiring: pressed reads LOW
onPress: () => setLed(v => (v ? 0 : 1)),
});
const mv = useAnalogRead(4, { hz: 4 }); // reactive millivolts

return (
<View style={[styles.face, pressed() && styles.pressed]}>
<Text>{led() ? 'ON' : 'OFF'} · {(mv() / 1000).toFixed(2)} V</Text>
</View>
);
}
  • useDigitalRead(pin, { mode, edge, debounceMs }) → accessor () => 0|1 (interrupt-driven; debounce happens natively, bounce never reaches JS)
  • useDigitalWrite(pin)[value, setValue]value is an accessor for the last-written level; setValue takes a level or an updater and drives the pin
  • useButton(pin, { activeLow, debounceMs, onPress, onRelease }){ pressed }
  • useAnalogRead(pin, { hz }) → accessor () => number (calibrated mV)
  • usePWM(pin, { freqHz, resBits }){ duty }, duty(0..1)

Everything cleans up on unmount: watches detach, PWM channels free, pin claims release.

v1 note — accessor returns. Hardware-hook reads (led, mv, pressed) are accessors: call them (led(), mv()), Solid-style. They're naturally reactive wherever you read them in JSX. A compiler whitelist that lets you write the plain value (led, not led()) — matching useState — is on the roadmap.

The power layer: @kog/hardware

Hooks are sugar over an imperative API with full control:

import { claim, digitalWrite, watch, analogRead, adc, pwm, i2c } from '@kog/hardware';

claim(5, 'output');
digitalWrite(5, 1);

const w = watch(6, 'both', (level) => { /* ... */ }, { debounceMs: 2 });
// w.stop() to detach

const sub = adc.subscribe(4, { hz: 10 }, (mv) => { /* ... */ });
const ch = pwm.open(9, { freqHz: 1000, resBits: 10 });
ch.setDuty(0.5); // 0..1

// I2C/SPI: bus transactions are Promise-based, on a native worker task that
// never blocks the UI. `writeRead` writes then reads back `readLen` bytes
// (sequential write-then-read) and resolves a Uint8Array; a bus/transfer error
// rejects with an HwError carrying a stable `.code`.
const bus = i2c.open({ sda: 8, scl: 9, freqHz: 400_000 });
const rx = await bus.writeRead(0x68, [0x30, 0xa2], 2); // device 0x68, write 2, read 2
// bus.write(addr, bytes) / bus.read(addr, len) for the one-way cases.
// spi.open({ sck, mosi, miso, cs }) → bus.transfer(tx, readLen) (no addr; cs selects).

Pin safety

Pins used by your board's display, touch panel, or backlight are declared in board.yaml and reserved. Claiming one fails at compile time and runtime:

  • Compile time: the project generates a Pin type from your board.yaml — autocomplete offers only free pins, and useButton(21, …) on a display pin is a type error.
  • Runtime: dynamic pin values are validated with errors that name the owner: GPIO 21 is reserved by the display (RGB DE), plus the list of free pins.

Input-only pins, strapping pins, and platform quirks (like ADC2's conflict with Wi-Fi) are flagged with specific, actionable errors.

In the simulator

All of this works without hardware: digital inputs toggle from the keyboard or scripts, ADC values can be set over the dev channel, and I2C/SPI can return mocked responses through the real async path. See The simulator.

GPIO expanders (MCP23017-class) are planned post-v1; the pin API already accepts expander-produced pins without breaking changes.