손바닥만 한 보드 하나에 클로드를 올렸다. 목표는 AI 스피커였다. 말을 걸면 대답하는, 콘센트만 꽂으면 혼자 돌아가는 상자.
결론부터 말하면 절반은 됐고 절반은 남았다. 그리고 그 과정에서 하루를 통째로 날린 벽이 하나 있었다. 이 글은 성공담이 아니라 그 벽에 대한 기록에 가깝다.
Arduino UNO Q라는 물건
이 보드는 뇌가 둘이다. 퀄컴 Dragonwing QRB2210(쿼드코어 A53, 데비안 13 리눅스)과 STM32U585(Cortex-M33, Zephyr). 아두이노 UNO 헤더를 그대로 달고 있지만 속은 완전히 다른 물건이다.
중요한 건 아두이노 핀들이 리눅스가 아니라 STM32에 붙어 있다는 것이다. 리눅스에서 아무리 GPIO를 뒤져도 거기 꽂은 센서는 안 보인다. 둘은 Bridge라는 다리로 대화한다. C++이 Bridge.notify()로 보내고 파이썬이 Bridge.provide()로 받는 식이다.
그리고 헤더 모양이 옛날 UNO와 같지만 이 보드는 3.3V다. 5V 센서 모듈을 그대로 꽂으면 핀이 상할 수 있다. 모양이 같으니 당연히 될 거라고 생각하게 되는 게 함정이다.
보드 위의 클로드
Claude Code를 보드에 직접 깔았다. 걸린 곳이 둘.
Node 버전. 데비안 13의 apt는 Node 20까지만 준다. Claude Code는 22 이상을 요구한다. 20에서도 실행은 됐지만 EBADENGINE 경고가 떴다. “지금 되는 것”과 “지원되는 것”은 다르다. 공식 arm64 바이너리로 Node 22를 따로 깔았다.
저장공간. eMMC는 32GB인데 df를 치면 9.8G만 보인다. 파티션이 69개로 쪼개져 있고, 루트는 10G(79% 사용), /home이 18G(4% 사용)이다. 시스템에 npm install -g 하면 루트가 터진다. npm prefix를 홈으로 돌려서 해결했다.
npm config set prefix ~/.npm-global
export PATH=$HOME/.local/node22/bin:$HOME/.npm-global/bin:$PATH
로그인하고 물어봤더니 답했다. 보드 안에서 uname -a를 실행하고, 한국어로 답하고, “이건 uname 결과만 본 것이라 실제 보드 종류까지는 확인되지 않았습니다”라고 선까지 그었다.
소리를 내보내기까지 넘은 벽 네 개
블루투스 스피커로 소리를 내는 데 벽이 넷 있었다.
1. 로그인 화면이 오디오를 쥐고 있었다. 모니터도 안 꽂혀 있는데 lightdm이 자기 PipeWire를 띄워서 블루투스 오디오를 선점하고 있었다. 데스크톱은 살려두고 오디오만 마스킹했다.
sudo systemctl --machine=lightdm@.host --user mask
pipewire.service pipewire.socket pipewire-pulse.service
pipewire-pulse.socket wireplumber.service
2. seat 없는 세션은 블루투스를 못 켠다. 이게 제일 지독했다. loginctl enable-linger로 세션을 만들어도 그 세션엔 seat가 없다. 그리고 WirePlumber의 bluez.lua에는 이런 게 박혀 있다.
-- if logind support is enabled, activate
-- the monitor only when the seat is active
if seat_state == "active" then
seat가 없으면 블루투스 모니터가 로그 한 줄 없이 조용히 안 뜬다. 설정 파일의 hardware.bluetooth = required도, wants = [monitor.bluez.seat-monitoring]도 이걸 못 막는다. 설정만 읽고 코드를 안 읽었으면 영원히 못 찾았을 것이다.
해결책은 이미 준비돼 있었다. main-systemwide 프로파일이 딱 이 경우(모니터 없이 도는 기계)를 위해 존재한다.
# ~/.config/systemd/user/wireplumber.service.d/profile.conf
[Service]
ExecStart=
ExecStart=/usr/bin/wireplumber -p main-systemwide
3. wpctl이 거짓말을 한다. wpctl set-profile은 종료코드 0을 뱉고 아무것도 안 바꾼다. 로그도 안 남긴다. pw-cli set-param <id> Profile '{ index: N, save: true }'는 제대로 동작한다. 이것 때문에 몇 시간을 HFP 탓으로 돌렸다.
4. 스피커가 노트북에 붙어 있었다. 페어링이 계속 AuthenticationRejected로 튕겼다. 리눅스 쪽엔 LinkKey가 없는데 스피커 쪽엔 남아 있는 짝사랑 상태였다. 양쪽 다 지우고 다시 붙였다. 폰만 의심했지 노트북은 생각도 못 했다.
그리고 넘지 못한 벽: 블루투스 마이크
여기가 오늘의 본론이다.
처음엔 낙관했다. 스피커가 Handsfree (0000111e) UUID를 광고하고 있었고, 로그에는 실제 AT 명령이 오갔다.
spa.bluez5.native: RFCOMM receive command ... AT+CHLD=?
spa.bluez5.native: RFCOMM receive command ... AT+BTRH?
기기가 직접 말하는 증거였으니 된다고 생각했다. 프로파일도 headset-head-unit으로 잘 바뀌었고, bluez_input_internal...(api.bluez5.sco.source) 노드도 생겼고, 네 개 노드가 전부 running이 됐다.
그런데 녹음하면 전부 0이었다. 순수한 무음. 소스를 바꿔봐도, media.role=Communication을 줘도, 재생과 동시에 돌려도 0.
답은 hciconfig 한 줄에 있었다. 몇 시간 동안 화면에 떠 있었는데 그냥 지나쳤던 숫자다.
$ hciconfig hci0
RX bytes:140139 acl:200 sco:0 events:2000 errors:0
TX bytes:932376 acl:1311 sco:0 commands:1089 errors:0
^^^^^
sco:0. eSCO 링크는 hcitool con에 멀쩡히 잡히는데, 통화 음성 패킷이 호스트로 단 한 개도 안 올라온다. 블루투스 칩은 Qualcomm WCN399x(UART 연결)이고, 이 계열은 SCO를 HCI가 아니라 별도 PCM 배선으로 빼는 것으로 알려져 있다. 소프트웨어가 닿을 수 없는 곳이다.
기기를 바꿔가며 확인했다.
| 기기 | 코덱 | 결과 |
|---|---|---|
| LG PH1R (구형) | CVSD | sco:0 |
| 노래방 마이크 (ASSA) | CVSD | RX만 0 (TX는 흐름 → 재생은 들림) |
| Britz Vtalk90DPlus (2024) | CVSD | sco:0 |
| Britz Vtalk90DPlus | mSBC | sco:0 |
브리츠만 mSBC를 지원했다. PipeWire 안에서 완전히 다른 코드 경로를 타므로 해볼 가치가 있었다. 그래도 0이었다.
서로 다른 세 기기, 두 코덱, 전부 0. 공통분모는 보드 하나다. 어떤 블루투스 마이크를 가져와도 이 벽을 못 넘는다. 다음에 이 보드로 음성을 다루려는 사람은 hciconfig의 sco: 카운터부터 보면 된다. 그거 하나면 몇 시간을 아낀다.
고장난 줄 알았던 버튼
확장 쉴드(ElectroCookie Uno Terminal Block Shield)에 네모난 버튼이 하나 있다. 아무리 눌러도 반응이 없었다. 리눅스 입력 장치에도 신호가 안 온다.
리셋 버튼이었다. RES 핀에 연결돼서 STM32만 리셋한다. 리눅스는 알 방법이 없다.
그런데 발주자가 물었다. “그럼 이 리셋 버튼을 종료 신호로 쓰면 안 되나?”
된다. 그리고 이 보드의 특성이 오히려 도움이 됐다. UNO Q는 STM32가 리셋되면 스케치가 살아 돌아오지 않는다. 리눅스가 다시 올려줘야 한다. 즉 심장박동이 끊기면 그건 사람이 버튼을 눌렀다는 뜻이다.
[스케치] Bridge.notify("alive", millis()/1000) 0.5초마다
↓
[컨테이너 파이썬] /app/.stm32_alive 파일 갱신
↓ (앱 폴더가 호스트에 bind-mount 되어 있다)
[호스트 감시자] 파일이 4초 넘게 안 바뀌면 → systemctl poweroff
파이썬 앱은 도커 컨테이너 안이라 호스트를 못 끈다(Privileged: false, dbus 소켓 없음). 그래서 파일을 통로로 썼다. 안전장치 둘을 넣었다. 심장박동을 한 번이라도 본 뒤에만 무장하고(안 그러면 부팅하자마자 꺼진다), 컨테이너가 멈춰 있으면 반응하지 않는다(개발 중 앱 재시작에 오작동 방지).
작동한다. 버튼 → 얌전히 종료. 다만 켜는 건 안 된다. 꺼진 칩은 리셋할 게 없으니까. 전원을 뺐다 꽂아야 한다.
부팅하면 클로드가 뜬다
마지막으로 자동 실행. 그냥 띄우면 안 되고 와이파이가 진짜 붙고 앤트로픽에 닿는지 확인해야 한다. 클로드는 클라우드에 있으니까.
여기서도 한 번 넘어졌다. curl -sf https://api.anthropic.com이 실패로 나왔다. -f 때문이었다. 이 서버는 맨 GET에 404를 준다. 404가 왔다는 건 서버가 답했다는 뜻이고, 그게 곧 도달했다는 증거인데, -f는 그걸 실패로 뒤집는다.
CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 5 https://api.anthropic.com)
[ "$CODE" != "000" ] && echo "도달" # 404든 405든 숫자가 오면 성공
실제 부팅 로그:
10:37:41 Starting claude-boot.service
10:37:55 wlan0 연결됨 (5번째)
10:37:55 앤트로픽 도달 확인 (HTTP 404, 1번째)
10:37:58 클로드 떴음 -> 합류: tmux attach -t claude
5번째 시도에 붙었다. 부팅 직후엔 와이파이가 아직이었다는 뜻이다. 기다리게 만든 게 헛수고가 아니었다.
남은 것
듣기. USB 마이크를 꽂으면 된다(snd_usb_audio는 이미 로드돼 있다). 나가는 길은 다 뚫어놨으니 들어오는 길만 붙이면 말 → 클로드 → 말이 이어진다.
오늘 배운 것
- 협상이 됐다고 소리가 흐르는 게 아니다. UUID도, AT 명령도, 프로파일 전환도, 노드가
running인 것도 전부 “할 수 있다”의 증거일 뿐이다.sco:0이 진실이었다. - 설정 파일만 읽고 코드를 안 읽으면 못 찾는 것이 있다. seat 문제가 그랬다.
- 도구가 거짓말을 한다.
wpctl set-profile은 0을 뱉고 아무것도 안 했다.app logs는 녹화본을 실시간처럼 틀어줬다. - 계측기가 고장나면 대상을 탓하게 된다. 로그를 세 번 믿었다가 세 번 속았고, 결국 LED를 깜빡이게 해서 눈으로 보니 5초 만에 끝났다.
- 고장난 것처럼 보이는 게 기능일 수 있다. 리셋 버튼도, 리셋되면 스케치가 안 살아나는 것도, 그대로 두면 결함이지만 뒤집으면 전원 버튼이 된다.