diff --git a/README.md b/README.md index e0b4215..e740ec5 100644 --- a/README.md +++ b/README.md @@ -13,27 +13,19 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and ### What It Does -1. **Creates directory** on the Pi at the configured path -2. **Copies files** from local `pi/` directory to the Pi -3. **Sets permissions** to make all TypeScript files executable -4. **Bootstrap (optional)**: If `--bootstrap` flag is passed, runs `bootstrap.ts` on the Pi with sudo -5. **Service management**: +1. **Copies files** from here to the pi (in ~/phone by default) +2. **Bootstrap (optional)**: If `--bootstrap` flag is passed it will bootstrap the pi with everything it needs +3. **Service management**: - Checks if `phone-ap.service` and `phone-web.service` exist - If they exist, restarts both services - If they don't exist and bootstrap wasn't run, warns the user ### Usage -**Standard deployment** (just copy files and restart services): - ```bash -bun deploy.ts -``` - -**First-time deployment** (copy files + run bootstrap): - -```bash -bun deploy.ts --bootstrap +bun scripts/deploy.ts +# or bun deploy.ts --bootstrap +>>>>>>> Stashed changes ``` ### Services @@ -50,8 +42,14 @@ After deployment, the Pi is accessible at: - **Web URL**: http://phone.local - **WiFi Network**: phone-setup -### Requirements +### Local Requirements - Bun runtime + <<<<<<< HEAD + <<<<<<< Updated upstream +- # SSH access to `yellow-phone.local` - SSH access to `phone.local` -- Local `pi/` directory with files to deploy + > > > > > > > 1c717a5b4772147c3b7ce20f512452f13c8cc510 +- # Local `pi/` directory with files to deploy +- SSH access to `phone.local` + > > > > > > > Stashed changes diff --git a/baresip/accounts b/baresip/accounts new file mode 100644 index 0000000..39cce39 --- /dev/null +++ b/baresip/accounts @@ -0,0 +1 @@ +;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300 \ No newline at end of file diff --git a/baresip/config b/baresip/config new file mode 100644 index 0000000..9485572 --- /dev/null +++ b/baresip/config @@ -0,0 +1,71 @@ +# +# baresip configuration +# + +#------------------------------------------------------------------------------ + +# Core +poll_method epoll # poll, select, epoll .. +ring_aufile none + +# Call +call_local_timeout 120 +call_max_calls 4 + +# Audio +audio_player alsa,default +audio_source alsa,default +audio_alert none +audio_alert_enable no +audio_level no +ausrc_format s16 # s16, float, .. +auplay_format s16 # s16, float, .. +auenc_format s16 # s16, float, .. +audec_format s16 # s16, float, .. +audio_buffer 20-160 # ms + +# AVT - Audio/Video Transport +rtp_tos 184 +rtcp_mux no +jitter_buffer_delay 5-10 # frames +rtp_stats no + + +#------------------------------------------------------------------------------ +# Modules + +module_path /usr/lib/baresip/modules + +# UI Modules +#module stdio.so + +# Audio codec Modules (in order) +module g711.so + + +# Audio driver Modules +module alsa.so + +# Media NAT modules +module stun.so +module turn.so +module ice.so + +module httpd.so + +#------------------------------------------------------------------------------ +# Temporary Modules (loaded then unloaded) + +module_tmp uuid.so +module_tmp account.so + + +#------------------------------------------------------------------------------ +# Application Modules + +module_app contact.so +module_app debug_cmd.so +module_app menu.so + + +http_listen 0.0.0.0:8000 # httpd - HTTP Serve \ No newline at end of file diff --git a/bun.lock b/bun.lock index ef6cf04..b555245 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "hono": "^4.10.4", "openai": "^6.9.0", + "robot3": "^1.2.0", }, "devDependencies": { "@types/bun": "latest", @@ -30,6 +31,8 @@ "openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="], + "robot3": ["robot3@1.2.0", "", {}, "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/package.json b/package.json index 951e23b..8c0ca93 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "hono": "^4.10.4", - "openai": "^6.9.0" + "openai": "^6.9.0", + "robot3": "^1.2.0" }, "prettier": { "semi": false, diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 9cc84c2..aa1a894 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -2,8 +2,8 @@ import { $ } from "bun" -const PI_HOST = process.env.PI_HOST || "phone.local" -const PI_DIR = "/home/corey/phone" +const PI_HOST = process.env.PI_HOST ?? "phone.local" +const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone" // Parse command line arguments const shouldBootstrap = process.argv.includes("--bootstrap") @@ -37,6 +37,9 @@ if (shouldBootstrap) { await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun ${PI_DIR}/scripts/bootstrap.ts ${PI_DIR}"` } +// make console beep +await $`afplay /System/Library/Sounds/Blow.aiff` + // Always check if services exist and restart them (whether we bootstrapped or not) console.log("Checking for existing services...") const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"` diff --git a/sounds/apology/excuse-me1.wav b/sounds/apology/excuse-me1.wav index 504afb2..02bd749 100644 Binary files a/sounds/apology/excuse-me1.wav and b/sounds/apology/excuse-me1.wav differ diff --git a/sounds/apology/excuse-me2.wav b/sounds/apology/excuse-me2.wav index 95f726e..29f7b3d 100644 Binary files a/sounds/apology/excuse-me2.wav and b/sounds/apology/excuse-me2.wav differ diff --git a/sounds/apology/excuse-me3.wav b/sounds/apology/excuse-me3.wav index b1026c6..e06f71a 100644 Binary files a/sounds/apology/excuse-me3.wav and b/sounds/apology/excuse-me3.wav differ diff --git a/sounds/body-noises/burp1.wav b/sounds/body-noises/burp1.wav index 0bbd034..c828aae 100644 Binary files a/sounds/body-noises/burp1.wav and b/sounds/body-noises/burp1.wav differ diff --git a/sounds/body-noises/burp2.wav b/sounds/body-noises/burp2.wav index f3f9fc2..b98baa5 100644 Binary files a/sounds/body-noises/burp2.wav and b/sounds/body-noises/burp2.wav differ diff --git a/sounds/body-noises/fart1.wav b/sounds/body-noises/fart1.wav index e273ca4..369a163 100644 Binary files a/sounds/body-noises/fart1.wav and b/sounds/body-noises/fart1.wav differ diff --git a/sounds/body-noises/fart2.wav b/sounds/body-noises/fart2.wav index 7d4b3b2..cee3178 100644 Binary files a/sounds/body-noises/fart2.wav and b/sounds/body-noises/fart2.wav differ diff --git a/sounds/stalling/cough1.wav b/sounds/stalling/cough1.wav index 0897991..85070db 100644 Binary files a/sounds/stalling/cough1.wav and b/sounds/stalling/cough1.wav differ diff --git a/sounds/stalling/cough2.wav b/sounds/stalling/cough2.wav index bc3f72c..a8f5a4a 100644 Binary files a/sounds/stalling/cough2.wav and b/sounds/stalling/cough2.wav differ diff --git a/sounds/stalling/hmm1.wav b/sounds/stalling/hmm1.wav index 460f5d1..2deca4d 100644 Binary files a/sounds/stalling/hmm1.wav and b/sounds/stalling/hmm1.wav differ diff --git a/sounds/stalling/hmm2.wav b/sounds/stalling/hmm2.wav index 56eac19..0d6e927 100644 Binary files a/sounds/stalling/hmm2.wav and b/sounds/stalling/hmm2.wav differ diff --git a/sounds/stalling/one-sec.wav b/sounds/stalling/one-sec.wav index daba071..8bd7022 100644 Binary files a/sounds/stalling/one-sec.wav and b/sounds/stalling/one-sec.wav differ diff --git a/sounds/stalling/sigh1.wav b/sounds/stalling/sigh1.wav index 5aa7c43..49e93cc 100644 Binary files a/sounds/stalling/sigh1.wav and b/sounds/stalling/sigh1.wav differ diff --git a/sounds/stalling/sigh2.wav b/sounds/stalling/sigh2.wav index 26bd5b3..365a6d3 100644 Binary files a/sounds/stalling/sigh2.wav and b/sounds/stalling/sigh2.wav differ diff --git a/sounds/stalling/sneeze.wav b/sounds/stalling/sneeze.wav index 83784c3..f9a8c25 100644 Binary files a/sounds/stalling/sneeze.wav and b/sounds/stalling/sneeze.wav differ diff --git a/sounds/stalling/uh-huh.wav b/sounds/stalling/uh-huh.wav index e09da43..ce96b5b 100644 Binary files a/sounds/stalling/uh-huh.wav and b/sounds/stalling/uh-huh.wav differ diff --git a/sounds/stalling/yeah.wav b/sounds/stalling/yeah.wav index 5895f1f..b46ba8c 100644 Binary files a/sounds/stalling/yeah.wav and b/sounds/stalling/yeah.wav differ diff --git a/src/buzz/player.ts b/src/buzz/player.ts index 04e251a..d1c8425 100644 --- a/src/buzz/player.ts +++ b/src/buzz/player.ts @@ -145,11 +145,7 @@ export class Player { "-", ] - const proc = Bun.spawn(["aplay", ...args], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }) + const proc = Bun.spawn(["aplay", ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe" }) let bufferFinishTime = performance.now() const format = this.#format diff --git a/src/buzz/utils.ts b/src/buzz/utils.ts index 9c06042..5035405 100644 --- a/src/buzz/utils.ts +++ b/src/buzz/utils.ts @@ -1,66 +1,66 @@ // Audio format configuration export type AudioFormat = { - format?: string; - sampleRate?: number; - channels?: number; -}; + format?: string + sampleRate?: number + channels?: number +} // Default audio format for recordings and tone generation export const DEFAULT_AUDIO_FORMAT = { - format: 'S16_LE', + format: "S16_LE", sampleRate: 16000, channels: 1, -} as const; +} as const // Device from ALSA listing export type Device = { - id: string; // "default" or "plughw:1,0" - card: number; // ALSA card number - device: number; // ALSA device number - label: string; // Human-readable name - type: 'playback' | 'capture'; -}; + id: string // "default" or "plughw:1,0" + card: number // ALSA card number + device: number // ALSA device number + label: string // Human-readable name + type: "playback" | "capture" +} // Playback control handle export type Playback = { - isPlaying: boolean; - stop: () => Promise; - finished: () => Promise; -}; + isPlaying: boolean + stop: () => Promise + finished: () => Promise +} // Streaming playback handle export type StreamingPlayback = { - isPlaying: boolean; - write: (chunk: Uint8Array) => void; - stop: () => Promise; - bufferEmptyFor: number; // milliseconds since buffer became empty, 0 if not empty -}; + isPlaying: boolean + write: (chunk: Uint8Array) => void + stop: () => Promise + bufferEmptyFor: number // milliseconds since buffer became empty, 0 if not empty +} // Streaming recording control handle export type StreamingRecording = { - isRecording: boolean; - stream: () => ReadableStream; - stop: () => Promise; -}; + isRecording: boolean + stream: () => ReadableStream + stop: () => Promise +} // File recording control handle export type FileRecording = { - isRecording: boolean; - stop: () => Promise; -}; + isRecording: boolean + stop: () => Promise +} -const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | undefined => { - if (!line.startsWith('card ')) return undefined; +const parseDeviceLine = (line: string, type: "playback" | "capture"): Device | undefined => { + if (!line.startsWith("card ")) return undefined - const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/); - if (!match) return undefined; + const match = line.match(/^card (\d+):\s+\w+\s+\[(.+?)\],\s+device (\d+):/) + if (!match) return undefined - const [, cardStr, label, deviceStr] = match; + const [, cardStr, label, deviceStr] = match - if (!cardStr || !label || !deviceStr) return undefined; + if (!cardStr || !label || !deviceStr) return undefined - const card = parseInt(cardStr); - const device = parseInt(deviceStr); + const card = parseInt(cardStr) + const device = parseInt(deviceStr) return { id: `plughw:${card},${device}`, @@ -68,79 +68,77 @@ const parseDeviceLine = (line: string, type: 'playback' | 'capture'): Device | u device, label, type, - }; -}; + } +} -const parseAlsaDevices = (output: string, type: 'playback' | 'capture'): Device[] => { +const parseAlsaDevices = (output: string, type: "playback" | "capture"): Device[] => { return output - .split('\n') - .map(line => parseDeviceLine(line, type)) - .filter(device => device !== undefined); -}; + .split("\n") + .map((line) => parseDeviceLine(line, type)) + .filter((device) => device !== undefined) +} export const listDevices = async (): Promise => { - const playbackOutput = await Bun.$`aplay -l`.text(); - const captureOutput = await Bun.$`arecord -l`.text(); + const playbackOutput = await Bun.$`aplay -l`.text() + const captureOutput = await Bun.$`arecord -l`.text() - const playback = parseAlsaDevices(playbackOutput, 'playback'); - const capture = parseAlsaDevices(captureOutput, 'capture'); + const playback = parseAlsaDevices(playbackOutput, "playback") + const capture = parseAlsaDevices(captureOutput, "capture") - return [...playback, ...capture]; -}; + return [...playback, ...capture] +} export const findDeviceByLabel = async ( label: string, - type?: 'playback' | 'capture' + type?: "playback" | "capture" ): Promise => { - const devices = await listDevices(); - const device = devices.find(d => - d.label === label && (!type || d.type === type) - ); + const devices = await listDevices() + const device = devices.find((d) => d.label === label && (!type || d.type === type)) if (!device) { - const typeStr = type ? ` (type: ${type})` : ''; - throw new Error(`Device not found: ${label}${typeStr}`); + const typeStr = type ? ` (type: ${type})` : "" + throw new Error(`Device not found: ${label}${typeStr}`) } - return device; -}; + return device +} export const calculateRMS = (chunk: Uint8Array): number => { - const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2); - let sum = 0; + const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 2) + let sum = 0 for (const sample of samples) { - sum += sample * sample; + sum += sample * sample } - return Math.sqrt(sum / samples.length); -}; + return Math.sqrt(sum / samples.length) +} export const generateToneSamples = ( frequencies: number[], sampleRate: number, durationSeconds: number ): Uint8Array => { - const numSamples = Math.floor(sampleRate * durationSeconds); - const buffer = new ArrayBuffer(numSamples * 2); // 2 bytes per S16_LE sample - const samples = new Int16Array(buffer); + const numSamples = Math.floor(sampleRate * durationSeconds) + const buffer = new ArrayBuffer(numSamples * 2) // 2 bytes per S16_LE sample + const samples = new Int16Array(buffer) for (let i = 0; i < numSamples; i++) { - const t = i / sampleRate; - let value = 0; + const t = i / sampleRate + let value = 0 // Mix all frequencies together for (const freq of frequencies) { - value += Math.sin(2 * Math.PI * freq * t); + value += Math.sin(2 * Math.PI * freq * t) } // Average and scale to Int16 range - value = (value / frequencies.length) * 32767; - samples[i] = Math.round(value); + value = (value / frequencies.length) * 32767 + samples[i] = Math.round(value) } - return new Uint8Array(buffer); -}; + return new Uint8Array(buffer) +} export const streamTone = async ( stream: { write: (chunk: Uint8Array) => void; end: () => void }, @@ -148,20 +146,24 @@ export const streamTone = async ( durationMs: number, format: Required ): Promise => { - const infinite = durationMs === Infinity; - const durationSeconds = durationMs / 1000; + const infinite = durationMs === Infinity + const durationSeconds = durationMs / 1000 // Continuous tone - const samples = generateToneSamples(frequencies, format.sampleRate, infinite ? 1 : durationSeconds); + const samples = generateToneSamples( + frequencies, + format.sampleRate, + infinite ? 1 : durationSeconds + ) if (infinite) { // Loop 1-second chunks forever while (true) { - stream.write(samples); - await Bun.sleep(1000); + stream.write(samples) + await Bun.sleep(1000) } } else { - stream.write(samples); - stream.end(); + stream.write(samples) + stream.end() } -}; +} diff --git a/src/hq.ts b/src/hq.ts deleted file mode 100644 index 089dbd8..0000000 --- a/src/hq.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { GPIO } from "./pins" - -console.log(`kill -9 ${process.pid}`) - -const gpio = new GPIO({ resetOnClose: true }) - -// // Blink an LED -using led = gpio.output(21) - -// Read a button -using inputs = gpio.inputGroup({ - button: { pin: 20, pull: "up", debounce: 10 }, - switch: { pin: 16, pull: "up", debounce: 10 } -}) - -led.value = inputs.pins.button.value - -const iteratorEvents = new Promise(async (resolve) => { - for await (const event of inputs.events()) { - if (event.pin === "button") { - console.log(`🌭`, event.value) - led.value = event.value - } - } -}) - -const switchEvent = new Promise(async (resolve) => { - await inputs.pins.switch.waitForValue(0) - console.log("Switch pressed!") - resolve() -}) - -process.on("SIGINT", () => { - inputs.close() - led.close() - process.exit(0) -}) - -process.on("SIGTERM", () => { - inputs.close() - - process.exit(0) -}) - -await Promise.race([iteratorEvents, switchEvent]) - -console.log(`šŸ‘‹ Goodbye!`) \ No newline at end of file diff --git a/src/phone.ts b/src/phone.ts new file mode 100644 index 0000000..c2f677b --- /dev/null +++ b/src/phone.ts @@ -0,0 +1,438 @@ +import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3" +import { Baresip } from "./sip" +import { log } from "./utils/log" +import { sleep } from "bun" +import { processStderr, processStdout } from "./utils/stdio" +import Buzz from "./buzz" +import { join } from "path" +import GPIO from "./pins" +import { Agent } from "./agent" +import { searchWeb } from "./agent/tools" + +// TODO: Kill baresip process on exit + +type CancelableTask = () => void + +type PhoneContext = { + lastError?: string + peer?: string + numberDialed: number + cancelDialTone?: CancelableTask + cancelRinger?: CancelableTask + baresip: Baresip + startAgent: () => CancelableTask + cancelAgent?: CancelableTask +} + +const gpio = new GPIO() +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 }) + +export const startPhone = async (agentId: string, apiKey: string) => { + await Buzz.setVolume(0.4) + log.info(`šŸ“ž Hook ${hook.value}`) + + let digit = 0 + + hook.onChange((event) => { + const type = event.value == 0 ? "hang_up" : "pick_up" + log.info(`šŸ“ž Hook ${event.value} sending ${type}`) + if (type === "hang_up") { + ringer.value = 1 + } else { + ringer.value = 0 + } + }) + + rotaryInUse.onChange((event) => { + if (event.value === 0) { + digit = 0 + } else { + log.info(`šŸ“ž Dialed digit: ${digit}`) + } + }) + + rotaryNumber.onChange((event) => { + if (event.value === 1) { + digit += 1 + } + }) + + // Keep process running + await new Promise(() => {}) +} + +const apiKey = process.env.ELEVEN_API_KEY +const agentId = process.env.ELEVEN_AGENT_ID + +if (!apiKey) { + console.error("āŒ Error: ELEVEN_API_KEY environment variable is required") + process.exit(1) +} + +if (!agentId) { + console.error( + "āŒ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required" + ) + console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai") + process.exit(1) +} + +await startPhone(agentId, apiKey) + +const startBaresip = async (hook: GPIO.InputPin) => { + // const baresipConfig = join(import.meta.dir, "..", "baresip") + // const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig]) + + // baresip.registrationSuccess.connect(async () => { + // log.info("🐻 server connected") + // const result = await gpio.get(pins.hook) + // if (result.state === "low") { + // phoneService.send({ type: "initialized" }) + // } else { + // phoneService.send({ type: "pick_up" }) + // } + // }) + + // baresip.callReceived.connect(({ contact }) => { + // log.info(`🐻 incoming call from ${contact}`) + // phoneService.send({ type: "incoming_call", from: contact }) + // }) + + // baresip.callEstablished.connect(({ contact }) => { + // log.info(`🐻 call established with ${contact}`) + // phoneService.send({ type: "answered" }) + // }) + + // baresip.hungUp.connect(() => { + // log.info("🐻 call hung up") + // phoneService.send({ type: "remote_hang_up" }) + // }) + + // baresip.connect().catch((error) => { + // log.error("🐻 connection error:", error) + // phoneService.send({ type: "error", message: error.message }) + // }) + + // baresip.error.connect(async ({ message }) => { + // log.error("🐻 error:", message) + // phoneService.send({ type: "error", message }) + // for (let i = 0; i < 4; i++) { + // await ring(500) + // await sleep(250) + // } + // process.exit(1) + // }) + + // const agent = new Agent({ + // agentId, + // apiKey, + // tools: { + // search_web: (args: { query: string }) => searchWeb(args.query), + // }, + // }) +} + +// handleAgentEvents(agent) + +// const startAgent = () => { +// log.info("ā˜Žļø Starting agent conversation") + +// if (agentProcess?.stdin) { +// agentProcess.stdin.write("start\n") +// } else { +// log.error("ā˜Žļø No agent process stdin available") +// phoneService.send({ type: "remote_hang_up" }) +// } + +// return () => { +// log.info("ā˜Žļø Stopping agent conversation") +// if (agentProcess?.stdin) { +// agentProcess.stdin.write("stop\n") +// } +// } +// } + +// const context = (initial?: Partial): PhoneContext => ({ +// 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 +// } diff --git a/src/pins/README.md b/src/pins/README.md index 37ea25c..63b70d0 100644 --- a/src/pins/README.md +++ b/src/pins/README.md @@ -4,11 +4,12 @@ High-level GPIO library for Bun using libgpiod v2 with automatic resource manage ## Features -- Type-safe TypeScript API with autocomplete for pin names +- True event-driven GPIO with worker-based architecture (<10ms latency) +- Zero CPU usage when idle (blocking on hardware events) +- Type-safe TypeScript API - Automatic resource cleanup with `using` keyword - Hardware debouncing via kernel -- Event-driven input handling -- Efficient multi-pin monitoring with input groups +- Callback-based event handling with multiple listeners - Zero external dependencies (uses Bun FFI) ## Requirements @@ -36,10 +37,13 @@ for (let i = 0; i < 10; i++) { using button = gpio.input(20, { pull: "up", debounce: 10 }) console.log(button.value) -// Listen for button events -for await (const event of button.events()) { +// Listen for button events with callback +button.onChange((event) => { console.log(event.value === 0 ? "Pressed!" : "Released") -} +}) + +// Keep process running +await new Promise(() => {}) ``` ## API @@ -86,27 +90,6 @@ Options: - `debounce?: number` - Debounce period in milliseconds (default: 0) - `edge?: 'rising' | 'falling' | 'both'` - Edge detection (default: 'both') -#### `gpio.inputGroup(config)` - -Monitor multiple inputs efficiently with a single file descriptor. Pin names are fully type-safe! - -```typescript -using inputs = gpio.inputGroup({ - hook: { pin: 20, pull: "up" }, - rotary: { pin: 21, pull: "up", debounce: 1 }, - button: { pin: 22, pull: "down" }, -}) - -// Access individual pins (fully typed!) -console.log(inputs.pins.hook.value) // TypeScript knows about .hook -console.log(inputs.pins.button.value) // TypeScript knows about .button - -// Monitor all pins -for await (const event of inputs.events()) { - console.log(`${event.pin}: ${event.value}`) // event.pin is "hook" | "rotary" | "button" -} -``` - #### `gpio.listChips()` List available GPIO chips. @@ -117,22 +100,30 @@ console.log(chips) // [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }] ``` -### InputPin +### Input ```typescript using button = gpio.input(20) -// Read current state +// Read current state (cached from last event) 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 await button.waitForValue(0) // wait for LOW await button.waitForValue(1, 5000) // wait for HIGH with 5s timeout - -// Event stream -for await (const event of button.events()) { - console.log(event.value, event.timestamp) -} ``` ### OutputPin @@ -146,29 +137,23 @@ const value = led.value led.toggle() ``` -### InputGroup +## Architecture -```typescript -using inputs = gpio.inputGroup({ - switch: { pin: 16, pull: "up" }, - button: { pin: 20, pull: "up", debounce: 10 } -}) +### Worker-Based Event Handling -// Access pins with full type safety -inputs.pins.switch.value // āœ“ TypeScript autocomplete -inputs.pins.button.value // āœ“ TypeScript autocomplete +Each input spawns a dedicated Web Worker that: -// Wait for specific pin values -await inputs.pins.button.waitForValue(0) // wait for button to go LOW -await inputs.pins.switch.waitForValue(1, 3000) // wait for switch to go HIGH with timeout +1. Blocks on `gpiod_line_request_wait_edge_events()` with `-1` timeout (infinite) +2. Wakes instantly when hardware GPIO edge event occurs +3. Reads event and posts message to main thread +4. Main thread fires registered callbacks -// Monitor all pins -for await (const event of inputs.events()) { - event.pin // Type: 'switch' | 'button' - event.value // Type: 0 | 1 - event.timestamp // Type: bigint (nanoseconds) -} -``` +**Benefits:** + +- **True blocking**: Zero CPU usage when idle +- **Low latency**: <10ms response time (vs 100ms with polling) +- **Independent inputs**: Each input has its own worker +- **Clean shutdown**: Workers terminated on close, kernel handles GPIO cleanup ## Resource Management @@ -176,17 +161,24 @@ for await (const event of inputs.events()) { ```typescript // Good - automatic cleanup -{ - using led = gpio.output(17) - led.value = 1 -} // Automatically released +using led = gpio.output(17) // Automatically released because of `using` +led.value = 1 +``` -// Bad - manual cleanup required +```typescript +// Meh - manual cleanup required const led = gpio.output(17) led.value = 1 led.close() // Must call manually ``` +```typescript +// Bad - leaks resources +const led = gpio.output(17) +led.value = 1 +// Forgot to close() - resource leak! +``` + ## Hardware Setup ### Pull Resistors @@ -197,6 +189,7 @@ 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). **Important:** Match your pull resistor to your wiring: + - Button to ground → use `pull: "up"` - Button to VCC (3.3V) → use `pull: "down"` @@ -252,26 +245,27 @@ import { GPIO } from "@/pins" const gpio = new GPIO() -using inputs = gpio.inputGroup({ - button: { pin: 20, pull: "up", debounce: 10 }, - switch: { pin: 16, pull: "up" } -}) - +using button = gpio.input(20, { pull: "up", debounce: 10 }) +using switchInput = gpio.input(16, { pull: "up" }) using led = gpio.output(21) // Set LED based on switch state -if (inputs.pins.switch.value === 1) { - led.value = 1 -} +led.value = switchInput.value // Toggle LED when button pressed -for await (const event of inputs.events()) { - if (event.pin === "button" && event.value === 0) { +button.onChange((event) => { + if (event.value === 0) { 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 @@ -281,18 +275,31 @@ import { GPIO } from "@/pins" const gpio = new GPIO() -using inputs = gpio.inputGroup({ - hook: { pin: 20, pull: "up" }, - rotary: { pin: 21, pull: "up", debounce: 1 }, +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 }) + +let digit = 0 + +hook.onChange((event) => { + console.log(event.value === 0 ? "Phone picked up" : "Phone hung up") }) -for await (const event of inputs.events()) { - if (event.pin === "hook") { - console.log(event.value === 0 ? "Phone picked up" : "Phone hung up") - } else if (event.pin === "rotary" && event.value === 0) { - console.log("Rotary pulse") +rotaryInUse.onChange((event) => { + if (event.value === 0) { + digit = 0 + } else { + console.log(`Dialed digit: ${digit}`) } -} +}) + +rotaryNumber.onChange((event) => { + if (event.value === 1) { + digit += 1 + } +}) + +await new Promise(() => {}) ``` ## Troubleshooting diff --git a/src/pins/gpio-helpers.ts b/src/pins/gpio-helpers.ts new file mode 100644 index 0000000..fca391c --- /dev/null +++ b/src/pins/gpio-helpers.ts @@ -0,0 +1,101 @@ +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) + }) +} diff --git a/src/pins/gpio.ts b/src/pins/gpio.ts deleted file mode 100644 index 928980c..0000000 --- a/src/pins/gpio.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { ptr } from "bun:ffi" -import { readdir } from "node:fs/promises" -import { gpiod, cstr, GPIOD_LINE_DIRECTION_OUTPUT, GPIOD_LINE_DIRECTION_INPUT } from "./ffi" -import { OutputPin } from "./output" -import { InputPin } from "./input" -import { InputGroup } from "./input-group" -import { ChipNotFoundError, PinInUseError } from "./errors" -import { mapPullToLibgpiod, mapEdgeToLibgpiod, hashInputConfig } from "./utils" -import type { OutputOptions, InputOptions, PullMode, ChipInfo } from "./types" - -export class GPIO { - #chipPath: string - #resetOnClose: boolean - - constructor(options?: { chip?: string; resetOnClose?: boolean }) { - this.#chipPath = options?.chip ?? "/dev/gpiochip0" - this.#resetOnClose = options?.resetOnClose ?? false - } - - output(pin: number, options?: OutputOptions): OutputPin { - const initialValue = options?.initialValue ?? 0 - - const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath)) - if (!chip) { - throw new ChipNotFoundError(this.#chipPath) - } - - try { - const reqConfig = gpiod.gpiod_request_config_new() - gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio")) - - const lineSettings = gpiod.gpiod_line_settings_new() - gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_OUTPUT) - gpiod.gpiod_line_settings_set_output_value(lineSettings, initialValue) - - const lineConfig = gpiod.gpiod_line_config_new() - const offsets = new Uint32Array([pin]) - gpiod.gpiod_line_config_add_line_settings(lineConfig, ptr(offsets), 1, lineSettings) - - const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig) - - gpiod.gpiod_line_settings_free(lineSettings) - gpiod.gpiod_line_config_free(lineConfig) - gpiod.gpiod_request_config_free(reqConfig) - - if (!request) { - gpiod.gpiod_chip_close(chip) - throw new PinInUseError(pin) - } - - let resetValue: 0 | 1 | undefined - if (this.#resetOnClose) { - const currentValue = gpiod.gpiod_line_request_get_value(request, pin) - if (currentValue === -1) { - console.warn(`Failed to read initial value for pin ${pin}, assuming 0`) - resetValue = 0 - } else { - resetValue = currentValue as 0 | 1 - } - } - - return new OutputPin(chip, request, pin, resetValue) - } catch (err) { - gpiod.gpiod_chip_close(chip) - throw err - } - } - - input(pin: number, options?: InputOptions): InputPin<"pin"> { - const group = this.inputGroup({ - pin: { pin, ...options }, - }) - - return new InputPin(group, "pin") - } - - inputGroup>( - config: T - ): InputGroup { - const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath)) - if (!chip) { - throw new ChipNotFoundError(this.#chipPath) - } - - try { - const reqConfig = gpiod.gpiod_request_config_new() - gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio")) - - const lineConfig = gpiod.gpiod_line_config_new() - - const groups = new Map< - string, - Array<{ name: string; pin: number; pull: PullMode; options: InputOptions }> - >() - - for (const [name, pinConfig] of Object.entries(config)) { - const pull = pinConfig.pull ?? "up" - const debounce = pinConfig.debounce ?? 0 - const edge = pinConfig.edge ?? "both" - - const hash = hashInputConfig(pull, debounce, edge) - if (!groups.has(hash)) groups.set(hash, []) - groups.get(hash)!.push({ name, pin: pinConfig.pin, pull, options: pinConfig }) - } - - for (const [hash, pins] of groups) { - const firstPin = pins[0] - if (!firstPin) continue - - const pull = firstPin.options.pull ?? "up" - const debounce = firstPin.options.debounce ?? 0 - const edge = firstPin.options.edge ?? "both" - - const lineSettings = gpiod.gpiod_line_settings_new() - gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_INPUT) - gpiod.gpiod_line_settings_set_bias(lineSettings, mapPullToLibgpiod(pull)) - gpiod.gpiod_line_settings_set_edge_detection(lineSettings, mapEdgeToLibgpiod(edge)) - gpiod.gpiod_line_settings_set_debounce_period_us(lineSettings, debounce * 1000) - - const offsets = new Uint32Array(pins.map((p) => p.pin)) - gpiod.gpiod_line_config_add_line_settings( - lineConfig, - ptr(offsets), - pins.length, - lineSettings - ) - - gpiod.gpiod_line_settings_free(lineSettings) - } - - const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig) - - gpiod.gpiod_line_config_free(lineConfig) - gpiod.gpiod_request_config_free(reqConfig) - - if (!request) { - gpiod.gpiod_chip_close(chip) - const firstConfig = Object.values(config)[0] - throw new PinInUseError(firstConfig?.pin ?? 0) - } - - const pinMap: Record = {} - for (const [name, pinConfig] of Object.entries(config)) { - pinMap[name] = { - offset: pinConfig.pin, - pull: pinConfig.pull ?? "up", - } - } - - return new InputGroup(chip, request, pinMap) - } catch (err) { - gpiod.gpiod_chip_close(chip) - throw err - } - } - - async listChips(): Promise { - const chips: ChipInfo[] = [] - - try { - const files = await readdir("/dev") - const chipFiles = files.filter((f) => f.startsWith("gpiochip")) - - for (const file of chipFiles) { - const path = `/dev/${file}` - - try { - const chip = gpiod.gpiod_chip_open(cstr(path)) - if (!chip) continue - - const info = gpiod.gpiod_chip_get_info(chip) - if (!info) { - gpiod.gpiod_chip_close(chip) - continue - } - - const name = gpiod.gpiod_chip_info_get_name(info) - const label = gpiod.gpiod_chip_info_get_label(info) - const numLines = gpiod.gpiod_chip_info_get_num_lines(info) - - chips.push({ - path, - name: String(name || ""), - label: String(label || ""), - numLines: Number(numLines), - }) - - gpiod.gpiod_chip_close(chip) - } catch { - continue - } - } - } catch { - // /dev might not be accessible, return empty array - } - - return chips - } -} diff --git a/src/pins/index.ts b/src/pins/index.ts index c2d155b..34b0606 100644 --- a/src/pins/index.ts +++ b/src/pins/index.ts @@ -1,17 +1,87 @@ -export { GPIO } from "./gpio" -export { +import { readdir } from "node:fs/promises" +import { gpiod, cstr } from "./ffi" +import { Output } from "./output" +import { Input } from "./input" +import type * as Type from "./types" +import { GPIOError, PermissionError, PinInUseError, ChipNotFoundError, InvalidConfigError, } from "./errors" -export type { - InputOptions, - OutputOptions, - InputEvent, - InputGroupEvent, - ChipInfo, - PullMode, - EdgeMode, -} from "./types" + +class GPIO { + #chipPath: string + + constructor(options?: { chip?: string }) { + this.#chipPath = options?.chip ?? "/dev/gpiochip0" + } + + output(pin: number, options?: Type.OutputOptions): Output { + return new Output(this.#chipPath, pin, options) + } + + input(pin: number, options?: Type.InputOptions): Input { + return new Input(this.#chipPath, pin, options) + } + + async listChips(): Promise { + 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 default GPIO diff --git a/src/pins/input-group.ts b/src/pins/input-group.ts deleted file mode 100644 index 8804fb0..0000000 --- a/src/pins/input-group.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { Pointer } from "bun:ffi" -import { gpiod } from "./ffi" -import { mapLibgpiodEdgeToPressedState } from "./utils" -import type { PullMode, InputEvent, InputGroupEvent, PinConfig } from "./types" - -export class InputGroup { - #closed = false - #chip: Pointer - #request: Pointer - #pinMap: Map - #offsetMap: Map - #eventBuffer: Pointer | undefined - #eventListeners: Array<(event: InputGroupEvent) => void> = [] - #closeHandlers: Array<() => void> = [] - - constructor(chip: Pointer, request: Pointer, pinConfig: PinConfig) { - this.#chip = chip - this.#request = request - - this.#pinMap = new Map() - this.#offsetMap = new Map() - - for (const [name, config] of Object.entries(pinConfig)) { - this.#pinMap.set(name, config) - this.#offsetMap.set(config.offset, { name, pull: config.pull }) - } - } - - get pins(): Record Promise }> { - const result = {} as Record Promise }> - - for (const [name, config] of this.#pinMap) { - const offset = config.offset - const closed = () => this.#closed - const request = this.#request - const pinName = name - - Object.defineProperty(result, name, { - get: () => ({ - get value(): 0 | 1 { - if (closed()) throw new Error("InputGroup is closed") - const ret = gpiod.gpiod_line_request_get_value(request, offset) - if (ret === -1) throw new Error("Failed to get pin value") - return ret as 0 | 1 - }, - waitForValue: (targetValue: 0 | 1, timeout?: number) => this.#waitForPinValue(pinName as T, targetValue, timeout) - }), - enumerable: true - }) - } - - return result - } - - async #waitForPinValue(pinName: T, targetValue: 0 | 1, timeout?: number): Promise { - return new Promise((resolve, reject) => { - if (this.#closed) { - reject(new Error("InputGroup is closed")) - return - } - - let timeoutId: ReturnType | undefined - - const cleanup = () => { - if (timeoutId) clearTimeout(timeoutId) - this.#eventListeners = this.#eventListeners.filter((l) => l !== listener) - this.#closeHandlers = this.#closeHandlers.filter((h) => h !== onClose) - } - - const onClose = () => { - cleanup() - reject(new Error("InputGroup closed while waiting")) - } - - const listener = (event: InputGroupEvent) => { - if (event.pin !== pinName) return - if (event.value !== targetValue) return - - cleanup() - resolve() - } - - if (timeout) { - timeoutId = setTimeout(() => { - cleanup() - reject(new Error(`Timeout waiting for pin ${pinName} to become ${targetValue}`)) - }, timeout) - } - - this.#eventListeners.push(listener) - this.#closeHandlers.push(onClose) - this.#startEventLoop() - }) - } - - async *events(): AsyncGenerator> { - if (this.#closed) throw new Error("InputGroup is closed") - - const eventQueue: InputGroupEvent[] = [] - let resolve: (() => void) | undefined - - const listener = (event: InputGroupEvent) => { - eventQueue.push(event) - resolve?.() - } - - this.#eventListeners.push(listener) - this.#startEventLoop() - - try { - while (!this.#closed) { - if (eventQueue.length === 0) { - await new Promise((r) => { - resolve = r - }) - } - - const event = eventQueue.shift() - if (event) yield event - } - } finally { - this.#eventListeners = this.#eventListeners.filter((l) => l !== listener) - } - } - - #startEventLoop() { - if (this.#eventBuffer !== undefined) return - - const buffer = gpiod.gpiod_edge_event_buffer_new(1) - if (!buffer) throw new Error("Failed to create event buffer") - - this.#eventBuffer = buffer - this.#runEventLoop() - } - - async #runEventLoop() { - try { - while (!this.#closed && this.#eventListeners.length > 0) { - const ret = gpiod.gpiod_line_request_wait_edge_events(this.#request, 100_000_000) - - if (ret === -1 || ret === 0) { - await Bun.sleep(0) - continue - } - - const numEvents = gpiod.gpiod_line_request_read_edge_events( - this.#request, - this.#eventBuffer!, - 1 - ) - - if (numEvents > 0) { - const event = gpiod.gpiod_edge_event_buffer_get_event(this.#eventBuffer!, 0) - const edgeType = gpiod.gpiod_edge_event_get_event_type(event) - const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event) - const offset = gpiod.gpiod_edge_event_get_line_offset(event) - - const pinInfo = this.#offsetMap.get(offset) - if (!pinInfo) continue - - const pressed = mapLibgpiodEdgeToPressedState(edgeType, pinInfo.pull) - const value = (pressed ? (pinInfo.pull === "up" ? 0 : 1) : (pinInfo.pull === "up" ? 1 : 0)) as 0 | 1 - const inputEvent: InputGroupEvent = { pin: pinInfo.name as T, value, timestamp } - - for (const listener of this.#eventListeners) { - listener(inputEvent) - } - } - } - } finally { - if (this.#eventBuffer) { - gpiod.gpiod_edge_event_buffer_free(this.#eventBuffer) - this.#eventBuffer = undefined - } - } - } - - close() { - if (this.#closed) return - this.#closed = true - - for (const handler of this.#closeHandlers) { - handler() - } - this.#closeHandlers = [] - - gpiod.gpiod_line_request_release(this.#request) - gpiod.gpiod_chip_close(this.#chip) - } - - [Symbol.dispose]() { - this.close() - } -} diff --git a/src/pins/input-worker.ts b/src/pins/input-worker.ts new file mode 100644 index 0000000..09b9492 --- /dev/null +++ b/src/pins/input-worker.ts @@ -0,0 +1,84 @@ +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) => { + self.onmessage = () => { + throw new Error("Worker already initialized") + } + run(event.data) +} diff --git a/src/pins/input.ts b/src/pins/input.ts index 6e5aa35..f67a680 100644 --- a/src/pins/input.ts +++ b/src/pins/input.ts @@ -1,36 +1,90 @@ -import type { Pointer } from "bun:ffi" -import { InputGroup } from "./input-group" -import type { PullMode, InputEvent } from "./types" +import type { InputEvent, InputOptions } from "./types" -export class InputPin { - #group: InputGroup - #pinName: T +type WorkerMessage = + | { type: "ready"; initialValue: 0 | 1 } + | { type: "event"; value: 0 | 1; timestamp: bigint } + | { type: "error"; message: string } - constructor(group: InputGroup, pinName: T) { - this.#group = group - this.#pinName = pinName +export class Input { + #worker: Worker + #callbacks = new Set<(event: InputEvent) => void>() + #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) => { + 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 { - return this.#group.pins[this.#pinName]!.value + if (this.#closed) throw new Error("Input is closed") + 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 { - for await (const event of this.#group.events()) { - if (event.value === targetValue) { - return - } - } - } + if (this.#closed) throw new Error("Input is closed") - async *events(): AsyncGenerator { - for await (const event of this.#group.events()) { - yield { value: event.value, timestamp: event.timestamp } - } + if (this.#lastValue === targetValue) return + + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId) + unsubscribe() + } + + const unsubscribe = this.onChange((event) => { + if (event.value === targetValue) { + cleanup() + resolve() + } + }) + + if (!timeout) return + + timeoutId = setTimeout(() => { + cleanup() + reject(new Error(`Timeout waiting for value ${targetValue}`)) + }, timeout) + }) } close() { - this.#group.close() + if (this.#closed) return + this.#closed = true + + this.#callbacks.clear() + this.#worker.onmessage = null + this.#worker.terminate() } [Symbol.dispose]() { diff --git a/src/pins/output.ts b/src/pins/output.ts index 0f097d4..eb167b6 100644 --- a/src/pins/output.ts +++ b/src/pins/output.ts @@ -1,30 +1,39 @@ import type { Pointer } from "bun:ffi" import { gpiod } from "./ffi" +import { requestOutputLine } from "./gpio-helpers" +import type { OutputOptions } from "./types" -export class OutputPin { +export class Output { #closed = false #chip: Pointer #request: Pointer - #pin: number + #offset: number #resetValue?: 0 | 1 - constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) { + constructor(chipPath: string, offset: number, options: OutputOptions = {}) { + const initialValue = options.initialValue ?? 0 + const { chip, request } = requestOutputLine({ chipPath, offset, initialValue }) + this.#chip = chip this.#request = request - this.#pin = pin - this.#resetValue = resetValue + this.#offset = offset + + 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 { - if (this.#closed) throw new Error("OutputPin is closed") - const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#pin) + if (this.#closed) throw new Error("Output is closed") + const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#offset) if (ret === -1) throw new Error("Failed to get pin value") return ret as 0 | 1 } set value(val: 0 | 1) { - if (this.#closed) throw new Error("OutputPin is closed") - const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#pin, val) + if (this.#closed) throw new Error("Output is closed") + const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#offset, val) if (ret === -1) throw new Error("Failed to set pin value") } @@ -37,7 +46,7 @@ export class OutputPin { this.#closed = true if (this.#resetValue !== undefined) { - gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue) + gpiod.gpiod_line_request_set_value(this.#request, this.#offset, this.#resetValue) } gpiod.gpiod_line_request_release(this.#request) diff --git a/src/pins/tsconfig.worker.json b/src/pins/tsconfig.worker.json new file mode 100644 index 0000000..1470823 --- /dev/null +++ b/src/pins/tsconfig.worker.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "WebWorker"] + }, + "include": ["input-worker.ts"] +} diff --git a/src/pins/types.ts b/src/pins/types.ts index 52e418f..a5756af 100644 --- a/src/pins/types.ts +++ b/src/pins/types.ts @@ -9,6 +9,7 @@ export type InputOptions = { export type OutputOptions = { initialValue?: 0 | 1 // default: 0 + resetOnClose?: boolean // default: false } export type InputEvent = { @@ -16,15 +17,9 @@ export type InputEvent = { timestamp: bigint // nanoseconds } -export type InputGroupEvent = InputEvent & { - pin: T // name of the pin that fired -} - export type ChipInfo = { path: string name: string label: string numLines: number } - -export type PinConfig = Record diff --git a/src/pins/utils.ts b/src/pins/utils.ts index 9735542..72feb65 100644 --- a/src/pins/utils.ts +++ b/src/pins/utils.ts @@ -38,10 +38,7 @@ export const mapEdgeToLibgpiod = (edge: EdgeMode): number => { // Hardware logic: // - Pull-up + button to GND: pressing pulls line LOW (falling edge = pressed) // - Pull-down + button to VCC: pressing pulls line HIGH (rising edge = pressed) -export const mapLibgpiodEdgeToPressedState = ( - edgeType: number, - pull: PullMode -): boolean => { +export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode): boolean => { if (pull === "up") { return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE } else if (pull === "down") { @@ -51,10 +48,6 @@ export const mapLibgpiodEdgeToPressedState = ( } } -export const hashInputConfig = ( - pull: PullMode, - debounce: number, - edge: EdgeMode -): string => { +export const hashInputConfig = (pull: PullMode, debounce: number, edge: EdgeMode): string => { return `${pull}-${debounce}-${edge}` } diff --git a/src/sip.ts b/src/sip.ts new file mode 100644 index 0000000..afbe9a6 --- /dev/null +++ b/src/sip.ts @@ -0,0 +1,114 @@ +import { log } from "./utils/log.ts" +import { Signal } from "./utils/signal.ts" +import { processStdout, processStderr } from "./utils/stdio.ts" + +export class Baresip { + baresipArgs: string[] + process?: Bun.PipedSubprocess + callEstablished = new Signal<{ contact: string }>() + callReceived = new Signal<{ contact: string }>() + hungUp = new Signal() + error = new Signal<{ message: string }>() + registrationSuccess = new Signal() + + constructor(baresipArgs: string[]) { + this.baresipArgs = baresipArgs + } + + async connect() { + this.process = Bun.spawn(this.baresipArgs, { + stdout: "pipe", + stderr: "pipe", + onExit: (_proc, exitCode, signalCode, error) => { + log.debug(`šŸ“ž Baresip process exited (code: ${exitCode}, signal: ${signalCode})`) + if (error) { + log.error("Process error:", error) + } + }, + }) + + Promise.all([ + processStdout(this.process, (line) => this.#parseLine(line)), + processStderr(this.process), + ]).catch((error) => { + log.error("Error processing output:", error) + }) + } + + accept() { + executeCommand("a") + } + + dial(phoneNumber: string) { + executeCommand(`d${phoneNumber}`) + } + + hangUp() { + executeCommand("b") + } + + disconnectAll() { + this.callEstablished.disconnect() + this.callReceived.disconnect() + this.hungUp.disconnect() + this.registrationSuccess.disconnect() + } + + kill() { + if (!this.process) throw new Error("Process not started") + this.process.kill() + this.disconnectAll() + this.process = undefined + } + + #parseLine(line: string) { + log.debug(`šŸ“ž Baresip: ${line}`) + const callEstablishedMatch = line.match(/Call established: (.+)/) + if (callEstablishedMatch) { + log.debug(`Call established with "${line}"`) + this.callEstablished.emit({ contact: callEstablishedMatch[1]! }) + } + + const callReceivedMatch = line.match(/Incoming call from: \+\d+ (\S+) -/) + if (callReceivedMatch) { + log.debug(`Incoming call from "${line}"`) + this.callReceived.emit({ contact: callReceivedMatch[1]!?.trim() }) + } + + const hangUpMatch = line.match(/(.+): session closed/) + if (hangUpMatch) { + log.debug(`Call hung up with "${line}"`) + this.hungUp.emit() + } + + const callTerminatedMatch = line.match(/(.+) terminated \(duration: /) + if (callTerminatedMatch) { + log.debug(`ā‰ļø NOT HANDLED: Call terminated with "${line}"`) + } + + const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/) + if (registrationSuccessMatch) { + this.registrationSuccess.emit() + } + + const registrationFailedMatch = line.match(/reg: sip:\S+ 403 Forbidden/) + const socketInUseMatch = line.match(/tcp: sock_bind:/) + if (registrationFailedMatch || socketInUseMatch) { + log.error(`ā‰ļø NOT HANDLED: Registration failed with "${line}"`) + this.error.emit({ message: line }) + } + } +} + +const executeCommand = async (command: string) => { + try { + const url = new URL(`/?${command}`, "http://127.0.0.1:8000") + const response = await Bun.fetch(url) + + if (!response.ok) { + throw new Error(`Error executing command: ${response.statusText}`) + } + } catch (error) { + log.error("Failed to execute command:", error) + } +} diff --git a/test.ts b/src/test-buzz.ts similarity index 98% rename from test.ts rename to src/test-buzz.ts index 4a8b872..3dffde2 100755 --- a/test.ts +++ b/src/test-buzz.ts @@ -5,7 +5,7 @@ * Tests device listing, player, recorder, and tone generation */ -import Buzz from "./src/buzz" +import Buzz from "./buzz" console.log("šŸŽµ Buzz Audio Library - Basic Test\n") diff --git a/src/operator.ts b/src/test-operator.ts similarity index 95% rename from src/operator.ts rename to src/test-operator.ts index d0426e6..ad33fd4 100755 --- a/src/operator.ts +++ b/src/test-operator.ts @@ -22,7 +22,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { let currentDialtone: Playback | undefined let currentBackgroundNoise: Playback | undefined let streamPlayback = player.playStream() - const waitingIndicator = new WaitingSounds(player, streamPlayback) + const waitingIndicator = new WaitingSounds(player) // Set up agent event listeners agent.events.connect(async (event) => { @@ -52,7 +52,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { break case "tool_call": - waitingIndicator.start() + waitingIndicator.start(streamPlayback) console.log(`šŸ”§ Tool call: ${event.name}(${JSON.stringify(event.args)})`) break @@ -73,6 +73,14 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => { case "error": console.error("Agent error:", event.error) + break + + case "ping": + break + + default: + console.log(`šŸ˜µā€šŸ’« ${event.type}`) + break } }) diff --git a/src/test-pins.ts b/src/test-pins.ts new file mode 100644 index 0000000..0ff95c2 --- /dev/null +++ b/src/test-pins.ts @@ -0,0 +1,39 @@ +import { GPIO } from "./pins" + +console.log(`kill -9 ${process.pid}`) + +const gpio = new GPIO() + +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 + +button.onChange((event) => { + led.value = event.value +}) + +const switchEvent = new Promise(async (resolve) => { + await switchInput.waitForValue(0) + console.log("Switch pressed!") + resolve() +}) + +process.on("SIGINT", () => { + button.close() + switchInput.close() + led.close() + process.exit(0) +}) + +process.on("SIGTERM", () => { + button.close() + switchInput.close() + led.close() + process.exit(0) +}) + +await switchEvent + +console.log(`šŸ‘‹ Goodbye!`) \ No newline at end of file diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 0000000..301611f --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,21 @@ +let showDebug = true +let showInfo = true +let showError = true + +export function setLogLevel(level: "debug" | "info" | "error" | "none") { + showDebug = level === "debug" + showInfo = level === "debug" || level === "info" + showError = level !== "none" +} + +export const log = { + debug: (...args: any[]) => { + if (showDebug) console.debug("DEBUG: ", ...args) + }, + info: (...args: any[]) => { + if (showInfo) console.log("INFO: ", ...args) + }, + error: (...args: any[]) => { + if (showError) console.error("ERROR: ", ...args) + }, +} diff --git a/src/utils/stdio.ts b/src/utils/stdio.ts new file mode 100644 index 0000000..0165074 --- /dev/null +++ b/src/utils/stdio.ts @@ -0,0 +1,41 @@ +import { log } from "./log.ts" + +export const LineSplitter = () => { + let buffer = "" + return new TransformStream({ + transform(chunk, controller) { + buffer += chunk + const parts = buffer.split(/\n/) + const lines = parts.slice(0, -1) + buffer = parts.at(-1) || "" + + for (const line of lines) { + controller.enqueue(line) + } + }, + flush(controller) { + if (buffer.length > 0) { + controller.enqueue(buffer) + } + }, + }) +} + +export async function processStdout( + process: Bun.ReadableSubprocess, + onLine: (line: string) => void +) { + for await (const line of process.stdout + .pipeThrough(new TextDecoderStream()) + .pipeThrough(LineSplitter())) { + onLine(line) + } +} + +export async function processStderr(process: Bun.ReadableSubprocess, prefix: string = "") { + for await (const line of process.stderr + .pipeThrough(new TextDecoderStream()) + .pipeThrough(LineSplitter())) { + log.error(`āŒ ${prefix}${line}`) + } +} diff --git a/src/utils/waiting-sounds.ts b/src/utils/waiting-sounds.ts index a46925b..5d4e4e2 100644 --- a/src/utils/waiting-sounds.ts +++ b/src/utils/waiting-sounds.ts @@ -7,13 +7,13 @@ export class WaitingSounds { typingPlayback?: Playback speakingPlayback?: Playback - constructor(private player: Player, private streamPlayback: StreamingPlayback) {} + constructor(private player: Player) {} - async start() { + async start(operatorStream: StreamingPlayback) { if (this.typingPlayback) return // Already playing this.#startTypingSounds() - this.#startSpeakingSounds() + this.#startSpeakingSounds(operatorStream) } async #startTypingSounds() { @@ -35,17 +35,15 @@ export class WaitingSounds { }) } - async #startSpeakingSounds() { + async #startSpeakingSounds(operatorStream: StreamingPlayback) { const playedSounds = new Set() let dir: SoundDir | undefined return new Promise(async (resolve) => { - // Don't start speaking until the stream playback buffer is empty! - while (this.streamPlayback.bufferEmptyFor < 1000) { + while (operatorStream.bufferEmptyFor < 1500) { await Bun.sleep(100) } do { - this.streamPlayback.bufferEmptyFor const lastSoundDir = dir const value = Math.random() * 100 if (lastSoundDir === "body-noises") { diff --git a/tsconfig.json b/tsconfig.json index 545396c..676063d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + "exclude": ["src/pins/input-worker.ts"] }