Joyuno's Dev Blog

2026-09-18 (금) 개발일지

dev-blog

오늘은 Home Assistant Voice PE 브리지가 2개월간 죽어 있던 원인을 추적하고, 동시에 bridge 서버의 6개 변경군을 전수 적용하면서 발견한 예상치 못한 버그들을 정리했다. DHCP가 IP를 전부 갈아엎은 게 시작이었고, 그 여파로 드러난 설계 결함들이 dispatch 이중 실행부터 announce 음성 잡아먹기까지 이어졌다.

Voice PE 2개월 사망 — DHCP IP 드리프트가 전부였다

HA 컨테이너를 기동하고 ha-voice, ha-whisper가 Up으로 뜨는 걸 확인했다. 그런데 Voice PE 엔티티가 unavailable이었다. 처음엔 브리지 서버가 꺼져 있나 싶어 :8765/healthz를 찔렀는데 브리지는 살아 있었고, 프로젝트 9개·세션 2개가 정상이었다. 문제는 Voice PE 쪽이었다.

ping을 날려보니 Voice PE의 IP가 .105로 살아 있었다. 그런데 HA configuration.yaml에는 .103으로 등록돼 있었다. DHCP가 맥 IP(.106)와 Voice PE IP(.103→.105)를 전부 바꿔버린 거였다. 맥은 고정 IP를 썼는데도 DHCP 갱신 시점에 .106으로 밀려난 것 같았다.

핵심 발견: HA는 엔티티가 unavailable이어도 서비스 호출에 200을 반환한다. _ha_state(entity_id) 헬퍼를 신설해 상태를 직접 GET 하지 않으면 거짓 보고를 계속 먹게 된다.

해법은 두 가지였다. 첫째, configuration.yaml의 media_player.voice_assistant_... IP를 .105로 수정했다. 둘째, /healthz 엔드포인트를 전면 교체해 GET {HA_URL}/api/config의 internal_url과 등록된 IP 3개(TV·Voice PE·HA 호스트)를 실시간 비교하도록 바꿨다. server.py 706~738행을 갈아엎었고, from urllib.parse import urlparse import 하나만 추가됐다.

검증: Voice PE가 idle로 돌아왔고, TTS 경로까지 실제로 발화를 날려 소리가 나는 걸 확인했다. healthz 응답에서 IP 드리프트가 3값 모두 일치하는 것도 확인됐다.

bridge dispatch 이중 실행 — try 구조가 원인이었다

QA 리포트를 읽다가 dispatch_and_report에서 run_claude가 2번 호출되는 코드 경로를 발견했다. try 블록 하나에 run_claude + report_result가 같이 들어 있어서, report_result가 터지면 except에서 run_claude를 또 실행했다. server.py 825줄짜리 파일이었다.

처음엔 단순히 except 범위를 줄이면 된다고 생각했다. 그런데 run_claude 안에 재시도 로직이 이미 있고, report_result는 단순 HTTP POST라서 분리해도 리스크가 적었다. try를 2중으로 쪼개 안쪽은 run_claude+재시도만, 바깥쪽은 report_result만 두는 구조로 정리했다.

시행착오: report_result가 실패해도 run_claude 실행 횟수가 1로 유지되는 걸 격리 프로브로 확인했다. 14개 발화 샘플에서 dispatch leak이 0건이었다. PERMISSION_MODE가 bypassPermissions인 것도 라이브 실측으로 확인했다.

announce가 사용자 발화를 잡아먹던 문제 — history deque의 설계 결함

“아이유 밤편지 틀어줘”라고 말하면 TV에서 재생은 되는데, 그 다음 발화가 먹통이 되는 현상이 있었다. 로그를 보니 announce()가 TTS를 재생하는 동안 history deque가 flush되고 있었다. server.py L104의 history deque가 L372에서 dispatch_and_report 진입 시 초기화되는 구조였다.

처음엔 announce 완료를 기다리는 플래그를 추가하려 했다. 그런데 HA의 announce 서비스가 async/non-blocking이라 기다릴 수 없었다. 대신 history를 유지하는 별도 버퍼를 두고, announce 중에는 사용자 발화를 버퍼에 쌓았다가 TTS 종료 후 flush하는 방식으로 바꿨다.

검증: test_history.py에서 3턴 연속 발화가 모두 라우팅되는 걸 확인했다. announce 중에도 사용자 발화가 drop되지 않았다.

ytmusicapi 인증 방식 교체 — SAPISIDHASH 수동 인증의 불안정성

기존 _ytm_song_id는 ytmusicapi + SAPISIDHASH 수동 인증으로 음악을 검색했다. 그런데 이 인증이 2~3주마다 만료되고, 재인증을 까먹으면 검색이 0건을 반환했다. yt-dlp ytsearch10 1회 호출로 id/duration/thumbnail을 바로 뽑는 방식으로 전환했다.

_NOT_SONG 정규식을 추가해 “full album”, “playlist”, “mix”, “podcast” 같은 비음악 결과를 필터링했다. server.py 145~177행이 통째로 교체됐다. ytmusicapi 의존성도 제거할 수 있었다.

검증: 8/8 검색 결과가 정확한 노래 ID를 반환했다. 1.12.3 버전에서도 동일하게 동작하는 걸 확인했다.

Netflix 특정 작품 재생 — JustWatch GraphQL을 1st-party 소스로

“넷플릭스에서 기생충 틀어줘” 같은 명령이 들어오면 bridge가 Netflix 딥링크를 생성해야 했다. 처음엔 Netflix 내부 검색 API를 크롤링하려 했는데, 인증이 필요했다. JustWatch의 공개 GraphQL 엔드포인트를 발견했다.

JW_GQL 쿼리로 작품 제목을 검색하면 offer 필드에 netflix.ninja 딥링크가 포함돼 있었다. server.py 279~326행에 _netflix_title() 함수를 추가했고, test_netflix_title.py로 5개 작품(기생충·오겜·수리남·D.P.·더글로리)의 딥링크를 전수 검증했다.

검증: 실기기 TV에서 넷플릭스가 열리고 정확한 작품이 재생되는 걸 확인했다. app_id가 netflix.ninja로 고정된 것도 실측으로 확인했다.

오늘의 한줄

DHCP 한 번이 2개월치 QA 리포트를 무효화할 수 있다 — 운영 안정성은 코드 완결성보다 네트워크 고정 IP와 상태 실시간 검증이 먼저다.