341 lines
9.1 KiB
TypeScript
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)
|
|
}
|