phone/src/phone.ts
2025-11-18 16:48:03 -08:00

341 lines
9.1 KiB
TypeScript

import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3"
import { Baresip } from "./sip"
import { log } from "./log"
import { gpio } from "./gpio"
import { sleep } from "bun"
import { ToneGenerator } from "./tone"
import { ring } from "./utils"
import { pins } from "./pins"
import { processStderr, processStdout } from "./stdio"
type CancelableTask = () => void
type PhoneContext = {
lastError?: string
peer?: string
numberDialed: number
cancelDialTone?: CancelableTask
cancelRinger?: CancelableTask
baresip: Baresip
startAgent: () => CancelableTask
cancelAgent?: CancelableTask
}
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
}
export const startPhone = async () => {
Bun.spawn({
cmd: "amixer -c 0 set PCM 20%".split(" "),
})
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", "/home/corey/code/tone/baresip"])
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)
})
let agentProcess: Bun.Subprocess
const initializeAgent = () => {
agentProcess = Bun.spawn({
cwd: "/home/corey/code/tone/packages/py-agent",
cmd: ["/home/corey/.local/bin/uv", "run", "main.py"],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, PYTHONUNBUFFERED: "1" },
})
log.info("☎️ Started agent process", agentProcess.pid)
processStdout(agentProcess, (line) => {
log.info(`🐍 ${line}`)
if (line === "Starting agent session") {
log.info(`💎 HEY! THE AGENT STARTED`)
} else if (line.startsWith("Conversation ended.")) {
phoneService.send({ type: "remote_hang_up" })
}
})
processStderr(agentProcess)
agentProcess.exited.then((code) => {
log.error(`💥 Agent process exited with code ${code}`)
phoneService.send({ type: "remote_hang_up" })
})
}
initializeAgent()
const startAgent = () => {
log.info("☎️ Starting agent conversation")
if (agentProcess?.stdin) {
agentProcess.stdin.write("start\n")
} else {
log.error("☎️ No agent process stdin available")
phoneService.send({ type: "remote_hang_up" })
}
return () => {
log.info("☎️ Stopping agent conversation")
if (agentProcess?.stdin) {
agentProcess.stdin.write("stop\n")
}
}
}
const context = (initial?: Partial<PhoneContext>): PhoneContext => ({
numberDialed: 0,
baresip,
startAgent,
...initial,
})
const phoneMachine = createMachine(
"initializing",
// prettier-ignore
{
initializing: state(
transition("initialized", "idle"),
transition("pick_up", "ready", reduce(playDialTone)),
transition("error", "fault", reduce(handleError))),
idle: state(
transition("incoming_call", "incoming", reduce(incomingCall)),
transition("pick_up", "ready", reduce(playDialTone))),
incoming: state(
transition("remote_hang_up", "idle", reduce(stopRinger)),
transition("pick_up", "connected", reduce(callAnswered))),
connected: state(
transition("remote_hang_up", "ready", reduce(playDialTone)),
transition("hang_up", "idle", reduce(stopCall))),
ready: state(
transition("dial_start", "dialing", reduce(dialStart)),
transition("dial_timeout", "aborted", reduce(stopDialTone)),
transition("hang_up", "idle", reduce(stopDialTone))),
dialing: state(
transition("dial_stop", "outgoing", reduce(makeCall), guard((ctx) => !callAgentGuard(ctx))),
transition("dial_stop", "connectedToAgent", reduce(makeAgentCall), guard((ctx) => callAgentGuard(ctx))),
transition("digit_increment", "dialing", reduce(digitIncrement)),
transition("hang_up", "idle", reduce(stopDialTone))),
outgoing: state(
transition("start_agent", "connectedToAgent"),
transition("answered", "connected"),
transition("hang_up", "idle", reduce(stopCall))),
connectedToAgent: state(
transition("remote_hang_up", "ready", reduce(stopAgent)),
transition("hang_up", "idle", reduce(stopAgent))),
aborted: state(
transition("hang_up", "idle")),
fault: state(),
},
context
)
const phoneService = interpret(phoneMachine, () => {})
d._onEnter = function (machine, to, state, prevState, event) {
log.info(`📱 ${machine.current} -> ${to} (${JSON.stringify(event)})`)
}
gpio.monitor(pins.hook, { bias: "pull-up" }, (event) => {
const type = event.edge === "falling" ? "hang_up" : "pick_up"
log.info(`📞 Hook ${event.edge} sending ${type}`)
phoneService.send({ type })
})
gpio.monitor(pins.rotaryInUse, { bias: "pull-up", throttleMs: 90 }, (event) => {
const type = event.edge === "falling" ? "dial_start" : "dial_stop"
log.debug(`📞 Rotary in-use ${event.edge} sending ${type}`)
phoneService.send({ type })
})
gpio.monitor(pins.rotaryNumber, { bias: "pull-up", throttleMs: 90 }, (event) => {
if (event.edge !== "rising") return
phoneService.send({ type: "digit_increment" })
})
// Graceful shutdown handling
const cleanup = () => {
log.info("🛑 Shutting down, stopping agent process")
if (agentProcess?.stdin) {
agentProcess.stdin.write("quit\n")
}
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
process.on("exit", cleanup)
}