Compare commits
No commits in common. "1c717a5b4772147c3b7ce20f512452f13c8cc510" and "18629565318688d5600ed00abef94801d2113546" have entirely different histories.
1c717a5b47
...
1862956531
16
README.md
16
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Deployment Script
|
# Deployment Script
|
||||||
|
|
||||||
This directory contains a deployment script for the Phone project to a Raspberry Pi.
|
This directory contains a deployment script for the Yellow Phone project to a Raspberry Pi.
|
||||||
|
|
||||||
## File: deploy.ts
|
## File: deploy.ts
|
||||||
|
|
||||||
|
|
@ -8,8 +8,8 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- **Target Host**: `phone.local`
|
- **Target Host**: `yellow-phone.local`
|
||||||
- **Target Directory**: `/home/corey/phone`
|
- **Target Directory**: `/home/corey/yellow-phone`
|
||||||
|
|
||||||
### What It Does
|
### What It Does
|
||||||
|
|
||||||
|
|
@ -25,13 +25,11 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
**Standard deployment** (just copy files and restart services):
|
**Standard deployment** (just copy files and restart services):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun deploy.ts
|
bun deploy.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**First-time deployment** (copy files + run bootstrap):
|
**First-time deployment** (copy files + run bootstrap):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun deploy.ts --bootstrap
|
bun deploy.ts --bootstrap
|
||||||
```
|
```
|
||||||
|
|
@ -39,19 +37,17 @@ bun deploy.ts --bootstrap
|
||||||
### Services
|
### Services
|
||||||
|
|
||||||
The script manages two systemd services:
|
The script manages two systemd services:
|
||||||
|
|
||||||
- `phone-ap.service` - Access point service
|
- `phone-ap.service` - Access point service
|
||||||
- `phone-web.service` - Web interface service
|
- `phone-web.service` - Web interface service
|
||||||
|
|
||||||
### Access
|
### Access
|
||||||
|
|
||||||
After deployment, the Pi is accessible at:
|
After deployment, the Pi is accessible at:
|
||||||
|
- **Web URL**: http://yellow-phone.local
|
||||||
- **Web URL**: http://phone.local
|
- **WiFi Network**: yellow-phone-setup
|
||||||
- **WiFi Network**: phone-setup
|
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Bun runtime
|
- Bun runtime
|
||||||
- SSH access to `phone.local`
|
- SSH access to `yellow-phone.local`
|
||||||
- Local `pi/` directory with files to deploy
|
- Local `pi/` directory with files to deploy
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,21 @@
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "==========================================
|
echo "==========================================
|
||||||
Bun Installation for Raspberry Pi
|
Bun Installation for Yellow Phone
|
||||||
==========================================
|
==========================================
|
||||||
"
|
"
|
||||||
|
|
||||||
# Check if already installed
|
# Check if already installed
|
||||||
if command -v bun >/dev/null 2>&1; then
|
if command -v bun >/dev/null 2>&1; then
|
||||||
echo "✓ Bun is already installed at: $(which bun) $(bun --version)"
|
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
|
exit 0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Step 1: Installing Bun..."
|
echo "Step 1: Installing Bun..."
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { writeFileSync } from "fs"
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
==========================================
|
==========================================
|
||||||
Phone Setup Bootstrap
|
Yellow Phone Setup Bootstrap
|
||||||
==========================================
|
==========================================
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ if (process.getuid && process.getuid() !== 0) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get install directory from argument or use default
|
// Get install directory from argument or use default
|
||||||
const INSTALL_DIR = process.argv[2] || "/home/corey/phone"
|
const INSTALL_DIR = process.argv[2] || "/home/corey/yellow-phone"
|
||||||
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
||||||
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.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
|
// Create AP monitor service
|
||||||
const apServiceContent = `[Unit]
|
const apServiceContent = `[Unit]
|
||||||
Description=Phone WiFi AP Monitor
|
Description=Yellow Phone WiFi AP Monitor
|
||||||
After=network.target
|
After=network.target
|
||||||
Before=phone-web.service
|
Before=phone-web.service
|
||||||
|
|
||||||
|
|
@ -64,7 +64,7 @@ console.log("✓ Created phone-ap.service")
|
||||||
|
|
||||||
// Create web server service
|
// Create web server service
|
||||||
const webServiceContent = `[Unit]
|
const webServiceContent = `[Unit]
|
||||||
Description=Phone Web Server
|
Description=Yellow Phone Web Server
|
||||||
After=network.target phone-ap.service
|
After=network.target phone-ap.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|
@ -102,9 +102,9 @@ Both services are now running and will start automatically on boot:
|
||||||
- phone-web.service: Web server for configuration
|
- phone-web.service: Web server for configuration
|
||||||
|
|
||||||
How it works:
|
How it works:
|
||||||
- If connected to WiFi: Access at http://phone.local
|
- If connected to WiFi: Access at http://yellow-phone.local
|
||||||
- If NOT connected: WiFi AP "phone-setup" will start automatically
|
- If NOT connected: WiFi AP "yellow-phone-setup" will start automatically
|
||||||
Connect to the AP at the same address http://phone.local
|
Connect to the AP at the same address http://yellow-phone.local
|
||||||
|
|
||||||
To check status use ./cli
|
To check status use ./cli
|
||||||
`)
|
`)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const command = process.argv[2];
|
||||||
|
|
||||||
if (!command || command === "help") {
|
if (!command || command === "help") {
|
||||||
console.log(`
|
console.log(`
|
||||||
Phone CLI - Service Management Tool
|
Yellow Phone CLI - Service Management Tool
|
||||||
|
|
||||||
Usage: bun cli <command>
|
Usage: bun cli <command>
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ if (!Object.keys(commands).includes(command)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n🔧 Phone CLI - ${command}\n`);
|
console.log(`\n🔧 Yellow Phone CLI - ${command}\n`);
|
||||||
|
|
||||||
// Parse service-specific commands
|
// Parse service-specific commands
|
||||||
const match = command.match(/^(ap|web)-(.+)$/);
|
const match = command.match(/^(ap|web)-(.+)$/);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
const PI_HOST = process.env.PI_HOST || "phone.local"
|
const PI_HOST = "yellow-phone.local"
|
||||||
const PI_DIR = "/home/corey/phone"
|
const PI_DIR = "/home/corey/yellow-phone"
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const shouldBootstrap = process.argv.includes("--bootstrap")
|
const shouldBootstrap = process.argv.includes("--bootstrap")
|
||||||
|
|
@ -30,11 +30,8 @@ console.log("\n✓ Files deployed!\n")
|
||||||
|
|
||||||
// Run bootstrap if requested
|
// Run bootstrap if requested
|
||||||
if (shouldBootstrap) {
|
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")
|
console.log("Running bootstrap on Pi...\n")
|
||||||
await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun ${PI_DIR}/scripts/bootstrap.ts ${PI_DIR}"`
|
await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun bootstrap.ts ${PI_DIR}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always check if services exist and restart them (whether we bootstrapped or not)
|
// Always check if services exist and restart them (whether we bootstrapped or not)
|
||||||
|
|
@ -57,6 +54,6 @@ if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) {
|
||||||
console.log(`
|
console.log(`
|
||||||
✓ Deploy complete!
|
✓ Deploy complete!
|
||||||
|
|
||||||
Access via WiFi at http://phone.local
|
Access via WiFi at http://yellow-phone.local
|
||||||
The Pi is discoverable as "phone-setup"
|
The Pi is discoverable as "yellow-phone-setup"
|
||||||
`)
|
`)
|
||||||
|
|
|
||||||
47
src/hq.ts
47
src/hq.ts
|
|
@ -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!`)
|
|
||||||
|
|
@ -25,7 +25,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
||||||
const waitingIndicator = new WaitingSounds(player, streamPlayback)
|
const waitingIndicator = new WaitingSounds(player, streamPlayback)
|
||||||
|
|
||||||
// Set up agent event listeners
|
// Set up agent event listeners
|
||||||
agent.events.connect(async (event) => {
|
agent.events.connect((event) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "connected":
|
case "connected":
|
||||||
console.log("✅ Connected to AI agent\n")
|
console.log("✅ Connected to AI agent\n")
|
||||||
|
|
@ -40,7 +40,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
||||||
break
|
break
|
||||||
|
|
||||||
case "audio":
|
case "audio":
|
||||||
await waitingIndicator.stop()
|
waitingIndicator.stop()
|
||||||
const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
||||||
streamPlayback.write(audioBuffer)
|
streamPlayback.write(audioBuffer)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
# 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)
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
171
src/pins/ffi.ts
171
src/pins/ffi.ts
|
|
@ -1,171 +0,0 @@
|
||||||
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
|
|
||||||
199
src/pins/gpio.ts
199
src/pins/gpio.ts
|
|
@ -1,199 +0,0 @@
|
||||||
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<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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async listChips(): Promise<ChipInfo[]> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
export { GPIO } from "./gpio"
|
|
||||||
export {
|
|
||||||
GPIOError,
|
|
||||||
PermissionError,
|
|
||||||
PinInUseError,
|
|
||||||
ChipNotFoundError,
|
|
||||||
InvalidConfigError,
|
|
||||||
} from "./errors"
|
|
||||||
export type {
|
|
||||||
InputOptions,
|
|
||||||
OutputOptions,
|
|
||||||
InputEvent,
|
|
||||||
InputGroupEvent,
|
|
||||||
ChipInfo,
|
|
||||||
PullMode,
|
|
||||||
EdgeMode,
|
|
||||||
} from "./types"
|
|
||||||
|
|
@ -1,194 +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>[] = []
|
|
||||||
let resolve: (() => void) | undefined
|
|
||||||
|
|
||||||
const listener = (event: InputGroupEvent<T>) => {
|
|
||||||
eventQueue.push(event)
|
|
||||||
resolve?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#eventListeners.push(listener)
|
|
||||||
this.#startEventLoop()
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!this.#closed) {
|
|
||||||
if (eventQueue.length === 0) {
|
|
||||||
await new Promise<void>((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<T> = { 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import type { Pointer } from "bun:ffi"
|
|
||||||
import { InputGroup } from "./input-group"
|
|
||||||
import type { PullMode, InputEvent } from "./types"
|
|
||||||
|
|
||||||
export class InputPin<T extends string = string> {
|
|
||||||
#group: InputGroup<T>
|
|
||||||
#pinName: T
|
|
||||||
|
|
||||||
constructor(group: InputGroup<T>, 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<void> {
|
|
||||||
for await (const event of this.#group.events()) {
|
|
||||||
if (event.value === targetValue) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async *events(): AsyncGenerator<InputEvent> {
|
|
||||||
for await (const event of this.#group.events()) {
|
|
||||||
yield { value: event.value, timestamp: event.timestamp }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.#group.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.dispose]() {
|
|
||||||
this.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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<T extends string = string> = InputEvent & {
|
|
||||||
pin: T // name of the pin that fired
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChipInfo = {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
label: string
|
|
||||||
numLines: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PinConfig = Record<string, { offset: number; pull: PullMode }>
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Layout } from "./Layout"
|
import { Layout } from "./Layout";
|
||||||
|
|
||||||
type ConnectingPageProps = {
|
type ConnectingPageProps = {
|
||||||
ssid: string
|
ssid: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
|
export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
|
||||||
<Layout title="Connecting...">
|
<Layout title="Connecting...">
|
||||||
|
|
@ -11,9 +11,7 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
|
||||||
<p>
|
<p>
|
||||||
<strong>SSID:</strong> {ssid}
|
<strong>SSID:</strong> {ssid}
|
||||||
</p>
|
</p>
|
||||||
<p id="status">
|
<p id="status">Testing connection... <span id="countdown">10</span>s remaining</p>
|
||||||
Testing connection... <span id="countdown">10</span>s remaining
|
|
||||||
</p>
|
|
||||||
<p>
|
<p>
|
||||||
<small>Waiting to see if connection succeeds...</small>
|
<small>Waiting to see if connection succeeds...</small>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -28,12 +26,8 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
|
||||||
<hr />
|
<hr />
|
||||||
<h3>Next Steps:</h3>
|
<h3>Next Steps:</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>Switch your device to the <strong>{ssid}</strong> network</li>
|
||||||
Switch your device to the <strong>{ssid}</strong> network
|
<li>Visit <a href="http://yellow-phone.local">http://yellow-phone.local</a></li>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Visit <a href="http://phone.local">http://phone.local</a>
|
|
||||||
</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
<small>The AP will shut down automatically since the Pi is now connected to WiFi.</small>
|
<small>The AP will shut down automatically since the Pi is now connected to WiFi.</small>
|
||||||
|
|
@ -42,14 +36,10 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
|
||||||
|
|
||||||
<div id="error-state" style="display: none;">
|
<div id="error-state" style="display: none;">
|
||||||
<h1>❌ Connection Failed</h1>
|
<h1>❌ Connection Failed</h1>
|
||||||
<p>
|
<p>Could not connect to <strong>{ssid}</strong></p>
|
||||||
Could not connect to <strong>{ssid}</strong>
|
|
||||||
</p>
|
|
||||||
<p>The password may be incorrect, or the network is out of range.</p>
|
<p>The password may be incorrect, or the network is out of range.</p>
|
||||||
<p>The AP is still running - you can try again.</p>
|
<p>The AP is still running - you can try again.</p>
|
||||||
<a href="/" role="button">
|
<a href="/" role="button">← Try Again</a>
|
||||||
← Try Again
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>{`
|
<script>{`
|
||||||
|
|
@ -81,4 +71,4 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
|
||||||
}, 1000);
|
}, 1000);
|
||||||
`}</script>
|
`}</script>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,98 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono";
|
||||||
import { join } from "node:path"
|
import {join} from "node:path";
|
||||||
import { $ } from "bun"
|
import { $ } from "bun";
|
||||||
import { IndexPage } from "./components/IndexPage"
|
import { IndexPage } from "./components/IndexPage";
|
||||||
import { LogsPage } from "./components/LogsPage"
|
import { LogsPage } from "./components/LogsPage";
|
||||||
import { ConnectingPage } from "./components/ConnectingPage"
|
import { ConnectingPage } from "./components/ConnectingPage";
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono();
|
||||||
|
|
||||||
// Ping endpoint for connectivity check
|
// Ping endpoint for connectivity check
|
||||||
app.get("/ping", (c) => {
|
app.get("/ping", (c) => {
|
||||||
return c.json({ ok: true })
|
return c.json({ ok: true });
|
||||||
})
|
});
|
||||||
|
|
||||||
// Serve static CSS
|
// Serve static CSS
|
||||||
app.get("/pico.css", async (c) => {
|
app.get("/pico.css", async (c) => {
|
||||||
const cssPath = join(import.meta.dir, "./static/pico.min.css")
|
const cssPath = join(import.meta.dir, "./static/pico.min.css");
|
||||||
const file = Bun.file(cssPath)
|
const file = Bun.file(cssPath);
|
||||||
return new Response(file)
|
return new Response(file);
|
||||||
})
|
});
|
||||||
|
|
||||||
// API endpoint to get available WiFi networks
|
// API endpoint to get available WiFi networks
|
||||||
app.get("/api/networks", async (c) => {
|
app.get("/api/networks", async (c) => {
|
||||||
try {
|
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
|
const networks = result
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split('\n')
|
||||||
.filter((ssid) => ssid && ssid !== "SSID") // Remove empty and header
|
.filter(ssid => ssid && ssid !== 'SSID') // Remove empty and header
|
||||||
.filter((ssid, index, self) => self.indexOf(ssid) === index) // Remove duplicates
|
.filter((ssid, index, self) => self.indexOf(ssid) === index); // Remove duplicates
|
||||||
return c.json({ networks })
|
return c.json({ networks });
|
||||||
} catch (error) {
|
} 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)
|
// API endpoint to get logs (for auto-refresh)
|
||||||
app.get("/api/logs", async (c) => {
|
app.get("/api/logs", async (c) => {
|
||||||
try {
|
try {
|
||||||
const logs =
|
const logs = await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text();
|
||||||
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
|
return c.json({ logs: logs.trim() });
|
||||||
return c.json({ logs: logs.trim() })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return c.json({ logs: "", error: String(error) }, 500)
|
return c.json({ logs: '', error: String(error) }, 500);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Main WiFi configuration page
|
// Main WiFi configuration page
|
||||||
app.get("/", (c) => {
|
app.get("/", (c) => {
|
||||||
return c.html(<IndexPage />)
|
return c.html(<IndexPage />);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Service logs with auto-refresh
|
// Service logs with auto-refresh
|
||||||
app.get("/logs", async (c) => {
|
app.get("/logs", async (c) => {
|
||||||
try {
|
try {
|
||||||
const logs =
|
const logs =
|
||||||
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
|
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text();
|
||||||
return c.html(<LogsPage logs={logs} />)
|
return c.html(<LogsPage logs={logs} />);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to fetch logs: ${error}`)
|
throw new Error(`Failed to fetch logs: ${error}`);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Handle WiFi configuration submission
|
// Handle WiFi configuration submission
|
||||||
app.post("/save", async (c) => {
|
app.post("/save", async (c) => {
|
||||||
const formData = await c.req.parseBody()
|
const formData = await c.req.parseBody();
|
||||||
const ssid = formData.ssid as string
|
const ssid = formData.ssid as string;
|
||||||
const password = formData.password as string
|
const password = formData.password as string;
|
||||||
|
|
||||||
// Return the connecting page immediately
|
// Return the connecting page immediately
|
||||||
const response = c.html(<ConnectingPage ssid={ssid} />)
|
const response = c.html(<ConnectingPage ssid={ssid} />);
|
||||||
|
|
||||||
// Trigger connection in background after a short delay (allows response to be sent)
|
// Trigger connection in background after a short delay (allows response to be sent)
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await $`sudo nmcli device wifi connect ${ssid} password ${password}`
|
await $`sudo nmcli device wifi connect ${ssid} password ${password}`;
|
||||||
console.log(`[WiFi] Successfully connected to ${ssid}`)
|
console.log(`[WiFi] Successfully connected to ${ssid}`);
|
||||||
} catch (error) {
|
} 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
|
// Delete the failed connection profile so ap-monitor doesn't try to use it
|
||||||
try {
|
try {
|
||||||
await $`sudo nmcli connection delete ${ssid}`.nothrow()
|
await $`sudo nmcli connection delete ${ssid}`.nothrow();
|
||||||
console.log(`[WiFi] Deleted failed connection profile for ${ssid}`)
|
console.log(`[WiFi] Deleted failed connection profile for ${ssid}`);
|
||||||
} catch (deleteError) {
|
} 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("Server running on http://0.0.0.0:80");
|
||||||
console.log("Access via WiFi or AP at http://phone.local")
|
console.log("Access via WiFi or AP at http://yellow-phone.local");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user