Compare commits
No commits in common. "843f3680e1a54543f0a75a837c9cc161f4ea129c" and "18629565318688d5600ed00abef94801d2113546" have entirely different histories.
843f3680e1
...
1862956531
42
README.md
42
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,48 +8,46 @@ 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
|
||||||
|
|
||||||
1. **Copies files** from here to the pi (in ~/phone by default)
|
1. **Creates directory** on the Pi at the configured path
|
||||||
2. **Bootstrap (optional)**: If `--bootstrap` flag is passed it will bootstrap the pi with everything it needs
|
2. **Copies files** from local `pi/` directory to the Pi
|
||||||
3. **Service management**:
|
3. **Sets permissions** to make all TypeScript files executable
|
||||||
|
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 scripts/deploy.ts
|
bun 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
|
||||||
|
|
||||||
- **Web URL**: http://phone.local
|
### Requirements
|
||||||
- **WiFi Network**: phone-setup
|
|
||||||
|
|
||||||
### Local Requirements
|
|
||||||
|
|
||||||
- Bun runtime
|
- Bun runtime
|
||||||
<<<<<<< HEAD
|
- SSH access to `yellow-phone.local`
|
||||||
<<<<<<< Updated upstream
|
- Local `pi/` directory with files to deploy
|
||||||
- # 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 +0,0 @@
|
||||||
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Tests device listing, player, recorder, and tone generation
|
* Tests device listing, player, recorder, and tone generation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Buzz from "./buzz"
|
import Buzz from "./src/buzz"
|
||||||
|
|
||||||
console.log("🎵 Buzz Audio Library - Basic Test\n")
|
console.log("🎵 Buzz Audio Library - Basic Test\n")
|
||||||
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -6,7 +6,6 @@
|
||||||
"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",
|
||||||
|
|
@ -31,8 +30,6 @@
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@
|
||||||
},
|
},
|
||||||
"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,
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,22 @@
|
||||||
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..."
|
||||||
echo "Running official Bun installer..."
|
echo "Running official Bun installer..."
|
||||||
|
|
|
||||||
|
|
@ -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 = process.env.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,16 +30,10 @@ 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}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"`
|
||||||
|
|
@ -60,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"
|
||||||
`)
|
`)
|
||||||
|
|
|
||||||
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.
|
|
@ -145,7 +145,11 @@ export class Player {
|
||||||
"-",
|
"-",
|
||||||
]
|
]
|
||||||
|
|
||||||
const proc = Bun.spawn(["aplay", ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe" })
|
const proc = Bun.spawn(["aplay", ...args], {
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
let bufferFinishTime = performance.now()
|
let bufferFinishTime = performance.now()
|
||||||
const format = this.#format
|
const format = this.#format
|
||||||
|
|
|
||||||
|
|
@ -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,77 +68,79 @@ 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) => d.label === label && (!type || d.type === type))
|
const device = devices.find(d =>
|
||||||
|
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 },
|
||||||
|
|
@ -146,24 +148,20 @@ 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(
|
const samples = generateToneSamples(frequencies, format.sampleRate, infinite ? 1 : durationSeconds);
|
||||||
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
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!`)
|
|
||||||
|
|
@ -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)
|
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
|
||||||
|
|
@ -52,7 +52,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
||||||
break
|
break
|
||||||
|
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
waitingIndicator.start(streamPlayback)
|
waitingIndicator.start()
|
||||||
console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -73,14 +73,6 @@ 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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
445
src/phone.ts
445
src/phone.ts
|
|
@ -1,445 +0,0 @@
|
||||||
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
|
|
||||||
// }
|
|
||||||
|
|
@ -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,205 +0,0 @@
|
||||||
import type { Pointer } from "bun:ffi"
|
|
||||||
import { gpiod } from "./ffi"
|
|
||||||
import { mapLibgpiodEdgeToPressedState } from "./utils"
|
|
||||||
import type { PullMode, InputEvent, InputGroupEvent, PinConfig } from "./types"
|
|
||||||
|
|
||||||
export class InputGroup<T extends string = string> {
|
|
||||||
#closed = false
|
|
||||||
#chip: Pointer
|
|
||||||
#request: Pointer
|
|
||||||
#pinMap: Map<string, { offset: number; pull: PullMode }>
|
|
||||||
#offsetMap: Map<number, { name: string; pull: PullMode }>
|
|
||||||
#eventBuffer: Pointer | undefined
|
|
||||||
#eventListeners: Array<(event: InputGroupEvent<T>) => void> = []
|
|
||||||
#closeHandlers: Array<() => void> = []
|
|
||||||
|
|
||||||
constructor(chip: Pointer, request: Pointer, pinConfig: PinConfig) {
|
|
||||||
this.#chip = chip
|
|
||||||
this.#request = request
|
|
||||||
|
|
||||||
this.#pinMap = new Map()
|
|
||||||
this.#offsetMap = new Map()
|
|
||||||
|
|
||||||
for (const [name, config] of Object.entries(pinConfig)) {
|
|
||||||
this.#pinMap.set(name, config)
|
|
||||||
this.#offsetMap.set(config.offset, { name, pull: config.pull })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get pins(): Record<
|
|
||||||
T,
|
|
||||||
{ readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }
|
|
||||||
> {
|
|
||||||
const result = {} as Record<
|
|
||||||
T,
|
|
||||||
{
|
|
||||||
readonly value: 0 | 1
|
|
||||||
waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
|
|
||||||
for (const [name, config] of this.#pinMap) {
|
|
||||||
const offset = config.offset
|
|
||||||
const closed = () => this.#closed
|
|
||||||
const request = this.#request
|
|
||||||
const pinName = name
|
|
||||||
|
|
||||||
Object.defineProperty(result, name, {
|
|
||||||
get: () => ({
|
|
||||||
get value(): 0 | 1 {
|
|
||||||
if (closed()) throw new Error("InputGroup is closed")
|
|
||||||
const ret = gpiod.gpiod_line_request_get_value(request, offset)
|
|
||||||
if (ret === -1) throw new Error("Failed to get pin value")
|
|
||||||
return ret as 0 | 1
|
|
||||||
},
|
|
||||||
waitForValue: (targetValue: 0 | 1, timeout?: number) =>
|
|
||||||
this.#waitForPinValue(pinName as T, targetValue, timeout),
|
|
||||||
}),
|
|
||||||
enumerable: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
async #waitForPinValue(pinName: T, targetValue: 0 | 1, timeout?: number): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (this.#closed) {
|
|
||||||
reject(new Error("InputGroup is closed"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
|
||||||
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
|
|
||||||
this.#closeHandlers = this.#closeHandlers.filter((h) => h !== onClose)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
cleanup()
|
|
||||||
reject(new Error("InputGroup closed while waiting"))
|
|
||||||
}
|
|
||||||
|
|
||||||
const listener = (event: InputGroupEvent<T>) => {
|
|
||||||
if (event.pin !== pinName) return
|
|
||||||
if (event.value !== targetValue) return
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeout) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
cleanup()
|
|
||||||
reject(new Error(`Timeout waiting for pin ${pinName} to become ${targetValue}`))
|
|
||||||
}, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#eventListeners.push(listener)
|
|
||||||
this.#closeHandlers.push(onClose)
|
|
||||||
this.#startEventLoop()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async *events(): AsyncGenerator<InputGroupEvent<T>> {
|
|
||||||
if (this.#closed) throw new Error("InputGroup is closed")
|
|
||||||
|
|
||||||
const eventQueue: InputGroupEvent<T>[] = []
|
|
||||||
const listener = (event: InputGroupEvent<T>) => {
|
|
||||||
eventQueue.push(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#eventListeners.push(listener)
|
|
||||||
this.#startEventLoop()
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!this.#closed) {
|
|
||||||
if (eventQueue.length > 0) {
|
|
||||||
for (const event of eventQueue) {
|
|
||||||
yield event
|
|
||||||
}
|
|
||||||
eventQueue.length = 0
|
|
||||||
} else {
|
|
||||||
await Bun.sleep(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#startEventLoop() {
|
|
||||||
if (this.#eventBuffer !== undefined) return
|
|
||||||
|
|
||||||
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
|
|
||||||
if (!buffer) throw new Error("Failed to create event buffer")
|
|
||||||
|
|
||||||
this.#eventBuffer = buffer
|
|
||||||
this.#runEventLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
async #runEventLoop() {
|
|
||||||
try {
|
|
||||||
while (!this.#closed && this.#eventListeners.length > 0) {
|
|
||||||
const ret = gpiod.gpiod_line_request_wait_edge_events(this.#request, 100_000_000)
|
|
||||||
|
|
||||||
if (ret === -1 || ret === 0) {
|
|
||||||
await Bun.sleep(0)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const numEvents = gpiod.gpiod_line_request_read_edge_events(
|
|
||||||
this.#request,
|
|
||||||
this.#eventBuffer!,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
|
|
||||||
if (numEvents > 0) {
|
|
||||||
const event = gpiod.gpiod_edge_event_buffer_get_event(this.#eventBuffer!, 0)
|
|
||||||
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
|
|
||||||
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
|
|
||||||
const offset = gpiod.gpiod_edge_event_get_line_offset(event)
|
|
||||||
|
|
||||||
const pinInfo = this.#offsetMap.get(offset)
|
|
||||||
if (!pinInfo) continue
|
|
||||||
|
|
||||||
const pressed = mapLibgpiodEdgeToPressedState(edgeType, pinInfo.pull)
|
|
||||||
const value = (
|
|
||||||
pressed ? (pinInfo.pull === "up" ? 0 : 1) : pinInfo.pull === "up" ? 1 : 0
|
|
||||||
) as 0 | 1
|
|
||||||
const inputEvent: InputGroupEvent<T> = { pin: pinInfo.name as T, value, timestamp }
|
|
||||||
|
|
||||||
for (const listener of this.#eventListeners) {
|
|
||||||
listener(inputEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Bun.sleep(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (this.#eventBuffer) {
|
|
||||||
gpiod.gpiod_edge_event_buffer_free(this.#eventBuffer)
|
|
||||||
this.#eventBuffer = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this.#closed) return
|
|
||||||
this.#closed = true
|
|
||||||
|
|
||||||
for (const handler of this.#closeHandlers) {
|
|
||||||
handler()
|
|
||||||
}
|
|
||||||
this.#closeHandlers = []
|
|
||||||
|
|
||||||
gpiod.gpiod_line_request_release(this.#request)
|
|
||||||
gpiod.gpiod_chip_close(this.#chip)
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.dispose]() {
|
|
||||||
this.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
114
src/sip.ts
114
src/sip.ts
|
|
@ -1,114 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +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") {
|
|
||||||
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!`)
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,13 +7,13 @@ export class WaitingSounds {
|
||||||
typingPlayback?: Playback
|
typingPlayback?: Playback
|
||||||
speakingPlayback?: Playback
|
speakingPlayback?: Playback
|
||||||
|
|
||||||
constructor(private player: Player) {}
|
constructor(private player: Player, private streamPlayback: StreamingPlayback) {}
|
||||||
|
|
||||||
async start(operatorStream: StreamingPlayback) {
|
async start() {
|
||||||
if (this.typingPlayback) return // Already playing
|
if (this.typingPlayback) return // Already playing
|
||||||
|
|
||||||
this.#startTypingSounds()
|
this.#startTypingSounds()
|
||||||
this.#startSpeakingSounds(operatorStream)
|
this.#startSpeakingSounds()
|
||||||
}
|
}
|
||||||
|
|
||||||
async #startTypingSounds() {
|
async #startTypingSounds() {
|
||||||
|
|
@ -35,15 +35,17 @@ export class WaitingSounds {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async #startSpeakingSounds(operatorStream: StreamingPlayback) {
|
async #startSpeakingSounds() {
|
||||||
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) => {
|
||||||
while (operatorStream.bufferEmptyFor < 1500) {
|
// Don't start speaking until the stream playback buffer is empty!
|
||||||
|
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") {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user