From dc68c1d729e5a0d6ba63cd2ac0e743245824d288 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 18 Nov 2025 14:33:25 -0800 Subject: [PATCH] we got pins --- README.md | 16 +- scripts/bootstrap-bun.sh | 13 +- scripts/bootstrap.ts | 14 +- scripts/cli.sh | 4 +- scripts/deploy.ts | 13 +- src/hq.ts | 47 +++ src/operator.ts | 4 +- src/pins/FFI-LEARNINGS.md | 173 +++++++++ src/pins/README.md | 341 ++++++++++++++++++ src/pins/errors.ts | 53 +++ src/pins/ffi.ts | 171 +++++++++ src/pins/gpio.ts | 199 ++++++++++ src/pins/index.ts | 17 + src/pins/input-group.ts | 194 ++++++++++ src/pins/input.ts | 39 ++ src/pins/output.ts | 50 +++ src/pins/types.ts | 30 ++ src/pins/utils.ts | 60 +++ .../server/components/ConnectingPage.tsx | 28 +- src/services/server/server.tsx | 93 ++--- basic-test.ts => test.ts | 0 21 files changed, 1472 insertions(+), 87 deletions(-) create mode 100644 src/hq.ts create mode 100644 src/pins/FFI-LEARNINGS.md create mode 100644 src/pins/README.md create mode 100644 src/pins/errors.ts create mode 100644 src/pins/ffi.ts create mode 100644 src/pins/gpio.ts create mode 100644 src/pins/index.ts create mode 100644 src/pins/input-group.ts create mode 100644 src/pins/input.ts create mode 100644 src/pins/output.ts create mode 100644 src/pins/types.ts create mode 100644 src/pins/utils.ts rename basic-test.ts => test.ts (100%) diff --git a/README.md b/README.md index 9b5f140..e0b4215 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Deployment Script -This directory contains a deployment script for the Yellow Phone project to a Raspberry Pi. +This directory contains a deployment script for the Phone project to a Raspberry Pi. ## File: deploy.ts @@ -8,8 +8,8 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and ### Configuration -- **Target Host**: `yellow-phone.local` -- **Target Directory**: `/home/corey/yellow-phone` +- **Target Host**: `phone.local` +- **Target Directory**: `/home/corey/phone` ### What It Does @@ -25,11 +25,13 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and ### Usage **Standard deployment** (just copy files and restart services): + ```bash bun deploy.ts ``` **First-time deployment** (copy files + run bootstrap): + ```bash bun deploy.ts --bootstrap ``` @@ -37,17 +39,19 @@ bun deploy.ts --bootstrap ### Services The script manages two systemd services: + - `phone-ap.service` - Access point service - `phone-web.service` - Web interface service ### Access After deployment, the Pi is accessible at: -- **Web URL**: http://yellow-phone.local -- **WiFi Network**: yellow-phone-setup + +- **Web URL**: http://phone.local +- **WiFi Network**: phone-setup ### Requirements - Bun runtime -- SSH access to `yellow-phone.local` +- SSH access to `phone.local` - Local `pi/` directory with files to deploy diff --git a/scripts/bootstrap-bun.sh b/scripts/bootstrap-bun.sh index 989594e..9bbfaa4 100644 --- a/scripts/bootstrap-bun.sh +++ b/scripts/bootstrap-bun.sh @@ -3,21 +3,14 @@ set -e echo "========================================== - Bun Installation for Yellow Phone + Bun Installation for Raspberry Pi ========================================== " # Check if already installed if command -v bun >/dev/null 2>&1; then - echo "✓ Bun is already installed at: $(which bun)" - bun --version - echo "" - read -p "Reinstall anyway? (y/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Skipping installation." - exit 0 - fi + echo "✓ Bun is already installed at: $(which bun) $(bun --version)" + exit 0 fi echo "Step 1: Installing Bun..." diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts index 04313aa..de73d65 100755 --- a/scripts/bootstrap.ts +++ b/scripts/bootstrap.ts @@ -3,7 +3,7 @@ import { writeFileSync } from "fs" console.log(` ========================================== - Yellow Phone Setup Bootstrap + Phone Setup Bootstrap ========================================== `) @@ -15,7 +15,7 @@ if (process.getuid && process.getuid() !== 0) { } // Get install directory from argument or use default -const INSTALL_DIR = process.argv[2] || "/home/corey/yellow-phone" +const INSTALL_DIR = process.argv[2] || "/home/corey/phone" const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service" const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service" @@ -44,7 +44,7 @@ console.log(`Using bun at: ${bunPath}`) // Create AP monitor service const apServiceContent = `[Unit] -Description=Yellow Phone WiFi AP Monitor +Description=Phone WiFi AP Monitor After=network.target Before=phone-web.service @@ -64,7 +64,7 @@ console.log("✓ Created phone-ap.service") // Create web server service const webServiceContent = `[Unit] -Description=Yellow Phone Web Server +Description=Phone Web Server After=network.target phone-ap.service [Service] @@ -102,9 +102,9 @@ Both services are now running and will start automatically on boot: - phone-web.service: Web server for configuration How it works: -- If connected to WiFi: Access at http://yellow-phone.local -- If NOT connected: WiFi AP "yellow-phone-setup" will start automatically - Connect to the AP at the same address http://yellow-phone.local +- If connected to WiFi: Access at http://phone.local +- If NOT connected: WiFi AP "phone-setup" will start automatically + Connect to the AP at the same address http://phone.local To check status use ./cli `) diff --git a/scripts/cli.sh b/scripts/cli.sh index 212897b..8d70307 100644 --- a/scripts/cli.sh +++ b/scripts/cli.sh @@ -33,7 +33,7 @@ const command = process.argv[2]; if (!command || command === "help") { console.log(` -Yellow Phone CLI - Service Management Tool +Phone CLI - Service Management Tool Usage: bun cli @@ -76,7 +76,7 @@ if (!Object.keys(commands).includes(command)) { process.exit(1); } -console.log(`\n🔧 Yellow Phone CLI - ${command}\n`); +console.log(`\n🔧 Phone CLI - ${command}\n`); // Parse service-specific commands const match = command.match(/^(ap|web)-(.+)$/); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index feb253e..9cc84c2 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -2,8 +2,8 @@ import { $ } from "bun" -const PI_HOST = "yellow-phone.local" -const PI_DIR = "/home/corey/yellow-phone" +const PI_HOST = process.env.PI_HOST || "phone.local" +const PI_DIR = "/home/corey/phone" // Parse command line arguments const shouldBootstrap = process.argv.includes("--bootstrap") @@ -30,8 +30,11 @@ console.log("\n✓ Files deployed!\n") // Run bootstrap if requested if (shouldBootstrap) { + console.log("🍞 Running bootstrap-bun on Pi...\n") + await $`ssh ${PI_HOST} "bash ${PI_DIR}/scripts/bootstrap-bun.sh"` + console.log("Running bootstrap on Pi...\n") - await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun bootstrap.ts ${PI_DIR}"` + await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun ${PI_DIR}/scripts/bootstrap.ts ${PI_DIR}"` } // Always check if services exist and restart them (whether we bootstrapped or not) @@ -54,6 +57,6 @@ if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) { console.log(` ✓ Deploy complete! -Access via WiFi at http://yellow-phone.local -The Pi is discoverable as "yellow-phone-setup" +Access via WiFi at http://phone.local +The Pi is discoverable as "phone-setup" `) diff --git a/src/hq.ts b/src/hq.ts new file mode 100644 index 0000000..089dbd8 --- /dev/null +++ b/src/hq.ts @@ -0,0 +1,47 @@ +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(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!`) \ No newline at end of file diff --git a/src/operator.ts b/src/operator.ts index fc02e02..d0426e6 100755 --- a/src/operator.ts +++ b/src/operator.ts @@ -25,7 +25,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { const waitingIndicator = new WaitingSounds(player, streamPlayback) // Set up agent event listeners - agent.events.connect((event) => { + agent.events.connect(async (event) => { switch (event.type) { case "connected": console.log("✅ Connected to AI agent\n") @@ -40,7 +40,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { break case "audio": - waitingIndicator.stop() + await waitingIndicator.stop() const audioBuffer = Buffer.from(event.audioBase64, "base64") streamPlayback.write(audioBuffer) break diff --git a/src/pins/FFI-LEARNINGS.md b/src/pins/FFI-LEARNINGS.md new file mode 100644 index 0000000..1e3adb0 --- /dev/null +++ b/src/pins/FFI-LEARNINGS.md @@ -0,0 +1,173 @@ +# Bun FFI Learnings + +After researching GitHub examples and Bun's FFI documentation, here's what I found surprising and helpful. + +## Surprising Discoveries + +### 1. **String Handling is Simpler Than Expected** +I initially thought you'd need `CString` everywhere, but: +- For **args**: `FFIType.cstring` just needs `ptr(Buffer.from(str + "\0"))` +- For **returns**: `FFIType.cstring` automatically converts pointers to JS strings +- `CString` is mainly for **reading** C strings from pointers, not passing them + +**Example from real code:** +```javascript +const str = Buffer.from("hello\0", "utf8"); +myFunction(ptr(str)); // Clean and simple! +``` + +### 2. **No Type Wrappers Needed** +Unlike Node-FFI, Bun doesn't require defining structs or complex type wrappers. Just: +```javascript +add: { + args: [FFIType.i32, FFIType.i32], + returns: FFIType.i32, +} +``` + +### 3. **TinyCC JIT Compilation** +Bun embeds TinyCC and JIT-compiles C bindings on the fly. This means: +- 2-6x faster than Node-API +- Zero build step for type conversions +- Direct memory access without serialization + +## Helpful Patterns + +### Pattern 1: String Helper +```typescript +import { ptr } from "bun:ffi" +const cstr = (s: string) => ptr(Buffer.from(s + "\0")) + +// Usage: +gpiod.open(cstr("/dev/gpiochip0")) +``` + +### Pattern 2: Resource Cleanup +Always use cleanup handlers: +```javascript +const cleanup = () => { + lib.symbols.release(resource) + lib.symbols.close(chip) +} +process.on("SIGINT", cleanup) +process.on("SIGTERM", cleanup) +``` + +### Pattern 3: Destructuring Symbols +```javascript +const { + symbols: { functionName } +} = dlopen(path, { /* defs */ }) + +// Call directly: +functionName(arg1, arg2) +``` + +## Common Mistakes to Avoid + +1. **Don't forget null terminators** - `Buffer.from(str + "\0")` not `Buffer.from(str)` +2. **Pointer lifetime** - Keep TypedArrays alive while C code uses them +3. **Type mismatches** - `FFIType.i32` vs `FFIType.u32` matters! +4. **Missing cleanup** - C libraries don't have garbage collection + +## Best Practices from Real Examples + +1. **Use `suffix` for cross-platform library loading:** + ```javascript + import { suffix } from "bun:ffi" + dlopen(`libname.${suffix}`, { /* ... */ }) + ``` + +2. **Check for null on resource creation:** + ```javascript + const chip = lib.gpiod_chip_open(cstr(path)) + if (!chip) { + console.error("Failed to open") + process.exit(1) + } + ``` + +3. **Free configs after use:** + ```javascript + const config = lib.create_config() + // ... use config ... + lib.free_config(config) // Don't leak! + ``` + +## What Makes Bun FFI Special + +- **Performance**: JIT compilation beats traditional FFI +- **Simplicity**: No build tools, no gyp, no node-gyp nightmares +- **TypeScript native**: Works seamlessly with TS type system +- **Built-in**: Ships with Bun, zero dependencies + +## Hard-Won Lessons from GPIO Implementation + +### 1. **Enum values MUST match the C header exactly** +We spent hours debugging because our constants were off by one: +```typescript +// WRONG - missing GPIOD_LINE_BIAS_AS_IS +export const GPIOD_LINE_BIAS_UNKNOWN = 1 // Actually should be 2! +export const GPIOD_LINE_BIAS_DISABLED = 2 // Actually should be 3! +export const GPIOD_LINE_BIAS_PULL_UP = 3 // Actually should be 4! + +// CORRECT - includes AS_IS at position 1 +export const GPIOD_LINE_BIAS_AS_IS = 1 +export const GPIOD_LINE_BIAS_UNKNOWN = 2 +export const GPIOD_LINE_BIAS_DISABLED = 3 +export const GPIOD_LINE_BIAS_PULL_UP = 4 +export const GPIOD_LINE_BIAS_PULL_DOWN = 5 +``` +**Lesson:** Always grep the header file for the complete enum, don't assume! + +### 2. **Hardware debouncing requires correct constants** +With wrong constants, we were accidentally passing `BIAS_DISABLED` instead of `BIAS_PULL_UP`, which meant: +- No pull resistor (pin floated) +- Debouncing didn't work at all +- Got 6+ events per button press + +After fixing: **Clean single events with 1ms debounce via kernel!** + +### 3. **Edge detection is event-driven, not polling** +Don't poll `get_value()` in a loop! Use: +- `gpiod_line_request_wait_edge_events()` - blocks until interrupt +- `gpiod_line_request_read_edge_events()` - reads queued events +- Much more efficient, CPU sleeps until hardware event + +### 4. **TypedArray to pointer needs `ptr()`** +When passing arrays to C functions: +```typescript +const offsets = new Uint32Array([21]) +gpiod.gpiod_line_config_add_line_settings( + lineConfig, + ptr(offsets), // Need ptr() wrapper! + 1, + lineSettings +) +``` + +### 5. **Signal handling for clean shutdown** +Generators don't run `finally` blocks if abandoned. Need: +```typescript +let shouldExit = false +process.on("SIGINT", () => { shouldExit = true }) + +while (!shouldExit) { + const ret = wait_edge_events(request, 100_000_000) // Use timeout! + // ... +} +``` + +### 6. **Button wiring determines logic** +- **GND button + pull-UP**: Press = FALLING edge (HIGH→LOW) +- **VCC button + pull-DOWN**: Press = RISING edge (LOW→HIGH) + +Always check initial pin state to verify wiring! + +## Resources Used + +- Official Bun FFI docs: https://bun.com/docs/runtime/ffi +- libgpiod v2 C API: https://libgpiod.readthedocs.io/en/latest/core_api.html +- Python bindings examples: https://github.com/brgl/libgpiod/tree/master/bindings/python/examples +- Real examples: GitHub searches for bun FFI projects +- Community discussions: Bun issue tracker and HN threads diff --git a/src/pins/README.md b/src/pins/README.md new file mode 100644 index 0000000..37ea25c --- /dev/null +++ b/src/pins/README.md @@ -0,0 +1,341 @@ +# Pins + +High-level GPIO library for Bun using libgpiod v2 with automatic resource management. + +## Features + +- Type-safe TypeScript API with autocomplete for pin names +- Automatic resource cleanup with `using` keyword +- Hardware debouncing via kernel +- Event-driven input handling +- Efficient multi-pin monitoring with input groups +- Zero external dependencies (uses Bun FFI) + +## Requirements + +- Bun 1.0+ +- libgpiod v2 (libgpiod.so.3) +- Linux system with GPIO support (Raspberry Pi, etc.) +- TypeScript 5.2+ (for `using` keyword support) + +## Quick Start + +```typescript +import { GPIO } from "pins" + +const gpio = new GPIO() + +// Blink an LED +using led = gpio.output(17) +for (let i = 0; i < 10; i++) { + led.toggle() + await Bun.sleep(500) +} + +// Read a button +using button = gpio.input(20, { pull: "up", debounce: 10 }) +console.log(button.value) + +// Listen for button events +for await (const event of button.events()) { + console.log(event.value === 0 ? "Pressed!" : "Released") +} +``` + +## API + +### GPIO Class + +#### `new GPIO(options?)` + +Create a GPIO instance. + +```typescript +const gpio = new GPIO({ chip: "/dev/gpiochip0" }) // defaults to /dev/gpiochip0 +``` + +#### `gpio.output(pin, options?)` + +Configure a pin as output. + +```typescript +using led = gpio.output(17, { initialValue: 0 }) +led.value = 1 +led.toggle() +``` + +Options: + +- `initialValue?: 0 | 1` - Initial pin state (default: 0) + +#### `gpio.input(pin, options?)` + +Configure a pin as input. + +```typescript +using button = gpio.input(20, { + pull: "up", // 'up' | 'down' | 'none' + debounce: 10, // milliseconds + edge: "both", // 'rising' | 'falling' | 'both' +}) +``` + +Options: + +- `pull?: 'up' | 'down' | 'none'` - Pull resistor (default: 'up') +- `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. + +```typescript +const chips = await gpio.listChips() +console.log(chips) +// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }] +``` + +### InputPin + +```typescript +using button = gpio.input(20) + +// Read current state +const value: 0 | 1 = button.value + +// 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 + +```typescript +using led = gpio.output(17) + +// Read/write state +led.value = 1 +const value = led.value +led.toggle() +``` + +### InputGroup + +```typescript +using inputs = gpio.inputGroup({ + switch: { pin: 16, pull: "up" }, + button: { pin: 20, pull: "up", debounce: 10 } +}) + +// Access pins with full type safety +inputs.pins.switch.value // ✓ TypeScript autocomplete +inputs.pins.button.value // ✓ TypeScript autocomplete + +// 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 + +// 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) +} +``` + +## Resource Management + +**IMPORTANT:** Always use the `using` keyword to ensure proper cleanup of GPIO resources. + +```typescript +// Good - automatic cleanup +{ + using led = gpio.output(17) + led.value = 1 +} // Automatically released + +// Bad - manual cleanup required +const led = gpio.output(17) +led.value = 1 +led.close() // Must call manually +``` + +## Hardware Setup + +### Pull Resistors + +Pull resistors prevent floating input values when nothing is connected to the pin. + +- **Pull-up + button to GND**: When released, pin reads HIGH (1). When pressed, pin reads LOW (0). +- **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"` + +### Debouncing + +Mechanical buttons "bounce" - they make and break contact multiple times when pressed. Hardware debouncing eliminates these spurious events at the kernel level: + +```typescript +using button = gpio.input(20, { + pull: "up", + debounce: 10, // 10ms debounce - ignore edges within 10ms of previous edge +}) +``` + +Typical debounce values: 5-50ms depending on your button quality. + +## Error Handling + +```typescript +try { + using led = gpio.output(17) +} catch (err) { + if (err instanceof PermissionError) { + // Add user to gpio group: sudo usermod -aG gpio $USER + } else if (err instanceof PinInUseError) { + // Pin is already in use by another process + } else if (err instanceof ChipNotFoundError) { + // GPIO chip not found at path + } +} +``` + +## Examples + +### Simple LED Blink + +```typescript +import { GPIO } from "@/pins" + +const gpio = new GPIO() +using led = gpio.output(17) + +for (let i = 0; i < 10; i++) { + led.toggle() + await Bun.sleep(500) +} +``` + +### Button and Switch + +```typescript +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 led = gpio.output(21) + +// Set LED based on switch state +if (inputs.pins.switch.value === 1) { + led.value = 1 +} + +// Toggle LED when button pressed +for await (const event of inputs.events()) { + if (event.pin === "button" && event.value === 0) { + led.toggle() + } else if (event.pin === "switch") { + led.value = event.value + } +} +``` + +### Rotary Phone Dialer + +```typescript +import { GPIO } from "@/pins" + +const gpio = new GPIO() + +using inputs = gpio.inputGroup({ + hook: { pin: 20, pull: "up" }, + rotary: { pin: 21, pull: "up", debounce: 1 }, +}) + +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") + } +} +``` + +## Troubleshooting + +### Permission Denied + +Add your user to the `gpio` group: + +```bash +sudo usermod -aG gpio $USER +``` + +Then log out and back in. + +### Pin Already in Use + +Another process has claimed the pin. Stop that process or use a different pin. + +### Chip Not Found + +Verify GPIO hardware is enabled: + +```bash +ls /dev/gpiochip* +``` + +If you see "libgpiod v2 (libgpiod.so.3) not found", install it: + +```bash +sudo apt-get install libgpiod-dev +``` + +## Design Philosophy + +This library provides a simple, type-safe interface to GPIO: + +- **Low-level values** - Events return raw 0/1 values, let users interpret semantics +- **Simple by default** - Sensible defaults for common use cases +- **Explicit when needed** - Full control via options +- **Type-safe** - TypeScript catches errors at compile time and provides autocomplete +- **Resource-safe** - `using` keyword prevents leaks + +## References + +- [libgpiod v2 documentation](https://libgpiod.readthedocs.io/) +- [Bun FFI documentation](https://bun.sh/docs/api/ffi) diff --git a/src/pins/errors.ts b/src/pins/errors.ts new file mode 100644 index 0000000..a742d57 --- /dev/null +++ b/src/pins/errors.ts @@ -0,0 +1,53 @@ +export class GPIOError extends Error { + code: string + + constructor(message: string, code: string) { + super(message) + this.name = "GPIOError" + this.code = code + } +} + +export class PermissionError extends GPIOError { + constructor(path: string) { + super( + `Permission denied accessing ${path}. Try:\n` + + ` 1. Add your user to the 'gpio' group: sudo usermod -aG gpio $USER\n` + + ` 2. Log out and back in\n` + + ` 3. Or run with sudo (not recommended for production)`, + "PERMISSION_DENIED" + ) + this.name = "PermissionError" + } +} + +export class PinInUseError extends GPIOError { + constructor(pin: number) { + super( + `Pin ${pin} is already in use by another process or request. ` + + `Only one process can control a GPIO pin at a time.`, + "PIN_IN_USE" + ) + this.name = "PinInUseError" + } +} + +export class ChipNotFoundError extends GPIOError { + constructor(path: string) { + super( + `GPIO chip not found at ${path}. Check that:\n` + + ` 1. The path exists (ls ${path})\n` + + ` 2. GPIO hardware is enabled in your system configuration\n` + + ` 3. You're running on compatible hardware (Raspberry Pi, etc.)`, + "CHIP_NOT_FOUND" + ) + this.name = "ChipNotFoundError" + } +} + +export class InvalidConfigError extends GPIOError { + constructor(message: string) { + super(message, "INVALID_CONFIG") + this.name = "InvalidConfigError" + } +} diff --git a/src/pins/ffi.ts b/src/pins/ffi.ts new file mode 100644 index 0000000..e2fb241 --- /dev/null +++ b/src/pins/ffi.ts @@ -0,0 +1,171 @@ +import { dlopen, FFIType, ptr } from "bun:ffi" + +export const cstr = (s: string) => ptr(Buffer.from(s + "\0")) + +const findLibgpiod = () => { + try { + dlopen("libgpiod.so.3", { + gpiod_chip_open: { + args: [FFIType.cstring], + returns: FFIType.ptr, + }, + }) + return "libgpiod.so.3" + } catch { + throw new Error( + "libgpiod v2 (libgpiod.so.3) not found. Install with: sudo apt-get install libgpiod-dev" + ) + } +} + +// Constants MUST match C header exactly +export const GPIOD_LINE_DIRECTION_AS_IS = 1 +export const GPIOD_LINE_DIRECTION_INPUT = 2 +export const GPIOD_LINE_DIRECTION_OUTPUT = 3 + +export const GPIOD_LINE_BIAS_AS_IS = 1 +export const GPIOD_LINE_BIAS_UNKNOWN = 2 +export const GPIOD_LINE_BIAS_DISABLED = 3 +export const GPIOD_LINE_BIAS_PULL_UP = 4 +export const GPIOD_LINE_BIAS_PULL_DOWN = 5 + +export const GPIOD_LINE_EDGE_NONE = 1 +export const GPIOD_LINE_EDGE_RISING = 2 +export const GPIOD_LINE_EDGE_FALLING = 3 +export const GPIOD_LINE_EDGE_BOTH = 4 + +export const GPIOD_EDGE_EVENT_RISING_EDGE = 1 +export const GPIOD_EDGE_EVENT_FALLING_EDGE = 2 + +const lib = dlopen(findLibgpiod(), { + gpiod_chip_open: { + args: [FFIType.cstring], + returns: FFIType.ptr, + }, + gpiod_chip_close: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + gpiod_chip_get_info: { + args: [FFIType.ptr], + returns: FFIType.ptr, + }, + gpiod_chip_info_get_name: { + args: [FFIType.ptr], + returns: FFIType.cstring, + }, + gpiod_chip_info_get_label: { + args: [FFIType.ptr], + returns: FFIType.cstring, + }, + gpiod_chip_info_get_num_lines: { + args: [FFIType.ptr], + returns: FFIType.u64, + }, + gpiod_request_config_new: { + args: [], + returns: FFIType.ptr, + }, + gpiod_request_config_set_consumer: { + args: [FFIType.ptr, FFIType.cstring], + returns: FFIType.void, + }, + gpiod_request_config_free: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + gpiod_line_config_new: { + args: [], + returns: FFIType.ptr, + }, + gpiod_line_config_add_line_settings: { + args: [FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.ptr], + returns: FFIType.i32, + }, + gpiod_line_config_free: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + gpiod_line_settings_new: { + args: [], + returns: FFIType.ptr, + }, + gpiod_line_settings_set_direction: { + args: [FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + gpiod_line_settings_set_output_value: { + args: [FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + gpiod_line_settings_set_bias: { + args: [FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + gpiod_line_settings_set_edge_detection: { + args: [FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + gpiod_line_settings_set_debounce_period_us: { + args: [FFIType.ptr, FFIType.u64], + returns: FFIType.i32, + }, + gpiod_line_settings_free: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + gpiod_chip_request_lines: { + args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], + returns: FFIType.ptr, + }, + gpiod_line_request_release: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + gpiod_line_request_get_fd: { + args: [FFIType.ptr], + returns: FFIType.i32, + }, + gpiod_line_request_set_value: { + args: [FFIType.ptr, FFIType.u32, FFIType.i32], + returns: FFIType.i32, + }, + gpiod_line_request_get_value: { + args: [FFIType.ptr, FFIType.u32], + returns: FFIType.i32, + }, + gpiod_line_request_wait_edge_events: { + args: [FFIType.ptr, FFIType.i64], + returns: FFIType.i32, + }, + gpiod_line_request_read_edge_events: { + args: [FFIType.ptr, FFIType.ptr, FFIType.u64], + returns: FFIType.i32, + }, + gpiod_edge_event_buffer_new: { + args: [FFIType.u64], + returns: FFIType.ptr, + }, + gpiod_edge_event_buffer_free: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + gpiod_edge_event_buffer_get_event: { + args: [FFIType.ptr, FFIType.u64], + returns: FFIType.ptr, + }, + gpiod_edge_event_get_line_offset: { + args: [FFIType.ptr], + returns: FFIType.u32, + }, + gpiod_edge_event_get_event_type: { + args: [FFIType.ptr], + returns: FFIType.i32, + }, + gpiod_edge_event_get_timestamp_ns: { + args: [FFIType.ptr], + returns: FFIType.u64, + }, +}) + +export const gpiod = lib.symbols diff --git a/src/pins/gpio.ts b/src/pins/gpio.ts new file mode 100644 index 0000000..928980c --- /dev/null +++ b/src/pins/gpio.ts @@ -0,0 +1,199 @@ +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" + +export class GPIO { + #chipPath: string + #resetOnClose: boolean + + constructor(options?: { chip?: string; resetOnClose?: boolean }) { + 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 + } + } + + 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 + } + } + + async listChips(): Promise { + const chips: ChipInfo[] = [] + + try { + const files = await readdir("/dev") + const chipFiles = files.filter((f) => f.startsWith("gpiochip")) + + for (const file of chipFiles) { + const path = `/dev/${file}` + + try { + const chip = gpiod.gpiod_chip_open(cstr(path)) + if (!chip) continue + + const info = gpiod.gpiod_chip_get_info(chip) + if (!info) { + gpiod.gpiod_chip_close(chip) + continue + } + + const name = gpiod.gpiod_chip_info_get_name(info) + const label = gpiod.gpiod_chip_info_get_label(info) + const numLines = gpiod.gpiod_chip_info_get_num_lines(info) + + chips.push({ + path, + name: String(name || ""), + label: String(label || ""), + numLines: Number(numLines), + }) + + gpiod.gpiod_chip_close(chip) + } catch { + continue + } + } + } catch { + // /dev might not be accessible, return empty array + } + + return chips + } +} diff --git a/src/pins/index.ts b/src/pins/index.ts new file mode 100644 index 0000000..c2d155b --- /dev/null +++ b/src/pins/index.ts @@ -0,0 +1,17 @@ +export { GPIO } from "./gpio" +export { + GPIOError, + PermissionError, + PinInUseError, + ChipNotFoundError, + InvalidConfigError, +} from "./errors" +export type { + InputOptions, + OutputOptions, + InputEvent, + InputGroupEvent, + ChipInfo, + PullMode, + EdgeMode, +} from "./types" diff --git a/src/pins/input-group.ts b/src/pins/input-group.ts new file mode 100644 index 0000000..8804fb0 --- /dev/null +++ b/src/pins/input-group.ts @@ -0,0 +1,194 @@ +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 Promise }> { + const result = {} as Record 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[] = [] + let resolve: (() => void) | undefined + + const listener = (event: InputGroupEvent) => { + eventQueue.push(event) + resolve?.() + } + + this.#eventListeners.push(listener) + this.#startEventLoop() + + try { + while (!this.#closed) { + if (eventQueue.length === 0) { + await new Promise((r) => { + resolve = r + }) + } + + const event = eventQueue.shift() + if (event) yield event + } + } 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) + } + } + } + } 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.ts b/src/pins/input.ts new file mode 100644 index 0000000..6e5aa35 --- /dev/null +++ b/src/pins/input.ts @@ -0,0 +1,39 @@ +import type { Pointer } from "bun:ffi" +import { InputGroup } from "./input-group" +import type { PullMode, InputEvent } from "./types" + +export class InputPin { + #group: InputGroup + #pinName: T + + constructor(group: InputGroup, pinName: T) { + this.#group = group + this.#pinName = pinName + } + + get value(): 0 | 1 { + return this.#group.pins[this.#pinName]!.value + } + + async waitForValue(targetValue: 0 | 1, timeout?: number): Promise { + for await (const event of this.#group.events()) { + if (event.value === targetValue) { + return + } + } + } + + async *events(): AsyncGenerator { + for await (const event of this.#group.events()) { + yield { value: event.value, timestamp: event.timestamp } + } + } + + close() { + this.#group.close() + } + + [Symbol.dispose]() { + this.close() + } +} diff --git a/src/pins/output.ts b/src/pins/output.ts new file mode 100644 index 0000000..0f097d4 --- /dev/null +++ b/src/pins/output.ts @@ -0,0 +1,50 @@ +import type { Pointer } from "bun:ffi" +import { gpiod } from "./ffi" + +export class OutputPin { + #closed = false + #chip: Pointer + #request: Pointer + #pin: number + #resetValue?: 0 | 1 + + constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) { + this.#chip = chip + this.#request = request + this.#pin = pin + this.#resetValue = resetValue + } + + 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 (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 (ret === -1) throw new Error("Failed to set pin value") + } + + toggle() { + this.value = this.value === 0 ? 1 : 0 + } + + close() { + if (this.#closed) return + this.#closed = true + + if (this.#resetValue !== undefined) { + gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue) + } + + gpiod.gpiod_line_request_release(this.#request) + gpiod.gpiod_chip_close(this.#chip) + } + + [Symbol.dispose]() { + this.close() + } +} diff --git a/src/pins/types.ts b/src/pins/types.ts new file mode 100644 index 0000000..52e418f --- /dev/null +++ b/src/pins/types.ts @@ -0,0 +1,30 @@ +export type PullMode = "up" | "down" | "none" +export type EdgeMode = "rising" | "falling" | "both" + +export type InputOptions = { + pull?: PullMode // default: 'up' + debounce?: number // milliseconds, default: 0 + edge?: EdgeMode // default: 'both' +} + +export type OutputOptions = { + initialValue?: 0 | 1 // default: 0 +} + +export type InputEvent = { + value: 0 | 1 + timestamp: bigint // nanoseconds +} + +export type InputGroupEvent = InputEvent & { + pin: T // name of the pin that fired +} + +export type ChipInfo = { + path: string + name: string + label: string + numLines: number +} + +export type PinConfig = Record diff --git a/src/pins/utils.ts b/src/pins/utils.ts new file mode 100644 index 0000000..9735542 --- /dev/null +++ b/src/pins/utils.ts @@ -0,0 +1,60 @@ +import { ptr } from "bun:ffi" +import { + GPIOD_LINE_BIAS_PULL_UP, + GPIOD_LINE_BIAS_PULL_DOWN, + GPIOD_LINE_BIAS_DISABLED, + GPIOD_LINE_EDGE_RISING, + GPIOD_LINE_EDGE_FALLING, + GPIOD_LINE_EDGE_BOTH, + GPIOD_EDGE_EVENT_RISING_EDGE, + GPIOD_EDGE_EVENT_FALLING_EDGE, +} from "./ffi" +import type { PullMode, EdgeMode } from "./types" + +export const cstr = (s: string) => ptr(Buffer.from(s + "\0")) + +export const mapPullToLibgpiod = (pull: PullMode): number => { + switch (pull) { + case "up": + return GPIOD_LINE_BIAS_PULL_UP + case "down": + return GPIOD_LINE_BIAS_PULL_DOWN + case "none": + return GPIOD_LINE_BIAS_DISABLED + } +} + +export const mapEdgeToLibgpiod = (edge: EdgeMode): number => { + switch (edge) { + case "rising": + return GPIOD_LINE_EDGE_RISING + case "falling": + return GPIOD_LINE_EDGE_FALLING + case "both": + return GPIOD_LINE_EDGE_BOTH + } +} + +// 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 => { + if (pull === "up") { + return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE + } else if (pull === "down") { + return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE + } else { + return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE + } +} + +export const hashInputConfig = ( + pull: PullMode, + debounce: number, + edge: EdgeMode +): string => { + return `${pull}-${debounce}-${edge}` +} diff --git a/src/services/server/components/ConnectingPage.tsx b/src/services/server/components/ConnectingPage.tsx index 42b4ae4..44aa725 100644 --- a/src/services/server/components/ConnectingPage.tsx +++ b/src/services/server/components/ConnectingPage.tsx @@ -1,8 +1,8 @@ -import { Layout } from "./Layout"; +import { Layout } from "./Layout" type ConnectingPageProps = { - ssid: string; -}; + ssid: string +} export const ConnectingPage = ({ ssid }: ConnectingPageProps) => ( @@ -11,7 +11,9 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (

SSID: {ssid}

-

Testing connection... 10s remaining

+

+ Testing connection... 10s remaining +

Waiting to see if connection succeeds...

@@ -26,8 +28,12 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (

Next Steps:

    -
  1. Switch your device to the {ssid} network
  2. -
  3. Visit http://yellow-phone.local
  4. +
  5. + Switch your device to the {ssid} network +
  6. +
  7. + Visit http://phone.local +

The AP will shut down automatically since the Pi is now connected to WiFi. @@ -36,10 +42,14 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (

-); +) diff --git a/src/services/server/server.tsx b/src/services/server/server.tsx index a14ffc0..d949c94 100644 --- a/src/services/server/server.tsx +++ b/src/services/server/server.tsx @@ -1,98 +1,99 @@ #!/usr/bin/env bun -import { Hono } from "hono"; -import {join} from "node:path"; -import { $ } from "bun"; -import { IndexPage } from "./components/IndexPage"; -import { LogsPage } from "./components/LogsPage"; -import { ConnectingPage } from "./components/ConnectingPage"; +import { Hono } from "hono" +import { join } from "node:path" +import { $ } from "bun" +import { IndexPage } from "./components/IndexPage" +import { LogsPage } from "./components/LogsPage" +import { ConnectingPage } from "./components/ConnectingPage" -const app = new Hono(); +const app = new Hono() // Ping endpoint for connectivity check app.get("/ping", (c) => { - return c.json({ ok: true }); -}); + return c.json({ ok: true }) +}) // Serve static CSS app.get("/pico.css", async (c) => { - const cssPath = join(import.meta.dir, "./static/pico.min.css"); - const file = Bun.file(cssPath); - return new Response(file); -}); + const cssPath = join(import.meta.dir, "./static/pico.min.css") + const file = Bun.file(cssPath) + return new Response(file) +}) // API endpoint to get available WiFi networks app.get("/api/networks", async (c) => { try { - const result = await $`nmcli -t -f SSID device wifi list`.text(); + const result = await $`nmcli -t -f SSID device wifi list`.text() const networks = result .trim() - .split('\n') - .filter(ssid => ssid && ssid !== 'SSID') // Remove empty and header - .filter((ssid, index, self) => self.indexOf(ssid) === index); // Remove duplicates - return c.json({ networks }); + .split("\n") + .filter((ssid) => ssid && ssid !== "SSID") // Remove empty and header + .filter((ssid, index, self) => self.indexOf(ssid) === index) // Remove duplicates + return c.json({ networks }) } catch (error) { - return c.json({ networks: [], error: String(error) }, 500); + return c.json({ networks: [], error: String(error) }, 500) } -}); +}) // API endpoint to get logs (for auto-refresh) app.get("/api/logs", async (c) => { try { - const logs = await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text(); - return c.json({ logs: logs.trim() }); + const logs = + await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text() + return c.json({ logs: logs.trim() }) } catch (error) { - return c.json({ logs: '', error: String(error) }, 500); + return c.json({ logs: "", error: String(error) }, 500) } -}); +}) // Main WiFi configuration page app.get("/", (c) => { - return c.html(); -}); + return c.html() +}) // Service logs with auto-refresh app.get("/logs", async (c) => { try { const logs = - await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text(); - return c.html(); + await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text() + return c.html() } catch (error) { - throw new Error(`Failed to fetch logs: ${error}`); + throw new Error(`Failed to fetch logs: ${error}`) } -}); +}) // Handle WiFi configuration submission app.post("/save", async (c) => { - const formData = await c.req.parseBody(); - const ssid = formData.ssid as string; - const password = formData.password as string; + const formData = await c.req.parseBody() + const ssid = formData.ssid as string + const password = formData.password as string // Return the connecting page immediately - const response = c.html(); + const response = c.html() // Trigger connection in background after a short delay (allows response to be sent) setTimeout(async () => { try { - await $`sudo nmcli device wifi connect ${ssid} password ${password}`; - console.log(`[WiFi] Successfully connected to ${ssid}`); + await $`sudo nmcli device wifi connect ${ssid} password ${password}` + console.log(`[WiFi] Successfully connected to ${ssid}`) } catch (error) { - console.error(`[WiFi] Failed to connect to ${ssid}:`, error); + console.error(`[WiFi] Failed to connect to ${ssid}:`, error) // Delete the failed connection profile so ap-monitor doesn't try to use it try { - await $`sudo nmcli connection delete ${ssid}`.nothrow(); - console.log(`[WiFi] Deleted failed connection profile for ${ssid}`); + await $`sudo nmcli connection delete ${ssid}`.nothrow() + console.log(`[WiFi] Deleted failed connection profile for ${ssid}`) } catch (deleteError) { - console.error(`[WiFi] Failed to delete connection profile:`, deleteError); + console.error(`[WiFi] Failed to delete connection profile:`, deleteError) } } - }, 1000); // 1 second delay + }, 1000) // 1 second delay - return response; -}); + return response +}) -export default { port: 80, fetch: app.fetch }; +export default { port: 80, fetch: app.fetch } -console.log("Server running on http://0.0.0.0:80"); -console.log("Access via WiFi or AP at http://yellow-phone.local"); +console.log("Server running on http://0.0.0.0:80") +console.log("Access via WiFi or AP at http://phone.local") diff --git a/basic-test.ts b/test.ts similarity index 100% rename from basic-test.ts rename to test.ts