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 => ({ 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) }