From dc902565f31ff8261496e44da5ee0688b1499458 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 19 Nov 2025 13:00:39 -0800 Subject: [PATCH] yes --- src/phone.ts | 71 +++++------- src/pins/README.md | 167 ++++++++++++++------------- src/pins/gpio-helpers.ts | 101 +++++++++++++++++ src/pins/gpio.ts | 154 ++----------------------- src/pins/input-group.ts | 205 ---------------------------------- src/pins/input-worker.ts | 84 ++++++++++++++ src/pins/input.ts | 96 ++++++++++++---- src/pins/output.ts | 29 +++-- src/pins/tsconfig.worker.json | 7 ++ src/pins/types.ts | 1 + src/pins/utils.ts | 11 +- src/test-pins.ts | 33 +++--- tsconfig.json | 3 +- 13 files changed, 432 insertions(+), 530 deletions(-) create mode 100644 src/pins/gpio-helpers.ts delete mode 100644 src/pins/input-group.ts create mode 100644 src/pins/input-worker.ts create mode 100644 src/pins/tsconfig.worker.json diff --git a/src/phone.ts b/src/phone.ts index 3040093..31cbd74 100644 --- a/src/phone.ts +++ b/src/phone.ts @@ -22,53 +22,44 @@ type PhoneContext = { cancelAgent?: CancelableTask } -const gpio = new GPIO({ resetOnClose: true }) -using ringer = gpio.output(17) -using inputs = gpio.inputGroup({ - hook: { pin: 27, debounce: 3 }, - rotaryInUse: { pin: 22, debounce: 3 }, - rotaryNumber: { pin: 23, debounce: 3 }, -}) +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": - const type = event.value == 0 ? "hang_up" : "pick_up" - log.info(`📞 Hook ${event.value} sending ${type}`) - if (type === "hang_up") { - ringer.value = 1 - } else { - ringer.value = 0 - } - break - case "rotaryInUse": - if (event.value === 0) { - digit = 0 - } else { - log.info(`📞 Dialed digit: ${digit}`) - } - break - - case "rotaryNumber": - if (event.value === 1) { - digit += 1 - } - break - - default: - log.error(`📞 Unknown pin event: ${event.pin}`) - break + hook.onChange((event) => { + const type = event.value == 0 ? "hang_up" : "pick_up" + log.info(`📞 Hook ${event.value} sending ${type}`) + if (type === "hang_up") { + ringer.value = 1 + } else { + ringer.value = 0 } - } + }) + + rotaryInUse.onChange((event) => { + if (event.value === 0) { + digit = 0 + } else { + log.info(`📞 Dialed digit: ${digit}`) + } + }) + + rotaryNumber.onChange((event) => { + if (event.value === 1) { + digit += 1 + } + }) + + // Keep process running + await new Promise(() => {}) } const apiKey = process.env.ELEVEN_API_KEY diff --git a/src/pins/README.md b/src/pins/README.md index 37ea25c..63b70d0 100644 --- a/src/pins/README.md +++ b/src/pins/README.md @@ -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) - led.value = 1 -} // Automatically released +using led = gpio.output(17) // Automatically released because of `using` +led.value = 1 +``` -// 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") { - led.value = event.value } -} +}) + +// 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 diff --git a/src/pins/gpio-helpers.ts b/src/pins/gpio-helpers.ts new file mode 100644 index 0000000..fca391c --- /dev/null +++ b/src/pins/gpio-helpers.ts @@ -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) + }) +} diff --git a/src/pins/gpio.ts b/src/pins/gpio.ts index 928980c..fea7051 100644 --- a/src/pins/gpio.ts +++ b/src/pins/gpio.ts @@ -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) - } - - 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 - } + output(pin: number, options?: OutputOptions): Output { + return new Output(this.#chipPath, pin, options) } - input(pin: number, options?: InputOptions): InputPin<"pin"> { - const group = this.inputGroup({ - pin: { pin, ...options }, - }) - - return new InputPin(group, "pin") - } - - inputGroup>( - config: T - ): InputGroup { - 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 = {} - 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 { diff --git a/src/pins/input-group.ts b/src/pins/input-group.ts deleted file mode 100644 index f3fb1d8..0000000 --- a/src/pins/input-group.ts +++ /dev/null @@ -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 { - #closed = false - #chip: Pointer - #request: Pointer - #pinMap: Map - #offsetMap: Map - #eventBuffer: Pointer | undefined - #eventListeners: Array<(event: InputGroupEvent) => 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 } - > { - const result = {} as Record< - T, - { - readonly value: 0 | 1 - waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise - } - > - - 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 { - return new Promise((resolve, reject) => { - if (this.#closed) { - reject(new Error("InputGroup is closed")) - return - } - - let timeoutId: ReturnType | 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) => { - 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> { - if (this.#closed) throw new Error("InputGroup is closed") - - const eventQueue: InputGroupEvent[] = [] - const listener = (event: InputGroupEvent) => { - 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 = { 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() - } -} diff --git a/src/pins/input-worker.ts b/src/pins/input-worker.ts new file mode 100644 index 0000000..09b9492 --- /dev/null +++ b/src/pins/input-worker.ts @@ -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) => { + self.onmessage = () => { + throw new Error("Worker already initialized") + } + run(event.data) +} diff --git a/src/pins/input.ts b/src/pins/input.ts index 6e5aa35..f67a680 100644 --- a/src/pins/input.ts +++ b/src/pins/input.ts @@ -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 { - #group: InputGroup - #pinName: T +type WorkerMessage = + | { type: "ready"; initialValue: 0 | 1 } + | { type: "event"; value: 0 | 1; timestamp: bigint } + | { type: "error"; message: string } - constructor(group: InputGroup, 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) => { + 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 { - for await (const event of this.#group.events()) { - if (event.value === targetValue) { - return - } - } - } + if (this.#closed) throw new Error("Input is closed") - async *events(): AsyncGenerator { - for await (const event of this.#group.events()) { - yield { value: event.value, timestamp: event.timestamp } - } + if (this.#lastValue === targetValue) return + + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId) + unsubscribe() + } + + 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]() { diff --git a/src/pins/output.ts b/src/pins/output.ts index 0f097d4..eb167b6 100644 --- a/src/pins/output.ts +++ b/src/pins/output.ts @@ -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) diff --git a/src/pins/tsconfig.worker.json b/src/pins/tsconfig.worker.json new file mode 100644 index 0000000..1470823 --- /dev/null +++ b/src/pins/tsconfig.worker.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "WebWorker"] + }, + "include": ["input-worker.ts"] +} diff --git a/src/pins/types.ts b/src/pins/types.ts index 52e418f..45bd01a 100644 --- a/src/pins/types.ts +++ b/src/pins/types.ts @@ -9,6 +9,7 @@ export type InputOptions = { export type OutputOptions = { initialValue?: 0 | 1 // default: 0 + resetOnClose?: boolean // default: false } export type InputEvent = { diff --git a/src/pins/utils.ts b/src/pins/utils.ts index 9735542..72feb65 100644 --- a/src/pins/utils.ts +++ b/src/pins/utils.ts @@ -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}` } diff --git a/src/test-pins.ts b/src/test-pins.ts index 9b79c6b..0ff95c2 100644 --- a/src/test-pins.ts +++ b/src/test-pins.ts @@ -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") { - led.value = event.value - } - } +button.onChange((event) => { + led.value = event.value }) const switchEvent = new Promise(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!`) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 545396c..676063d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + "exclude": ["src/pins/input-worker.ts"] }