Compare commits

..

6 Commits

Author SHA1 Message Date
843f3680e1 wip 2025-11-18 17:43:12 -08:00
a0cc0c85cf Merge branch 'main' of 54.219.130.253:probablycorey/phone 2025-11-18 16:54:26 -08:00
1527ce7d13 wip 2025-11-18 16:53:41 -08:00
28164c2394 opk 2025-11-18 16:48:03 -08:00
1c717a5b47 Merge pull request 'libgpiod v2 for Bun!' (#2) from pins into main
Reviewed-on: #2
2025-11-19 00:46:08 +00:00
dc68c1d729 we got pins 2025-11-18 14:33:25 -08:00
50 changed files with 2342 additions and 198 deletions

View File

@ -1,6 +1,6 @@
# Deployment Script # 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 ## File: deploy.ts
@ -8,46 +8,48 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and
### Configuration ### Configuration
- **Target Host**: `yellow-phone.local` - **Target Host**: `phone.local`
- **Target Directory**: `/home/corey/yellow-phone` - **Target Directory**: `/home/corey/phone`
### What It Does ### What It Does
1. **Creates directory** on the Pi at the configured path 1. **Copies files** from here to the pi (in ~/phone by default)
2. **Copies files** from local `pi/` directory to the Pi 2. **Bootstrap (optional)**: If `--bootstrap` flag is passed it will bootstrap the pi with everything it needs
3. **Sets permissions** to make all TypeScript files executable 3. **Service management**:
4. **Bootstrap (optional)**: If `--bootstrap` flag is passed, runs `bootstrap.ts` on the Pi with sudo
5. **Service management**:
- Checks if `phone-ap.service` and `phone-web.service` exist - Checks if `phone-ap.service` and `phone-web.service` exist
- If they exist, restarts both services - If they exist, restarts both services
- If they don't exist and bootstrap wasn't run, warns the user - If they don't exist and bootstrap wasn't run, warns the user
### Usage ### Usage
**Standard deployment** (just copy files and restart services):
```bash ```bash
bun deploy.ts bun scripts/deploy.ts
``` # or bun deploy.ts --bootstrap
>>>>>>> Stashed changes
**First-time deployment** (copy files + run bootstrap):
```bash
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
- **WiFi Network**: yellow-phone-setup
### Requirements - **Web URL**: http://phone.local
- **WiFi Network**: phone-setup
### Local Requirements
- Bun runtime - Bun runtime
- SSH access to `yellow-phone.local` <<<<<<< HEAD
- Local `pi/` directory with files to deploy <<<<<<< Updated upstream
- # SSH access to `yellow-phone.local`
- SSH access to `phone.local`
> > > > > > > 1c717a5b4772147c3b7ce20f512452f13c8cc510
- # Local `pi/` directory with files to deploy
- SSH access to `phone.local`
> > > > > > > Stashed changes

1
baresip/accounts Normal file
View File

@ -0,0 +1 @@
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300

71
baresip/config Normal file
View File

@ -0,0 +1,71 @@
#
# baresip configuration
#
#------------------------------------------------------------------------------
# Core
poll_method epoll # poll, select, epoll ..
ring_aufile none
# Call
call_local_timeout 120
call_max_calls 4
# Audio
audio_player alsa,default
audio_source alsa,default
audio_alert none
audio_alert_enable no
audio_level no
ausrc_format s16 # s16, float, ..
auplay_format s16 # s16, float, ..
auenc_format s16 # s16, float, ..
audec_format s16 # s16, float, ..
audio_buffer 20-160 # ms
# AVT - Audio/Video Transport
rtp_tos 184
rtcp_mux no
jitter_buffer_delay 5-10 # frames
rtp_stats no
#------------------------------------------------------------------------------
# Modules
module_path /usr/lib/baresip/modules
# UI Modules
#module stdio.so
# Audio codec Modules (in order)
module g711.so
# Audio driver Modules
module alsa.so
# Media NAT modules
module stun.so
module turn.so
module ice.so
module httpd.so
#------------------------------------------------------------------------------
# Temporary Modules (loaded then unloaded)
module_tmp uuid.so
module_tmp account.so
#------------------------------------------------------------------------------
# Application Modules
module_app contact.so
module_app debug_cmd.so
module_app menu.so
http_listen 0.0.0.0:8000 # httpd - HTTP Serve

View File

@ -6,6 +6,7 @@
"dependencies": { "dependencies": {
"hono": "^4.10.4", "hono": "^4.10.4",
"openai": "^6.9.0", "openai": "^6.9.0",
"robot3": "^1.2.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@ -30,6 +31,8 @@
"openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="], "openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="],
"robot3": ["robot3@1.2.0", "", {}, "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],

View File

@ -14,7 +14,8 @@
}, },
"dependencies": { "dependencies": {
"hono": "^4.10.4", "hono": "^4.10.4",
"openai": "^6.9.0" "openai": "^6.9.0",
"robot3": "^1.2.0"
}, },
"prettier": { "prettier": {
"semi": false, "semi": false,

View File

@ -3,21 +3,14 @@
set -e set -e
echo "========================================== echo "==========================================
Bun Installation for Yellow Phone Bun Installation for Raspberry Pi
========================================== ==========================================
" "
# 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)" echo "✓ Bun is already installed at: $(which bun) $(bun --version)"
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..."

View File

@ -3,7 +3,7 @@ import { writeFileSync } from "fs"
console.log(` 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 // 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 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=Yellow Phone WiFi AP Monitor Description=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=Yellow Phone Web Server Description=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://yellow-phone.local - If connected to WiFi: Access at http://phone.local
- If NOT connected: WiFi AP "yellow-phone-setup" will start automatically - If NOT connected: WiFi AP "phone-setup" will start automatically
Connect to the AP at the same address http://yellow-phone.local Connect to the AP at the same address http://phone.local
To check status use ./cli To check status use ./cli
`) `)

View File

@ -33,7 +33,7 @@ const command = process.argv[2];
if (!command || command === "help") { if (!command || command === "help") {
console.log(` console.log(`
Yellow Phone CLI - Service Management Tool 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🔧 Yellow Phone CLI - ${command}\n`); console.log(`\n🔧 Phone CLI - ${command}\n`);
// Parse service-specific commands // Parse service-specific commands
const match = command.match(/^(ap|web)-(.+)$/); const match = command.match(/^(ap|web)-(.+)$/);

View File

@ -2,8 +2,8 @@
import { $ } from "bun" import { $ } from "bun"
const PI_HOST = "yellow-phone.local" const PI_HOST = process.env.PI_HOST ?? "phone.local"
const PI_DIR = "/home/corey/yellow-phone" const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone"
// Parse command line arguments // Parse command line arguments
const shouldBootstrap = process.argv.includes("--bootstrap") const shouldBootstrap = process.argv.includes("--bootstrap")
@ -30,10 +30,16 @@ 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 bootstrap.ts ${PI_DIR}"` await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun ${PI_DIR}/scripts/bootstrap.ts ${PI_DIR}"`
} }
// make console beep
await $`afplay /System/Library/Sounds/Blow.aiff`
// 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)
console.log("Checking for existing services...") console.log("Checking for existing services...")
const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"` const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"`
@ -54,6 +60,6 @@ if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) {
console.log(` console.log(`
Deploy complete! Deploy complete!
Access via WiFi at http://yellow-phone.local Access via WiFi at http://phone.local
The Pi is discoverable as "yellow-phone-setup" The Pi is discoverable as "phone-setup"
`) `)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -145,11 +145,7 @@ export class Player {
"-", "-",
] ]
const proc = Bun.spawn(["aplay", ...args], { const proc = Bun.spawn(["aplay", ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe" })
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
let bufferFinishTime = performance.now() let bufferFinishTime = performance.now()
const format = this.#format const format = this.#format

View File

@ -1,66 +1,66 @@
// Audio format configuration // Audio format configuration
export type AudioFormat = { export type AudioFormat = {
format?: string; format?: string
sampleRate?: number; sampleRate?: number
channels?: number; channels?: number
}; }
// Default audio format for recordings and tone generation // Default audio format for recordings and tone generation
export const DEFAULT_AUDIO_FORMAT = { export const DEFAULT_AUDIO_FORMAT = {
format: 'S16_LE', format: "S16_LE",
sampleRate: 16000, sampleRate: 16000,
channels: 1, channels: 1,
} as const; } as const
// Device from ALSA listing // Device from ALSA listing
export type Device = { export type Device = {
id: string; // "default" or "plughw:1,0" id: string // "default" or "plughw:1,0"
card: number; // ALSA card number card: number // ALSA card number
device: number; // ALSA device number device: number // ALSA device number
label: string; // Human-readable name label: string // Human-readable name
type: 'playback' | 'capture'; type: "playback" | "capture"
}; }
// Playback control handle // Playback control handle
export type Playback = { export type Playback = {
isPlaying: boolean; isPlaying: boolean
stop: () => Promise<void>; stop: () => Promise<void>
finished: () => Promise<void>; finished: () => Promise<void>
}; }
// Streaming playback handle // Streaming playback handle
export type StreamingPlayback = { export type StreamingPlayback = {
isPlaying: boolean; isPlaying: boolean
write: (chunk: Uint8Array) => void; write: (chunk: Uint8Array) => void
stop: () => Promise<void>; stop: () => Promise<void>
bufferEmptyFor: number; // milliseconds since buffer became empty, 0 if not empty bufferEmptyFor: number // milliseconds since buffer became empty, 0 if not empty
}; }
// Streaming recording control handle // Streaming recording control handle
export type StreamingRecording = { export type StreamingRecording = {
isRecording: boolean; isRecording: boolean
stream: () => ReadableStream<Uint8Array>; stream: () => ReadableStream<Uint8Array>
stop: () => Promise<void>; stop: () => Promise<void>
}; }
// File recording control handle // File recording control handle
export type FileRecording = { export type FileRecording = {
isRecording: boolean; isRecording: boolean
stop: () => Promise<void>; stop: () => Promise<void>
}; }
const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | undefined => { const parseDeviceLine = (line: string, type: "playback" | "capture"): Device | undefined => {
if (!line.startsWith('card ')) return undefined; if (!line.startsWith("card ")) return undefined
const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/); const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/)
if (!match) return undefined; if (!match) return undefined
const [, cardStr, label, deviceStr] = match; const [, cardStr, label, deviceStr] = match
if (!cardStr || !label || !deviceStr) return undefined; if (!cardStr || !label || !deviceStr) return undefined
const card = parseInt(cardStr); const card = parseInt(cardStr)
const device = parseInt(deviceStr); const device = parseInt(deviceStr)
return { return {
id: `plughw:${card},${device}`, id: `plughw:${card},${device}`,
@ -68,79 +68,77 @@ const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | u
device, device,
label, label,
type, type,
}; }
}; }
const parseAlsaDevices = (output: string, type: 'playback' | 'capture'): Device[] => { const parseAlsaDevices = (output: string, type: "playback" | "capture"): Device[] => {
return output return output
.split('\n') .split("\n")
.map(line => parseDeviceLine(line, type)) .map((line) => parseDeviceLine(line, type))
.filter(device => device !== undefined); .filter((device) => device !== undefined)
}; }
export const listDevices = async (): Promise<Device[]> => { export const listDevices = async (): Promise<Device[]> => {
const playbackOutput = await Bun.$`aplay -l`.text(); const playbackOutput = await Bun.$`aplay -l`.text()
const captureOutput = await Bun.$`arecord -l`.text(); const captureOutput = await Bun.$`arecord -l`.text()
const playback = parseAlsaDevices(playbackOutput, 'playback'); const playback = parseAlsaDevices(playbackOutput, "playback")
const capture = parseAlsaDevices(captureOutput, 'capture'); const capture = parseAlsaDevices(captureOutput, "capture")
return [...playback, ...capture]; return [...playback, ...capture]
}; }
export const findDeviceByLabel = async ( export const findDeviceByLabel = async (
label: string, label: string,
type?: 'playback' | 'capture' type?: "playback" | "capture"
): Promise<Device> => { ): Promise<Device> => {
const devices = await listDevices(); const devices = await listDevices()
const device = devices.find(d => const device = devices.find((d) => d.label === label && (!type || d.type === type))
d.label === label && (!type || d.type === type)
);
if (!device) { if (!device) {
const typeStr = type ? ` (type: ${type})` : ''; const typeStr = type ? ` (type: ${type})` : ""
throw new Error(`Device not found: ${label}${typeStr}`); throw new Error(`Device not found: ${label}${typeStr}`)
} }
return device; return device
}; }
export const calculateRMS = (chunk: Uint8Array): number => { export const calculateRMS = (chunk: Uint8Array): number => {
const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2); const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2)
let sum = 0; let sum = 0
for (const sample of samples) { for (const sample of samples) {
sum += sample * sample; sum += sample * sample
} }
return Math.sqrt(sum / samples.length); return Math.sqrt(sum / samples.length)
}; }
export const generateToneSamples = ( export const generateToneSamples = (
frequencies: number[], frequencies: number[],
sampleRate: number, sampleRate: number,
durationSeconds: number durationSeconds: number
): Uint8Array => { ): Uint8Array => {
const numSamples = Math.floor(sampleRate * durationSeconds); const numSamples = Math.floor(sampleRate * durationSeconds)
const buffer = new ArrayBuffer(numSamples * 2); // 2 bytes per S16_LE sample const buffer = new ArrayBuffer(numSamples * 2) // 2 bytes per S16_LE sample
const samples = new Int16Array(buffer); const samples = new Int16Array(buffer)
for (let i = 0; i < numSamples; i++) { for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate; const t = i / sampleRate
let value = 0; let value = 0
// Mix all frequencies together // Mix all frequencies together
for (const freq of frequencies) { for (const freq of frequencies) {
value += Math.sin(2 * Math.PI * freq * t); value += Math.sin(2 * Math.PI * freq * t)
} }
// Average and scale to Int16 range // Average and scale to Int16 range
value = (value / frequencies.length) * 32767; value = (value / frequencies.length) * 32767
samples[i] = Math.round(value); samples[i] = Math.round(value)
} }
return new Uint8Array(buffer); return new Uint8Array(buffer)
}; }
export const streamTone = async ( export const streamTone = async (
stream: { write: (chunk: Uint8Array) => void; end: () => void }, stream: { write: (chunk: Uint8Array) => void; end: () => void },
@ -148,20 +146,24 @@ export const streamTone = async (
durationMs: number, durationMs: number,
format: Required<AudioFormat> format: Required<AudioFormat>
): Promise<void> => { ): Promise<void> => {
const infinite = durationMs === Infinity; const infinite = durationMs === Infinity
const durationSeconds = durationMs / 1000; const durationSeconds = durationMs / 1000
// Continuous tone // Continuous tone
const samples = generateToneSamples(frequencies, format.sampleRate, infinite ? 1 : durationSeconds); const samples = generateToneSamples(
frequencies,
format.sampleRate,
infinite ? 1 : durationSeconds
)
if (infinite) { if (infinite) {
// Loop 1-second chunks forever // Loop 1-second chunks forever
while (true) { while (true) {
stream.write(samples); stream.write(samples)
await Bun.sleep(1000); await Bun.sleep(1000)
} }
} else { } else {
stream.write(samples); stream.write(samples)
stream.end(); stream.end()
} }
}; }

47
src/hq.ts Normal file
View File

@ -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<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!`)

445
src/phone.ts Normal file
View File

@ -0,0 +1,445 @@
import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3"
import { Baresip } from "./sip"
import { log } from "./utils/log"
import { sleep } from "bun"
import { processStderr, processStdout } from "./utils/stdio"
import Buzz from "./buzz"
import { join } from "path"
import { GPIO } from "./pins"
import { Agent } from "./agent"
import { searchWeb } from "./agent/tools"
type CancelableTask = () => void
type PhoneContext = {
lastError?: string
peer?: string
numberDialed: number
cancelDialTone?: CancelableTask
cancelRinger?: CancelableTask
baresip: Baresip
startAgent: () => CancelableTask
cancelAgent?: CancelableTask
}
const gpio = new GPIO({ resetOnClose: true })
using ringer = gpio.output(17)
using inputs = gpio.inputGroup({
hook: { pin: 27, debounce: 50 },
rotaryInUse: { pin: 22, debounce: 50 },
rotaryNumber: { pin: 23, debounce: 10 },
})
export const startPhone = async (agentId: string, apiKey: string) => {
await Buzz.setVolume(0.4)
log.info(`📞 Hook ${inputs.pins.hook.value}`)
await handleInputEvents()
}
const handleInputEvents = async () => {
let digit = 0
for await (const event of inputs.events()) {
switch (event.pin) {
case "hook":
const type = event.value == 0 ? "hang_up" : "pick_up"
log.info(`📞 Hook ${event.value} sending ${type}`)
if (type === "hang_up") {
ringer.value = 1
} else {
ringer.value = 0
}
break
case "rotaryInUse":
if (event.value === 0) {
digit = 0
} else {
log.info(`📞 Dialed digit: ${digit}`)
}
break
case "rotaryNumber":
if (event.value === 1) {
digit += 1
}
break
default:
log.error(`📞 Unknown pin event: ${event.pin}`)
break
}
}
}
const apiKey = process.env.ELEVEN_API_KEY
const agentId = process.env.ELEVEN_AGENT_ID
if (!apiKey) {
console.error("❌ Error: ELEVEN_API_KEY environment variable is required")
process.exit(1)
}
if (!agentId) {
console.error(
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required"
)
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
process.exit(1)
}
await startPhone(agentId, apiKey)
// log.info("📞 GPIO inputs initialized")
// // const baresipConfig = join(import.meta.dir, "..", "baresip")
// // const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
// // baresip.registrationSuccess.connect(async () => {
// // log.info("🐻 server connected")
// // const result = await gpio.get(pins.hook)
// // if (result.state === "low") {
// // phoneService.send({ type: "initialized" })
// // } else {
// // phoneService.send({ type: "pick_up" })
// // }
// // })
// // baresip.callReceived.connect(({ contact }) => {
// // log.info(`🐻 incoming call from ${contact}`)
// // phoneService.send({ type: "incoming_call", from: contact })
// // })
// // baresip.callEstablished.connect(({ contact }) => {
// // log.info(`🐻 call established with ${contact}`)
// // phoneService.send({ type: "answered" })
// // })
// // baresip.hungUp.connect(() => {
// // log.info("🐻 call hung up")
// // phoneService.send({ type: "remote_hang_up" })
// // })
// // baresip.connect().catch((error) => {
// // log.error("🐻 connection error:", error)
// // phoneService.send({ type: "error", message: error.message })
// // })
// // baresip.error.connect(async ({ message }) => {
// // log.error("🐻 error:", message)
// // phoneService.send({ type: "error", message })
// // for (let i = 0; i < 4; i++) {
// // await ring(500)
// // await sleep(250)
// // }
// // process.exit(1)
// // })
// const agent = new Agent({
// agentId,
// apiKey,
// tools: {
// search_web: (args: { query: string }) => searchWeb(args.query),
// },
// })
// handleAgentEvents(agent)
// const startAgent = () => {
// log.info("☎️ Starting agent conversation")
// if (agentProcess?.stdin) {
// agentProcess.stdin.write("start\n")
// } else {
// log.error("☎️ No agent process stdin available")
// phoneService.send({ type: "remote_hang_up" })
// }
// return () => {
// log.info("☎️ Stopping agent conversation")
// if (agentProcess?.stdin) {
// agentProcess.stdin.write("stop\n")
// }
// }
// }
// const context = (initial?: Partial<PhoneContext>): PhoneContext => ({
// numberDialed: 0,
// baresip,
// startAgent,
// ...initial,
// })
// const phoneMachine = createMachine(
// "initializing",
// // prettier-ignore
// {
// initializing: state(
// transition("initialized", "idle"),
// transition("pick_up", "ready", reduce(playDialTone)),
// transition("error", "fault", reduce(handleError))),
// idle: state(
// transition("incoming_call", "incoming", reduce(incomingCall)),
// transition("pick_up", "ready", reduce(playDialTone))),
// incoming: state(
// transition("remote_hang_up", "idle", reduce(stopRinger)),
// transition("pick_up", "connected", reduce(callAnswered))),
// connected: state(
// transition("remote_hang_up", "ready", reduce(playDialTone)),
// transition("hang_up", "idle", reduce(stopCall))),
// ready: state(
// transition("dial_start", "dialing", reduce(dialStart)),
// transition("dial_timeout", "aborted", reduce(stopDialTone)),
// transition("hang_up", "idle", reduce(stopDialTone))),
// dialing: state(
// transition("dial_stop", "outgoing", reduce(makeCall), guard((ctx) => !callAgentGuard(ctx))),
// transition("dial_stop", "connectedToAgent", reduce(makeAgentCall), guard((ctx) => callAgentGuard(ctx))),
// transition("digit_increment", "dialing", reduce(digitIncrement)),
// transition("hang_up", "idle", reduce(stopDialTone))),
// outgoing: state(
// transition("start_agent", "connectedToAgent"),
// transition("answered", "connected"),
// transition("hang_up", "idle", reduce(stopCall))),
// connectedToAgent: state(
// transition("remote_hang_up", "ready", reduce(stopAgent)),
// transition("hang_up", "idle", reduce(stopAgent))),
// aborted: state(
// transition("hang_up", "idle")),
// fault: state(),
// },
// context
// )
// const phoneService = interpret(phoneMachine, () => {})
// d._onEnter = function (machine, to, state, prevState, event) {
// log.info(`📱 ${machine.current} -> ${to} (${JSON.stringify(event)})`)
// }
// gpio.monitor(pins.hook, { bias: "pull-up" }, (event) => {
// const type = event.edge === "falling" ? "hang_up" : "pick_up"
// log.info(`📞 Hook ${event.edge} sending ${type}`)
// phoneService.send({ type })
// })
// gpio.monitor(pins.rotaryInUse, { bias: "pull-up", throttleMs: 90 }, (event) => {
// const type = event.edge === "falling" ? "dial_start" : "dial_stop"
// log.debug(`📞 Rotary in-use ${event.edge} sending ${type}`)
// phoneService.send({ type })
// })
// gpio.monitor(pins.rotaryNumber, { bias: "pull-up", throttleMs: 90 }, (event) => {
// if (event.edge !== "rising") return
// phoneService.send({ type: "digit_increment" })
// })
// // Graceful shutdown handling
// const cleanup = () => {
// log.info("🛑 Shutting down, stopping agent process")
// if (agentProcess?.stdin) {
// agentProcess.stdin.write("quit\n")
// }
// }
// process.on("SIGINT", cleanup)
// process.on("SIGTERM", cleanup)
// process.on("exit", cleanup)
// }
// const handleAgentEvents = (agent: Agent) => {
// agent.events.connect(async (event) => {
// switch (event.type) {
// case "connected":
// console.log("✅ Connected to AI agent\n")
// break
// case "user_transcript":
// console.log(`👤 You: ${event.transcript}`)
// break
// case "agent_response":
// console.log(`🤖 Agent: ${event.response}`)
// break
// case "audio":
// await waitingIndicator.stop()
// const audioBuffer = Buffer.from(event.audioBase64, "base64")
// streamPlayback.write(audioBuffer)
// break
// case "interruption":
// console.log("🛑 User interrupted")
// streamPlayback?.stop()
// streamPlayback = player.playStream() // Reset playback stream
// break
// case "tool_call":
// waitingIndicator.start(streamPlayback)
// console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
// break
// case "tool_result":
// console.log(`✅ Tool result: ${JSON.stringify(event.result)}`)
// break
// case "tool_error":
// console.error(`❌ Tool error: ${event.error}`)
// break
// case "disconnected":
// console.log("\n👋 Conversation ended, returning to dialtone\n")
// streamPlayback?.stop()
// state = "WAITING_FOR_VOICE"
// phoneService.send({ type: "remote_hang_up" })
// break
// case "error":
// console.error("Agent error:", event.error)
// break
// case "ping":
// break
// default:
// console.log(`😵‍💫 ${event.type}`)
// break
// }
// })
// }
// const incomingCallRing = (): CancelableTask => {
// let abortController = new AbortController()
// const playRingtone = async () => {
// while (!abortController.signal.aborted) {
// await ring(2000, abortController.signal)
// await sleep(4000)
// }
// }
// playRingtone().catch((error) => log.error("Ringer error:", error))
// return () => abortController.abort()
// }
// const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
// ctx.lastError = event.message
// log.error(`Phone error: ${event.message}`)
// return ctx
// }
// const incomingCall = (ctx: PhoneContext, event: { type: "incoming_call"; from?: string }) => {
// ctx.peer = event.from
// ctx.cancelRinger = incomingCallRing()
// log.info(`Incoming call from ${event.from}`)
// return ctx
// }
// const stopRinger = (ctx: PhoneContext) => {
// ctx.cancelRinger?.()
// ctx.cancelRinger = undefined
// return ctx
// }
// const playDialTone = (ctx: PhoneContext) => {
// const tone = new ToneGenerator()
// tone.loopTone([350, 440])
// ctx.cancelDialTone = () => {
// tone.stop()
// }
// return ctx
// }
// const playOutgoingTone = () => {
// const tone = new ToneGenerator()
// let canceled = false
// const play = async () => {
// while (!canceled) {
// await tone.playTone([440, 480], 2000)
// await sleep(4000)
// }
// }
// play().catch((error) => log.error("Outgoing tone error:", error))
// return () => {
// tone.stop()
// canceled = true
// }
// }
// const dialStart = (ctx: PhoneContext) => {
// ctx.numberDialed = 0
// ctx = stopDialTone(ctx)
// return ctx
// }
// const makeCall = (ctx: PhoneContext) => {
// log.info(`Dialing number: ${ctx.numberDialed}`)
// if (ctx.numberDialed === 1) {
// ctx.baresip.dial("+13476229543")
// } else if (ctx.numberDialed === 2) {
// ctx.baresip.dial("+18109643563")
// } else {
// const playTone = async () => {
// const tone = new ToneGenerator()
// await tone.playTone([900], 200)
// await tone.playTone([1350], 200)
// await tone.playTone([1750], 200)
// }
// playTone().catch((error) => log.error("Error playing tone:", error))
// }
// return ctx
// }
// const makeAgentCall = (ctx: PhoneContext) => {
// log.info(`Calling agent`)
// ctx.cancelAgent = ctx.startAgent()
// return ctx
// }
// const callAgentGuard = (ctx: PhoneContext) => {
// return ctx.numberDialed === 10
// }
// const callAnswered = (ctx: PhoneContext) => {
// ctx.baresip.accept()
// ctx.cancelDialTone?.()
// ctx.cancelDialTone = undefined
// ctx.cancelRinger?.()
// ctx.cancelRinger = undefined
// return ctx
// }
// const stopCall = (ctx: PhoneContext) => {
// ctx.baresip.hangUp()
// return ctx
// }
// const stopAgent = (ctx: PhoneContext) => {
// log.info("🛑 Stopping agent")
// ctx.cancelAgent?.()
// ctx.cancelAgent = undefined
// return ctx
// }
// const stopDialTone = (ctx: PhoneContext) => {
// ctx.cancelDialTone?.()
// ctx.cancelDialTone = undefined
// return ctx
// }
// const digitIncrement = (ctx: PhoneContext) => {
// ctx.numberDialed += 1
// return ctx
// }

173
src/pins/FFI-LEARNINGS.md Normal file
View File

@ -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

341
src/pins/README.md Normal file
View File

@ -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)

53
src/pins/errors.ts Normal file
View File

@ -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"
}
}

171
src/pins/ffi.ts Normal file
View File

@ -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

199
src/pins/gpio.ts Normal file
View File

@ -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<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
}
}

17
src/pins/index.ts Normal file
View File

@ -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"

205
src/pins/input-group.ts Normal file
View File

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

39
src/pins/input.ts Normal file
View File

@ -0,0 +1,39 @@
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()
}
}

50
src/pins/output.ts Normal file
View File

@ -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()
}
}

30
src/pins/types.ts Normal file
View File

@ -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<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 }>

60
src/pins/utils.ts Normal file
View File

@ -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}`
}

View File

@ -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,7 +11,9 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
<p> <p>
<strong>SSID:</strong> {ssid} <strong>SSID:</strong> {ssid}
</p> </p>
<p id="status">Testing connection... <span id="countdown">10</span>s remaining</p> <p id="status">
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>
@ -26,8 +28,12 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
<hr /> <hr />
<h3>Next Steps:</h3> <h3>Next Steps:</h3>
<ol> <ol>
<li>Switch your device to the <strong>{ssid}</strong> network</li> <li>
<li>Visit <a href="http://yellow-phone.local">http://yellow-phone.local</a></li> Switch your device to the <strong>{ssid}</strong> network
</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>
@ -36,10 +42,14 @@ 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>Could not connect to <strong>{ssid}</strong></p> <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"> Try Again</a> <a href="/" role="button">
Try Again
</a>
</div> </div>
<script>{` <script>{`
@ -71,4 +81,4 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
}, 1000); }, 1000);
`}</script> `}</script>
</Layout> </Layout>
); )

View File

@ -1,98 +1,99 @@
#!/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 = await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text(); const logs =
return c.json({ logs: logs.trim() }); await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
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://yellow-phone.local"); console.log("Access via WiFi or AP at http://phone.local")

114
src/sip.ts Normal file
View File

@ -0,0 +1,114 @@
import { log } from "./utils/log.ts"
import { Signal } from "./utils/signal.ts"
import { processStdout, processStderr } from "./utils/stdio.ts"
export class Baresip {
baresipArgs: string[]
process?: Bun.PipedSubprocess
callEstablished = new Signal<{ contact: string }>()
callReceived = new Signal<{ contact: string }>()
hungUp = new Signal()
error = new Signal<{ message: string }>()
registrationSuccess = new Signal()
constructor(baresipArgs: string[]) {
this.baresipArgs = baresipArgs
}
async connect() {
this.process = Bun.spawn(this.baresipArgs, {
stdout: "pipe",
stderr: "pipe",
onExit: (_proc, exitCode, signalCode, error) => {
log.debug(`📞 Baresip process exited (code: ${exitCode}, signal: ${signalCode})`)
if (error) {
log.error("Process error:", error)
}
},
})
Promise.all([
processStdout(this.process, (line) => this.#parseLine(line)),
processStderr(this.process),
]).catch((error) => {
log.error("Error processing output:", error)
})
}
accept() {
executeCommand("a")
}
dial(phoneNumber: string) {
executeCommand(`d${phoneNumber}`)
}
hangUp() {
executeCommand("b")
}
disconnectAll() {
this.callEstablished.disconnect()
this.callReceived.disconnect()
this.hungUp.disconnect()
this.registrationSuccess.disconnect()
}
kill() {
if (!this.process) throw new Error("Process not started")
this.process.kill()
this.disconnectAll()
this.process = undefined
}
#parseLine(line: string) {
log.debug(`📞 Baresip: ${line}`)
const callEstablishedMatch = line.match(/Call established: (.+)/)
if (callEstablishedMatch) {
log.debug(`Call established with "${line}"`)
this.callEstablished.emit({ contact: callEstablishedMatch[1]! })
}
const callReceivedMatch = line.match(/Incoming call from: \+\d+ (\S+) -/)
if (callReceivedMatch) {
log.debug(`Incoming call from "${line}"`)
this.callReceived.emit({ contact: callReceivedMatch[1]!?.trim() })
}
const hangUpMatch = line.match(/(.+): session closed/)
if (hangUpMatch) {
log.debug(`Call hung up with "${line}"`)
this.hungUp.emit()
}
const callTerminatedMatch = line.match(/(.+) terminated \(duration: /)
if (callTerminatedMatch) {
log.debug(`⁉️ NOT HANDLED: Call terminated with "${line}"`)
}
const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/)
if (registrationSuccessMatch) {
this.registrationSuccess.emit()
}
const registrationFailedMatch = line.match(/reg: sip:\S+ 403 Forbidden/)
const socketInUseMatch = line.match(/tcp: sock_bind:/)
if (registrationFailedMatch || socketInUseMatch) {
log.error(`⁉️ NOT HANDLED: Registration failed with "${line}"`)
this.error.emit({ message: line })
}
}
}
const executeCommand = async (command: string) => {
try {
const url = new URL(`/?${command}`, "http://127.0.0.1:8000")
const response = await Bun.fetch(url)
if (!response.ok) {
throw new Error(`Error executing command: ${response.statusText}`)
}
} catch (error) {
log.error("Failed to execute command:", error)
}
}

View File

@ -5,7 +5,7 @@
* Tests device listing, player, recorder, and tone generation * Tests device listing, player, recorder, and tone generation
*/ */
import Buzz from "./src/buzz" import Buzz from "./buzz"
console.log("🎵 Buzz Audio Library - Basic Test\n") console.log("🎵 Buzz Audio Library - Basic Test\n")

View File

@ -22,10 +22,10 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
let currentDialtone: Playback | undefined let currentDialtone: Playback | undefined
let currentBackgroundNoise: Playback | undefined let currentBackgroundNoise: Playback | undefined
let streamPlayback = player.playStream() let streamPlayback = player.playStream()
const waitingIndicator = new WaitingSounds(player, streamPlayback) const waitingIndicator = new WaitingSounds(player)
// Set up agent event listeners // Set up agent event listeners
agent.events.connect((event) => { agent.events.connect(async (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":
waitingIndicator.stop() await waitingIndicator.stop()
const audioBuffer = Buffer.from(event.audioBase64, "base64") const audioBuffer = Buffer.from(event.audioBase64, "base64")
streamPlayback.write(audioBuffer) streamPlayback.write(audioBuffer)
break break
@ -52,7 +52,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
break break
case "tool_call": case "tool_call":
waitingIndicator.start() waitingIndicator.start(streamPlayback)
console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`) console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
break break
@ -73,6 +73,14 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
case "error": case "error":
console.error("Agent error:", event.error) console.error("Agent error:", event.error)
break
case "ping":
break
default:
console.log(`😵‍💫 ${event.type}`)
break
} }
}) })

46
src/test-pins.ts Normal file
View File

@ -0,0 +1,46 @@
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") {
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!`)

21
src/utils/log.ts Normal file
View File

@ -0,0 +1,21 @@
let showDebug = true
let showInfo = true
let showError = true
export function setLogLevel(level: "debug" | "info" | "error" | "none") {
showDebug = level === "debug"
showInfo = level === "debug" || level === "info"
showError = level !== "none"
}
export const log = {
debug: (...args: any[]) => {
if (showDebug) console.debug("DEBUG: ", ...args)
},
info: (...args: any[]) => {
if (showInfo) console.log("INFO: ", ...args)
},
error: (...args: any[]) => {
if (showError) console.error("ERROR: ", ...args)
},
}

41
src/utils/stdio.ts Normal file
View File

@ -0,0 +1,41 @@
import { log } from "./log.ts"
export const LineSplitter = () => {
let buffer = ""
return new TransformStream({
transform(chunk, controller) {
buffer += chunk
const parts = buffer.split(/\n/)
const lines = parts.slice(0, -1)
buffer = parts.at(-1) || ""
for (const line of lines) {
controller.enqueue(line)
}
},
flush(controller) {
if (buffer.length > 0) {
controller.enqueue(buffer)
}
},
})
}
export async function processStdout(
process: Bun.ReadableSubprocess,
onLine: (line: string) => void
) {
for await (const line of process.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(LineSplitter())) {
onLine(line)
}
}
export async function processStderr(process: Bun.ReadableSubprocess, prefix: string = "") {
for await (const line of process.stderr
.pipeThrough(new TextDecoderStream())
.pipeThrough(LineSplitter())) {
log.error(`${prefix}${line}`)
}
}

View File

@ -7,13 +7,13 @@ export class WaitingSounds {
typingPlayback?: Playback typingPlayback?: Playback
speakingPlayback?: Playback speakingPlayback?: Playback
constructor(private player: Player, private streamPlayback: StreamingPlayback) {} constructor(private player: Player) {}
async start() { async start(operatorStream: StreamingPlayback) {
if (this.typingPlayback) return // Already playing if (this.typingPlayback) return // Already playing
this.#startTypingSounds() this.#startTypingSounds()
this.#startSpeakingSounds() this.#startSpeakingSounds(operatorStream)
} }
async #startTypingSounds() { async #startTypingSounds() {
@ -35,17 +35,15 @@ export class WaitingSounds {
}) })
} }
async #startSpeakingSounds() { async #startSpeakingSounds(operatorStream: StreamingPlayback) {
const playedSounds = new Set<string>() const playedSounds = new Set<string>()
let dir: SoundDir | undefined let dir: SoundDir | undefined
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
// Don't start speaking until the stream playback buffer is empty! while (operatorStream.bufferEmptyFor < 1500) {
while (this.streamPlayback.bufferEmptyFor < 1000) {
await Bun.sleep(100) await Bun.sleep(100)
} }
do { do {
this.streamPlayback.bufferEmptyFor
const lastSoundDir = dir const lastSoundDir = dir
const value = Math.random() * 100 const value = Math.random() * 100
if (lastSoundDir === "body-noises") { if (lastSoundDir === "body-noises") {