Compare commits
No commits in common. "main" and "ding-a-ling" have entirely different histories.
main
...
ding-a-lin
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"typescript.tsdk": "node_modules/typescript/lib"
|
|
||||||
}
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
<sip:yellow@probablycorey.sip.twilio.com>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes
|
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300
|
||||||
|
|
@ -1,26 +1,71 @@
|
||||||
call_max_calls 4
|
#
|
||||||
call_local_timeout 120
|
# baresip configuration
|
||||||
|
#
|
||||||
|
|
||||||
# Audio
|
|
||||||
audio_player alsa,default
|
|
||||||
audio_source alsa,default
|
|
||||||
audio_alert alsa,default
|
|
||||||
|
|
||||||
ring_aufile /dev/null
|
|
||||||
|
|
||||||
# Modules
|
|
||||||
#------------------------------------------------------------------------------
|
#------------------------------------------------------------------------------
|
||||||
|
|
||||||
module_path /usr/lib/baresip/modules
|
# 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 codec Modules
|
|
||||||
module g711.so
|
|
||||||
|
|
||||||
# Audio driver Modules
|
# Audio driver Modules
|
||||||
module alsa.so
|
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
|
# Application Modules
|
||||||
module_app account.so
|
|
||||||
module_app menu.so
|
|
||||||
|
|
||||||
module httpd.so
|
module_app contact.so
|
||||||
|
module_app debug_cmd.so
|
||||||
|
module_app menu.so
|
||||||
|
|
||||||
|
|
||||||
|
http_listen 0.0.0.0:8000 # httpd - HTTP Serve
|
||||||
13
bun.lock
13
bun.lock
|
|
@ -6,11 +6,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.10.4",
|
"hono": "^4.10.4",
|
||||||
"openai": "^6.9.0",
|
"openai": "^6.9.0",
|
||||||
"robot3": "1.1.1",
|
"robot3": "^1.2.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"prettier": "^3.6.2",
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|
@ -22,19 +21,17 @@
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
|
"@types/react": ["@types/react@19.2.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.2", "", {}, "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g=="],
|
||||||
|
|
||||||
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
|
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
|
||||||
|
|
||||||
"openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="],
|
"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=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
"robot3": ["robot3@1.2.0", "", {}, "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q=="],
|
||||||
|
|
||||||
"robot3": ["robot3@1.1.1", "", {}, "sha512-kuD0oQg2KUE74FCQ1a5uoRsEJ/bUKrU1D3vnluop9X7LSiGLndejQgjUEcMqJMVzUA836HSXhtY7XNtQiPTCLQ=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@
|
||||||
"start": "bun run src/operator.ts"
|
"start": "bun run src/operator.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest"
|
||||||
"prettier": "^3.6.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|
@ -16,7 +15,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.10.4",
|
"hono": "^4.10.4",
|
||||||
"openai": "^6.9.0",
|
"openai": "^6.9.0",
|
||||||
"robot3": "1.1.1"
|
"robot3": "^1.2.0"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"semi": false,
|
"semi": false,
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { $ } from "bun"
|
|
||||||
import { writeFileSync } from "fs"
|
|
||||||
|
|
||||||
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
|
||||||
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
|
|
||||||
const PHONE_SERVICE_FILE = "/etc/systemd/system/phone.service"
|
|
||||||
|
|
||||||
export const setupServices = async (installDir: string) => {
|
|
||||||
console.log("\nInstalling systemd services...")
|
|
||||||
|
|
||||||
// Detect user from environment or use default
|
|
||||||
// SUDO_USER is set when running with sudo, which is what we want
|
|
||||||
const serviceUser = process.env.SERVICE_USER || process.env.SUDO_USER || process.env.USER || "corey"
|
|
||||||
const userUid = await $`id -u ${serviceUser}`.text().then((s) => s.trim())
|
|
||||||
|
|
||||||
console.log(`Setting up services for user: ${serviceUser} (UID: ${userUid})`)
|
|
||||||
|
|
||||||
// Find where bun is installed
|
|
||||||
const bunPath = await $`which bun`
|
|
||||||
.quiet()
|
|
||||||
.nothrow()
|
|
||||||
.text()
|
|
||||||
.then((p) => p.trim())
|
|
||||||
|
|
||||||
if (!bunPath) {
|
|
||||||
console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
console.log(`Using bun at: ${bunPath}`)
|
|
||||||
|
|
||||||
// Create AP monitor service
|
|
||||||
const apServiceContent = `[Unit]
|
|
||||||
Description=Phone WiFi AP Monitor
|
|
||||||
After=network.target
|
|
||||||
Before=phone-web.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=${bunPath} ${installDir}/src/services/ap-monitor.ts
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
`
|
|
||||||
writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8")
|
|
||||||
console.log("✓ Created phone-ap.service")
|
|
||||||
|
|
||||||
// Create web server service
|
|
||||||
const webServiceContent = `[Unit]
|
|
||||||
Description=Phone Web Server
|
|
||||||
After=network.target phone-ap.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=${bunPath} ${installDir}/src/services/server/server.tsx
|
|
||||||
WorkingDirectory=${installDir}
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
`
|
|
||||||
writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8")
|
|
||||||
console.log("✓ Created phone-web.service")
|
|
||||||
|
|
||||||
// Create phone service (system service with environment variables for audio access)
|
|
||||||
const phoneServiceContent = `[Unit]
|
|
||||||
Description=Phone Application
|
|
||||||
After=network.target sound.target
|
|
||||||
Requires=sound.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${serviceUser}
|
|
||||||
Group=audio
|
|
||||||
Environment=XDG_RUNTIME_DIR=/run/user/${userUid}
|
|
||||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userUid}/bus
|
|
||||||
ExecStart=${bunPath} ${installDir}/src/main.ts
|
|
||||||
WorkingDirectory=${installDir}
|
|
||||||
EnvironmentFile=${installDir}/.env
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
`
|
|
||||||
writeFileSync(PHONE_SERVICE_FILE, phoneServiceContent, "utf8")
|
|
||||||
console.log("✓ Created phone.service")
|
|
||||||
|
|
||||||
await $`systemctl daemon-reload`
|
|
||||||
await $`systemctl enable phone-ap.service`
|
|
||||||
await $`systemctl enable phone-web.service`
|
|
||||||
await $`systemctl enable phone.service`
|
|
||||||
console.log("✓ Services enabled")
|
|
||||||
|
|
||||||
console.log("\nRestarting the services...")
|
|
||||||
await $`systemctl restart phone-ap.service`
|
|
||||||
await $`systemctl restart phone-web.service`
|
|
||||||
await $`systemctl restart phone.service`
|
|
||||||
console.log("✓ Services restarted")
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
import { writeFileSync } from "fs"
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
==========================================
|
==========================================
|
||||||
|
|
@ -14,22 +15,96 @@ if (process.getuid && process.getuid() !== 0) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get install directory from argument or use default
|
// Get install directory from argument or use default
|
||||||
const defaultUser = process.env.USER || "corey"
|
const INSTALL_DIR = process.argv[2] || "/home/corey/phone"
|
||||||
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
|
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
||||||
|
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
|
||||||
|
|
||||||
console.log(`Install directory: ${INSTALL_DIR}`)
|
console.log(`Install directory: ${INSTALL_DIR}`)
|
||||||
|
|
||||||
console.log("\nEnsuring directory exists...")
|
console.log("\nStep 1: Ensuring directory exists...")
|
||||||
await $`mkdir -p ${INSTALL_DIR}`
|
await $`mkdir -p ${INSTALL_DIR}`
|
||||||
console.log(`✓ Directory ready: ${INSTALL_DIR}`)
|
console.log(`✓ Directory ready: ${INSTALL_DIR}`)
|
||||||
|
|
||||||
console.log("\nInstalling dependencies...")
|
console.log("\nStep 2: Installing dependencies...")
|
||||||
await $`cd ${INSTALL_DIR} && bun install`
|
await $`cd ${INSTALL_DIR} && bun install`
|
||||||
console.log(`✓ Dependencies installed`)
|
console.log(`✓ Dependencies installed`)
|
||||||
|
|
||||||
console.log("\nInstalling Baresip...")
|
console.log("\nStep 3: Installing systemd services...")
|
||||||
await $`sudo apt install -y baresip`
|
// Find where bun is installed
|
||||||
|
const bunPath = await $`which bun`
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
.then((p) => p.trim())
|
||||||
|
if (!bunPath) {
|
||||||
|
console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(`Using bun at: ${bunPath}`)
|
||||||
|
|
||||||
|
// Create AP monitor service
|
||||||
|
const apServiceContent = `[Unit]
|
||||||
|
Description=Phone WiFi AP Monitor
|
||||||
|
After=network.target
|
||||||
|
Before=phone-web.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${bunPath} ${INSTALL_DIR}/services/ap-monitor.ts
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`
|
||||||
|
writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8")
|
||||||
|
console.log("✓ Created phone-ap.service")
|
||||||
|
|
||||||
|
// Create web server service
|
||||||
|
const webServiceContent = `[Unit]
|
||||||
|
Description=Phone Web Server
|
||||||
|
After=network.target phone-ap.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${bunPath} ${INSTALL_DIR}/services/server/server.tsx
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`
|
||||||
|
writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8")
|
||||||
|
console.log("✓ Created phone-web.service")
|
||||||
|
|
||||||
|
await $`systemctl daemon-reload`
|
||||||
|
await $`systemctl enable phone-ap.service`
|
||||||
|
await $`systemctl enable phone-web.service`
|
||||||
|
console.log("✓ Services enabled")
|
||||||
|
|
||||||
|
console.log("\nStep 4: Starting the services...")
|
||||||
|
await $`systemctl start phone-ap.service`
|
||||||
|
await $`systemctl start phone-web.service`
|
||||||
|
console.log("✓ Services started")
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
✅ Bootstrap complete!
|
==========================================
|
||||||
|
✓ Bootstrap complete!
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
Both services are now running and will start automatically on boot:
|
||||||
|
- phone-ap.service: Monitors WiFi and manages AP
|
||||||
|
- phone-web.service: Web server for configuration
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
- If connected to WiFi: Access at http://phone.local
|
||||||
|
- If NOT connected: WiFi AP "phone-setup" will start automatically
|
||||||
|
Connect to the AP at the same address http://phone.local
|
||||||
|
|
||||||
|
To check status use ./cli
|
||||||
`)
|
`)
|
||||||
|
|
|
||||||
178
scripts/cli.sh
Normal file
178
scripts/cli.sh
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import {$} from "bun";
|
||||||
|
|
||||||
|
const SERVICES = {
|
||||||
|
ap: "phone-ap",
|
||||||
|
web: "phone-web",
|
||||||
|
};
|
||||||
|
|
||||||
|
const commands = {
|
||||||
|
status: "Show status of all services",
|
||||||
|
logs: "Show recent logs from all services (last 50 lines)",
|
||||||
|
tail: "Tail logs from all services in real-time",
|
||||||
|
restart: "Restart all services",
|
||||||
|
stop: "Stop all services",
|
||||||
|
start: "Start all services",
|
||||||
|
"ap-status": "Show status of AP service",
|
||||||
|
"ap-logs": "Show recent logs from AP service (last 50 lines)",
|
||||||
|
"ap-tail": "Tail logs from AP service in real-time",
|
||||||
|
"ap-restart": "Restart AP service",
|
||||||
|
"ap-stop": "Stop AP service",
|
||||||
|
"ap-start": "Start AP service",
|
||||||
|
"web-status": "Show status of web service",
|
||||||
|
"web-logs": "Show recent logs from web service (last 50 lines)",
|
||||||
|
"web-tail": "Tail logs from web service in real-time",
|
||||||
|
"web-restart": "Restart web service",
|
||||||
|
"web-stop": "Stop web service",
|
||||||
|
"web-start": "Start web service",
|
||||||
|
help: "Show this help message",
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = process.argv[2];
|
||||||
|
|
||||||
|
if (!command || command === "help") {
|
||||||
|
console.log(`
|
||||||
|
Phone CLI - Service Management Tool
|
||||||
|
|
||||||
|
Usage: bun cli <command>
|
||||||
|
|
||||||
|
All Services:
|
||||||
|
status Show status of all services
|
||||||
|
logs Show recent logs from all services (last 50 lines)
|
||||||
|
tail Tail logs from all services in real-time
|
||||||
|
restart Restart all services
|
||||||
|
stop Stop all services
|
||||||
|
start Start all services
|
||||||
|
|
||||||
|
AP Service (phone-ap):
|
||||||
|
ap-status Show AP status
|
||||||
|
ap-logs Show AP logs (last 50 lines)
|
||||||
|
ap-tail Tail AP logs in real-time
|
||||||
|
ap-restart Restart AP service
|
||||||
|
ap-stop Stop AP service
|
||||||
|
ap-start Start AP service
|
||||||
|
|
||||||
|
Web Service (phone-web):
|
||||||
|
web-status Show web status
|
||||||
|
web-logs Show web logs (last 50 lines)
|
||||||
|
web-tail Tail web logs in real-time
|
||||||
|
web-restart Restart web service
|
||||||
|
web-stop Stop web service
|
||||||
|
web-start Start web service
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bun cli status
|
||||||
|
bun cli ap-logs
|
||||||
|
bun cli web-tail
|
||||||
|
sudo bun cli ap-restart
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.keys(commands).includes(command)) {
|
||||||
|
console.error(`❌ Unknown command: ${command}`);
|
||||||
|
console.log(`Run 'bun cli.ts help' to see available commands`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🔧 Phone CLI - ${command}\n`);
|
||||||
|
|
||||||
|
// Parse service-specific commands
|
||||||
|
const match = command.match(/^(ap|web)-(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, prefix, action] = match;
|
||||||
|
const service = SERVICES[prefix as keyof typeof SERVICES];
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "status":
|
||||||
|
console.log(`━━━ ${service}.service ━━━`);
|
||||||
|
await $`systemctl status ${service}.service --no-pager -l`.nothrow();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "logs":
|
||||||
|
console.log(`📋 Recent logs (last 50 lines):\n`);
|
||||||
|
await $`journalctl -u ${service}.service -n 50 --no-pager`.nothrow();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tail":
|
||||||
|
console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
|
||||||
|
await $`journalctl -u ${service}.service -f --no-pager`.nothrow();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "restart":
|
||||||
|
console.log(`🔄 Restarting ${service}.service...\n`);
|
||||||
|
await $`sudo systemctl restart ${service}.service`;
|
||||||
|
console.log(`✓ ${service}.service restarted!`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stop":
|
||||||
|
console.log(`🛑 Stopping ${service}.service...\n`);
|
||||||
|
await $`sudo systemctl stop ${service}.service`;
|
||||||
|
console.log(`✓ ${service}.service stopped!`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
console.log(`▶️ Starting ${service}.service...\n`);
|
||||||
|
await $`sudo systemctl start ${service}.service`;
|
||||||
|
console.log(`✓ ${service}.service started!`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// All-services commands
|
||||||
|
const allServices = Object.values(SERVICES);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "status":
|
||||||
|
for (const service of allServices) {
|
||||||
|
console.log(`━━━ ${service}.service ━━━`);
|
||||||
|
await $`systemctl status ${service}.service --no-pager -l`.nothrow();
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "logs":
|
||||||
|
console.log("📋 Recent logs (last 50 lines):\n");
|
||||||
|
const serviceFlags = allServices.map(s => `-u ${s}.service`).join(" ");
|
||||||
|
await $`journalctl ${serviceFlags} -n 50 --no-pager`.nothrow();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tail":
|
||||||
|
console.log("📡 Tailing logs (Ctrl+C to stop)...\n");
|
||||||
|
const tailFlags = allServices.map(s => `-u ${s}.service`).join(" ");
|
||||||
|
await $`journalctl ${tailFlags} -f --no-pager`.nothrow();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "restart":
|
||||||
|
console.log("🔄 Restarting services...\n");
|
||||||
|
for (const service of allServices) {
|
||||||
|
console.log(`Restarting ${service}.service...`);
|
||||||
|
await $`sudo systemctl restart ${service}.service`;
|
||||||
|
console.log(`✓ ${service}.service restarted`);
|
||||||
|
}
|
||||||
|
console.log("\n✓ All services restarted!");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stop":
|
||||||
|
console.log("🛑 Stopping services...\n");
|
||||||
|
for (const service of allServices) {
|
||||||
|
console.log(`Stopping ${service}.service...`);
|
||||||
|
await $`sudo systemctl stop ${service}.service`;
|
||||||
|
console.log(`✓ ${service}.service stopped`);
|
||||||
|
}
|
||||||
|
console.log("\n✓ All services stopped!");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
console.log("▶️ Starting services...\n");
|
||||||
|
for (const service of allServices) {
|
||||||
|
console.log(`Starting ${service}.service...`);
|
||||||
|
await $`sudo systemctl start ${service}.service`;
|
||||||
|
console.log(`✓ ${service}.service started`);
|
||||||
|
}
|
||||||
|
console.log("\n✓ All services started!");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
130
scripts/cli.ts
130
scripts/cli.ts
|
|
@ -1,130 +0,0 @@
|
||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import {$} from "bun";
|
|
||||||
|
|
||||||
const SERVICES = {
|
|
||||||
ap: "phone-ap",
|
|
||||||
web: "phone-web",
|
|
||||||
phone: "phone",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const COMMANDS = {
|
|
||||||
status: "Show service status",
|
|
||||||
logs: "Show recent logs (last 50 lines)",
|
|
||||||
tail: "Tail logs in real-time",
|
|
||||||
restart: "Restart service (requires sudo)",
|
|
||||||
stop: "Stop service (requires sudo)",
|
|
||||||
start: "Start service (requires sudo)",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const showHelp = () => {
|
|
||||||
console.log(`
|
|
||||||
Phone CLI - Service Management
|
|
||||||
|
|
||||||
Usage: cli SERVICE COMMAND [-v]
|
|
||||||
|
|
||||||
Services:
|
|
||||||
ap WiFi AP Monitor (phone-ap.service)
|
|
||||||
web Web Server (phone-web.service)
|
|
||||||
phone Phone Application (phone.service)
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
status Show service status
|
|
||||||
logs Show recent logs (last 50 lines)
|
|
||||||
tail Tail logs in real-time
|
|
||||||
restart Restart service (requires sudo)
|
|
||||||
stop Stop service (requires sudo)
|
|
||||||
start Start service (requires sudo)
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-v Verbose mode - show actual systemd commands
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
cli ap status
|
|
||||||
cli web logs
|
|
||||||
cli phone tail
|
|
||||||
cli -v ap status
|
|
||||||
sudo cli ap restart
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse arguments
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
// Check for help
|
|
||||||
if (args.length === 0 || args[0] === "help") {
|
|
||||||
showHelp();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract verbose flag and remaining args
|
|
||||||
const verbose = args.includes("-v");
|
|
||||||
const [service, command] = args.filter(arg => arg !== "-v");
|
|
||||||
|
|
||||||
// Validate service
|
|
||||||
if (!service || !(service in SERVICES)) {
|
|
||||||
console.error(`❌ Unknown service: ${service || "(missing)"}`);
|
|
||||||
console.log(`Available services: ${Object.keys(SERVICES).join(", ")}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate command
|
|
||||||
if (!command || !(command in COMMANDS)) {
|
|
||||||
console.error(`❌ Unknown command: ${command || "(missing)"}`);
|
|
||||||
console.log(`Available commands: ${Object.keys(COMMANDS).join(", ")}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get systemd service name
|
|
||||||
const serviceName = SERVICES[service as keyof typeof SERVICES];
|
|
||||||
|
|
||||||
// Execute command
|
|
||||||
console.log(`\n🔧 Phone CLI - ${service} ${command}\n`);
|
|
||||||
|
|
||||||
const logCommand = (cmd: string) => {
|
|
||||||
if (verbose) {
|
|
||||||
console.log(`→ ${cmd}\n`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (command) {
|
|
||||||
case "status":
|
|
||||||
logCommand(`systemctl status ${serviceName}.service --no-pager -l`);
|
|
||||||
await $`systemctl status ${serviceName}.service --no-pager -l`.nothrow();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "logs":
|
|
||||||
console.log(`📋 Recent logs (last 50 lines):\n`);
|
|
||||||
logCommand(`journalctl -u ${serviceName}.service -n 50 --no-pager`);
|
|
||||||
await $`journalctl -u ${serviceName}.service -n 50 --no-pager`.nothrow();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tail":
|
|
||||||
console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
|
|
||||||
logCommand(`journalctl -u ${serviceName}.service -f --no-pager`);
|
|
||||||
await $`journalctl -u ${serviceName}.service -f --no-pager`.nothrow();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "restart":
|
|
||||||
console.log(`🔄 Restarting ${serviceName}.service...\n`);
|
|
||||||
logCommand(`sudo systemctl restart ${serviceName}.service`);
|
|
||||||
await $`sudo systemctl restart ${serviceName}.service`;
|
|
||||||
console.log(`✓ ${serviceName}.service restarted!`);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stop":
|
|
||||||
console.log(`🛑 Stopping ${serviceName}.service...\n`);
|
|
||||||
logCommand(`sudo systemctl stop ${serviceName}.service`);
|
|
||||||
await $`sudo systemctl stop ${serviceName}.service`;
|
|
||||||
console.log(`✓ ${serviceName}.service stopped!`);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "start":
|
|
||||||
console.log(`▶️ Starting ${serviceName}.service...\n`);
|
|
||||||
logCommand(`sudo systemctl start ${serviceName}.service`);
|
|
||||||
await $`sudo systemctl start ${serviceName}.service`;
|
|
||||||
console.log(`✓ ${serviceName}.service started!`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("");
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
const defaultUser = process.env.USER ?? "corey"
|
|
||||||
const PI_HOST = process.env.PI_HOST ?? "phone.local"
|
const PI_HOST = process.env.PI_HOST ?? "phone.local"
|
||||||
const PI_DIR = process.env.PI_DIR ?? `/home/${defaultUser}/phone`
|
const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone"
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const shouldBootstrap = process.argv.includes("--bootstrap")
|
const shouldBootstrap = process.argv.includes("--bootstrap")
|
||||||
|
|
@ -41,10 +40,22 @@ if (shouldBootstrap) {
|
||||||
// make console beep
|
// make console beep
|
||||||
await $`afplay /System/Library/Sounds/Blow.aiff`
|
await $`afplay /System/Library/Sounds/Blow.aiff`
|
||||||
|
|
||||||
// Always set up services on every deploy
|
// Always check if services exist and restart them (whether we bootstrapped or not)
|
||||||
console.log("Setting up services...")
|
console.log("Checking for existing services...")
|
||||||
await $`ssh ${PI_HOST} "sudo bun ${PI_DIR}/scripts/setup-services.ts ${PI_DIR}"`
|
const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"`
|
||||||
console.log("✓ Services configured and running\n")
|
.nothrow()
|
||||||
|
.quiet()
|
||||||
|
const webServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-web.service"`
|
||||||
|
.nothrow()
|
||||||
|
.quiet()
|
||||||
|
|
||||||
|
if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) {
|
||||||
|
console.log("Restarting services...")
|
||||||
|
await $`ssh ${PI_HOST} "sudo systemctl restart phone-ap.service phone-web.service"`
|
||||||
|
console.log("✓ Services restarted\n")
|
||||||
|
} else if (!shouldBootstrap) {
|
||||||
|
console.log("Services not installed. Run with --bootstrap to install.\n")
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
✓ Deploy complete!
|
✓ Deploy complete!
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { setupServices } from "./bootstrap-services"
|
|
||||||
|
|
||||||
// Get install directory from argument or use default
|
|
||||||
const defaultUser = process.env.USER || "corey"
|
|
||||||
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
|
|
||||||
|
|
||||||
console.log(`Setting up services for: ${INSTALL_DIR}`)
|
|
||||||
|
|
||||||
await setupServices(INSTALL_DIR)
|
|
||||||
|
|
||||||
console.log(`
|
|
||||||
✓ Services configured and running!
|
|
||||||
`)
|
|
||||||
BIN
sounds/stalling/sigh2.wav
Normal file
BIN
sounds/stalling/sigh2.wav
Normal file
Binary file not shown.
BIN
sounds/typing/typing1.wav
Normal file
BIN
sounds/typing/typing1.wav
Normal file
Binary file not shown.
|
|
@ -1,12 +1,12 @@
|
||||||
# Agent
|
# Agent
|
||||||
|
|
||||||
A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses events and provides simple tool registration.
|
A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses Signal-based events and provides simple tool registration.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Agent } from "./pi/agent"
|
import { Agent } from './pi/agent'
|
||||||
import Buzz from "./pi/buzz"
|
import Buzz from './pi/buzz'
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
agentId: process.env.ELEVEN_AGENT_ID!,
|
agentId: process.env.ELEVEN_AGENT_ID!,
|
||||||
|
|
@ -14,24 +14,27 @@ const agent = new Agent({
|
||||||
tools: {
|
tools: {
|
||||||
search_web: async (args) => {
|
search_web: async (args) => {
|
||||||
return { results: [`Result for ${args.query}`] }
|
return { results: [`Result for ${args.query}`] }
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
const player = await Buzz.player()
|
const player = await Buzz.defaultPlayer()
|
||||||
let playback = player.playStream()
|
let playback = player.playStream()
|
||||||
|
|
||||||
agent.events.on((event) => {
|
agent.events.connect((event) => {
|
||||||
if (event.type === "audio") {
|
if (event.type === 'audio') {
|
||||||
const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
const audioBuffer = Buffer.from(event.audioBase64, 'base64')
|
||||||
if (!playback.isPlaying) playback = player.playStream()
|
if (!playback.isPlaying) playback = player.playStream()
|
||||||
playback.write(audioBuffer)
|
playback.write(audioBuffer)
|
||||||
} else if (event.type === "interruption") {
|
}
|
||||||
|
else if (event.type === 'interruption') {
|
||||||
playback.stop()
|
playback.stop()
|
||||||
} else if (event.type === "user_transcript") {
|
}
|
||||||
|
else if (event.type === 'user_transcript') {
|
||||||
console.log(`User: ${event.transcript}`)
|
console.log(`User: ${event.transcript}`)
|
||||||
} else if (event.type === "agent_response") {
|
}
|
||||||
|
else if (event.type === 'agent_response') {
|
||||||
console.log(`Agent: ${event.response}`)
|
console.log(`Agent: ${event.response}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -40,7 +43,7 @@ agent.events.on((event) => {
|
||||||
await agent.start()
|
await agent.start()
|
||||||
|
|
||||||
// Continuously stream audio
|
// Continuously stream audio
|
||||||
const recorder = await Buzz.recorder()
|
const recorder = await Buzz.defaultRecorder()
|
||||||
const recording = recorder.start()
|
const recording = recorder.start()
|
||||||
for await (const chunk of recording.stream()) {
|
for await (const chunk of recording.stream()) {
|
||||||
agent.sendAudio(chunk)
|
agent.sendAudio(chunk)
|
||||||
|
|
@ -50,7 +53,7 @@ for await (const chunk of recording.stream()) {
|
||||||
## VAD Pattern
|
## VAD Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const recorder = await Buzz.recorder()
|
const recorder = await Buzz.defaultRecorder()
|
||||||
const recording = recorder.start()
|
const recording = recorder.start()
|
||||||
const buffer = new RollingBuffer()
|
const buffer = new RollingBuffer()
|
||||||
|
|
||||||
|
|
@ -65,7 +68,7 @@ for await (const chunk of recording.stream()) {
|
||||||
if (rms > vadThreshold) {
|
if (rms > vadThreshold) {
|
||||||
// Speech detected! Start conversation
|
// Speech detected! Start conversation
|
||||||
agent = new Agent({ agentId, apiKey, tools })
|
agent = new Agent({ agentId, apiKey, tools })
|
||||||
agent.events.on(eventHandler)
|
agent.events.connect(eventHandler)
|
||||||
await agent.start()
|
await agent.start()
|
||||||
|
|
||||||
// Send buffered audio
|
// Send buffered audio
|
||||||
|
|
@ -109,7 +112,7 @@ new Agent({
|
||||||
|
|
||||||
### Properties
|
### Properties
|
||||||
|
|
||||||
- `agent.events: Emitter<AgentEvent>` - Connect to receive all events
|
- `agent.events: Signal<AgentEvent>` - Connect to receive all events
|
||||||
- `agent.isConnected: boolean` - Current connection state
|
- `agent.isConnected: boolean` - Current connection state
|
||||||
- `agent.conversationId?: string` - Available after connected event
|
- `agent.conversationId?: string` - Available after connected event
|
||||||
|
|
||||||
|
|
@ -118,13 +121,11 @@ new Agent({
|
||||||
All events are emitted through `agent.events`:
|
All events are emitted through `agent.events`:
|
||||||
|
|
||||||
### Connection
|
### Connection
|
||||||
|
|
||||||
- `{ type: 'connected', conversationId, audioFormat }`
|
- `{ type: 'connected', conversationId, audioFormat }`
|
||||||
- `{ type: 'disconnected' }`
|
- `{ type: 'disconnected' }`
|
||||||
- `{ type: 'error', error }`
|
- `{ type: 'error', error }`
|
||||||
|
|
||||||
### Conversation
|
### Conversation
|
||||||
|
|
||||||
- `{ type: 'user_transcript', transcript }`
|
- `{ type: 'user_transcript', transcript }`
|
||||||
- `{ type: 'agent_response', response }`
|
- `{ type: 'agent_response', response }`
|
||||||
- `{ type: 'agent_response_correction', original, corrected }`
|
- `{ type: 'agent_response_correction', original, corrected }`
|
||||||
|
|
@ -133,13 +134,11 @@ All events are emitted through `agent.events`:
|
||||||
- `{ type: 'interruption', eventId }`
|
- `{ type: 'interruption', eventId }`
|
||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
|
|
||||||
- `{ type: 'tool_call', name, args, callId }`
|
- `{ type: 'tool_call', name, args, callId }`
|
||||||
- `{ type: 'tool_result', name, result, callId }`
|
- `{ type: 'tool_result', name, result, callId }`
|
||||||
- `{ type: 'tool_error', name, error, callId }`
|
- `{ type: 'tool_error', name, error, callId }`
|
||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- `{ type: 'vad_score', score }`
|
- `{ type: 'vad_score', score }`
|
||||||
- `{ type: 'ping', eventId, pingMs }`
|
- `{ type: 'ping', eventId, pingMs }`
|
||||||
|
|
||||||
|
|
@ -147,7 +146,7 @@ All events are emitted through `agent.events`:
|
||||||
|
|
||||||
- **Generic**: Not tied to phone systems, works in any context
|
- **Generic**: Not tied to phone systems, works in any context
|
||||||
- **Flexible audio**: You control when to send audio, Agent just handles WebSocket
|
- **Flexible audio**: You control when to send audio, Agent just handles WebSocket
|
||||||
- **Event-driven**: All communication through events, no throws
|
- **Event-driven**: All communication through Signal events, no throws
|
||||||
- **Simple tools**: Just pass a function map to constructor
|
- **Simple tools**: Just pass a function map to constructor
|
||||||
- **Automatic buffering**: Sends buffered audio when connection opens
|
- **Automatic buffering**: Sends buffered audio when connection opens
|
||||||
- **Automatic chunking**: Handles 8000-byte chunking internally
|
- **Automatic chunking**: Handles 8000-byte chunking internally
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Emitter } from "../utils/emitter"
|
import { Signal } from "../utils/signal"
|
||||||
import type { AgentConfig, AgentEvent } from "./types"
|
import type { AgentConfig, AgentEvent } from "./types"
|
||||||
|
|
||||||
type AgentState = "disconnected" | "connecting" | "connected"
|
type AgentState = "disconnected" | "connecting" | "connected"
|
||||||
|
|
@ -11,7 +11,7 @@ export class Agent {
|
||||||
#chunkBuffer = new Uint8Array(0)
|
#chunkBuffer = new Uint8Array(0)
|
||||||
#chunkSize = 8000
|
#chunkSize = 8000
|
||||||
|
|
||||||
public readonly events = new Emitter<AgentEvent>()
|
public readonly events = new Signal<AgentEvent>()
|
||||||
public conversationId?: string
|
public conversationId?: string
|
||||||
|
|
||||||
constructor(config: AgentConfig) {
|
constructor(config: AgentConfig) {
|
||||||
|
|
@ -255,7 +255,7 @@ export class Agent {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleClose = (event: CloseEvent): void => {
|
#handleClose = (): void => {
|
||||||
this.#cleanup()
|
this.#cleanup()
|
||||||
this.events.emit({ type: "disconnected" })
|
this.events.emit({ type: "disconnected" })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,528 +0,0 @@
|
||||||
# Buzz
|
|
||||||
|
|
||||||
High-level audio library for Bun using ALSA with streaming support and voice activity detection.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Play audio files with repeat option
|
|
||||||
- Generate and play multi-frequency tones (dial tones, DTMF, etc.)
|
|
||||||
- Stream audio playback with buffer tracking
|
|
||||||
- Record audio to stream or file (WAV)
|
|
||||||
- Volume control via ALSA mixer
|
|
||||||
- Device discovery and selection
|
|
||||||
- Voice activity detection via RMS calculation
|
|
||||||
- Type-safe TypeScript API with namespace types
|
|
||||||
- Zero external dependencies (uses ALSA `aplay` and `arecord`)
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Bun 1.0+
|
|
||||||
- ALSA utilities (`aplay`, `arecord`, `amixer`)
|
|
||||||
- Linux system with ALSA support
|
|
||||||
- TypeScript 5.2+
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
// Play an audio file
|
|
||||||
const player = await Buzz.player()
|
|
||||||
const playback = await player.play("./sounds/greeting.wav")
|
|
||||||
await playback.finished()
|
|
||||||
|
|
||||||
// Generate a dial tone
|
|
||||||
const dialTone = await player.playTone([350, 440], Infinity) // infinite duration
|
|
||||||
await Buzz.sleep(3000)
|
|
||||||
await dialTone.stop()
|
|
||||||
|
|
||||||
// Record audio
|
|
||||||
const recorder = await Buzz.recorder()
|
|
||||||
const recording = recorder.start()
|
|
||||||
|
|
||||||
for await (const chunk of recording.stream()) {
|
|
||||||
const rms = Buzz.calculateRMS(chunk)
|
|
||||||
if (rms > 5000) {
|
|
||||||
console.log("Speech detected!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### Buzz Module
|
|
||||||
|
|
||||||
#### `Buzz.player(label?, format?)`
|
|
||||||
|
|
||||||
Create a player. Omit `label` to use the default playback device.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const player = await Buzz.player() // default device
|
|
||||||
const player = await Buzz.player(undefined, { sampleRate: 16000 }) // default device with custom format
|
|
||||||
const player = await Buzz.player("USB Audio") // specific device
|
|
||||||
const player = await Buzz.player("Speaker", { sampleRate: 44100 }) // specific device with format
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Buzz.recorder(label?, format?)`
|
|
||||||
|
|
||||||
Create a recorder. Omit `label` to use the default capture device.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const recorder = await Buzz.recorder() // default device
|
|
||||||
const recorder = await Buzz.recorder(undefined, { sampleRate: 16000 }) // default device with custom format
|
|
||||||
const recorder = await Buzz.recorder("USB Microphone") // specific device
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Buzz.setVolume(volume, label?)`
|
|
||||||
|
|
||||||
Set playback volume (0.0 to 1.0).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await Buzz.setVolume(0.5) // 50% on default device
|
|
||||||
await Buzz.setVolume(0.8, "Speaker") // 80% on specific device
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Buzz.getVolume(label?)`
|
|
||||||
|
|
||||||
Get current playback volume.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const volume = await Buzz.getVolume() // returns 0.0 to 1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Buzz.listDevices()`
|
|
||||||
|
|
||||||
List all available audio devices.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const devices = await Buzz.listDevices()
|
|
||||||
// [
|
|
||||||
// { id: 'plughw:0,0', card: 0, device: 0, label: 'bcm2835 Headphones', type: 'playback' },
|
|
||||||
// { id: 'plughw:1,0', card: 1, device: 0, label: 'USB Audio', type: 'capture' }
|
|
||||||
// ]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Buzz.calculateRMS(audioChunk)`
|
|
||||||
|
|
||||||
Calculate root mean square (RMS) for voice activity detection.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const chunk: Uint8Array = // ... audio data
|
|
||||||
const rms = Buzz.calculateRMS(chunk)
|
|
||||||
if (rms > 5000) {
|
|
||||||
console.log("Voice detected!")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Player
|
|
||||||
|
|
||||||
#### `player.play(filePath, options?)`
|
|
||||||
|
|
||||||
Play an audio file (WAV format).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const playback = await player.play("./sounds/beep.wav")
|
|
||||||
const playback = await player.play("./music.wav", { repeat: true })
|
|
||||||
|
|
||||||
// Wait for playback to finish
|
|
||||||
await playback.finished()
|
|
||||||
|
|
||||||
// Stop playback
|
|
||||||
await playback.stop()
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns: `Buzz.Playback`
|
|
||||||
|
|
||||||
Options:
|
|
||||||
- `repeat?: boolean` - Loop the file indefinitely (default: false)
|
|
||||||
|
|
||||||
#### `player.playTone(frequencies, duration)`
|
|
||||||
|
|
||||||
Generate and play a tone with one or more frequencies.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Dial tone (350 Hz + 440 Hz)
|
|
||||||
const dialTone = await player.playTone([350, 440], Infinity)
|
|
||||||
|
|
||||||
// DTMF "1" key (697 Hz + 1209 Hz) for 200ms
|
|
||||||
const dtmf = await player.playTone([697, 1209], 200)
|
|
||||||
|
|
||||||
// Single frequency beep
|
|
||||||
const beep = await player.playTone([440], 1000) // 440 Hz for 1 second
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns: `Buzz.Playback`
|
|
||||||
|
|
||||||
#### `player.playStream()`
|
|
||||||
|
|
||||||
Create a streaming playback handle for real-time audio.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const stream = player.playStream()
|
|
||||||
|
|
||||||
// Write audio chunks
|
|
||||||
stream.write(audioChunk1)
|
|
||||||
stream.write(audioChunk2)
|
|
||||||
|
|
||||||
// Check if buffer is empty
|
|
||||||
if (stream.bufferEmptyFor > 1000) {
|
|
||||||
console.log("Buffer empty for 1+ seconds")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop streaming
|
|
||||||
await stream.stop()
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns: `Buzz.StreamingPlayback`
|
|
||||||
|
|
||||||
### Recorder
|
|
||||||
|
|
||||||
#### `recorder.start()`
|
|
||||||
|
|
||||||
Start recording to a stream.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const recording = recorder.start()
|
|
||||||
|
|
||||||
for await (const chunk of recording.stream()) {
|
|
||||||
// Process audio chunks (Uint8Array)
|
|
||||||
console.log("Received", chunk.byteLength, "bytes")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns: `Buzz.StreamingRecording`
|
|
||||||
|
|
||||||
#### `recorder.start(outputFile)`
|
|
||||||
|
|
||||||
Start recording to a WAV file.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const recording = recorder.start("./output.wav")
|
|
||||||
|
|
||||||
// Stop when done
|
|
||||||
await Bun.sleep(5000)
|
|
||||||
await recording.stop()
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns: `Buzz.FileRecording`
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
All types are available under the `Buzz` namespace:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
Buzz.AudioFormat // { format?, sampleRate?, channels? }
|
|
||||||
Buzz.Device // { id, card, device, label, type }
|
|
||||||
Buzz.Playback // { isPlaying, stop(), finished() }
|
|
||||||
Buzz.StreamingPlayback // { isPlaying, write(), stop(), bufferEmptyFor }
|
|
||||||
Buzz.StreamingRecording // { isRecording, stream(), stop() }
|
|
||||||
Buzz.FileRecording // { isRecording, stop() }
|
|
||||||
Buzz.Player // Player class type
|
|
||||||
Buzz.Recorder // Recorder class type
|
|
||||||
```
|
|
||||||
|
|
||||||
## Audio Format
|
|
||||||
|
|
||||||
Default format: `S16_LE`, 16000 Hz, mono
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type AudioFormat = {
|
|
||||||
format?: string // e.g., "S16_LE", "S32_LE"
|
|
||||||
sampleRate?: number // e.g., 16000, 44100, 48000
|
|
||||||
channels?: number // 1 = mono, 2 = stereo
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Common formats:
|
|
||||||
- **Phone quality**: `{ sampleRate: 8000, channels: 1 }`
|
|
||||||
- **Voice/AI**: `{ sampleRate: 16000, channels: 1 }` (default)
|
|
||||||
- **CD quality**: `{ sampleRate: 44100, channels: 2 }`
|
|
||||||
- **Professional**: `{ sampleRate: 48000, channels: 2 }`
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Voice Activity Detection
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
const recorder = await Buzz.recorder()
|
|
||||||
const player = await Buzz.player()
|
|
||||||
|
|
||||||
const recording = recorder.start()
|
|
||||||
let talking = false
|
|
||||||
|
|
||||||
for await (const chunk of recording.stream()) {
|
|
||||||
const rms = Buzz.calculateRMS(chunk)
|
|
||||||
|
|
||||||
if (rms > 5000 && !talking) {
|
|
||||||
console.log("🗣️ Started talking")
|
|
||||||
talking = true
|
|
||||||
} else if (rms < 1000 && talking) {
|
|
||||||
console.log("🤫 Stopped talking")
|
|
||||||
talking = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Streaming Playback with Buffer Tracking
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
const player = await Buzz.player()
|
|
||||||
const stream = player.playStream()
|
|
||||||
|
|
||||||
// Simulate receiving audio chunks from network
|
|
||||||
const chunks = [chunk1, chunk2, chunk3] // Uint8Array[]
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
stream.write(chunk)
|
|
||||||
|
|
||||||
// Wait until buffer is nearly empty before requesting more
|
|
||||||
while (stream.bufferEmptyFor < 500) {
|
|
||||||
await Bun.sleep(100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await stream.stop()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dial Tone with Voice Detection
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
await Buzz.setVolume(0.4)
|
|
||||||
|
|
||||||
const player = await Buzz.player()
|
|
||||||
const recorder = await Buzz.recorder()
|
|
||||||
|
|
||||||
// Play dial tone
|
|
||||||
const dialTone = await player.playTone([350, 440], Infinity)
|
|
||||||
|
|
||||||
// Wait for voice
|
|
||||||
const recording = recorder.start()
|
|
||||||
const vadThreshold = 5000
|
|
||||||
|
|
||||||
for await (const chunk of recording.stream()) {
|
|
||||||
const rms = Buzz.calculateRMS(chunk)
|
|
||||||
|
|
||||||
if (rms > vadThreshold) {
|
|
||||||
console.log("Voice detected, stopping dial tone")
|
|
||||||
await dialTone.stop()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Play Sound Effects
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
const player = await Buzz.player()
|
|
||||||
|
|
||||||
// Play multiple sounds in sequence
|
|
||||||
const sounds = ["./start.wav", "./beep.wav", "./end.wav"]
|
|
||||||
|
|
||||||
for (const sound of sounds) {
|
|
||||||
const playback = await player.play(sound)
|
|
||||||
await playback.finished()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Background Music Loop
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
const player = await Buzz.player()
|
|
||||||
|
|
||||||
// Play background music on repeat
|
|
||||||
const bgMusic = await player.play("./background.wav", { repeat: true })
|
|
||||||
|
|
||||||
// Stop after 30 seconds
|
|
||||||
await Bun.sleep(30000)
|
|
||||||
await bgMusic.stop()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Record to File
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
const recorder = await Buzz.recorder()
|
|
||||||
|
|
||||||
console.log("Recording for 10 seconds...")
|
|
||||||
const recording = recorder.start("./output.wav")
|
|
||||||
|
|
||||||
await Bun.sleep(10000)
|
|
||||||
await recording.stop()
|
|
||||||
|
|
||||||
console.log("Saved to output.wav")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-Device Setup
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import Buzz from "./buzz"
|
|
||||||
|
|
||||||
// List all devices
|
|
||||||
const devices = await Buzz.listDevices()
|
|
||||||
console.log("Available devices:", devices)
|
|
||||||
|
|
||||||
// Use specific devices
|
|
||||||
const speaker = await Buzz.player("Speaker")
|
|
||||||
const mic = await Buzz.recorder("USB Microphone")
|
|
||||||
|
|
||||||
// Independent volume control
|
|
||||||
await Buzz.setVolume(0.8, "Speaker")
|
|
||||||
await Buzz.setVolume(1.0, "Headphones")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### ALSA Backend
|
|
||||||
|
|
||||||
Buzz wraps ALSA command-line tools (`aplay`, `arecord`) via Bun's subprocess API:
|
|
||||||
|
|
||||||
- **Playback**: Spawns `aplay` with stdin pipe for streaming or file path for file playback
|
|
||||||
- **Recording**: Spawns `arecord` with stdout pipe for streaming or file path for WAV output
|
|
||||||
- **Volume**: Uses `amixer` for volume control
|
|
||||||
|
|
||||||
**Benefits:**
|
|
||||||
|
|
||||||
- **Simple**: No C bindings or FFI required
|
|
||||||
- **Reliable**: ALSA tools are battle-tested
|
|
||||||
- **Flexible**: Full format support (sample rates, channels, encodings)
|
|
||||||
- **Portable**: Works on any Linux system with ALSA
|
|
||||||
|
|
||||||
### Streaming Architecture
|
|
||||||
|
|
||||||
Streaming playback uses Bun's subprocess stdin pipe:
|
|
||||||
|
|
||||||
1. Spawn `aplay` with raw audio format and stdin input
|
|
||||||
2. Write audio chunks to process stdin as they arrive
|
|
||||||
3. Track buffer duration based on bytes written
|
|
||||||
4. Calculate `bufferEmptyFor` using performance timestamps
|
|
||||||
|
|
||||||
This enables:
|
|
||||||
- Real-time playback of network streams (WebSocket, API responses)
|
|
||||||
- Buffer management for smooth playback
|
|
||||||
- Low-latency audio (<100ms with proper buffering)
|
|
||||||
|
|
||||||
### Voice Activity Detection
|
|
||||||
|
|
||||||
`calculateRMS()` computes the root mean square of audio samples:
|
|
||||||
|
|
||||||
```
|
|
||||||
RMS = sqrt(sum(sample²) / count)
|
|
||||||
```
|
|
||||||
|
|
||||||
This provides a simple but effective measure of audio energy:
|
|
||||||
- Silence: RMS < 1000
|
|
||||||
- Noise: RMS 1000-5000
|
|
||||||
- Speech: RMS > 5000
|
|
||||||
|
|
||||||
Adjust thresholds based on your microphone and environment.
|
|
||||||
|
|
||||||
## Device Selection
|
|
||||||
|
|
||||||
### By Default (Recommended)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const player = await Buzz.player()
|
|
||||||
const recorder = await Buzz.recorder()
|
|
||||||
```
|
|
||||||
|
|
||||||
Uses ALSA default device (usually correct).
|
|
||||||
|
|
||||||
### By Label
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const devices = await Buzz.listDevices()
|
|
||||||
// Find device with label containing "USB"
|
|
||||||
const usbDevice = devices.find(d => d.label.includes("USB"))
|
|
||||||
|
|
||||||
const player = await Buzz.player(usbDevice.label)
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful for multi-device setups (USB audio, HDMI, headphones).
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
const player = await Buzz.player()
|
|
||||||
} catch (err) {
|
|
||||||
if (err.message.includes("No playback devices found")) {
|
|
||||||
console.error("No audio output devices available")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Buzz.setVolume(0.5)
|
|
||||||
} catch (err) {
|
|
||||||
if (err.message.includes("Failed to set volume")) {
|
|
||||||
console.error("Could not control volume (check mixer permissions)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### No devices found
|
|
||||||
|
|
||||||
Check ALSA devices:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
aplay -l # list playback devices
|
|
||||||
arecord -l # list capture devices
|
|
||||||
```
|
|
||||||
|
|
||||||
### Volume control fails
|
|
||||||
|
|
||||||
Check mixer controls:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
amixer scontrols
|
|
||||||
amixer sget Master
|
|
||||||
```
|
|
||||||
|
|
||||||
### Crackling or distortion
|
|
||||||
|
|
||||||
Try different buffer sizes by adjusting format:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const player = await Buzz.player(undefined, {
|
|
||||||
sampleRate: 16000,
|
|
||||||
channels: 1,
|
|
||||||
format: "S16_LE"
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Device already in use
|
|
||||||
|
|
||||||
Only one process can use an ALSA device at a time. Stop other audio applications or use PulseAudio/PipeWire for mixing.
|
|
||||||
|
|
||||||
## Design Philosophy
|
|
||||||
|
|
||||||
- **Simple by default** - `player()` and `recorder()` work out of the box without arguments
|
|
||||||
- **Streaming-first** - Built for real-time audio (AI voice, telephony, WebRTC)
|
|
||||||
- **Type-safe** - Namespace types provide autocomplete and compile-time safety
|
|
||||||
- **Flexible** - Support for files, tones, and streams
|
|
||||||
- **Minimal dependencies** - Uses standard ALSA tools, no native bindings
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Latency**: ~50-100ms for streaming playback (depends on buffering)
|
|
||||||
- **CPU**: Minimal overhead (subprocess spawning + pipe I/O)
|
|
||||||
- **Memory**: Efficient streaming (no need to load entire files)
|
|
||||||
- **Voice detection**: `calculateRMS()` is fast (~1µs per chunk on modern hardware)
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ALSA documentation](https://www.alsa-project.org/wiki/Main_Page)
|
|
||||||
- [Bun subprocess API](https://bun.sh/docs/api/spawn)
|
|
||||||
- [Audio sample formats](https://en.wikipedia.org/wiki/Audio_bit_depth)
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
import { Player as PlayerClass } from "./player.js"
|
import { Player } from "./player.js"
|
||||||
import { Recorder as RecorderClass } from "./recorder.js"
|
import { Recorder } from "./recorder.js"
|
||||||
import {
|
import {
|
||||||
listDevices,
|
listDevices,
|
||||||
calculateRMS,
|
calculateRMS,
|
||||||
findDeviceByLabel,
|
findDeviceByLabel,
|
||||||
type AudioFormat as AudioFormatType,
|
type AudioFormat,
|
||||||
type Device as DeviceType,
|
type Device,
|
||||||
type Playback as PlaybackType,
|
|
||||||
type StreamingPlayback as StreamingPlaybackType,
|
|
||||||
type StreamingRecording as StreamingRecordingType,
|
|
||||||
type FileRecording as FileRecordingType,
|
|
||||||
} from "./utils.js"
|
} from "./utils.js"
|
||||||
|
|
||||||
const player = (label?: string, format?: AudioFormatType) => PlayerClass.create({ label, format })
|
const defaultPlayer = (format?: AudioFormat) => Player.create({ format })
|
||||||
|
|
||||||
const recorder = (label?: string, format?: AudioFormatType) =>
|
const player = (label: string, format?: AudioFormat) => Player.create({ label, format })
|
||||||
RecorderClass.create({ label, format })
|
|
||||||
|
const defaultRecorder = (format?: AudioFormat) => Recorder.create({ format })
|
||||||
|
|
||||||
|
const recorder = (label: string, format?: AudioFormat) => Recorder.create({ label, format })
|
||||||
|
|
||||||
const getVolumeControl = async (cardNumber?: number): Promise<string> => {
|
const getVolumeControl = async (cardNumber?: number): Promise<string> => {
|
||||||
const output = cardNumber
|
const output = cardNumber
|
||||||
|
|
@ -81,20 +80,16 @@ const getVolume = async (label?: string): Promise<number> => {
|
||||||
|
|
||||||
const Buzz = {
|
const Buzz = {
|
||||||
listDevices,
|
listDevices,
|
||||||
|
defaultPlayer,
|
||||||
player,
|
player,
|
||||||
|
defaultRecorder,
|
||||||
recorder,
|
recorder,
|
||||||
setVolume,
|
setVolume,
|
||||||
getVolume,
|
getVolume,
|
||||||
calculateRMS,
|
calculateRMS,
|
||||||
}
|
}
|
||||||
|
|
||||||
declare namespace Buzz {
|
|
||||||
export type Playback = PlaybackType
|
|
||||||
export type StreamingPlayback = StreamingPlaybackType
|
|
||||||
export type StreamingRecording = StreamingRecordingType
|
|
||||||
export type FileRecording = FileRecordingType
|
|
||||||
export type Player = PlayerClass
|
|
||||||
export type Recorder = RecorderClass
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Buzz
|
export default Buzz
|
||||||
|
export type { Device, AudioFormat }
|
||||||
|
export { type Player } from "./player.js"
|
||||||
|
export { type Recorder } from "./recorder.js"
|
||||||
|
|
|
||||||
47
src/hq.ts
Normal file
47
src/hq.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { GPIO } from "./pins"
|
||||||
|
|
||||||
|
console.log(`kill -9 ${process.pid}`)
|
||||||
|
|
||||||
|
const gpio = new GPIO({ resetOnClose: true })
|
||||||
|
|
||||||
|
// // Blink an LED
|
||||||
|
using led = gpio.output(21)
|
||||||
|
|
||||||
|
// Read a button
|
||||||
|
using inputs = gpio.inputGroup({
|
||||||
|
button: { pin: 20, pull: "up", debounce: 10 },
|
||||||
|
switch: { pin: 16, pull: "up", debounce: 10 }
|
||||||
|
})
|
||||||
|
|
||||||
|
led.value = inputs.pins.button.value
|
||||||
|
|
||||||
|
const iteratorEvents = new Promise(async (resolve) => {
|
||||||
|
for await (const event of inputs.events()) {
|
||||||
|
if (event.pin === "button") {
|
||||||
|
console.log(`🌭`, event.value)
|
||||||
|
led.value = event.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchEvent = new Promise<void>(async (resolve) => {
|
||||||
|
await inputs.pins.switch.waitForValue(0)
|
||||||
|
console.log("Switch pressed!")
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
inputs.close()
|
||||||
|
led.close()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
inputs.close()
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.race([iteratorEvents, switchEvent])
|
||||||
|
|
||||||
|
console.log(`👋 Goodbye!`)
|
||||||
26
src/main.ts
26
src/main.ts
|
|
@ -1,26 +0,0 @@
|
||||||
import { runPhone } from "./phone"
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`☎️ Starting phone with pid=${process.pid}`)
|
|
||||||
try {
|
|
||||||
await runPhone(agentId, apiKey)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Error starting phone: ${(error as Error).message}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
console.log(`👋 Goodbye!`)
|
|
||||||
803
src/phone.ts
803
src/phone.ts
|
|
@ -1,24 +1,13 @@
|
||||||
import {
|
import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3"
|
||||||
d,
|
|
||||||
reduce,
|
|
||||||
createMachine,
|
|
||||||
state,
|
|
||||||
transition,
|
|
||||||
interpret,
|
|
||||||
action,
|
|
||||||
invoke,
|
|
||||||
type Service,
|
|
||||||
} from "robot3"
|
|
||||||
import { Baresip } from "./sip"
|
import { Baresip } from "./sip"
|
||||||
import log from "./utils/log"
|
import { log } from "./utils/log"
|
||||||
import { sleep } from "bun"
|
import { sleep } from "bun"
|
||||||
|
import { processStderr, processStdout } from "./utils/stdio"
|
||||||
import Buzz from "./buzz"
|
import Buzz from "./buzz"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import GPIO from "./pins"
|
import { GPIO } from "./pins"
|
||||||
import { Agent } from "./agent"
|
import { Agent } from "./agent"
|
||||||
import { searchWeb } from "./agent/tools"
|
import { searchWeb } from "./agent/tools"
|
||||||
import { ring } from "./utils"
|
|
||||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
|
|
||||||
|
|
||||||
type CancelableTask = () => void
|
type CancelableTask = () => void
|
||||||
|
|
||||||
|
|
@ -26,433 +15,431 @@ type PhoneContext = {
|
||||||
lastError?: string
|
lastError?: string
|
||||||
peer?: string
|
peer?: string
|
||||||
numberDialed: number
|
numberDialed: number
|
||||||
|
cancelDialTone?: CancelableTask
|
||||||
cancelRinger?: CancelableTask
|
cancelRinger?: CancelableTask
|
||||||
baresip: Baresip
|
baresip: Baresip
|
||||||
stopAgent?: CancelableTask
|
startAgent: () => CancelableTask
|
||||||
ringer: GPIO.Output
|
cancelAgent?: CancelableTask
|
||||||
agentId: string
|
|
||||||
agentKey: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PhoneService = Service<typeof phoneMachine>
|
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 },
|
||||||
|
})
|
||||||
|
|
||||||
const player = await Buzz.player()
|
export const startPhone = async (agentId: string, apiKey: string) => {
|
||||||
let dialTonePlayback: Buzz.Playback | undefined
|
await Buzz.setVolume(0.4)
|
||||||
|
log.info(`📞 Hook ${inputs.pins.hook.value}`)
|
||||||
export const runPhone = async (agentId: string, agentKey: string) => {
|
await handleInputEvents()
|
||||||
const gpio = new GPIO()
|
|
||||||
using ringer = gpio.output(17, { resetOnClose: true })
|
|
||||||
using hook = gpio.input(27, { pull: "up", debounce: 3 })
|
|
||||||
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
|
||||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
|
||||||
|
|
||||||
await Buzz.setVolume(0.3)
|
|
||||||
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
|
|
||||||
|
|
||||||
playStartRing(ringer)
|
|
||||||
|
|
||||||
const phoneService = interpret(phoneMachine, () => {})
|
|
||||||
listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber)
|
|
||||||
const baresip = await startBaresip(phoneService, hook, ringer)
|
|
||||||
phoneService.send({ type: "config", baresip, agentId, agentKey, ringer })
|
|
||||||
|
|
||||||
process.on("SIGINT", () => cleanup(baresip, ringer))
|
|
||||||
process.on("SIGTERM", () => cleanup(baresip, ringer))
|
|
||||||
|
|
||||||
// Keep process running
|
|
||||||
await new Promise(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenForPhoneEvents = (
|
const handleInputEvents = async () => {
|
||||||
phoneService: PhoneService,
|
let digit = 0
|
||||||
hook: GPIO.Input,
|
for await (const event of inputs.events()) {
|
||||||
rotaryInUse: GPIO.Input,
|
switch (event.pin) {
|
||||||
rotaryNumber: GPIO.Input,
|
case "hook":
|
||||||
) => {
|
const type = event.value == 0 ? "hang_up" : "pick_up"
|
||||||
hook.onChange((event) => {
|
log.info(`📞 Hook ${event.value} sending ${type}`)
|
||||||
const type = event.value == 0 ? "hang-up" : "pick-up"
|
if (type === "hang_up") {
|
||||||
log(`📞 Hook ${event.value} sending ${type}`)
|
ringer.value = 1
|
||||||
phoneService.send({ type })
|
} else {
|
||||||
})
|
ringer.value = 0
|
||||||
|
|
||||||
rotaryInUse.onChange((event) => {
|
|
||||||
if (event.value === 0) {
|
|
||||||
phoneService.send({ type: "dial-start" })
|
|
||||||
} else {
|
|
||||||
phoneService.send({ type: "dial-stop" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
rotaryNumber.onChange((event) => {
|
|
||||||
if (event.value === 1) {
|
|
||||||
phoneService.send({ type: "digit_increment" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer: GPIO.Output) => {
|
|
||||||
const baresipConfig = join(import.meta.dir, "..", "baresip")
|
|
||||||
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
|
||||||
|
|
||||||
baresip.registrationSuccess.on(async () => {
|
|
||||||
log("🐻 server connected")
|
|
||||||
if (hook.value === 0) {
|
|
||||||
phoneService.send({ type: "initialized" })
|
|
||||||
} else {
|
|
||||||
phoneService.send({ type: "pick-up" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
baresip.callReceived.on(({ contact }) => {
|
|
||||||
log(`🐻 incoming call from ${contact}`)
|
|
||||||
phoneService.send({ type: "incoming-call", from: contact })
|
|
||||||
})
|
|
||||||
|
|
||||||
baresip.callEstablished.on(({ contact }) => {
|
|
||||||
log(`🐻 call established with ${contact}`)
|
|
||||||
phoneService.send({ type: "answered" })
|
|
||||||
})
|
|
||||||
|
|
||||||
baresip.hungUp.on(() => {
|
|
||||||
log("🐻 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.on(async ({ message }) => {
|
|
||||||
log.error("🐻 error:", message)
|
|
||||||
phoneService.send({ type: "error", message })
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
await ring(ringer, 500)
|
|
||||||
await sleep(250)
|
|
||||||
}
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
return baresip
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = (baresip: Baresip, ringer: GPIO.Output) => {
|
|
||||||
try {
|
|
||||||
log("🛑 Shutting down, stopping agent process")
|
|
||||||
playExitRing(ringer)
|
|
||||||
baresip.kill()
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Error during shutdown:", error)
|
|
||||||
} finally {
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
|
|
||||||
ctx.lastError = event.message
|
|
||||||
log.error(`Phone Error: ${event.message}`)
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = (
|
|
||||||
ctx: PhoneContext,
|
|
||||||
event: { baresip: Baresip; agentId: string; agentKey: string; ringer: GPIO.Output },
|
|
||||||
) => {
|
|
||||||
ctx.baresip = event.baresip
|
|
||||||
ctx.agentId = event.agentId
|
|
||||||
ctx.agentKey = event.agentKey
|
|
||||||
ctx.ringer = event.ringer
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) => {
|
|
||||||
let streamPlayback = player.playStream()
|
|
||||||
|
|
||||||
const agent = new Agent({
|
|
||||||
agentId: ctx.agentId,
|
|
||||||
apiKey: ctx.agentKey,
|
|
||||||
tools: {
|
|
||||||
search_web: (args: { query: string }) => searchWeb(args.query),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
handleAgentEvents(service, agent, streamPlayback)
|
|
||||||
const stopListening = startListening(service, agent)
|
|
||||||
|
|
||||||
ctx.stopAgent = () => {
|
|
||||||
stopListening()
|
|
||||||
dialTonePlayback?.stop()
|
|
||||||
streamPlayback.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
const startListening = (service: Service<typeof phoneMachine>, agent: Agent) => {
|
|
||||||
const abortAgent = new AbortController()
|
|
||||||
|
|
||||||
new Promise<void>(async (resolve) => {
|
|
||||||
const recorder = await Buzz.recorder()
|
|
||||||
const listenPlayback = recorder.start()
|
|
||||||
let backgroundNoisePlayback: Buzz.Playback | undefined
|
|
||||||
let waitingForVoice = true
|
|
||||||
const maxPreBufferChunks = 4 // Keep ~1 second of audio before speech detection
|
|
||||||
|
|
||||||
let preConnectionBuffer: Uint8Array[] = []
|
|
||||||
|
|
||||||
agent.events.on(async (event) => {
|
|
||||||
if (event.type === "disconnected") abortAgent.abort()
|
|
||||||
})
|
|
||||||
|
|
||||||
for await (const chunk of listenPlayback.stream()) {
|
|
||||||
if (abortAgent.signal.aborted) {
|
|
||||||
agent.stop()
|
|
||||||
listenPlayback.stop()
|
|
||||||
backgroundNoisePlayback?.stop()
|
|
||||||
|
|
||||||
resolve()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (waitingForVoice) {
|
|
||||||
preConnectionBuffer.push(chunk)
|
|
||||||
if (preConnectionBuffer.length > maxPreBufferChunks) {
|
|
||||||
preConnectionBuffer.shift()
|
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
|
||||||
const rms = Buzz.calculateRMS(chunk)
|
case "rotaryInUse":
|
||||||
if (rms > 5000) {
|
if (event.value === 0) {
|
||||||
dialTonePlayback?.stop()
|
digit = 0
|
||||||
service.send({ type: "start-agent" })
|
} else {
|
||||||
waitingForVoice = false
|
log.info(`📞 Dialed digit: ${digit}`)
|
||||||
|
|
||||||
backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
|
|
||||||
|
|
||||||
await agent.start()
|
|
||||||
|
|
||||||
// Send pre-buffered audio
|
|
||||||
for (const chunk of preConnectionBuffer) agent.sendAudio(chunk)
|
|
||||||
preConnectionBuffer = []
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
agent.sendAudio(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => abortAgent.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAgentEvents = (
|
|
||||||
service: Service<typeof phoneMachine>,
|
|
||||||
agent: Agent,
|
|
||||||
streamPlayback: Buzz.StreamingPlayback,
|
|
||||||
) => {
|
|
||||||
const waitingIndicator = new WaitingSounds(player)
|
|
||||||
|
|
||||||
agent.events.on(async (event) => {
|
|
||||||
switch (event.type) {
|
|
||||||
case "connected":
|
|
||||||
log("🤖 Connected to AI agent\n")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case "user_transcript":
|
case "rotaryNumber":
|
||||||
log(`🤖 You: ${event.transcript}`)
|
if (event.value === 1) {
|
||||||
break
|
digit += 1
|
||||||
|
}
|
||||||
case "agent_response":
|
|
||||||
log(`🤖 Agent: ${event.response}`)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "audio":
|
|
||||||
await waitingIndicator.stop()
|
|
||||||
const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
|
||||||
streamPlayback.write(audioBuffer)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "interruption":
|
|
||||||
log("🤖 User interrupted")
|
|
||||||
await waitingIndicator.stop()
|
|
||||||
streamPlayback?.stop()
|
|
||||||
streamPlayback = player.playStream() // Reset playback stream
|
|
||||||
break
|
|
||||||
|
|
||||||
case "tool_call":
|
|
||||||
waitingIndicator.start(streamPlayback)
|
|
||||||
log(`🤖 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "tool_result":
|
|
||||||
log(`🤖 Tool result: ${JSON.stringify(event.result)}`)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "tool_error":
|
|
||||||
console.error(`❌ Tool error: ${event.error}`)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "disconnected":
|
|
||||||
log(`🤖 👋 Conversation ended, returning to dialtone`)
|
|
||||||
streamPlayback?.stop()
|
|
||||||
service.send({ type: "remote-hang-up" })
|
|
||||||
break
|
|
||||||
|
|
||||||
case "error":
|
|
||||||
log.error("🤖 Agent error:", event.error)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "ping":
|
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log.debug(`😵 Unknown agent event ${event.type}`)
|
log.error(`📞 Unknown pin event: ${event.pin}`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopAgent = (ctx: PhoneContext) => {
|
|
||||||
ctx.stopAgent?.()
|
|
||||||
ctx.stopAgent = undefined
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?: string }) => {
|
|
||||||
ctx.peer = event.from
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
const hangUp = (ctx: PhoneContext) => {
|
|
||||||
console.log(`📞 Hanging up call`)
|
|
||||||
ctx.baresip.hangUp()
|
|
||||||
}
|
|
||||||
|
|
||||||
const answerCall = (ctx: PhoneContext) => {
|
|
||||||
log(`📞 Answering call`)
|
|
||||||
ctx.baresip.accept()
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeCall = async (ctx: PhoneContext) => {
|
|
||||||
log(`Dialing number: ${ctx.numberDialed}`)
|
|
||||||
if (ctx.numberDialed === 1) {
|
|
||||||
ctx.baresip.dial("+13476229543")
|
|
||||||
} else if (ctx.numberDialed === 2) {
|
|
||||||
ctx.baresip.dial("+18109643563")
|
|
||||||
} else {
|
|
||||||
log.error(`No contact for number dialed: ${ctx.numberDialed}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRinger = async (ctx: PhoneContext) => {
|
const apiKey = process.env.ELEVEN_API_KEY
|
||||||
let abortController = new AbortController()
|
const agentId = process.env.ELEVEN_AGENT_ID
|
||||||
const keepRinging = async () => {
|
|
||||||
while (!abortController.signal.aborted) {
|
|
||||||
await ring(ctx.ringer, 2000, abortController.signal)
|
|
||||||
await sleep(4000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keepRinging().catch((error) => log.error("Ringer error:", error))
|
|
||||||
|
|
||||||
ctx.cancelRinger = () => abortController.abort()
|
if (!apiKey) {
|
||||||
|
console.error("❌ Error: ELEVEN_API_KEY environment variable is required")
|
||||||
return ctx
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopRinger = (ctx: PhoneContext) => {
|
if (!agentId) {
|
||||||
ctx.cancelRinger?.()
|
console.error(
|
||||||
ctx.cancelRinger = undefined
|
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required"
|
||||||
return ctx
|
)
|
||||||
|
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
|
||||||
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDialToneAndAgent(this: any, ctx: PhoneContext) {
|
await startPhone(agentId, apiKey)
|
||||||
ctx = await startAgent(this, ctx)
|
|
||||||
|
|
||||||
await dialTonePlayback?.stop()
|
// log.info("📞 GPIO inputs initialized")
|
||||||
dialTonePlayback = await player.playTone([350, 440], Infinity)
|
|
||||||
|
|
||||||
return ctx
|
// // const baresipConfig = join(import.meta.dir, "..", "baresip")
|
||||||
}
|
// // const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
||||||
|
|
||||||
const stopDialTone = () => {
|
// // baresip.registrationSuccess.connect(async () => {
|
||||||
dialTonePlayback?.stop()
|
// // 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" })
|
||||||
|
// // }
|
||||||
|
// // })
|
||||||
|
|
||||||
const dialStart = (ctx: PhoneContext) => {
|
// // baresip.callReceived.connect(({ contact }) => {
|
||||||
ctx.numberDialed = 0
|
// // log.info(`🐻 incoming call from ${contact}`)
|
||||||
return ctx
|
// // phoneService.send({ type: "incoming_call", from: contact })
|
||||||
}
|
// // })
|
||||||
|
|
||||||
const digitIncrement = (ctx: PhoneContext) => {
|
// // baresip.callEstablished.connect(({ contact }) => {
|
||||||
ctx.numberDialed += 1
|
// // log.info(`🐻 call established with ${contact}`)
|
||||||
return ctx
|
// // phoneService.send({ type: "answered" })
|
||||||
}
|
// // })
|
||||||
|
|
||||||
const playStartRing = async (ringer: GPIO.Output) => {
|
// // baresip.hungUp.connect(() => {
|
||||||
// Three quick beeps, getting faster = energetic/welcoming
|
// // log.info("🐻 call hung up")
|
||||||
ringer.value = 1
|
// // phoneService.send({ type: "remote_hang_up" })
|
||||||
await Bun.sleep(80)
|
// // })
|
||||||
ringer.value = 0
|
|
||||||
await Bun.sleep(120)
|
|
||||||
|
|
||||||
ringer.value = 1
|
// // baresip.connect().catch((error) => {
|
||||||
await Bun.sleep(80)
|
// // log.error("🐻 connection error:", error)
|
||||||
ringer.value = 0
|
// // phoneService.send({ type: "error", message: error.message })
|
||||||
await Bun.sleep(100)
|
// // })
|
||||||
|
|
||||||
ringer.value = 1
|
// // baresip.error.connect(async ({ message }) => {
|
||||||
await Bun.sleep(80)
|
// // log.error("🐻 error:", message)
|
||||||
ringer.value = 0
|
// // phoneService.send({ type: "error", message })
|
||||||
}
|
// // for (let i = 0; i < 4; i++) {
|
||||||
|
// // await ring(500)
|
||||||
|
// // await sleep(250)
|
||||||
|
// // }
|
||||||
|
// // process.exit(1)
|
||||||
|
// // })
|
||||||
|
|
||||||
const playExitRing = async (ringer: GPIO.Output) => {
|
// const agent = new Agent({
|
||||||
ringer.value = 0 // Always try and turn it off!
|
// agentId,
|
||||||
}
|
// apiKey,
|
||||||
|
// tools: {
|
||||||
|
// search_web: (args: { query: string }) => searchWeb(args.query),
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
|
||||||
const t = transition
|
// handleAgentEvents(agent)
|
||||||
const r = reduce
|
|
||||||
const a = action
|
|
||||||
|
|
||||||
const phoneMachine = createMachine(
|
// const startAgent = () => {
|
||||||
"booting",
|
// log.info("☎️ Starting agent conversation")
|
||||||
// prettier-ignore
|
|
||||||
{
|
|
||||||
booting: state(
|
|
||||||
t("config", "initializing", r(config))
|
|
||||||
),
|
|
||||||
initializing: state(
|
|
||||||
t("initialized", "idle"),
|
|
||||||
t("pick-up", "ready"),
|
|
||||||
t("error", "fault", r(handleError))),
|
|
||||||
idle: state(
|
|
||||||
t("incoming-call", "incoming", r(incomingCall)),
|
|
||||||
t("pick-up", "ready")),
|
|
||||||
incoming: invoke(startRinger,
|
|
||||||
t("remote-hang-up", "idle", r(stopRinger)),
|
|
||||||
t("pick-up", "connected", r(stopRinger), a(answerCall))),
|
|
||||||
connected: state(
|
|
||||||
t("remote-hang-up", "ready"),
|
|
||||||
t("hang-up", "idle", a(hangUp))),
|
|
||||||
ready: invoke(startDialToneAndAgent,
|
|
||||||
t("dial-start", "dialing", a(stopDialTone), r(dialStart), a(stopAgent)),
|
|
||||||
t("hang-up", "idle", a(stopDialTone), a(stopAgent)),
|
|
||||||
t("start-agent", "connectToAgent", a(stopDialTone))),
|
|
||||||
connectToAgent: state(
|
|
||||||
t("hang-up", "idle", r(stopAgent)),
|
|
||||||
t("remote-hang-up", "ready", r(stopAgent))),
|
|
||||||
dialing: state(
|
|
||||||
t("dial-stop", "outgoing"),
|
|
||||||
t("digit_increment", "dialing", r(digitIncrement)),
|
|
||||||
t("hang-up", "idle")),
|
|
||||||
outgoing: invoke(makeCall,
|
|
||||||
t("answered", "connected"),
|
|
||||||
t("hang-up", "idle", a(hangUp))),
|
|
||||||
aborted: state(
|
|
||||||
t("hang-up", "idle")),
|
|
||||||
fault: state(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
d._onEnter = function (machine, to, state, prevState, event) {
|
// if (agentProcess?.stdin) {
|
||||||
log(`📱 ${machine.current} -> ${to} (${(event as any).type})`)
|
// agentProcess.stdin.write("start\n")
|
||||||
}
|
// } else {
|
||||||
|
// log.error("☎️ No agent process stdin available")
|
||||||
|
// phoneService.send({ type: "remote_hang_up" })
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// log.info("☎️ Stopping agent conversation")
|
||||||
|
// if (agentProcess?.stdin) {
|
||||||
|
// agentProcess.stdin.write("stop\n")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const context = (initial?: Partial<PhoneContext>): PhoneContext => ({
|
||||||
|
// numberDialed: 0,
|
||||||
|
// baresip,
|
||||||
|
// startAgent,
|
||||||
|
// ...initial,
|
||||||
|
// })
|
||||||
|
|
||||||
|
// const phoneMachine = createMachine(
|
||||||
|
// "initializing",
|
||||||
|
// // prettier-ignore
|
||||||
|
// {
|
||||||
|
// initializing: state(
|
||||||
|
// transition("initialized", "idle"),
|
||||||
|
// transition("pick_up", "ready", reduce(playDialTone)),
|
||||||
|
// transition("error", "fault", reduce(handleError))),
|
||||||
|
// idle: state(
|
||||||
|
// transition("incoming_call", "incoming", reduce(incomingCall)),
|
||||||
|
// transition("pick_up", "ready", reduce(playDialTone))),
|
||||||
|
// incoming: state(
|
||||||
|
// transition("remote_hang_up", "idle", reduce(stopRinger)),
|
||||||
|
// transition("pick_up", "connected", reduce(callAnswered))),
|
||||||
|
// connected: state(
|
||||||
|
// transition("remote_hang_up", "ready", reduce(playDialTone)),
|
||||||
|
// transition("hang_up", "idle", reduce(stopCall))),
|
||||||
|
// ready: state(
|
||||||
|
// transition("dial_start", "dialing", reduce(dialStart)),
|
||||||
|
// transition("dial_timeout", "aborted", reduce(stopDialTone)),
|
||||||
|
// transition("hang_up", "idle", reduce(stopDialTone))),
|
||||||
|
// dialing: state(
|
||||||
|
// transition("dial_stop", "outgoing", reduce(makeCall), guard((ctx) => !callAgentGuard(ctx))),
|
||||||
|
// transition("dial_stop", "connectedToAgent", reduce(makeAgentCall), guard((ctx) => callAgentGuard(ctx))),
|
||||||
|
// transition("digit_increment", "dialing", reduce(digitIncrement)),
|
||||||
|
// transition("hang_up", "idle", reduce(stopDialTone))),
|
||||||
|
// outgoing: state(
|
||||||
|
// transition("start_agent", "connectedToAgent"),
|
||||||
|
// transition("answered", "connected"),
|
||||||
|
// transition("hang_up", "idle", reduce(stopCall))),
|
||||||
|
// connectedToAgent: state(
|
||||||
|
// transition("remote_hang_up", "ready", reduce(stopAgent)),
|
||||||
|
// transition("hang_up", "idle", reduce(stopAgent))),
|
||||||
|
// aborted: state(
|
||||||
|
// transition("hang_up", "idle")),
|
||||||
|
// fault: state(),
|
||||||
|
// },
|
||||||
|
// context
|
||||||
|
// )
|
||||||
|
|
||||||
|
// const phoneService = interpret(phoneMachine, () => {})
|
||||||
|
|
||||||
|
// d._onEnter = function (machine, to, state, prevState, event) {
|
||||||
|
// log.info(`📱 ${machine.current} -> ${to} (${JSON.stringify(event)})`)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// gpio.monitor(pins.hook, { bias: "pull-up" }, (event) => {
|
||||||
|
// const type = event.edge === "falling" ? "hang_up" : "pick_up"
|
||||||
|
// log.info(`📞 Hook ${event.edge} sending ${type}`)
|
||||||
|
// phoneService.send({ type })
|
||||||
|
// })
|
||||||
|
|
||||||
|
// gpio.monitor(pins.rotaryInUse, { bias: "pull-up", throttleMs: 90 }, (event) => {
|
||||||
|
// const type = event.edge === "falling" ? "dial_start" : "dial_stop"
|
||||||
|
// log.debug(`📞 Rotary in-use ${event.edge} sending ${type}`)
|
||||||
|
// phoneService.send({ type })
|
||||||
|
// })
|
||||||
|
|
||||||
|
// gpio.monitor(pins.rotaryNumber, { bias: "pull-up", throttleMs: 90 }, (event) => {
|
||||||
|
// if (event.edge !== "rising") return
|
||||||
|
// phoneService.send({ type: "digit_increment" })
|
||||||
|
// })
|
||||||
|
|
||||||
|
// // Graceful shutdown handling
|
||||||
|
// const cleanup = () => {
|
||||||
|
// log.info("🛑 Shutting down, stopping agent process")
|
||||||
|
// if (agentProcess?.stdin) {
|
||||||
|
// agentProcess.stdin.write("quit\n")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// process.on("SIGINT", cleanup)
|
||||||
|
// process.on("SIGTERM", cleanup)
|
||||||
|
// process.on("exit", cleanup)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const handleAgentEvents = (agent: Agent) => {
|
||||||
|
// agent.events.connect(async (event) => {
|
||||||
|
// switch (event.type) {
|
||||||
|
// case "connected":
|
||||||
|
// console.log("✅ Connected to AI agent\n")
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "user_transcript":
|
||||||
|
// console.log(`👤 You: ${event.transcript}`)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "agent_response":
|
||||||
|
// console.log(`🤖 Agent: ${event.response}`)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "audio":
|
||||||
|
// await waitingIndicator.stop()
|
||||||
|
// const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
||||||
|
// streamPlayback.write(audioBuffer)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "interruption":
|
||||||
|
// console.log("🛑 User interrupted")
|
||||||
|
// streamPlayback?.stop()
|
||||||
|
// streamPlayback = player.playStream() // Reset playback stream
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "tool_call":
|
||||||
|
// waitingIndicator.start(streamPlayback)
|
||||||
|
// console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "tool_result":
|
||||||
|
// console.log(`✅ Tool result: ${JSON.stringify(event.result)}`)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "tool_error":
|
||||||
|
// console.error(`❌ Tool error: ${event.error}`)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "disconnected":
|
||||||
|
// console.log("\n👋 Conversation ended, returning to dialtone\n")
|
||||||
|
// streamPlayback?.stop()
|
||||||
|
// state = "WAITING_FOR_VOICE"
|
||||||
|
// phoneService.send({ type: "remote_hang_up" })
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "error":
|
||||||
|
// console.error("Agent error:", event.error)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case "ping":
|
||||||
|
// break
|
||||||
|
|
||||||
|
// default:
|
||||||
|
// console.log(`😵💫 ${event.type}`)
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const incomingCallRing = (): CancelableTask => {
|
||||||
|
// let abortController = new AbortController()
|
||||||
|
|
||||||
|
// const playRingtone = async () => {
|
||||||
|
// while (!abortController.signal.aborted) {
|
||||||
|
// await ring(2000, abortController.signal)
|
||||||
|
// await sleep(4000)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// playRingtone().catch((error) => log.error("Ringer error:", error))
|
||||||
|
|
||||||
|
// return () => abortController.abort()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
|
||||||
|
// ctx.lastError = event.message
|
||||||
|
// log.error(`Phone error: ${event.message}`)
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const incomingCall = (ctx: PhoneContext, event: { type: "incoming_call"; from?: string }) => {
|
||||||
|
// ctx.peer = event.from
|
||||||
|
// ctx.cancelRinger = incomingCallRing()
|
||||||
|
// log.info(`Incoming call from ${event.from}`)
|
||||||
|
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const stopRinger = (ctx: PhoneContext) => {
|
||||||
|
// ctx.cancelRinger?.()
|
||||||
|
// ctx.cancelRinger = undefined
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const playDialTone = (ctx: PhoneContext) => {
|
||||||
|
// const tone = new ToneGenerator()
|
||||||
|
|
||||||
|
// tone.loopTone([350, 440])
|
||||||
|
|
||||||
|
// ctx.cancelDialTone = () => {
|
||||||
|
// tone.stop()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const playOutgoingTone = () => {
|
||||||
|
// const tone = new ToneGenerator()
|
||||||
|
// let canceled = false
|
||||||
|
|
||||||
|
// const play = async () => {
|
||||||
|
// while (!canceled) {
|
||||||
|
// await tone.playTone([440, 480], 2000)
|
||||||
|
// await sleep(4000)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// play().catch((error) => log.error("Outgoing tone error:", error))
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// tone.stop()
|
||||||
|
// canceled = true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const dialStart = (ctx: PhoneContext) => {
|
||||||
|
// ctx.numberDialed = 0
|
||||||
|
// ctx = stopDialTone(ctx)
|
||||||
|
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const makeCall = (ctx: PhoneContext) => {
|
||||||
|
// log.info(`Dialing number: ${ctx.numberDialed}`)
|
||||||
|
// if (ctx.numberDialed === 1) {
|
||||||
|
// ctx.baresip.dial("+13476229543")
|
||||||
|
// } else if (ctx.numberDialed === 2) {
|
||||||
|
// ctx.baresip.dial("+18109643563")
|
||||||
|
// } else {
|
||||||
|
// const playTone = async () => {
|
||||||
|
// const tone = new ToneGenerator()
|
||||||
|
// await tone.playTone([900], 200)
|
||||||
|
// await tone.playTone([1350], 200)
|
||||||
|
// await tone.playTone([1750], 200)
|
||||||
|
// }
|
||||||
|
// playTone().catch((error) => log.error("Error playing tone:", error))
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const makeAgentCall = (ctx: PhoneContext) => {
|
||||||
|
// log.info(`Calling agent`)
|
||||||
|
// ctx.cancelAgent = ctx.startAgent()
|
||||||
|
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const callAgentGuard = (ctx: PhoneContext) => {
|
||||||
|
// return ctx.numberDialed === 10
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const callAnswered = (ctx: PhoneContext) => {
|
||||||
|
// ctx.baresip.accept()
|
||||||
|
|
||||||
|
// ctx.cancelDialTone?.()
|
||||||
|
// ctx.cancelDialTone = undefined
|
||||||
|
|
||||||
|
// ctx.cancelRinger?.()
|
||||||
|
// ctx.cancelRinger = undefined
|
||||||
|
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const stopCall = (ctx: PhoneContext) => {
|
||||||
|
// ctx.baresip.hangUp()
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const stopAgent = (ctx: PhoneContext) => {
|
||||||
|
// log.info("🛑 Stopping agent")
|
||||||
|
// ctx.cancelAgent?.()
|
||||||
|
// ctx.cancelAgent = undefined
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const stopDialTone = (ctx: PhoneContext) => {
|
||||||
|
// ctx.cancelDialTone?.()
|
||||||
|
// ctx.cancelDialTone = undefined
|
||||||
|
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const digitIncrement = (ctx: PhoneContext) => {
|
||||||
|
// ctx.numberDialed += 1
|
||||||
|
// return ctx
|
||||||
|
// }
|
||||||
|
|
|
||||||
173
src/pins/FFI-LEARNINGS.md
Normal file
173
src/pins/FFI-LEARNINGS.md
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
# Bun FFI Learnings
|
||||||
|
|
||||||
|
After researching GitHub examples and Bun's FFI documentation, here's what I found surprising and helpful.
|
||||||
|
|
||||||
|
## Surprising Discoveries
|
||||||
|
|
||||||
|
### 1. **String Handling is Simpler Than Expected**
|
||||||
|
I initially thought you'd need `CString` everywhere, but:
|
||||||
|
- For **args**: `FFIType.cstring` just needs `ptr(Buffer.from(str + "\0"))`
|
||||||
|
- For **returns**: `FFIType.cstring` automatically converts pointers to JS strings
|
||||||
|
- `CString` is mainly for **reading** C strings from pointers, not passing them
|
||||||
|
|
||||||
|
**Example from real code:**
|
||||||
|
```javascript
|
||||||
|
const str = Buffer.from("hello\0", "utf8");
|
||||||
|
myFunction(ptr(str)); // Clean and simple!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **No Type Wrappers Needed**
|
||||||
|
Unlike Node-FFI, Bun doesn't require defining structs or complex type wrappers. Just:
|
||||||
|
```javascript
|
||||||
|
add: {
|
||||||
|
args: [FFIType.i32, FFIType.i32],
|
||||||
|
returns: FFIType.i32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **TinyCC JIT Compilation**
|
||||||
|
Bun embeds TinyCC and JIT-compiles C bindings on the fly. This means:
|
||||||
|
- 2-6x faster than Node-API
|
||||||
|
- Zero build step for type conversions
|
||||||
|
- Direct memory access without serialization
|
||||||
|
|
||||||
|
## Helpful Patterns
|
||||||
|
|
||||||
|
### Pattern 1: String Helper
|
||||||
|
```typescript
|
||||||
|
import { ptr } from "bun:ffi"
|
||||||
|
const cstr = (s: string) => ptr(Buffer.from(s + "\0"))
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
gpiod.open(cstr("/dev/gpiochip0"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Resource Cleanup
|
||||||
|
Always use cleanup handlers:
|
||||||
|
```javascript
|
||||||
|
const cleanup = () => {
|
||||||
|
lib.symbols.release(resource)
|
||||||
|
lib.symbols.close(chip)
|
||||||
|
}
|
||||||
|
process.on("SIGINT", cleanup)
|
||||||
|
process.on("SIGTERM", cleanup)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Destructuring Symbols
|
||||||
|
```javascript
|
||||||
|
const {
|
||||||
|
symbols: { functionName }
|
||||||
|
} = dlopen(path, { /* defs */ })
|
||||||
|
|
||||||
|
// Call directly:
|
||||||
|
functionName(arg1, arg2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes to Avoid
|
||||||
|
|
||||||
|
1. **Don't forget null terminators** - `Buffer.from(str + "\0")` not `Buffer.from(str)`
|
||||||
|
2. **Pointer lifetime** - Keep TypedArrays alive while C code uses them
|
||||||
|
3. **Type mismatches** - `FFIType.i32` vs `FFIType.u32` matters!
|
||||||
|
4. **Missing cleanup** - C libraries don't have garbage collection
|
||||||
|
|
||||||
|
## Best Practices from Real Examples
|
||||||
|
|
||||||
|
1. **Use `suffix` for cross-platform library loading:**
|
||||||
|
```javascript
|
||||||
|
import { suffix } from "bun:ffi"
|
||||||
|
dlopen(`libname.${suffix}`, { /* ... */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check for null on resource creation:**
|
||||||
|
```javascript
|
||||||
|
const chip = lib.gpiod_chip_open(cstr(path))
|
||||||
|
if (!chip) {
|
||||||
|
console.error("Failed to open")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Free configs after use:**
|
||||||
|
```javascript
|
||||||
|
const config = lib.create_config()
|
||||||
|
// ... use config ...
|
||||||
|
lib.free_config(config) // Don't leak!
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Makes Bun FFI Special
|
||||||
|
|
||||||
|
- **Performance**: JIT compilation beats traditional FFI
|
||||||
|
- **Simplicity**: No build tools, no gyp, no node-gyp nightmares
|
||||||
|
- **TypeScript native**: Works seamlessly with TS type system
|
||||||
|
- **Built-in**: Ships with Bun, zero dependencies
|
||||||
|
|
||||||
|
## Hard-Won Lessons from GPIO Implementation
|
||||||
|
|
||||||
|
### 1. **Enum values MUST match the C header exactly**
|
||||||
|
We spent hours debugging because our constants were off by one:
|
||||||
|
```typescript
|
||||||
|
// WRONG - missing GPIOD_LINE_BIAS_AS_IS
|
||||||
|
export const GPIOD_LINE_BIAS_UNKNOWN = 1 // Actually should be 2!
|
||||||
|
export const GPIOD_LINE_BIAS_DISABLED = 2 // Actually should be 3!
|
||||||
|
export const GPIOD_LINE_BIAS_PULL_UP = 3 // Actually should be 4!
|
||||||
|
|
||||||
|
// CORRECT - includes AS_IS at position 1
|
||||||
|
export const GPIOD_LINE_BIAS_AS_IS = 1
|
||||||
|
export const GPIOD_LINE_BIAS_UNKNOWN = 2
|
||||||
|
export const GPIOD_LINE_BIAS_DISABLED = 3
|
||||||
|
export const GPIOD_LINE_BIAS_PULL_UP = 4
|
||||||
|
export const GPIOD_LINE_BIAS_PULL_DOWN = 5
|
||||||
|
```
|
||||||
|
**Lesson:** Always grep the header file for the complete enum, don't assume!
|
||||||
|
|
||||||
|
### 2. **Hardware debouncing requires correct constants**
|
||||||
|
With wrong constants, we were accidentally passing `BIAS_DISABLED` instead of `BIAS_PULL_UP`, which meant:
|
||||||
|
- No pull resistor (pin floated)
|
||||||
|
- Debouncing didn't work at all
|
||||||
|
- Got 6+ events per button press
|
||||||
|
|
||||||
|
After fixing: **Clean single events with 1ms debounce via kernel!**
|
||||||
|
|
||||||
|
### 3. **Edge detection is event-driven, not polling**
|
||||||
|
Don't poll `get_value()` in a loop! Use:
|
||||||
|
- `gpiod_line_request_wait_edge_events()` - blocks until interrupt
|
||||||
|
- `gpiod_line_request_read_edge_events()` - reads queued events
|
||||||
|
- Much more efficient, CPU sleeps until hardware event
|
||||||
|
|
||||||
|
### 4. **TypedArray to pointer needs `ptr()`**
|
||||||
|
When passing arrays to C functions:
|
||||||
|
```typescript
|
||||||
|
const offsets = new Uint32Array([21])
|
||||||
|
gpiod.gpiod_line_config_add_line_settings(
|
||||||
|
lineConfig,
|
||||||
|
ptr(offsets), // Need ptr() wrapper!
|
||||||
|
1,
|
||||||
|
lineSettings
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. **Signal handling for clean shutdown**
|
||||||
|
Generators don't run `finally` blocks if abandoned. Need:
|
||||||
|
```typescript
|
||||||
|
let shouldExit = false
|
||||||
|
process.on("SIGINT", () => { shouldExit = true })
|
||||||
|
|
||||||
|
while (!shouldExit) {
|
||||||
|
const ret = wait_edge_events(request, 100_000_000) // Use timeout!
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. **Button wiring determines logic**
|
||||||
|
- **GND button + pull-UP**: Press = FALLING edge (HIGH→LOW)
|
||||||
|
- **VCC button + pull-DOWN**: Press = RISING edge (LOW→HIGH)
|
||||||
|
|
||||||
|
Always check initial pin state to verify wiring!
|
||||||
|
|
||||||
|
## Resources Used
|
||||||
|
|
||||||
|
- Official Bun FFI docs: https://bun.com/docs/runtime/ffi
|
||||||
|
- libgpiod v2 C API: https://libgpiod.readthedocs.io/en/latest/core_api.html
|
||||||
|
- Python bindings examples: https://github.com/brgl/libgpiod/tree/master/bindings/python/examples
|
||||||
|
- Real examples: GitHub searches for bun FFI projects
|
||||||
|
- Community discussions: Bun issue tracker and HN threads
|
||||||
|
|
@ -4,12 +4,11 @@ High-level GPIO library for Bun using libgpiod v2 with automatic resource manage
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- True event-driven GPIO with worker-based architecture (<10ms latency)
|
- Type-safe TypeScript API with autocomplete for pin names
|
||||||
- Zero CPU usage when idle (blocking on hardware events)
|
|
||||||
- Type-safe TypeScript API
|
|
||||||
- Automatic resource cleanup with `using` keyword
|
- Automatic resource cleanup with `using` keyword
|
||||||
- Hardware debouncing via kernel
|
- Hardware debouncing via kernel
|
||||||
- Callback-based event handling with multiple listeners
|
- Event-driven input handling
|
||||||
|
- Efficient multi-pin monitoring with input groups
|
||||||
- Zero external dependencies (uses Bun FFI)
|
- Zero external dependencies (uses Bun FFI)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
@ -37,13 +36,10 @@ for (let i = 0; i < 10; i++) {
|
||||||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
||||||
console.log(button.value)
|
console.log(button.value)
|
||||||
|
|
||||||
// Listen for button events with callback
|
// Listen for button events
|
||||||
button.onChange((event) => {
|
for await (const event of button.events()) {
|
||||||
console.log(event.value === 0 ? "Pressed!" : "Released")
|
console.log(event.value === 0 ? "Pressed!" : "Released")
|
||||||
})
|
}
|
||||||
|
|
||||||
// Keep process running
|
|
||||||
await new Promise(() => {})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
@ -90,6 +86,27 @@ Options:
|
||||||
- `debounce?: number` - Debounce period in milliseconds (default: 0)
|
- `debounce?: number` - Debounce period in milliseconds (default: 0)
|
||||||
- `edge?: 'rising' | 'falling' | 'both'` - Edge detection (default: 'both')
|
- `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()`
|
#### `gpio.listChips()`
|
||||||
|
|
||||||
List available GPIO chips.
|
List available GPIO chips.
|
||||||
|
|
@ -100,30 +117,22 @@ console.log(chips)
|
||||||
// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }]
|
// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Input
|
### InputPin
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
using button = gpio.input(20)
|
using button = gpio.input(20)
|
||||||
|
|
||||||
// Read current state (cached from last event)
|
// Read current state
|
||||||
const value: 0 | 1 = button.value
|
const value: 0 | 1 = button.value
|
||||||
|
|
||||||
// Listen for changes (returns unsubscribe function)
|
|
||||||
const unsubscribe = button.onChange((event) => {
|
|
||||||
console.log(event.value, event.timestamp)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add multiple listeners
|
|
||||||
const unsub2 = button.onChange((event) => {
|
|
||||||
console.log("Second listener:", event.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove specific listener
|
|
||||||
unsubscribe()
|
|
||||||
|
|
||||||
// Wait for specific value
|
// Wait for specific value
|
||||||
await button.waitForValue(0) // wait for LOW
|
await button.waitForValue(0) // wait for LOW
|
||||||
await button.waitForValue(1, 5000) // wait for HIGH with 5s timeout
|
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
|
### OutputPin
|
||||||
|
|
@ -137,23 +146,29 @@ const value = led.value
|
||||||
led.toggle()
|
led.toggle()
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
### InputGroup
|
||||||
|
|
||||||
### Worker-Based Event Handling
|
```typescript
|
||||||
|
using inputs = gpio.inputGroup({
|
||||||
|
switch: { pin: 16, pull: "up" },
|
||||||
|
button: { pin: 20, pull: "up", debounce: 10 }
|
||||||
|
})
|
||||||
|
|
||||||
Each input spawns a dedicated Web Worker that:
|
// Access pins with full type safety
|
||||||
|
inputs.pins.switch.value // ✓ TypeScript autocomplete
|
||||||
|
inputs.pins.button.value // ✓ TypeScript autocomplete
|
||||||
|
|
||||||
1. Blocks on `gpiod_line_request_wait_edge_events()` with `-1` timeout (infinite)
|
// Wait for specific pin values
|
||||||
2. Wakes instantly when hardware GPIO edge event occurs
|
await inputs.pins.button.waitForValue(0) // wait for button to go LOW
|
||||||
3. Reads event and posts message to main thread
|
await inputs.pins.switch.waitForValue(1, 3000) // wait for switch to go HIGH with timeout
|
||||||
4. Main thread fires registered callbacks
|
|
||||||
|
|
||||||
**Benefits:**
|
// Monitor all pins
|
||||||
|
for await (const event of inputs.events()) {
|
||||||
- **True blocking**: Zero CPU usage when idle
|
event.pin // Type: 'switch' | 'button'
|
||||||
- **Low latency**: <10ms response time (vs 100ms with polling)
|
event.value // Type: 0 | 1
|
||||||
- **Independent inputs**: Each input has its own worker
|
event.timestamp // Type: bigint (nanoseconds)
|
||||||
- **Clean shutdown**: Workers terminated on close, kernel handles GPIO cleanup
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Resource Management
|
## Resource Management
|
||||||
|
|
||||||
|
|
@ -161,24 +176,17 @@ Each input spawns a dedicated Web Worker that:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Good - automatic cleanup
|
// Good - automatic cleanup
|
||||||
using led = gpio.output(17) // Automatically released because of `using`
|
{
|
||||||
led.value = 1
|
using led = gpio.output(17)
|
||||||
```
|
led.value = 1
|
||||||
|
} // Automatically released
|
||||||
|
|
||||||
```typescript
|
// Bad - manual cleanup required
|
||||||
// Meh - manual cleanup required
|
|
||||||
const led = gpio.output(17)
|
const led = gpio.output(17)
|
||||||
led.value = 1
|
led.value = 1
|
||||||
led.close() // Must call manually
|
led.close() // Must call manually
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Bad - leaks resources
|
|
||||||
const led = gpio.output(17)
|
|
||||||
led.value = 1
|
|
||||||
// Forgot to close() - resource leak!
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hardware Setup
|
## Hardware Setup
|
||||||
|
|
||||||
### Pull Resistors
|
### Pull Resistors
|
||||||
|
|
@ -189,7 +197,6 @@ Pull resistors prevent floating input values when nothing is connected to the pi
|
||||||
- **Pull-down + button to VCC**: When released, pin reads LOW (0). When pressed, pin reads HIGH (1).
|
- **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:
|
**Important:** Match your pull resistor to your wiring:
|
||||||
|
|
||||||
- Button to ground → use `pull: "up"`
|
- Button to ground → use `pull: "up"`
|
||||||
- Button to VCC (3.3V) → use `pull: "down"`
|
- Button to VCC (3.3V) → use `pull: "down"`
|
||||||
|
|
||||||
|
|
@ -245,27 +252,26 @@ import { GPIO } from "@/pins"
|
||||||
|
|
||||||
const gpio = new GPIO()
|
const gpio = new GPIO()
|
||||||
|
|
||||||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
using inputs = gpio.inputGroup({
|
||||||
using switchInput = gpio.input(16, { pull: "up" })
|
button: { pin: 20, pull: "up", debounce: 10 },
|
||||||
|
switch: { pin: 16, pull: "up" }
|
||||||
|
})
|
||||||
|
|
||||||
using led = gpio.output(21)
|
using led = gpio.output(21)
|
||||||
|
|
||||||
// Set LED based on switch state
|
// Set LED based on switch state
|
||||||
led.value = switchInput.value
|
if (inputs.pins.switch.value === 1) {
|
||||||
|
led.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle LED when button pressed
|
// Toggle LED when button pressed
|
||||||
button.onChange((event) => {
|
for await (const event of inputs.events()) {
|
||||||
if (event.value === 0) {
|
if (event.pin === "button" && event.value === 0) {
|
||||||
led.toggle()
|
led.toggle()
|
||||||
|
} else if (event.pin === "switch") {
|
||||||
|
led.value = event.value
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
// Mirror switch to LED
|
|
||||||
switchInput.onChange((event) => {
|
|
||||||
led.value = event.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep process running
|
|
||||||
await new Promise(() => {})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rotary Phone Dialer
|
### Rotary Phone Dialer
|
||||||
|
|
@ -275,31 +281,18 @@ import { GPIO } from "@/pins"
|
||||||
|
|
||||||
const gpio = new GPIO()
|
const gpio = new GPIO()
|
||||||
|
|
||||||
using hook = gpio.input(27, { pull: "up", debounce: 3 })
|
using inputs = gpio.inputGroup({
|
||||||
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
hook: { pin: 20, pull: "up" },
|
||||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
rotary: { pin: 21, pull: "up", debounce: 1 },
|
||||||
|
|
||||||
let digit = 0
|
|
||||||
|
|
||||||
hook.onChange((event) => {
|
|
||||||
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
rotaryInUse.onChange((event) => {
|
for await (const event of inputs.events()) {
|
||||||
if (event.value === 0) {
|
if (event.pin === "hook") {
|
||||||
digit = 0
|
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
|
||||||
} else {
|
} else if (event.pin === "rotary" && event.value === 0) {
|
||||||
console.log(`Dialed digit: ${digit}`)
|
console.log("Rotary pulse")
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
rotaryNumber.onChange((event) => {
|
|
||||||
if (event.value === 1) {
|
|
||||||
digit += 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise(() => {})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import type { Pointer } from "bun:ffi"
|
|
||||||
import { gpiod, GPIOD_LINE_DIRECTION_INPUT, GPIOD_LINE_DIRECTION_OUTPUT } from "./ffi"
|
|
||||||
import { cstr, mapPullToLibgpiod, mapEdgeToLibgpiod } from "./utils"
|
|
||||||
import type { PullMode, EdgeMode } from "./types"
|
|
||||||
|
|
||||||
type LineRequestResult = {
|
|
||||||
chip: Pointer
|
|
||||||
request: Pointer
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InputLineConfig = {
|
|
||||||
chipPath: string
|
|
||||||
offset: number
|
|
||||||
pull: PullMode
|
|
||||||
debounce: number
|
|
||||||
edge: EdgeMode
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OutputLineConfig = {
|
|
||||||
chipPath: string
|
|
||||||
offset: number
|
|
||||||
initialValue: 0 | 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = (message: string): never => {
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestLine = (
|
|
||||||
chipPath: string,
|
|
||||||
offset: number,
|
|
||||||
consumer: string,
|
|
||||||
configureSettings: (settings: Pointer) => void
|
|
||||||
): LineRequestResult => {
|
|
||||||
const chip = gpiod.gpiod_chip_open(cstr(chipPath))
|
|
||||||
if (!chip) cleanup("Failed to open GPIO chip")
|
|
||||||
|
|
||||||
const settings = gpiod.gpiod_line_settings_new()
|
|
||||||
if (!settings) {
|
|
||||||
gpiod.gpiod_chip_close(chip)
|
|
||||||
cleanup("Failed to create line settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
configureSettings(settings!)
|
|
||||||
|
|
||||||
const lineConfig = gpiod.gpiod_line_config_new()
|
|
||||||
if (!lineConfig) {
|
|
||||||
gpiod.gpiod_line_settings_free(settings)
|
|
||||||
gpiod.gpiod_chip_close(chip)
|
|
||||||
cleanup("Failed to create line config")
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsets = new Uint32Array([offset])
|
|
||||||
const ret = gpiod.gpiod_line_config_add_line_settings(lineConfig, offsets, 1, settings)
|
|
||||||
gpiod.gpiod_line_settings_free(settings)
|
|
||||||
|
|
||||||
if (ret !== 0) {
|
|
||||||
gpiod.gpiod_line_config_free(lineConfig)
|
|
||||||
gpiod.gpiod_chip_close(chip)
|
|
||||||
cleanup("Failed to add line settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestConfig = gpiod.gpiod_request_config_new()
|
|
||||||
if (!requestConfig) {
|
|
||||||
gpiod.gpiod_line_config_free(lineConfig)
|
|
||||||
gpiod.gpiod_chip_close(chip)
|
|
||||||
cleanup("Failed to create request config")
|
|
||||||
}
|
|
||||||
|
|
||||||
gpiod.gpiod_request_config_set_consumer(requestConfig, cstr(consumer))
|
|
||||||
|
|
||||||
const request = gpiod.gpiod_chip_request_lines(chip, requestConfig, lineConfig)
|
|
||||||
gpiod.gpiod_request_config_free(requestConfig)
|
|
||||||
gpiod.gpiod_line_config_free(lineConfig)
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
gpiod.gpiod_chip_close(chip)
|
|
||||||
cleanup("Failed to request GPIO line")
|
|
||||||
}
|
|
||||||
|
|
||||||
return { chip: chip!, request: request! }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const requestInputLine = (config: InputLineConfig): LineRequestResult => {
|
|
||||||
return requestLine(config.chipPath, config.offset, "bun-gpio-input", (settings) => {
|
|
||||||
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_INPUT)
|
|
||||||
gpiod.gpiod_line_settings_set_bias(settings, mapPullToLibgpiod(config.pull))
|
|
||||||
gpiod.gpiod_line_settings_set_edge_detection(settings, mapEdgeToLibgpiod(config.edge))
|
|
||||||
|
|
||||||
if (config.debounce > 0) {
|
|
||||||
gpiod.gpiod_line_settings_set_debounce_period_us(settings, config.debounce * 1000)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const requestOutputLine = (config: OutputLineConfig): LineRequestResult => {
|
|
||||||
return requestLine(config.chipPath, config.offset, "bun-gpio", (settings) => {
|
|
||||||
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_OUTPUT)
|
|
||||||
gpiod.gpiod_line_settings_set_output_value(settings, config.initialValue)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
199
src/pins/gpio.ts
Normal file
199
src/pins/gpio.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { ptr } from "bun:ffi"
|
||||||
|
import { readdir } from "node:fs/promises"
|
||||||
|
import { gpiod, cstr, GPIOD_LINE_DIRECTION_OUTPUT, GPIOD_LINE_DIRECTION_INPUT } from "./ffi"
|
||||||
|
import { OutputPin } from "./output"
|
||||||
|
import { InputPin } from "./input"
|
||||||
|
import { InputGroup } from "./input-group"
|
||||||
|
import { ChipNotFoundError, PinInUseError } from "./errors"
|
||||||
|
import { mapPullToLibgpiod, mapEdgeToLibgpiod, hashInputConfig } from "./utils"
|
||||||
|
import type { OutputOptions, InputOptions, PullMode, ChipInfo } from "./types"
|
||||||
|
|
||||||
|
export class GPIO {
|
||||||
|
#chipPath: string
|
||||||
|
#resetOnClose: boolean
|
||||||
|
|
||||||
|
constructor(options?: { chip?: string; resetOnClose?: boolean }) {
|
||||||
|
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
|
||||||
|
this.#resetOnClose = options?.resetOnClose ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
output(pin: number, options?: OutputOptions): OutputPin {
|
||||||
|
const initialValue = options?.initialValue ?? 0
|
||||||
|
|
||||||
|
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
|
||||||
|
if (!chip) {
|
||||||
|
throw new ChipNotFoundError(this.#chipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reqConfig = gpiod.gpiod_request_config_new()
|
||||||
|
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
|
||||||
|
|
||||||
|
const lineSettings = gpiod.gpiod_line_settings_new()
|
||||||
|
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_OUTPUT)
|
||||||
|
gpiod.gpiod_line_settings_set_output_value(lineSettings, initialValue)
|
||||||
|
|
||||||
|
const lineConfig = gpiod.gpiod_line_config_new()
|
||||||
|
const offsets = new Uint32Array([pin])
|
||||||
|
gpiod.gpiod_line_config_add_line_settings(lineConfig, ptr(offsets), 1, lineSettings)
|
||||||
|
|
||||||
|
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
|
||||||
|
|
||||||
|
gpiod.gpiod_line_settings_free(lineSettings)
|
||||||
|
gpiod.gpiod_line_config_free(lineConfig)
|
||||||
|
gpiod.gpiod_request_config_free(reqConfig)
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
gpiod.gpiod_chip_close(chip)
|
||||||
|
throw new PinInUseError(pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resetValue: 0 | 1 | undefined
|
||||||
|
if (this.#resetOnClose) {
|
||||||
|
const currentValue = gpiod.gpiod_line_request_get_value(request, pin)
|
||||||
|
if (currentValue === -1) {
|
||||||
|
console.warn(`Failed to read initial value for pin ${pin}, assuming 0`)
|
||||||
|
resetValue = 0
|
||||||
|
} else {
|
||||||
|
resetValue = currentValue as 0 | 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OutputPin(chip, request, pin, resetValue)
|
||||||
|
} catch (err) {
|
||||||
|
gpiod.gpiod_chip_close(chip)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input(pin: number, options?: InputOptions): InputPin<"pin"> {
|
||||||
|
const group = this.inputGroup({
|
||||||
|
pin: { pin, ...options },
|
||||||
|
})
|
||||||
|
|
||||||
|
return new InputPin(group, "pin")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputGroup<T extends Record<string, { pin: number } & InputOptions>>(
|
||||||
|
config: T
|
||||||
|
): InputGroup<keyof T & string> {
|
||||||
|
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
|
||||||
|
if (!chip) {
|
||||||
|
throw new ChipNotFoundError(this.#chipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reqConfig = gpiod.gpiod_request_config_new()
|
||||||
|
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
|
||||||
|
|
||||||
|
const lineConfig = gpiod.gpiod_line_config_new()
|
||||||
|
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ name: string; pin: number; pull: PullMode; options: InputOptions }>
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const [name, pinConfig] of Object.entries(config)) {
|
||||||
|
const pull = pinConfig.pull ?? "up"
|
||||||
|
const debounce = pinConfig.debounce ?? 0
|
||||||
|
const edge = pinConfig.edge ?? "both"
|
||||||
|
|
||||||
|
const hash = hashInputConfig(pull, debounce, edge)
|
||||||
|
if (!groups.has(hash)) groups.set(hash, [])
|
||||||
|
groups.get(hash)!.push({ name, pin: pinConfig.pin, pull, options: pinConfig })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [hash, pins] of groups) {
|
||||||
|
const firstPin = pins[0]
|
||||||
|
if (!firstPin) continue
|
||||||
|
|
||||||
|
const pull = firstPin.options.pull ?? "up"
|
||||||
|
const debounce = firstPin.options.debounce ?? 0
|
||||||
|
const edge = firstPin.options.edge ?? "both"
|
||||||
|
|
||||||
|
const lineSettings = gpiod.gpiod_line_settings_new()
|
||||||
|
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_INPUT)
|
||||||
|
gpiod.gpiod_line_settings_set_bias(lineSettings, mapPullToLibgpiod(pull))
|
||||||
|
gpiod.gpiod_line_settings_set_edge_detection(lineSettings, mapEdgeToLibgpiod(edge))
|
||||||
|
gpiod.gpiod_line_settings_set_debounce_period_us(lineSettings, debounce * 1000)
|
||||||
|
|
||||||
|
const offsets = new Uint32Array(pins.map((p) => p.pin))
|
||||||
|
gpiod.gpiod_line_config_add_line_settings(
|
||||||
|
lineConfig,
|
||||||
|
ptr(offsets),
|
||||||
|
pins.length,
|
||||||
|
lineSettings
|
||||||
|
)
|
||||||
|
|
||||||
|
gpiod.gpiod_line_settings_free(lineSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
|
||||||
|
|
||||||
|
gpiod.gpiod_line_config_free(lineConfig)
|
||||||
|
gpiod.gpiod_request_config_free(reqConfig)
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
gpiod.gpiod_chip_close(chip)
|
||||||
|
const firstConfig = Object.values(config)[0]
|
||||||
|
throw new PinInUseError(firstConfig?.pin ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinMap: Record<string, { offset: number; pull: PullMode }> = {}
|
||||||
|
for (const [name, pinConfig] of Object.entries(config)) {
|
||||||
|
pinMap[name] = {
|
||||||
|
offset: pinConfig.pin,
|
||||||
|
pull: pinConfig.pull ?? "up",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InputGroup(chip, request, pinMap)
|
||||||
|
} catch (err) {
|
||||||
|
gpiod.gpiod_chip_close(chip)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listChips(): Promise<ChipInfo[]> {
|
||||||
|
const chips: ChipInfo[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await readdir("/dev")
|
||||||
|
const chipFiles = files.filter((f) => f.startsWith("gpiochip"))
|
||||||
|
|
||||||
|
for (const file of chipFiles) {
|
||||||
|
const path = `/dev/${file}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chip = gpiod.gpiod_chip_open(cstr(path))
|
||||||
|
if (!chip) continue
|
||||||
|
|
||||||
|
const info = gpiod.gpiod_chip_get_info(chip)
|
||||||
|
if (!info) {
|
||||||
|
gpiod.gpiod_chip_close(chip)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = gpiod.gpiod_chip_info_get_name(info)
|
||||||
|
const label = gpiod.gpiod_chip_info_get_label(info)
|
||||||
|
const numLines = gpiod.gpiod_chip_info_get_num_lines(info)
|
||||||
|
|
||||||
|
chips.push({
|
||||||
|
path,
|
||||||
|
name: String(name || ""),
|
||||||
|
label: String(label || ""),
|
||||||
|
numLines: Number(numLines),
|
||||||
|
})
|
||||||
|
|
||||||
|
gpiod.gpiod_chip_close(chip)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// /dev might not be accessible, return empty array
|
||||||
|
}
|
||||||
|
|
||||||
|
return chips
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,89 +1,17 @@
|
||||||
import { readdir } from "node:fs/promises"
|
export { GPIO } from "./gpio"
|
||||||
import { gpiod, cstr } from "./ffi"
|
export {
|
||||||
import { Output } from "./output"
|
|
||||||
import { Input } from "./input"
|
|
||||||
import type * as Type from "./types"
|
|
||||||
import {
|
|
||||||
GPIOError,
|
GPIOError,
|
||||||
PermissionError,
|
PermissionError,
|
||||||
PinInUseError,
|
PinInUseError,
|
||||||
ChipNotFoundError,
|
ChipNotFoundError,
|
||||||
InvalidConfigError,
|
InvalidConfigError,
|
||||||
} from "./errors"
|
} from "./errors"
|
||||||
|
export type {
|
||||||
class GPIO {
|
InputOptions,
|
||||||
#chipPath: string
|
OutputOptions,
|
||||||
|
InputEvent,
|
||||||
constructor(options?: { chip?: string }) {
|
InputGroupEvent,
|
||||||
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
|
ChipInfo,
|
||||||
}
|
PullMode,
|
||||||
|
EdgeMode,
|
||||||
output(pin: number, options?: Type.OutputOptions): Output {
|
} from "./types"
|
||||||
return new Output(this.#chipPath, pin, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
input(pin: number, options?: Type.InputOptions): Input {
|
|
||||||
return new Input(this.#chipPath, pin, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
async listChips(): Promise<Type.ChipInfo[]> {
|
|
||||||
const chips: Type.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
|
|
||||||
}
|
|
||||||
|
|
||||||
static Error = GPIOError
|
|
||||||
static PermissionError = PermissionError
|
|
||||||
static PinInUseError = PinInUseError
|
|
||||||
static ChipNotFoundError = ChipNotFoundError
|
|
||||||
static InvalidConfigError = InvalidConfigError
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace GPIO {
|
|
||||||
export type PullMode = Type.PullMode
|
|
||||||
export type EdgeMode = Type.EdgeMode
|
|
||||||
export type InputOptions = Type.InputOptions
|
|
||||||
export type OutputOptions = Type.OutputOptions
|
|
||||||
export type InputEvent = Type.InputEvent
|
|
||||||
export type Input = import("./input").Input
|
|
||||||
export type Output = import("./output").Output
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GPIO
|
|
||||||
|
|
|
||||||
205
src/pins/input-group.ts
Normal file
205
src/pins/input-group.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import type { Pointer } from "bun:ffi"
|
||||||
|
import { gpiod } from "./ffi"
|
||||||
|
import { mapLibgpiodEdgeToPressedState } from "./utils"
|
||||||
|
import type { PullMode, InputEvent, InputGroupEvent, PinConfig } from "./types"
|
||||||
|
|
||||||
|
export class InputGroup<T extends string = string> {
|
||||||
|
#closed = false
|
||||||
|
#chip: Pointer
|
||||||
|
#request: Pointer
|
||||||
|
#pinMap: Map<string, { offset: number; pull: PullMode }>
|
||||||
|
#offsetMap: Map<number, { name: string; pull: PullMode }>
|
||||||
|
#eventBuffer: Pointer | undefined
|
||||||
|
#eventListeners: Array<(event: InputGroupEvent<T>) => void> = []
|
||||||
|
#closeHandlers: Array<() => void> = []
|
||||||
|
|
||||||
|
constructor(chip: Pointer, request: Pointer, pinConfig: PinConfig) {
|
||||||
|
this.#chip = chip
|
||||||
|
this.#request = request
|
||||||
|
|
||||||
|
this.#pinMap = new Map()
|
||||||
|
this.#offsetMap = new Map()
|
||||||
|
|
||||||
|
for (const [name, config] of Object.entries(pinConfig)) {
|
||||||
|
this.#pinMap.set(name, config)
|
||||||
|
this.#offsetMap.set(config.offset, { name, pull: config.pull })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get pins(): Record<
|
||||||
|
T,
|
||||||
|
{ readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }
|
||||||
|
> {
|
||||||
|
const result = {} as Record<
|
||||||
|
T,
|
||||||
|
{
|
||||||
|
readonly value: 0 | 1
|
||||||
|
waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
for (const [name, config] of this.#pinMap) {
|
||||||
|
const offset = config.offset
|
||||||
|
const closed = () => this.#closed
|
||||||
|
const request = this.#request
|
||||||
|
const pinName = name
|
||||||
|
|
||||||
|
Object.defineProperty(result, name, {
|
||||||
|
get: () => ({
|
||||||
|
get value(): 0 | 1 {
|
||||||
|
if (closed()) throw new Error("InputGroup is closed")
|
||||||
|
const ret = gpiod.gpiod_line_request_get_value(request, offset)
|
||||||
|
if (ret === -1) throw new Error("Failed to get pin value")
|
||||||
|
return ret as 0 | 1
|
||||||
|
},
|
||||||
|
waitForValue: (targetValue: 0 | 1, timeout?: number) =>
|
||||||
|
this.#waitForPinValue(pinName as T, targetValue, timeout),
|
||||||
|
}),
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async #waitForPinValue(pinName: T, targetValue: 0 | 1, timeout?: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.#closed) {
|
||||||
|
reject(new Error("InputGroup is closed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
|
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
|
||||||
|
this.#closeHandlers = this.#closeHandlers.filter((h) => h !== onClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error("InputGroup closed while waiting"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = (event: InputGroupEvent<T>) => {
|
||||||
|
if (event.pin !== pinName) return
|
||||||
|
if (event.value !== targetValue) return
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`Timeout waiting for pin ${pinName} to become ${targetValue}`))
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#eventListeners.push(listener)
|
||||||
|
this.#closeHandlers.push(onClose)
|
||||||
|
this.#startEventLoop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async *events(): AsyncGenerator<InputGroupEvent<T>> {
|
||||||
|
if (this.#closed) throw new Error("InputGroup is closed")
|
||||||
|
|
||||||
|
const eventQueue: InputGroupEvent<T>[] = []
|
||||||
|
const listener = (event: InputGroupEvent<T>) => {
|
||||||
|
eventQueue.push(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#eventListeners.push(listener)
|
||||||
|
this.#startEventLoop()
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!this.#closed) {
|
||||||
|
if (eventQueue.length > 0) {
|
||||||
|
for (const event of eventQueue) {
|
||||||
|
yield event
|
||||||
|
}
|
||||||
|
eventQueue.length = 0
|
||||||
|
} else {
|
||||||
|
await Bun.sleep(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#startEventLoop() {
|
||||||
|
if (this.#eventBuffer !== undefined) return
|
||||||
|
|
||||||
|
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
|
||||||
|
if (!buffer) throw new Error("Failed to create event buffer")
|
||||||
|
|
||||||
|
this.#eventBuffer = buffer
|
||||||
|
this.#runEventLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runEventLoop() {
|
||||||
|
try {
|
||||||
|
while (!this.#closed && this.#eventListeners.length > 0) {
|
||||||
|
const ret = gpiod.gpiod_line_request_wait_edge_events(this.#request, 100_000_000)
|
||||||
|
|
||||||
|
if (ret === -1 || ret === 0) {
|
||||||
|
await Bun.sleep(0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const numEvents = gpiod.gpiod_line_request_read_edge_events(
|
||||||
|
this.#request,
|
||||||
|
this.#eventBuffer!,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (numEvents > 0) {
|
||||||
|
const event = gpiod.gpiod_edge_event_buffer_get_event(this.#eventBuffer!, 0)
|
||||||
|
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
|
||||||
|
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
|
||||||
|
const offset = gpiod.gpiod_edge_event_get_line_offset(event)
|
||||||
|
|
||||||
|
const pinInfo = this.#offsetMap.get(offset)
|
||||||
|
if (!pinInfo) continue
|
||||||
|
|
||||||
|
const pressed = mapLibgpiodEdgeToPressedState(edgeType, pinInfo.pull)
|
||||||
|
const value = (
|
||||||
|
pressed ? (pinInfo.pull === "up" ? 0 : 1) : pinInfo.pull === "up" ? 1 : 0
|
||||||
|
) as 0 | 1
|
||||||
|
const inputEvent: InputGroupEvent<T> = { pin: pinInfo.name as T, value, timestamp }
|
||||||
|
|
||||||
|
for (const listener of this.#eventListeners) {
|
||||||
|
listener(inputEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.sleep(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.#eventBuffer) {
|
||||||
|
gpiod.gpiod_edge_event_buffer_free(this.#eventBuffer)
|
||||||
|
this.#eventBuffer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.#closed) return
|
||||||
|
this.#closed = true
|
||||||
|
|
||||||
|
for (const handler of this.#closeHandlers) {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
this.#closeHandlers = []
|
||||||
|
|
||||||
|
gpiod.gpiod_line_request_release(this.#request)
|
||||||
|
gpiod.gpiod_chip_close(this.#chip)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { gpiod, GPIOD_EDGE_EVENT_RISING_EDGE } from "./ffi"
|
|
||||||
import { requestInputLine } from "./gpio-helpers"
|
|
||||||
import type { PullMode, EdgeMode } from "./types"
|
|
||||||
|
|
||||||
type WorkerConfig = {
|
|
||||||
chipPath: string
|
|
||||||
offset: number
|
|
||||||
pull: PullMode
|
|
||||||
debounce: number
|
|
||||||
edge: EdgeMode
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkerMessage =
|
|
||||||
| { type: "ready"; initialValue: 0 | 1 }
|
|
||||||
| { type: "event"; value: 0 | 1; timestamp: bigint }
|
|
||||||
| { type: "error"; message: string }
|
|
||||||
|
|
||||||
const postMessage = (message: WorkerMessage) => {
|
|
||||||
self.postMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = (message: string): never => {
|
|
||||||
postMessage({ type: "error", message })
|
|
||||||
self.close()
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapEdgeToValue = (edgeType: number, pull: PullMode): 0 | 1 => {
|
|
||||||
// Pull-up: rising edge = released (1), falling edge = pressed (0)
|
|
||||||
// Pull-down: rising edge = pressed (1), falling edge = released (0)
|
|
||||||
if (pull === "up") {
|
|
||||||
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
|
|
||||||
}
|
|
||||||
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = (config: WorkerConfig) => {
|
|
||||||
const { chip, request } = requestInputLine(config)
|
|
||||||
|
|
||||||
const initialValue = gpiod.gpiod_line_request_get_value(request, config.offset)
|
|
||||||
if (initialValue === -1) {
|
|
||||||
gpiod.gpiod_line_request_release(request)
|
|
||||||
gpiod.gpiod_chip_close(chip)
|
|
||||||
cleanup("Failed to read initial value")
|
|
||||||
}
|
|
||||||
|
|
||||||
postMessage({ type: "ready", initialValue: initialValue as 0 | 1 })
|
|
||||||
|
|
||||||
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
|
|
||||||
if (!buffer) {
|
|
||||||
gpiod.gpiod_line_request_release(request)
|
|
||||||
gpiod.gpiod_chip_close(chip)
|
|
||||||
cleanup("Failed to create event buffer")
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
// Block forever (-1 timeout) until edge event occurs
|
|
||||||
const waitResult = gpiod.gpiod_line_request_wait_edge_events(request, -1)
|
|
||||||
|
|
||||||
if (waitResult === 1) {
|
|
||||||
const numEvents = gpiod.gpiod_line_request_read_edge_events(request, buffer, 1)
|
|
||||||
if (numEvents === -1) cleanup("Failed to read edge events")
|
|
||||||
|
|
||||||
const event = gpiod.gpiod_edge_event_buffer_get_event(buffer, 0)
|
|
||||||
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
|
|
||||||
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
|
|
||||||
|
|
||||||
const value = mapEdgeToValue(edgeType, config.pull)
|
|
||||||
|
|
||||||
postMessage({ type: "event", value, timestamp })
|
|
||||||
} else if (waitResult === -1) {
|
|
||||||
cleanup("GPIO wait_edge_events failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Worker terminates - kernel cleans up GPIO resources automatically
|
|
||||||
}
|
|
||||||
|
|
||||||
self.onmessage = (event: MessageEvent<WorkerConfig>) => {
|
|
||||||
self.onmessage = () => {
|
|
||||||
throw new Error("Worker already initialized")
|
|
||||||
}
|
|
||||||
run(event.data)
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +1,36 @@
|
||||||
import type { InputEvent, InputOptions } from "./types"
|
import type { Pointer } from "bun:ffi"
|
||||||
|
import { InputGroup } from "./input-group"
|
||||||
|
import type { PullMode, InputEvent } from "./types"
|
||||||
|
|
||||||
type WorkerMessage =
|
export class InputPin<T extends string = string> {
|
||||||
| { type: "ready"; initialValue: 0 | 1 }
|
#group: InputGroup<T>
|
||||||
| { type: "event"; value: 0 | 1; timestamp: bigint }
|
#pinName: T
|
||||||
| { type: "error"; message: string }
|
|
||||||
|
|
||||||
export class Input {
|
constructor(group: InputGroup<T>, pinName: T) {
|
||||||
#worker: Worker
|
this.#group = group
|
||||||
#callbacks = new Set<(event: InputEvent) => void>()
|
this.#pinName = pinName
|
||||||
#closed = false
|
|
||||||
#lastValue: 0 | 1 = 0
|
|
||||||
|
|
||||||
constructor(chipPath: string, offset: number, options: InputOptions = {}) {
|
|
||||||
const pull = options.pull ?? "up"
|
|
||||||
const debounce = options.debounce ?? 0
|
|
||||||
const edge = options.edge ?? "both"
|
|
||||||
|
|
||||||
this.#worker = new Worker(new URL("./input-worker.ts", import.meta.url).href)
|
|
||||||
|
|
||||||
this.#worker.onmessage = (msg: MessageEvent<WorkerMessage>) => {
|
|
||||||
if (this.#closed) return
|
|
||||||
|
|
||||||
const data = msg.data
|
|
||||||
|
|
||||||
if (data.type === "ready") {
|
|
||||||
this.#lastValue = data.initialValue
|
|
||||||
} else if (data.type === "event") {
|
|
||||||
this.#lastValue = data.value
|
|
||||||
for (const callback of this.#callbacks) {
|
|
||||||
callback({ value: data.value, timestamp: data.timestamp })
|
|
||||||
}
|
|
||||||
} else if (data.type === "error") {
|
|
||||||
console.error(`GPIO Input Error (pin ${offset}):`, data.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#worker.postMessage({ chipPath, offset, pull, debounce, edge })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): 0 | 1 {
|
get value(): 0 | 1 {
|
||||||
if (this.#closed) throw new Error("Input is closed")
|
return this.#group.pins[this.#pinName]!.value
|
||||||
return this.#lastValue
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(callback: (event: InputEvent) => void): () => void {
|
|
||||||
if (this.#closed) throw new Error("Input is closed")
|
|
||||||
this.#callbacks.add(callback)
|
|
||||||
return () => this.#callbacks.delete(callback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForValue(targetValue: 0 | 1, timeout?: number): Promise<void> {
|
async waitForValue(targetValue: 0 | 1, timeout?: number): Promise<void> {
|
||||||
if (this.#closed) throw new Error("Input is closed")
|
for await (const event of this.#group.events()) {
|
||||||
|
if (event.value === targetValue) {
|
||||||
if (this.#lastValue === targetValue) return
|
return
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
|
||||||
unsubscribe()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribe = this.onChange((event) => {
|
async *events(): AsyncGenerator<InputEvent> {
|
||||||
if (event.value === targetValue) {
|
for await (const event of this.#group.events()) {
|
||||||
cleanup()
|
yield { value: event.value, timestamp: event.timestamp }
|
||||||
resolve()
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!timeout) return
|
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
cleanup()
|
|
||||||
reject(new Error(`Timeout waiting for value ${targetValue}`))
|
|
||||||
}, timeout)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
if (this.#closed) return
|
this.#group.close()
|
||||||
this.#closed = true
|
|
||||||
|
|
||||||
this.#callbacks.clear()
|
|
||||||
this.#worker.onmessage = null
|
|
||||||
this.#worker.terminate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Symbol.dispose]() {
|
[Symbol.dispose]() {
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,30 @@
|
||||||
import type { Pointer } from "bun:ffi"
|
import type { Pointer } from "bun:ffi"
|
||||||
import { gpiod } from "./ffi"
|
import { gpiod } from "./ffi"
|
||||||
import { requestOutputLine } from "./gpio-helpers"
|
|
||||||
import type { OutputOptions } from "./types"
|
|
||||||
|
|
||||||
export class Output {
|
export class OutputPin {
|
||||||
#closed = false
|
#closed = false
|
||||||
#chip: Pointer
|
#chip: Pointer
|
||||||
#request: Pointer
|
#request: Pointer
|
||||||
#offset: number
|
#pin: number
|
||||||
#resetValue?: 0 | 1
|
#resetValue?: 0 | 1
|
||||||
|
|
||||||
constructor(chipPath: string, offset: number, options: OutputOptions = {}) {
|
constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) {
|
||||||
const initialValue = options.initialValue ?? 0
|
|
||||||
const { chip, request } = requestOutputLine({ chipPath, offset, initialValue })
|
|
||||||
|
|
||||||
this.#chip = chip
|
this.#chip = chip
|
||||||
this.#request = request
|
this.#request = request
|
||||||
this.#offset = offset
|
this.#pin = pin
|
||||||
|
this.#resetValue = resetValue
|
||||||
if (options.resetOnClose) {
|
|
||||||
const currentValue = gpiod.gpiod_line_request_get_value(request, offset)
|
|
||||||
this.#resetValue = currentValue === -1 ? 0 : (currentValue as 0 | 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): 0 | 1 {
|
get value(): 0 | 1 {
|
||||||
if (this.#closed) throw new Error("Output is closed")
|
if (this.#closed) throw new Error("OutputPin is closed")
|
||||||
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#offset)
|
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#pin)
|
||||||
if (ret === -1) throw new Error("Failed to get pin value")
|
if (ret === -1) throw new Error("Failed to get pin value")
|
||||||
return ret as 0 | 1
|
return ret as 0 | 1
|
||||||
}
|
}
|
||||||
|
|
||||||
set value(val: 0 | 1) {
|
set value(val: 0 | 1) {
|
||||||
if (this.#closed) throw new Error("Output is closed")
|
if (this.#closed) throw new Error("OutputPin is closed")
|
||||||
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#offset, val)
|
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#pin, val)
|
||||||
if (ret === -1) throw new Error("Failed to set pin value")
|
if (ret === -1) throw new Error("Failed to set pin value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +37,7 @@ export class Output {
|
||||||
this.#closed = true
|
this.#closed = true
|
||||||
|
|
||||||
if (this.#resetValue !== undefined) {
|
if (this.#resetValue !== undefined) {
|
||||||
gpiod.gpiod_line_request_set_value(this.#request, this.#offset, this.#resetValue)
|
gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
gpiod.gpiod_line_request_release(this.#request)
|
gpiod.gpiod_line_request_release(this.#request)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext", "WebWorker"]
|
|
||||||
},
|
|
||||||
"include": ["input-worker.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@ export type InputOptions = {
|
||||||
|
|
||||||
export type OutputOptions = {
|
export type OutputOptions = {
|
||||||
initialValue?: 0 | 1 // default: 0
|
initialValue?: 0 | 1 // default: 0
|
||||||
resetOnClose?: boolean // default: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InputEvent = {
|
export type InputEvent = {
|
||||||
|
|
@ -17,9 +16,15 @@ export type InputEvent = {
|
||||||
timestamp: bigint // nanoseconds
|
timestamp: bigint // nanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InputGroupEvent<T extends string = string> = InputEvent & {
|
||||||
|
pin: T // name of the pin that fired
|
||||||
|
}
|
||||||
|
|
||||||
export type ChipInfo = {
|
export type ChipInfo = {
|
||||||
path: string
|
path: string
|
||||||
name: string
|
name: string
|
||||||
label: string
|
label: string
|
||||||
numLines: number
|
numLines: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PinConfig = Record<string, { offset: number; pull: PullMode }>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ export const mapEdgeToLibgpiod = (edge: EdgeMode): number => {
|
||||||
// Hardware logic:
|
// Hardware logic:
|
||||||
// - Pull-up + button to GND: pressing pulls line LOW (falling edge = pressed)
|
// - Pull-up + button to GND: pressing pulls line LOW (falling edge = pressed)
|
||||||
// - Pull-down + button to VCC: pressing pulls line HIGH (rising edge = pressed)
|
// - Pull-down + button to VCC: pressing pulls line HIGH (rising edge = pressed)
|
||||||
export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode): boolean => {
|
export const mapLibgpiodEdgeToPressedState = (
|
||||||
|
edgeType: number,
|
||||||
|
pull: PullMode
|
||||||
|
): boolean => {
|
||||||
if (pull === "up") {
|
if (pull === "up") {
|
||||||
return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE
|
return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE
|
||||||
} else if (pull === "down") {
|
} else if (pull === "down") {
|
||||||
|
|
@ -48,6 +51,10 @@ export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hashInputConfig = (pull: PullMode, debounce: number, edge: EdgeMode): string => {
|
export const hashInputConfig = (
|
||||||
|
pull: PullMode,
|
||||||
|
debounce: number,
|
||||||
|
edge: EdgeMode
|
||||||
|
): string => {
|
||||||
return `${pull}-${debounce}-${edge}`
|
return `${pull}-${debounce}-${edge}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,12 @@ async function stopAP() {
|
||||||
async function checkAndManageAP() {
|
async function checkAndManageAP() {
|
||||||
const connected = await isConnectedToWiFi()
|
const connected = await isConnectedToWiFi()
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
|
||||||
|
apRunning ? "running" : "stopped"
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
if (connected && apRunning) {
|
if (connected && apRunning) {
|
||||||
console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP")
|
console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP")
|
||||||
await stopAP()
|
await stopAP()
|
||||||
|
|
@ -128,7 +134,7 @@ async function checkAndManageAP() {
|
||||||
const savedNetwork = await findAvailableSavedNetwork()
|
const savedNetwork = await findAvailableSavedNetwork()
|
||||||
if (savedNetwork) {
|
if (savedNetwork) {
|
||||||
console.log(
|
console.log(
|
||||||
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`,
|
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try to connect first
|
// Try to connect first
|
||||||
|
|
@ -224,12 +230,6 @@ async function tryConnect(connectionName: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial check
|
// Initial check
|
||||||
const connected = await isConnectedToWiFi()
|
|
||||||
console.log(
|
|
||||||
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
|
|
||||||
apRunning ? "running" : "stopped"
|
|
||||||
}`,
|
|
||||||
)
|
|
||||||
await checkAndManageAP()
|
await checkAndManageAP()
|
||||||
|
|
||||||
// Check periodically
|
// Check periodically
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ export const IndexPage = () => (
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Connect to Network</button>
|
<button type="submit">Connect to Network</button>
|
||||||
</form>
|
</form>
|
||||||
|
<footer>
|
||||||
|
<small>
|
||||||
|
<a href="/logs">📋 View Service Logs</a>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<script dangerouslySetInnerHTML={{__html: `
|
<script dangerouslySetInnerHTML={{__html: `
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,7 @@ export const Layout = ({ title, children, refresh }: LayoutProps) => (
|
||||||
<link rel="stylesheet" href="/pico.css" />
|
<link rel="stylesheet" href="/pico.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="container">
|
<main class="container">{children}</main>
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><strong>☎️ Phone</strong></li>
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/">WiFi Setup</a></li>
|
|
||||||
<li><a href="/logs">Logs</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,62 @@
|
||||||
import { Layout } from "./Layout";
|
import { Layout } from "./Layout";
|
||||||
|
|
||||||
type LogsPageProps = {
|
type LogsPageProps = {
|
||||||
service: string;
|
|
||||||
logs: string;
|
logs: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogsPage = ({ service, logs }: LogsPageProps) => (
|
export const LogsPage = ({ logs }: LogsPageProps) => (
|
||||||
<Layout title="Service Logs">
|
<Layout title="Service Logs">
|
||||||
<h1>📋 Service Logs</h1>
|
<h1>📋 Service Logs</h1>
|
||||||
|
<p>
|
||||||
<div role="group">
|
<small>
|
||||||
<a
|
<label>
|
||||||
href="/logs?service=phone-ap"
|
<input type="checkbox" id="auto-refresh" checked /> Auto-refresh
|
||||||
role="button"
|
</label>
|
||||||
aria-current={service === "phone-ap" ? "true" : undefined}
|
{" | "}
|
||||||
>
|
<a href="/">← Back</a>
|
||||||
📡 WiFi AP
|
</small>
|
||||||
</a>
|
</p>
|
||||||
<a
|
<pre>
|
||||||
href="/logs?service=phone-web"
|
<code id="logs-content">{logs.trim()}</code>
|
||||||
role="button"
|
|
||||||
aria-current={service === "phone-web" ? "true" : undefined}
|
|
||||||
>
|
|
||||||
🌐 Web Server
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/logs?service=phone"
|
|
||||||
role="button"
|
|
||||||
aria-current={service === "phone" ? "true" : undefined}
|
|
||||||
>
|
|
||||||
☎️ Phone App
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<pre style="margin-top: 1rem;">
|
|
||||||
<code>{logs.trim()}</code>
|
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
|
<script>{`
|
||||||
|
let interval = null;
|
||||||
|
const checkbox = document.getElementById('auto-refresh');
|
||||||
|
const logsContent = document.getElementById('logs-content');
|
||||||
|
|
||||||
|
async function refreshLogs() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/logs');
|
||||||
|
const data = await res.json();
|
||||||
|
logsContent.textContent = data.logs;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh logs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRefresh() {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
interval = setInterval(refreshLogs, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRefresh() {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
interval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkbox.addEventListener('change', (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
startRefresh();
|
||||||
|
} else {
|
||||||
|
stopRefresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start auto-refresh by default
|
||||||
|
startRefresh();
|
||||||
|
`}</script>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
#!/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"
|
||||||
|
|
@ -34,24 +36,30 @@ app.get("/api/networks", async (c) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// API endpoint to get logs (for auto-refresh)
|
||||||
|
app.get("/api/logs", async (c) => {
|
||||||
|
try {
|
||||||
|
const logs =
|
||||||
|
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
|
||||||
|
return c.json({ logs: logs.trim() })
|
||||||
|
} catch (error) {
|
||||||
|
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
|
// Service logs with auto-refresh
|
||||||
app.get("/logs", async (c) => {
|
app.get("/logs", async (c) => {
|
||||||
const service = c.req.query("service") || "phone-ap"
|
|
||||||
const validServices = ["phone-ap", "phone-web", "phone"]
|
|
||||||
|
|
||||||
// Default to phone-ap if invalid service
|
|
||||||
const selectedService = validServices.includes(service) ? service : "phone-ap"
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const logs = await $`journalctl -u ${selectedService}.service -n 200 --no-pager --no-hostname`.text()
|
const logs =
|
||||||
return c.html(<LogsPage service={selectedService} logs={logs} />)
|
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
|
||||||
|
return c.html(<LogsPage logs={logs} />)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return c.html(<LogsPage service={selectedService} logs={`Error loading logs: ${error}`} />)
|
throw new Error(`Failed to fetch logs: ${error}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -85,8 +93,7 @@ app.post("/save", async (c) => {
|
||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
|
|
||||||
const port = process.env.PORT ? Number(process.env.PORT) : 80
|
export default { port: 80, fetch: app.fetch }
|
||||||
export default { port, fetch: app.fetch }
|
|
||||||
|
|
||||||
console.log(`Server running on http://0.0.0.0:${port}`)
|
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://phone.local")
|
||||||
|
|
|
||||||
22
src/sip.ts
22
src/sip.ts
|
|
@ -1,15 +1,15 @@
|
||||||
import log from "./utils/log.ts"
|
import { log } from "./utils/log.ts"
|
||||||
import { Emitter } from "./utils/emitter.ts"
|
import { Signal } from "./utils/signal.ts"
|
||||||
import { processStdout, processStderr } from "./utils/stdio.ts"
|
import { processStdout, processStderr } from "./utils/stdio.ts"
|
||||||
|
|
||||||
export class Baresip {
|
export class Baresip {
|
||||||
baresipArgs: string[]
|
baresipArgs: string[]
|
||||||
process?: Bun.PipedSubprocess
|
process?: Bun.PipedSubprocess
|
||||||
callEstablished = new Emitter<{ contact: string }>()
|
callEstablished = new Signal<{ contact: string }>()
|
||||||
callReceived = new Emitter<{ contact: string }>()
|
callReceived = new Signal<{ contact: string }>()
|
||||||
hungUp = new Emitter()
|
hungUp = new Signal()
|
||||||
error = new Emitter<{ message: string }>()
|
error = new Signal<{ message: string }>()
|
||||||
registrationSuccess = new Emitter()
|
registrationSuccess = new Signal()
|
||||||
|
|
||||||
constructor(baresipArgs: string[]) {
|
constructor(baresipArgs: string[]) {
|
||||||
this.baresipArgs = baresipArgs
|
this.baresipArgs = baresipArgs
|
||||||
|
|
@ -48,10 +48,10 @@ export class Baresip {
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectAll() {
|
disconnectAll() {
|
||||||
this.callEstablished.removeAllListeners()
|
this.callEstablished.disconnect()
|
||||||
this.callReceived.removeAllListeners()
|
this.callReceived.disconnect()
|
||||||
this.hungUp.removeAllListeners()
|
this.hungUp.disconnect()
|
||||||
this.registrationSuccess.removeAllListeners()
|
this.registrationSuccess.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
kill() {
|
kill() {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ console.log("")
|
||||||
// Test 2: Create player
|
// Test 2: Create player
|
||||||
console.log("🔊 Creating default player...")
|
console.log("🔊 Creating default player...")
|
||||||
try {
|
try {
|
||||||
const player = await Buzz.player()
|
const player = await Buzz.defaultPlayer()
|
||||||
console.log("✅ Player created\n")
|
console.log("✅ Player created\n")
|
||||||
|
|
||||||
// Test 3: Play sound file
|
// Test 3: Play sound file
|
||||||
|
|
@ -42,7 +42,7 @@ try {
|
||||||
// Test 5: Create recorder
|
// Test 5: Create recorder
|
||||||
console.log("🎤 Creating default recorder...")
|
console.log("🎤 Creating default recorder...")
|
||||||
try {
|
try {
|
||||||
const recorder = await Buzz.recorder()
|
const recorder = await Buzz.defaultRecorder()
|
||||||
console.log("✅ Recorder created\n")
|
console.log("✅ Recorder created\n")
|
||||||
|
|
||||||
// Test 6: Stream recording with RMS
|
// Test 6: Stream recording with RMS
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Buzz from "./buzz/index.ts"
|
import Buzz from "./buzz/index.ts"
|
||||||
|
import type { Playback } from "./buzz/utils.ts"
|
||||||
import { Agent } from "./agent/index.ts"
|
import { Agent } from "./agent/index.ts"
|
||||||
import { searchWeb } from "./agent/tools.ts"
|
import { searchWeb } from "./agent/tools.ts"
|
||||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts"
|
import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts"
|
||||||
|
|
@ -7,8 +8,8 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
||||||
console.log("📞 Phone System Starting\n")
|
console.log("📞 Phone System Starting\n")
|
||||||
await Buzz.setVolume(0.4)
|
await Buzz.setVolume(0.4)
|
||||||
|
|
||||||
const recorder = await Buzz.recorder()
|
const recorder = await Buzz.defaultRecorder()
|
||||||
const player = await Buzz.player()
|
const player = await Buzz.defaultPlayer()
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
agentId,
|
agentId,
|
||||||
|
|
@ -18,13 +19,13 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let currentDialtone: Buzz.Playback | undefined
|
let currentDialtone: Playback | undefined
|
||||||
let currentBackgroundNoise: Buzz.Playback | undefined
|
let currentBackgroundNoise: Playback | undefined
|
||||||
let streamPlayback = player.playStream()
|
let streamPlayback = player.playStream()
|
||||||
const waitingIndicator = new WaitingSounds(player)
|
const waitingIndicator = new WaitingSounds(player)
|
||||||
|
|
||||||
// Set up agent event listeners
|
// Set up agent event listeners
|
||||||
agent.events.on(async (event) => {
|
agent.events.connect(async (event) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "connected":
|
case "connected":
|
||||||
console.log("✅ Connected to AI agent\n")
|
console.log("✅ Connected to AI agent\n")
|
||||||
|
|
@ -162,7 +163,7 @@ if (!apiKey) {
|
||||||
|
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
console.error(
|
console.error(
|
||||||
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required",
|
"❌ 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")
|
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,46 @@
|
||||||
import GPIO from "./pins"
|
import { GPIO } from "./pins"
|
||||||
|
|
||||||
console.log(`kill -9 ${process.pid}`)
|
console.log(`kill -9 ${process.pid}`)
|
||||||
|
|
||||||
const gpio = new GPIO()
|
const gpio = new GPIO({ resetOnClose: true })
|
||||||
|
|
||||||
|
// // Blink an LED
|
||||||
using led = gpio.output(21)
|
using led = gpio.output(21)
|
||||||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
|
||||||
using switchInput = gpio.input(16, { pull: "up", debounce: 10 })
|
|
||||||
|
|
||||||
led.value = button.value
|
// Read a button
|
||||||
|
using inputs = gpio.inputGroup({
|
||||||
|
button: { pin: 20, pull: "up", debounce: 10 },
|
||||||
|
switch: { pin: 16, pull: "up", debounce: 10 }
|
||||||
|
})
|
||||||
|
|
||||||
button.onChange((event) => {
|
led.value = inputs.pins.button.value
|
||||||
led.value = event.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) => {
|
const switchEvent = new Promise<void>(async (resolve) => {
|
||||||
await switchInput.waitForValue(0)
|
await inputs.pins.switch.waitForValue(0)
|
||||||
console.log("Switch pressed!")
|
console.log("Switch pressed!")
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
button.close()
|
inputs.close()
|
||||||
switchInput.close()
|
|
||||||
led.close()
|
led.close()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
button.close()
|
inputs.close()
|
||||||
switchInput.close()
|
|
||||||
led.close()
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
await switchEvent
|
await Promise.race([iteratorEvents, switchEvent])
|
||||||
|
|
||||||
console.log(`👋 Goodbye!`)
|
console.log(`👋 Goodbye!`)
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/**
|
|
||||||
* How to use Emitter:
|
|
||||||
*
|
|
||||||
* Create an emitter:
|
|
||||||
* const chat = new Emitter<{ username: string, message: string }>()
|
|
||||||
*
|
|
||||||
* Listen to events:
|
|
||||||
* const off = chat.on((data) => {
|
|
||||||
* const {username, message} = data;
|
|
||||||
* console.log(`${username} said "${message}"`);
|
|
||||||
* })
|
|
||||||
*
|
|
||||||
* Emit an event:
|
|
||||||
* chat.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
|
|
||||||
*
|
|
||||||
* Remove a specific listener:
|
|
||||||
* off(); // The off function is returned when you add a listener
|
|
||||||
*
|
|
||||||
* Remove all listeners:
|
|
||||||
* chat.removeAllListeners()
|
|
||||||
*/
|
|
||||||
export class Emitter<T = void> {
|
|
||||||
private listeners: Array<(data: T) => void> = []
|
|
||||||
|
|
||||||
on(listener: (data: T) => void) {
|
|
||||||
this.listeners.push(listener)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.listeners = this.listeners.filter((l) => l !== listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(data: T) {
|
|
||||||
for (const listener of this.listeners) {
|
|
||||||
listener(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAllListeners() {
|
|
||||||
this.listeners = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import type GPIO from "../pins"
|
|
||||||
|
|
||||||
export const ensure = <T>(value: T, message: string): T => {
|
export const ensure = <T>(value: T, message: string): T => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
|
|
@ -11,17 +9,3 @@ export const ensure = <T>(value: T, message: string): T => {
|
||||||
export const random = <T>(arr: ReadonlyArray<T>): T => {
|
export const random = <T>(arr: ReadonlyArray<T>): T => {
|
||||||
return arr[Math.floor(Math.random() * arr.length)]!
|
return arr[Math.floor(Math.random() * arr.length)]!
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ring = async (ringer: GPIO.Output, duration: number, signal?: AbortSignal) => {
|
|
||||||
try {
|
|
||||||
const endAt = performance.now() + duration
|
|
||||||
while (performance.now() < endAt && !signal?.aborted) {
|
|
||||||
ringer.value = 1
|
|
||||||
await Bun.sleep(50)
|
|
||||||
ringer.value = 0
|
|
||||||
await Bun.sleep(50)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
ringer.value = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
let showDebug = process.env.DEBUG ?? false
|
let showDebug = true
|
||||||
let showInfo = true
|
let showInfo = true
|
||||||
|
let showError = true
|
||||||
|
|
||||||
export function setLogLevel(level: "debug" | "info" | "error") {
|
export function setLogLevel(level: "debug" | "info" | "error" | "none") {
|
||||||
showDebug = level === "debug"
|
showDebug = level === "debug"
|
||||||
showInfo = level === "debug" || level === "info"
|
showInfo = level === "debug" || level === "info"
|
||||||
|
showError = level !== "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = (...args: any[]) => {
|
export const log = {
|
||||||
if (showInfo) console.log("👁️🗨️ INFO: ", ...args)
|
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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug = (...args: any[]) => {
|
|
||||||
if (showDebug) console.debug("🪲 DEBUG: ", ...args)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error = (...args: any[]) => {
|
|
||||||
console.error("💥 ERROR: ", ...args)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default log
|
|
||||||
|
|
|
||||||
57
src/utils/signal.ts
Normal file
57
src/utils/signal.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* How to use a Signal:
|
||||||
|
*
|
||||||
|
* Create a signal:
|
||||||
|
* const chatSignal = new Signal<{ username: string, message: string }>()
|
||||||
|
*
|
||||||
|
* Connect to the signal:
|
||||||
|
* const disconnect = chatSignal.connect((data) => {
|
||||||
|
* const {username, message} = data;
|
||||||
|
* console.log(`${username} said "${message}"`);
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* Emit a signal:
|
||||||
|
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
|
||||||
|
*
|
||||||
|
* Forward a signal:
|
||||||
|
* const relaySignal = new Signal<{ username: string, message: string }>()
|
||||||
|
* const disconnectRelay = chatSignal.connect(relaySignal)
|
||||||
|
* // Now, when chatSignal emits, relaySignal will also emit the same data
|
||||||
|
*
|
||||||
|
* Disconnect a single listener:
|
||||||
|
* disconnect(); // The disconnect function is returned when you connect to a signal
|
||||||
|
*
|
||||||
|
* Disconnect all listeners:
|
||||||
|
* chatSignal.disconnect()
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Signal<T extends object | void> {
|
||||||
|
private listeners: Array<(data: T) => void> = []
|
||||||
|
|
||||||
|
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
|
||||||
|
let listener: (data: T) => void
|
||||||
|
|
||||||
|
// If it is a signal, forward the data to the signal
|
||||||
|
if (listenerOrSignal instanceof Signal) {
|
||||||
|
listener = (data: T) => listenerOrSignal.emit(data)
|
||||||
|
} else {
|
||||||
|
listener = listenerOrSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listeners.push(listener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter((l) => l !== listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(data: T) {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.listeners = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import log from "./log.ts"
|
import { log } from "./log.ts"
|
||||||
|
|
||||||
export const LineSplitter = () => {
|
export const LineSplitter = () => {
|
||||||
let buffer = ""
|
let buffer = ""
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,21 @@
|
||||||
import Buzz from "../buzz/index.ts"
|
import { type Player } from "../buzz/index.ts"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
|
import type { Playback, StreamingPlayback } from "../buzz/utils.ts"
|
||||||
import { random } from "./index.ts"
|
import { random } from "./index.ts"
|
||||||
import { log } from "console"
|
|
||||||
|
|
||||||
export class WaitingSounds {
|
export class WaitingSounds {
|
||||||
typingPlayback?: Buzz.Playback
|
typingPlayback?: Playback
|
||||||
speakingPlayback?: Buzz.Playback
|
speakingPlayback?: Playback
|
||||||
playing = false
|
|
||||||
|
|
||||||
constructor(private player: Buzz.Player) {}
|
constructor(private player: Player) {}
|
||||||
|
|
||||||
async start(operatorStream: Buzz.StreamingPlayback) {
|
async start(operatorStream: StreamingPlayback) {
|
||||||
if (this.playing) return // Already playing
|
if (this.typingPlayback) return // Already playing
|
||||||
|
|
||||||
this.playing = true
|
|
||||||
this.#startTypingSounds()
|
this.#startTypingSounds()
|
||||||
this.#startSpeakingSounds(operatorStream)
|
this.#startSpeakingSounds(operatorStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
get isPlaying() {
|
|
||||||
return this.playing
|
|
||||||
}
|
|
||||||
|
|
||||||
async #startTypingSounds() {
|
async #startTypingSounds() {
|
||||||
return new Promise<void>(async (resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
do {
|
do {
|
||||||
|
|
@ -35,53 +29,50 @@ export class WaitingSounds {
|
||||||
const typingSound = getSound(dir)
|
const typingSound = getSound(dir)
|
||||||
this.typingPlayback = await this.player.play(typingSound)
|
this.typingPlayback = await this.player.play(typingSound)
|
||||||
await this.typingPlayback.finished()
|
await this.typingPlayback.finished()
|
||||||
} while (this.isPlaying)
|
} while (this.typingPlayback)
|
||||||
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async #startSpeakingSounds(operatorStream: Buzz.StreamingPlayback) {
|
async #startSpeakingSounds(operatorStream: StreamingPlayback) {
|
||||||
const playedSounds = new Set<string>()
|
const playedSounds = new Set<string>()
|
||||||
let dir: SoundDir | undefined
|
let dir: SoundDir | undefined
|
||||||
return new Promise<void>(async (resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
// Don't start playing speaking sounds until the operator stream has been silent for a bit
|
|
||||||
while (operatorStream.bufferEmptyFor < 1500) {
|
while (operatorStream.bufferEmptyFor < 1500) {
|
||||||
await Bun.sleep(100)
|
await Bun.sleep(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const lastSoundDir = dir
|
const lastSoundDir = dir
|
||||||
|
const value = Math.random() * 100
|
||||||
if (lastSoundDir === "body-noises") {
|
if (lastSoundDir === "body-noises") {
|
||||||
dir = "apology"
|
dir = "apology"
|
||||||
|
} else if (value > 99 && !lastSoundDir) {
|
||||||
|
dir = "body-noises"
|
||||||
|
} else if (value > 75 && !lastSoundDir) {
|
||||||
|
dir = "stalling"
|
||||||
} else {
|
} else {
|
||||||
// sleep for 4-6 seconds
|
dir = undefined
|
||||||
await Bun.sleep(4000 + Math.random() * 2000)
|
await Bun.sleep(1000)
|
||||||
const value = Math.random() * 100
|
|
||||||
|
|
||||||
if (value > 95) {
|
|
||||||
dir = "body-noises"
|
|
||||||
} else {
|
|
||||||
dir = "stalling"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const speakingSound = getSound(dir, Array.from(playedSounds))
|
if (dir) {
|
||||||
this.speakingPlayback = await this.player.play(speakingSound)
|
const speakingSound = getSound(dir, Array.from(playedSounds))
|
||||||
playedSounds.add(speakingSound)
|
this.speakingPlayback = await this.player.play(speakingSound)
|
||||||
await this.speakingPlayback.finished()
|
playedSounds.add(speakingSound)
|
||||||
} while (this.isPlaying)
|
await this.speakingPlayback.finished()
|
||||||
|
}
|
||||||
|
} while (this.typingPlayback)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
log(`🛑 Stopping waiting sounds. Playing? ${this.playing}`)
|
if (!this.typingPlayback) return
|
||||||
if (!this.playing) return
|
|
||||||
this.playing = false
|
|
||||||
|
|
||||||
await Promise.all([this.typingPlayback?.stop(), this.speakingPlayback?.finished()])
|
await Promise.all([this.typingPlayback.stop(), this.speakingPlayback?.finished()])
|
||||||
log("🛑 Waiting sounds stopped")
|
this.typingPlayback = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,5 @@
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
}
|
||||||
"exclude": ["src/pins/input-worker.ts"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user