Compare commits

...

2 Commits

Author SHA1 Message Date
dc902565f3 yes 2025-11-19 13:00:39 -08:00
4ff9508933 wip 2025-11-19 10:47:57 -08:00
14 changed files with 432 additions and 577 deletions

View File

@ -1,47 +0,0 @@
import { GPIO } from "./pins"
console.log(`kill -9 ${process.pid}`)
const gpio = new GPIO({ resetOnClose: true })
// // Blink an LED
using led = gpio.output(21)
// Read a button
using inputs = gpio.inputGroup({
button: { pin: 20, pull: "up", debounce: 10 },
switch: { pin: 16, pull: "up", debounce: 10 }
})
led.value = inputs.pins.button.value
const iteratorEvents = new Promise(async (resolve) => {
for await (const event of inputs.events()) {
if (event.pin === "button") {
console.log(`🌭`, event.value)
led.value = event.value
}
}
})
const switchEvent = new Promise<void>(async (resolve) => {
await inputs.pins.switch.waitForValue(0)
console.log("Switch pressed!")
resolve()
})
process.on("SIGINT", () => {
inputs.close()
led.close()
process.exit(0)
})
process.on("SIGTERM", () => {
inputs.close()
process.exit(0)
})
await Promise.race([iteratorEvents, switchEvent])
console.log(`👋 Goodbye!`)

View File

@ -22,25 +22,19 @@ type PhoneContext = {
cancelAgent?: CancelableTask
}
const gpio = new GPIO({ resetOnClose: true })
using ringer = gpio.output(17)
using inputs = gpio.inputGroup({
hook: { pin: 27, debounce: 50 },
rotaryInUse: { pin: 22, debounce: 50 },
rotaryNumber: { pin: 23, debounce: 10 },
})
const gpio = new GPIO()
using ringer = gpio.output(17, { resetOnClose: true })
using hook = gpio.input(27, { pull: "up", debounce: 3 })
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
export const startPhone = async (agentId: string, apiKey: string) => {
await Buzz.setVolume(0.4)
log.info(`📞 Hook ${inputs.pins.hook.value}`)
await handleInputEvents()
}
log.info(`📞 Hook ${hook.value}`)
const handleInputEvents = async () => {
let digit = 0
for await (const event of inputs.events()) {
switch (event.pin) {
case "hook":
hook.onChange((event) => {
const type = event.value == 0 ? "hang_up" : "pick_up"
log.info(`📞 Hook ${event.value} sending ${type}`)
if (type === "hang_up") {
@ -48,27 +42,24 @@ const handleInputEvents = async () => {
} else {
ringer.value = 0
}
break
})
case "rotaryInUse":
rotaryInUse.onChange((event) => {
if (event.value === 0) {
digit = 0
} else {
log.info(`📞 Dialed digit: ${digit}`)
}
break
})
case "rotaryNumber":
rotaryNumber.onChange((event) => {
if (event.value === 1) {
digit += 1
}
break
})
default:
log.error(`📞 Unknown pin event: ${event.pin}`)
break
}
}
// Keep process running
await new Promise(() => {})
}
const apiKey = process.env.ELEVEN_API_KEY

View File

@ -4,11 +4,12 @@ High-level GPIO library for Bun using libgpiod v2 with automatic resource manage
## Features
- Type-safe TypeScript API with autocomplete for pin names
- True event-driven GPIO with worker-based architecture (<10ms latency)
- Zero CPU usage when idle (blocking on hardware events)
- Type-safe TypeScript API
- Automatic resource cleanup with `using` keyword
- Hardware debouncing via kernel
- Event-driven input handling
- Efficient multi-pin monitoring with input groups
- Callback-based event handling with multiple listeners
- Zero external dependencies (uses Bun FFI)
## Requirements
@ -36,10 +37,13 @@ for (let i = 0; i < 10; i++) {
using button = gpio.input(20, { pull: "up", debounce: 10 })
console.log(button.value)
// Listen for button events
for await (const event of button.events()) {
// Listen for button events with callback
button.onChange((event) => {
console.log(event.value === 0 ? "Pressed!" : "Released")
}
})
// Keep process running
await new Promise(() => {})
```
## API
@ -86,27 +90,6 @@ Options:
- `debounce?: number` - Debounce period in milliseconds (default: 0)
- `edge?: 'rising' | 'falling' | 'both'` - Edge detection (default: 'both')
#### `gpio.inputGroup(config)`
Monitor multiple inputs efficiently with a single file descriptor. Pin names are fully type-safe!
```typescript
using inputs = gpio.inputGroup({
hook: { pin: 20, pull: "up" },
rotary: { pin: 21, pull: "up", debounce: 1 },
button: { pin: 22, pull: "down" },
})
// Access individual pins (fully typed!)
console.log(inputs.pins.hook.value) // TypeScript knows about .hook
console.log(inputs.pins.button.value) // TypeScript knows about .button
// Monitor all pins
for await (const event of inputs.events()) {
console.log(`${event.pin}: ${event.value}`) // event.pin is "hook" | "rotary" | "button"
}
```
#### `gpio.listChips()`
List available GPIO chips.
@ -117,22 +100,30 @@ console.log(chips)
// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }]
```
### InputPin
### Input
```typescript
using button = gpio.input(20)
// Read current state
// Read current state (cached from last event)
const value: 0 | 1 = button.value
// Listen for changes (returns unsubscribe function)
const unsubscribe = button.onChange((event) => {
console.log(event.value, event.timestamp)
})
// Add multiple listeners
const unsub2 = button.onChange((event) => {
console.log("Second listener:", event.value)
})
// Remove specific listener
unsubscribe()
// Wait for specific value
await button.waitForValue(0) // wait for LOW
await button.waitForValue(1, 5000) // wait for HIGH with 5s timeout
// Event stream
for await (const event of button.events()) {
console.log(event.value, event.timestamp)
}
```
### OutputPin
@ -146,29 +137,23 @@ const value = led.value
led.toggle()
```
### InputGroup
## Architecture
```typescript
using inputs = gpio.inputGroup({
switch: { pin: 16, pull: "up" },
button: { pin: 20, pull: "up", debounce: 10 }
})
### Worker-Based Event Handling
// Access pins with full type safety
inputs.pins.switch.value // ✓ TypeScript autocomplete
inputs.pins.button.value // ✓ TypeScript autocomplete
Each input spawns a dedicated Web Worker that:
// Wait for specific pin values
await inputs.pins.button.waitForValue(0) // wait for button to go LOW
await inputs.pins.switch.waitForValue(1, 3000) // wait for switch to go HIGH with timeout
1. Blocks on `gpiod_line_request_wait_edge_events()` with `-1` timeout (infinite)
2. Wakes instantly when hardware GPIO edge event occurs
3. Reads event and posts message to main thread
4. Main thread fires registered callbacks
// Monitor all pins
for await (const event of inputs.events()) {
event.pin // Type: 'switch' | 'button'
event.value // Type: 0 | 1
event.timestamp // Type: bigint (nanoseconds)
}
```
**Benefits:**
- **True blocking**: Zero CPU usage when idle
- **Low latency**: <10ms response time (vs 100ms with polling)
- **Independent inputs**: Each input has its own worker
- **Clean shutdown**: Workers terminated on close, kernel handles GPIO cleanup
## Resource Management
@ -176,17 +161,24 @@ for await (const event of inputs.events()) {
```typescript
// Good - automatic cleanup
{
using led = gpio.output(17)
using led = gpio.output(17) // Automatically released because of `using`
led.value = 1
} // Automatically released
```
// Bad - manual cleanup required
```typescript
// Meh - manual cleanup required
const led = gpio.output(17)
led.value = 1
led.close() // Must call manually
```
```typescript
// Bad - leaks resources
const led = gpio.output(17)
led.value = 1
// Forgot to close() - resource leak!
```
## Hardware Setup
### Pull Resistors
@ -197,6 +189,7 @@ Pull resistors prevent floating input values when nothing is connected to the pi
- **Pull-down + button to VCC**: When released, pin reads LOW (0). When pressed, pin reads HIGH (1).
**Important:** Match your pull resistor to your wiring:
- Button to ground → use `pull: "up"`
- Button to VCC (3.3V) → use `pull: "down"`
@ -252,26 +245,27 @@ import { GPIO } from "@/pins"
const gpio = new GPIO()
using inputs = gpio.inputGroup({
button: { pin: 20, pull: "up", debounce: 10 },
switch: { pin: 16, pull: "up" }
})
using button = gpio.input(20, { pull: "up", debounce: 10 })
using switchInput = gpio.input(16, { pull: "up" })
using led = gpio.output(21)
// Set LED based on switch state
if (inputs.pins.switch.value === 1) {
led.value = 1
}
led.value = switchInput.value
// Toggle LED when button pressed
for await (const event of inputs.events()) {
if (event.pin === "button" && event.value === 0) {
button.onChange((event) => {
if (event.value === 0) {
led.toggle()
} else if (event.pin === "switch") {
}
})
// Mirror switch to LED
switchInput.onChange((event) => {
led.value = event.value
}
}
})
// Keep process running
await new Promise(() => {})
```
### Rotary Phone Dialer
@ -281,18 +275,31 @@ import { GPIO } from "@/pins"
const gpio = new GPIO()
using inputs = gpio.inputGroup({
hook: { pin: 20, pull: "up" },
rotary: { pin: 21, pull: "up", debounce: 1 },
using hook = gpio.input(27, { pull: "up", debounce: 3 })
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
let digit = 0
hook.onChange((event) => {
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
})
for await (const event of inputs.events()) {
if (event.pin === "hook") {
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
} else if (event.pin === "rotary" && event.value === 0) {
console.log("Rotary pulse")
rotaryInUse.onChange((event) => {
if (event.value === 0) {
digit = 0
} else {
console.log(`Dialed digit: ${digit}`)
}
})
rotaryNumber.onChange((event) => {
if (event.value === 1) {
digit += 1
}
})
await new Promise(() => {})
```
## Troubleshooting

101
src/pins/gpio-helpers.ts Normal file
View File

@ -0,0 +1,101 @@
import type { Pointer } from "bun:ffi"
import { gpiod, GPIOD_LINE_DIRECTION_INPUT, GPIOD_LINE_DIRECTION_OUTPUT } from "./ffi"
import { cstr, mapPullToLibgpiod, mapEdgeToLibgpiod } from "./utils"
import type { PullMode, EdgeMode } from "./types"
type LineRequestResult = {
chip: Pointer
request: Pointer
}
export type InputLineConfig = {
chipPath: string
offset: number
pull: PullMode
debounce: number
edge: EdgeMode
}
export type OutputLineConfig = {
chipPath: string
offset: number
initialValue: 0 | 1
}
const cleanup = (message: string): never => {
throw new Error(message)
}
const requestLine = (
chipPath: string,
offset: number,
consumer: string,
configureSettings: (settings: Pointer) => void
): LineRequestResult => {
const chip = gpiod.gpiod_chip_open(cstr(chipPath))
if (!chip) cleanup("Failed to open GPIO chip")
const settings = gpiod.gpiod_line_settings_new()
if (!settings) {
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create line settings")
}
configureSettings(settings!)
const lineConfig = gpiod.gpiod_line_config_new()
if (!lineConfig) {
gpiod.gpiod_line_settings_free(settings)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create line config")
}
const offsets = new Uint32Array([offset])
const ret = gpiod.gpiod_line_config_add_line_settings(lineConfig, offsets, 1, settings)
gpiod.gpiod_line_settings_free(settings)
if (ret !== 0) {
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to add line settings")
}
const requestConfig = gpiod.gpiod_request_config_new()
if (!requestConfig) {
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create request config")
}
gpiod.gpiod_request_config_set_consumer(requestConfig, cstr(consumer))
const request = gpiod.gpiod_chip_request_lines(chip, requestConfig, lineConfig)
gpiod.gpiod_request_config_free(requestConfig)
gpiod.gpiod_line_config_free(lineConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
cleanup("Failed to request GPIO line")
}
return { chip: chip!, request: request! }
}
export const requestInputLine = (config: InputLineConfig): LineRequestResult => {
return requestLine(config.chipPath, config.offset, "bun-gpio-input", (settings) => {
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_INPUT)
gpiod.gpiod_line_settings_set_bias(settings, mapPullToLibgpiod(config.pull))
gpiod.gpiod_line_settings_set_edge_detection(settings, mapEdgeToLibgpiod(config.edge))
if (config.debounce > 0) {
gpiod.gpiod_line_settings_set_debounce_period_us(settings, config.debounce * 1000)
}
})
}
export const requestOutputLine = (config: OutputLineConfig): LineRequestResult => {
return requestLine(config.chipPath, config.offset, "bun-gpio", (settings) => {
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_OUTPUT)
gpiod.gpiod_line_settings_set_output_value(settings, config.initialValue)
})
}

View File

@ -1,157 +1,23 @@
import { ptr } from "bun:ffi"
import { readdir } from "node:fs/promises"
import { gpiod, cstr, GPIOD_LINE_DIRECTION_OUTPUT, GPIOD_LINE_DIRECTION_INPUT } from "./ffi"
import { OutputPin } from "./output"
import { InputPin } from "./input"
import { InputGroup } from "./input-group"
import { ChipNotFoundError, PinInUseError } from "./errors"
import { mapPullToLibgpiod, mapEdgeToLibgpiod, hashInputConfig } from "./utils"
import type { OutputOptions, InputOptions, PullMode, ChipInfo } from "./types"
import { gpiod, cstr } from "./ffi"
import { Output } from "./output"
import { Input } from "./input"
import { ChipNotFoundError } from "./errors"
import type { OutputOptions, InputOptions, ChipInfo } from "./types"
export class GPIO {
#chipPath: string
#resetOnClose: boolean
constructor(options?: { chip?: string; resetOnClose?: boolean }) {
constructor(options?: { chip?: string }) {
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
this.#resetOnClose = options?.resetOnClose ?? false
}
output(pin: number, options?: OutputOptions): OutputPin {
const initialValue = options?.initialValue ?? 0
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
if (!chip) {
throw new ChipNotFoundError(this.#chipPath)
output(pin: number, options?: OutputOptions): Output {
return new Output(this.#chipPath, pin, options)
}
try {
const reqConfig = gpiod.gpiod_request_config_new()
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
const lineSettings = gpiod.gpiod_line_settings_new()
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_OUTPUT)
gpiod.gpiod_line_settings_set_output_value(lineSettings, initialValue)
const lineConfig = gpiod.gpiod_line_config_new()
const offsets = new Uint32Array([pin])
gpiod.gpiod_line_config_add_line_settings(lineConfig, ptr(offsets), 1, lineSettings)
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
gpiod.gpiod_line_settings_free(lineSettings)
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_request_config_free(reqConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
throw new PinInUseError(pin)
}
let resetValue: 0 | 1 | undefined
if (this.#resetOnClose) {
const currentValue = gpiod.gpiod_line_request_get_value(request, pin)
if (currentValue === -1) {
console.warn(`Failed to read initial value for pin ${pin}, assuming 0`)
resetValue = 0
} else {
resetValue = currentValue as 0 | 1
}
}
return new OutputPin(chip, request, pin, resetValue)
} catch (err) {
gpiod.gpiod_chip_close(chip)
throw err
}
}
input(pin: number, options?: InputOptions): InputPin<"pin"> {
const group = this.inputGroup({
pin: { pin, ...options },
})
return new InputPin(group, "pin")
}
inputGroup<T extends Record<string, { pin: number } & InputOptions>>(
config: T
): InputGroup<keyof T & string> {
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
if (!chip) {
throw new ChipNotFoundError(this.#chipPath)
}
try {
const reqConfig = gpiod.gpiod_request_config_new()
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
const lineConfig = gpiod.gpiod_line_config_new()
const groups = new Map<
string,
Array<{ name: string; pin: number; pull: PullMode; options: InputOptions }>
>()
for (const [name, pinConfig] of Object.entries(config)) {
const pull = pinConfig.pull ?? "up"
const debounce = pinConfig.debounce ?? 0
const edge = pinConfig.edge ?? "both"
const hash = hashInputConfig(pull, debounce, edge)
if (!groups.has(hash)) groups.set(hash, [])
groups.get(hash)!.push({ name, pin: pinConfig.pin, pull, options: pinConfig })
}
for (const [hash, pins] of groups) {
const firstPin = pins[0]
if (!firstPin) continue
const pull = firstPin.options.pull ?? "up"
const debounce = firstPin.options.debounce ?? 0
const edge = firstPin.options.edge ?? "both"
const lineSettings = gpiod.gpiod_line_settings_new()
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_INPUT)
gpiod.gpiod_line_settings_set_bias(lineSettings, mapPullToLibgpiod(pull))
gpiod.gpiod_line_settings_set_edge_detection(lineSettings, mapEdgeToLibgpiod(edge))
gpiod.gpiod_line_settings_set_debounce_period_us(lineSettings, debounce * 1000)
const offsets = new Uint32Array(pins.map((p) => p.pin))
gpiod.gpiod_line_config_add_line_settings(
lineConfig,
ptr(offsets),
pins.length,
lineSettings
)
gpiod.gpiod_line_settings_free(lineSettings)
}
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_request_config_free(reqConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
const firstConfig = Object.values(config)[0]
throw new PinInUseError(firstConfig?.pin ?? 0)
}
const pinMap: Record<string, { offset: number; pull: PullMode }> = {}
for (const [name, pinConfig] of Object.entries(config)) {
pinMap[name] = {
offset: pinConfig.pin,
pull: pinConfig.pull ?? "up",
}
}
return new InputGroup(chip, request, pinMap)
} catch (err) {
gpiod.gpiod_chip_close(chip)
throw err
}
input(pin: number, options?: InputOptions): Input {
return new Input(this.#chipPath, pin, options)
}
async listChips(): Promise<ChipInfo[]> {

View File

@ -1,205 +0,0 @@
import type { Pointer } from "bun:ffi"
import { gpiod } from "./ffi"
import { mapLibgpiodEdgeToPressedState } from "./utils"
import type { PullMode, InputEvent, InputGroupEvent, PinConfig } from "./types"
export class InputGroup<T extends string = string> {
#closed = false
#chip: Pointer
#request: Pointer
#pinMap: Map<string, { offset: number; pull: PullMode }>
#offsetMap: Map<number, { name: string; pull: PullMode }>
#eventBuffer: Pointer | undefined
#eventListeners: Array<(event: InputGroupEvent<T>) => void> = []
#closeHandlers: Array<() => void> = []
constructor(chip: Pointer, request: Pointer, pinConfig: PinConfig) {
this.#chip = chip
this.#request = request
this.#pinMap = new Map()
this.#offsetMap = new Map()
for (const [name, config] of Object.entries(pinConfig)) {
this.#pinMap.set(name, config)
this.#offsetMap.set(config.offset, { name, pull: config.pull })
}
}
get pins(): Record<
T,
{ readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }
> {
const result = {} as Record<
T,
{
readonly value: 0 | 1
waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void>
}
>
for (const [name, config] of this.#pinMap) {
const offset = config.offset
const closed = () => this.#closed
const request = this.#request
const pinName = name
Object.defineProperty(result, name, {
get: () => ({
get value(): 0 | 1 {
if (closed()) throw new Error("InputGroup is closed")
const ret = gpiod.gpiod_line_request_get_value(request, offset)
if (ret === -1) throw new Error("Failed to get pin value")
return ret as 0 | 1
},
waitForValue: (targetValue: 0 | 1, timeout?: number) =>
this.#waitForPinValue(pinName as T, targetValue, timeout),
}),
enumerable: true,
})
}
return result
}
async #waitForPinValue(pinName: T, targetValue: 0 | 1, timeout?: number): Promise<void> {
return new Promise((resolve, reject) => {
if (this.#closed) {
reject(new Error("InputGroup is closed"))
return
}
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
this.#closeHandlers = this.#closeHandlers.filter((h) => h !== onClose)
}
const onClose = () => {
cleanup()
reject(new Error("InputGroup closed while waiting"))
}
const listener = (event: InputGroupEvent<T>) => {
if (event.pin !== pinName) return
if (event.value !== targetValue) return
cleanup()
resolve()
}
if (timeout) {
timeoutId = setTimeout(() => {
cleanup()
reject(new Error(`Timeout waiting for pin ${pinName} to become ${targetValue}`))
}, timeout)
}
this.#eventListeners.push(listener)
this.#closeHandlers.push(onClose)
this.#startEventLoop()
})
}
async *events(): AsyncGenerator<InputGroupEvent<T>> {
if (this.#closed) throw new Error("InputGroup is closed")
const eventQueue: InputGroupEvent<T>[] = []
const listener = (event: InputGroupEvent<T>) => {
eventQueue.push(event)
}
this.#eventListeners.push(listener)
this.#startEventLoop()
try {
while (!this.#closed) {
if (eventQueue.length > 0) {
for (const event of eventQueue) {
yield event
}
eventQueue.length = 0
} else {
await Bun.sleep(0)
}
}
} finally {
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
}
}
#startEventLoop() {
if (this.#eventBuffer !== undefined) return
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
if (!buffer) throw new Error("Failed to create event buffer")
this.#eventBuffer = buffer
this.#runEventLoop()
}
async #runEventLoop() {
try {
while (!this.#closed && this.#eventListeners.length > 0) {
const ret = gpiod.gpiod_line_request_wait_edge_events(this.#request, 100_000_000)
if (ret === -1 || ret === 0) {
await Bun.sleep(0)
continue
}
const numEvents = gpiod.gpiod_line_request_read_edge_events(
this.#request,
this.#eventBuffer!,
1
)
if (numEvents > 0) {
const event = gpiod.gpiod_edge_event_buffer_get_event(this.#eventBuffer!, 0)
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
const offset = gpiod.gpiod_edge_event_get_line_offset(event)
const pinInfo = this.#offsetMap.get(offset)
if (!pinInfo) continue
const pressed = mapLibgpiodEdgeToPressedState(edgeType, pinInfo.pull)
const value = (
pressed ? (pinInfo.pull === "up" ? 0 : 1) : pinInfo.pull === "up" ? 1 : 0
) as 0 | 1
const inputEvent: InputGroupEvent<T> = { pin: pinInfo.name as T, value, timestamp }
for (const listener of this.#eventListeners) {
listener(inputEvent)
}
await Bun.sleep(0)
}
}
} finally {
if (this.#eventBuffer) {
gpiod.gpiod_edge_event_buffer_free(this.#eventBuffer)
this.#eventBuffer = undefined
}
}
}
close() {
if (this.#closed) return
this.#closed = true
for (const handler of this.#closeHandlers) {
handler()
}
this.#closeHandlers = []
gpiod.gpiod_line_request_release(this.#request)
gpiod.gpiod_chip_close(this.#chip)
}
[Symbol.dispose]() {
this.close()
}
}

84
src/pins/input-worker.ts Normal file
View File

@ -0,0 +1,84 @@
import { gpiod, GPIOD_EDGE_EVENT_RISING_EDGE } from "./ffi"
import { requestInputLine } from "./gpio-helpers"
import type { PullMode, EdgeMode } from "./types"
type WorkerConfig = {
chipPath: string
offset: number
pull: PullMode
debounce: number
edge: EdgeMode
}
type WorkerMessage =
| { type: "ready"; initialValue: 0 | 1 }
| { type: "event"; value: 0 | 1; timestamp: bigint }
| { type: "error"; message: string }
const postMessage = (message: WorkerMessage) => {
self.postMessage(message)
}
const cleanup = (message: string): never => {
postMessage({ type: "error", message })
self.close()
throw new Error(message)
}
const mapEdgeToValue = (edgeType: number, pull: PullMode): 0 | 1 => {
// Pull-up: rising edge = released (1), falling edge = pressed (0)
// Pull-down: rising edge = pressed (1), falling edge = released (0)
if (pull === "up") {
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
}
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
}
const run = (config: WorkerConfig) => {
const { chip, request } = requestInputLine(config)
const initialValue = gpiod.gpiod_line_request_get_value(request, config.offset)
if (initialValue === -1) {
gpiod.gpiod_line_request_release(request)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to read initial value")
}
postMessage({ type: "ready", initialValue: initialValue as 0 | 1 })
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
if (!buffer) {
gpiod.gpiod_line_request_release(request)
gpiod.gpiod_chip_close(chip)
cleanup("Failed to create event buffer")
}
while (true) {
// Block forever (-1 timeout) until edge event occurs
const waitResult = gpiod.gpiod_line_request_wait_edge_events(request, -1)
if (waitResult === 1) {
const numEvents = gpiod.gpiod_line_request_read_edge_events(request, buffer, 1)
if (numEvents === -1) cleanup("Failed to read edge events")
const event = gpiod.gpiod_edge_event_buffer_get_event(buffer, 0)
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
const value = mapEdgeToValue(edgeType, config.pull)
postMessage({ type: "event", value, timestamp })
} else if (waitResult === -1) {
cleanup("GPIO wait_edge_events failed")
}
}
// Worker terminates - kernel cleans up GPIO resources automatically
}
self.onmessage = (event: MessageEvent<WorkerConfig>) => {
self.onmessage = () => {
throw new Error("Worker already initialized")
}
run(event.data)
}

View File

@ -1,36 +1,90 @@
import type { Pointer } from "bun:ffi"
import { InputGroup } from "./input-group"
import type { PullMode, InputEvent } from "./types"
import type { InputEvent, InputOptions } from "./types"
export class InputPin<T extends string = string> {
#group: InputGroup<T>
#pinName: T
type WorkerMessage =
| { type: "ready"; initialValue: 0 | 1 }
| { type: "event"; value: 0 | 1; timestamp: bigint }
| { type: "error"; message: string }
constructor(group: InputGroup<T>, pinName: T) {
this.#group = group
this.#pinName = pinName
export class Input {
#worker: Worker
#callbacks = new Set<(event: InputEvent) => void>()
#closed = false
#lastValue: 0 | 1 = 0
constructor(chipPath: string, offset: number, options: InputOptions = {}) {
const pull = options.pull ?? "up"
const debounce = options.debounce ?? 0
const edge = options.edge ?? "both"
this.#worker = new Worker(new URL("./input-worker.ts", import.meta.url).href)
this.#worker.onmessage = (msg: MessageEvent<WorkerMessage>) => {
if (this.#closed) return
const data = msg.data
if (data.type === "ready") {
this.#lastValue = data.initialValue
} else if (data.type === "event") {
this.#lastValue = data.value
for (const callback of this.#callbacks) {
callback({ value: data.value, timestamp: data.timestamp })
}
} else if (data.type === "error") {
console.error(`GPIO Input Error (pin ${offset}):`, data.message)
}
}
this.#worker.postMessage({ chipPath, offset, pull, debounce, edge })
}
get value(): 0 | 1 {
return this.#group.pins[this.#pinName]!.value
if (this.#closed) throw new Error("Input is closed")
return this.#lastValue
}
onChange(callback: (event: InputEvent) => void): () => void {
if (this.#closed) throw new Error("Input is closed")
this.#callbacks.add(callback)
return () => this.#callbacks.delete(callback)
}
async waitForValue(targetValue: 0 | 1, timeout?: number): Promise<void> {
for await (const event of this.#group.events()) {
if (event.value === targetValue) {
return
}
}
if (this.#closed) throw new Error("Input is closed")
if (this.#lastValue === targetValue) return
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
unsubscribe()
}
async *events(): AsyncGenerator<InputEvent> {
for await (const event of this.#group.events()) {
yield { value: event.value, timestamp: event.timestamp }
const unsubscribe = this.onChange((event) => {
if (event.value === targetValue) {
cleanup()
resolve()
}
})
if (!timeout) return
timeoutId = setTimeout(() => {
cleanup()
reject(new Error(`Timeout waiting for value ${targetValue}`))
}, timeout)
})
}
close() {
this.#group.close()
if (this.#closed) return
this.#closed = true
this.#callbacks.clear()
this.#worker.onmessage = null
this.#worker.terminate()
}
[Symbol.dispose]() {

View File

@ -1,30 +1,39 @@
import type { Pointer } from "bun:ffi"
import { gpiod } from "./ffi"
import { requestOutputLine } from "./gpio-helpers"
import type { OutputOptions } from "./types"
export class OutputPin {
export class Output {
#closed = false
#chip: Pointer
#request: Pointer
#pin: number
#offset: number
#resetValue?: 0 | 1
constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) {
constructor(chipPath: string, offset: number, options: OutputOptions = {}) {
const initialValue = options.initialValue ?? 0
const { chip, request } = requestOutputLine({ chipPath, offset, initialValue })
this.#chip = chip
this.#request = request
this.#pin = pin
this.#resetValue = resetValue
this.#offset = offset
if (options.resetOnClose) {
const currentValue = gpiod.gpiod_line_request_get_value(request, offset)
this.#resetValue = currentValue === -1 ? 0 : (currentValue as 0 | 1)
}
}
get value(): 0 | 1 {
if (this.#closed) throw new Error("OutputPin is closed")
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#pin)
if (this.#closed) throw new Error("Output is closed")
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#offset)
if (ret === -1) throw new Error("Failed to get pin value")
return ret as 0 | 1
}
set value(val: 0 | 1) {
if (this.#closed) throw new Error("OutputPin is closed")
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#pin, val)
if (this.#closed) throw new Error("Output is closed")
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#offset, val)
if (ret === -1) throw new Error("Failed to set pin value")
}
@ -37,7 +46,7 @@ export class OutputPin {
this.#closed = true
if (this.#resetValue !== undefined) {
gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue)
gpiod.gpiod_line_request_set_value(this.#request, this.#offset, this.#resetValue)
}
gpiod.gpiod_line_request_release(this.#request)

View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "WebWorker"]
},
"include": ["input-worker.ts"]
}

View File

@ -9,6 +9,7 @@ export type InputOptions = {
export type OutputOptions = {
initialValue?: 0 | 1 // default: 0
resetOnClose?: boolean // default: false
}
export type InputEvent = {

View File

@ -38,10 +38,7 @@ export const mapEdgeToLibgpiod = (edge: EdgeMode): number => {
// Hardware logic:
// - Pull-up + button to GND: pressing pulls line LOW (falling edge = pressed)
// - Pull-down + button to VCC: pressing pulls line HIGH (rising edge = pressed)
export const mapLibgpiodEdgeToPressedState = (
edgeType: number,
pull: PullMode
): boolean => {
export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode): boolean => {
if (pull === "up") {
return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE
} else if (pull === "down") {
@ -51,10 +48,6 @@ export const mapLibgpiodEdgeToPressedState = (
}
}
export const hashInputConfig = (
pull: PullMode,
debounce: number,
edge: EdgeMode
): string => {
export const hashInputConfig = (pull: PullMode, debounce: number, edge: EdgeMode): string => {
return `${pull}-${debounce}-${edge}`
}

View File

@ -2,45 +2,38 @@ import { GPIO } from "./pins"
console.log(`kill -9 ${process.pid}`)
const gpio = new GPIO({ resetOnClose: true })
const gpio = new GPIO()
// // Blink an LED
using led = gpio.output(21)
using button = gpio.input(20, { pull: "up", debounce: 10 })
using switchInput = gpio.input(16, { pull: "up", debounce: 10 })
// Read a button
using inputs = gpio.inputGroup({
button: { pin: 20, pull: "up", debounce: 10 },
switch: { pin: 16, pull: "up", debounce: 10 }
})
led.value = button.value
led.value = inputs.pins.button.value
const iteratorEvents = new Promise(async (resolve) => {
for await (const event of inputs.events()) {
if (event.pin === "button") {
button.onChange((event) => {
led.value = event.value
}
}
})
const switchEvent = new Promise<void>(async (resolve) => {
await inputs.pins.switch.waitForValue(0)
await switchInput.waitForValue(0)
console.log("Switch pressed!")
resolve()
})
process.on("SIGINT", () => {
inputs.close()
button.close()
switchInput.close()
led.close()
process.exit(0)
})
process.on("SIGTERM", () => {
inputs.close()
button.close()
switchInput.close()
led.close()
process.exit(0)
})
await Promise.race([iteratorEvents, switchEvent])
await switchEvent
console.log(`👋 Goodbye!`)

View File

@ -26,5 +26,6 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
},
"exclude": ["src/pins/input-worker.ts"]
}