AI

날씨 시계를 Codex·Claude 사용량 모니터로 바꾸기 — 1편: 하드웨어와 화면

mingoon.com 2026. 11. 3. 16:40
반응형

시리즈: 날씨 시계를 Codex·Claude 사용량 모니터로 바꾸기
1편: 하드웨어와 화면 (이 글)
2편: MQTT TLS와 안정적인 운영

 

책상 위 작은 날씨 시계에 AI 사용량을 띄우면 어떨까. Codex와 Claude를 함께 쓰다 보면 지금 얼마나 남았는지, 언제 다시 쓸 수 있는지를 각각 확인하게 된다. 이 정보를 작은 전용 화면에 모으면 작업 중에도 빠르게 살펴볼 수 있을 것 같았다.

 

목표는 Smart Weather Clock SD Pro의 시계·날씨·사진 기능을 걷어내고, Codex와 Claude의 Session/Weekly 잔여량을 보여주는 모니터를 만드는 것이었다. 기기를 새로 설계하기보다 공개된 펌웨어를 바탕으로 디스플레이와 Wi-Fi, 웹 설정, OTA 업데이트 기능을 활용했다. AI 코딩 도구와 함께 소스를 분석하고 수정했으며, 최종 확인은 실제 기기로 진행했다.

 

 

먼저 하드웨어와 업데이트 경로를 확인했다

기기는 ESP8266과 4 MB flash, ST7789 240×240 디스플레이를 사용한다. 화면은 TFT_eSPI로 제어한다. GPIO와 패널 초기화는 기존 펌웨어에서 검증한 값을 유지했다. 특히 백라이트는 GPIO5의 active-low PWM이다. 단순히 밝기 값을 반대로 넣으면 기대와 다르게 보일 수 있어 원래 제어 방식을 그대로 남겼다.

 

항목 이번 기기의 설정
MCU ESP8266 / ESP-12 계열, CPU 160 MHz (TLS 처리용)
디스플레이 ST7789, 240×240, 회전 0, RGB565, SPI 40 MHz
SPI 핀 SCLK 14, MOSI 13, CS 15, DC 0, RST 2
백라이트 GPIO5, active-low PWM, range 1023
버튼 GPIO4로 정의돼 있지만 실제로는 없을 수 있어 복구에 의존하지 않음
파일시스템 배치 stock과 같은 eagle.flash.4m3m.ld / LittleFS
OTA 바이너리 상한 0x7E000 = 516,096 B

 

여기서 4 MB라는 용량만 보고 여유가 많다고 생각하면 곤란하다. 순정과 호환되는 flash 배치를 유지하면 첫 1 MB 안에 실행 이미지와 OTA 임시 이미지가 함께 들어가야 한다. 그래서 OTA 바이너리 상한이 516,096 B다. 파티션은 바꾸지 않았다. 디스플레이 배선뿐 아니라 flash 배치와 복구 경로도 기능 개발 전에 확인할 항목이었다.

 

순정 SD Pro(V1.0.6)에서 새 펌웨어를 OTA한 뒤 대시보드가 부팅하는 것을 확인했다. 다만 이것이 모양이 비슷한 다른 기기(SmallTV Ultra 등)에도 적용된다는 뜻은 아니다. 순정으로 되돌리는 작업에는 UART가 필요하므로, 개조 전에 UART로 전체 flash를 백업해 두는 편이 좋다.

 

로그인과 조회는 Mac, 표시는 시계가 맡는다

데이터는 Mac에 설치한 open-usage CLI에서 가져왔다. 이름이 비슷한 Swift GUI 프로그램 (robinebers/openusage)이 있어 처음에는 어느 프로젝트인지부터 구분해야 했다. 실제 사용한 것은 arionrefat/open-usage의 CLI 0.16.0이다.

 

AI 로그인을 ESP8266에 옮기지 않고, 이미 로그인된 Mac의 프로그램이 만든 사용량 캐시를 Python 브리지가 읽도록 했다. 로그인 토큰과 대화 내용은 시계에 보내지 않는다.

 

open-usage daemon → 로컬 사용량 캐시 → Python 브리지
                                            ↓ MQTT TLS
                                   원격 Mosquitto 서버
                                            ↓ MQTT TLS
                                   SD Pro의 두 서비스 카드

 

캐시는 ~/.config/open-usage/usage-cache.json에 있다. 여기에는 사용한 비율이 들어 있으므로 (Codex는 usedPercent, Claude는 percent) 화면의 잔여량은 100 - 사용률로 변환한다. 타임스탬프도 원본의 밀리초(fetchedAtMs, resetsAtMs)를 1000으로 나눠 Unix 초로 바꾼다. 변환을 한 곳에서 끝내면 기기는 작은 JSON을 검증하고 그리는 일에 집중할 수 있다.

 

처음에는 최대 세 서비스를 고려했지만 최종 화면은 Codex와 Claude 두 개로 정했다. 각 서비스의 Session과 Weekly를 항상 볼 수 있도록 하는 편이 실제 사용 목적에 맞았다. 지원하지 않는 요금제 이름이나 리셋 시각은 추측해서 채우지 않는다.

 

기기로 보내는 메시지

브리지는 토픽 llm-usage/summary에 아래 JSON을 QoS 1, retained로 발행한다. 메시지 하나가 화면의 두 카드를 통째로 바꾼다. 본문은 UTF-8, 최대 1024 B다.

 

{
  "schema": "openusage.device.v1",
  "accounts": [
    {
      "provider": "codex",
      "plan": "plus",
      "fetched_at": 1793683979,
      "stale": false,
      "session": {"remaining_percent": 45, "resets_at": 1793689624},
      "weekly": {"remaining_percent": 91, "resets_at": 1794276424}
    },
    {
      "provider": "claude",
      "plan": "",
      "fetched_at": 1793683979,
      "stale": false,
      "session": {"remaining_percent": 72},
      "weekly": {"remaining_percent": 20}
    }
  ]
}

 

  • provider는 codex, claude 각각 하나씩만 받는다. 중복이나 모르는 서비스는 거부한다.
  • plan은 ASCII 최대 16자다. Claude 캐시에 요금제 이름이 없으면 빈칸으로 둔다.
  • fetched_at은 발행 시각이 아니라 서비스에서 마지막으로 조회한 시각이다.
  • 조회 후 10분이 지나면 stale이다. 기기도 NTP 시각으로 다시 판정한다.
  • resets_at은 원본에 시간이 없으면 만들어 넣지 않는다.

 

잘못된 메시지는 통째로 거부하고 마지막 정상 데이터를 유지한다.

 

큰 디자인 시안을 240×240에 맞추기

참고한 디자인은 어두운 배경 위에 서비스별 카드를 쌓고, 이름·요금제·진행 막대·남은 비율· 리셋 시간을 보여주는 형태였다. Codex는 청록/파랑, Claude는 주황 계열을 사용해 구분한다. 진행 막대는 사용량이 아니라 남은 양을 채우므로 숫자와 방향이 일치해야 한다.

 

작은 화면에서는 원본 시안의 여백이나 장식을 그대로 옮기기 어렵다. 두 카드를 위아래로 배치하고 Session/Weekly의 숫자와 막대를 우선했다. 리셋 시간과 데이터 상태(요금제 또는 STALE)는 보조 정보로 정리했다. 시안의 큰 로고와 그라데이션, 리셋 시점 예상 잔여량은 넣지 않았다. 예상 잔여량은 계산 모델이 없어서다.

 

 

이 크기의 RGB565 전체 framebuffer는 115,200 B다. ESP8266의 런타임 힙과 TLS까지 고려하면 사용하기 어렵다. 전체 화면 버퍼를 만들지 않고 직접 그리며, 폰트도 실제 사용하는 영어 기본 폰트 두 개만 빌드에 포함했다. 메시지도 최대 1024 B, 서비스는 고정 두 개로 제한했다.

 

실제 렌더러와 JSON 파서를 호스트에서 실행하는 미리보기 검사를 추가해 잘못된 JSON, 중복 서비스, 범위 밖 비율을 거부하는지도 확인했다. 화면이 그럴듯하게 보이는 것과 입력이 깨졌을 때 안정적인 것은 별개의 확인이었다.

 

빌드와 검사

PlatformIO는 원래 빌드에 썼던 6.2.0으로 고정했고, 라이브러리 버전도 platformio.ini에 고정했다. 펌웨어 빌드와 함께 호스트에서 돌리는 테스트를 한 번에 실행한다.

 

python3 -m venv .venv
.venv/bin/python -m pip install 'platformio==6.2.0' -r scripts/bridge-requirements.txt
.venv/bin/pio run
.venv/bin/python tests/run_safety_tests.py
.venv/bin/python tests/run_mqtt_memory_tests.py
.venv/bin/python -m unittest discover -s tests -p 'test_*.py'
.venv/bin/python tests/run_dashboard_preview.py

 

Apple Silicon Mac에서 Bad CPU type in executable이 나오면 Intel용 ESP8266 툴체인을 돌릴 Rosetta 2가 필요하다. 미리보기 결과는 .pio/dashboard.ppm으로 나온다.

 

배포용 이미지는 별도 스크립트(scripts/make_release.py)로 만든다. Wi-Fi 정보가 든 secrets.ini를 무시하고 빌드한 뒤, 이미지에 비밀값이 섞이지 않았는지 검사하고 SHA-256 체크섬을 함께 만든다.

 

순정 기기에 처음 올리기

순정 SD Pro의 웹 설정에 있는 업데이트 페이지를 그대로 이용한다. 순정 updater가 최초 업로드를 검증한다.

 

  1. 시계 IP의 웹 설정에서 Firmware Update / OTA를 연다.
  2. SDP로 시작하는 펌웨어 파일을 고른다. 이름이 firmware.bin이면 순정 페이지가 거부한다.
  3. 완료·재부팅까지 전원을 유지한다. filesystem 이미지를 올리거나 포맷하지 않는다.
  4. 화면에 표시된 암호로 Smart Weather Clock AP에 연결하고 http://192.168.4.1/을 연다.
  5. 같은 기기 암호로 로그인하고 집 Wi-Fi를 설정한다.
  6. 새 IP로 웹 설정에 들어간다. mDNS는 뺐으므로 .local 이름에 의존하지 않는다.

 

이후 업데이트는 웹 Firmware Update에 새 .bin을 올리면 된다. Wi-Fi·MQTT 설정은 /config.json, 기기 암호는 EEPROM에 남는다. 업데이트 전에는 TLS 연결 버퍼를 먼저 해제하고, 이미지 길이·CRC·flash 형식을 검사한다. CRC는 손상 검출일 뿐 전자서명은 아니다.

원격 설정과 업데이트를 계속 사용할 수 있게

Wi-Fi 연결, 웹브라우저 설정, 기기 로그인과 OTA는 유지했다. 새 펌웨어에서는 웹 설정에 아래 값을 넣고 Save settings를 누르면 된다.

 

항목 값
MQTT enabled true
Broker host mqtt.example.com (IP 대신 DNS 이름)
Port 8883
Username clock-device
Password 시계용 MQTT 암호 (기기 로그인 암호와 별개)
Subscription topic llm-usage/summary
NTP pool.ntp.org 등

 

NTP로 시각을 맞춘 뒤 TLS를 시도하고, 실패하면 30초마다 다시 시도한다. 예전 1883 포트 설정은 TLS 접속에서 막히므로 8883으로 바꿔야 한다. 루트 CA는 공개 ISRG Root X1/X2를 펌웨어에 넣었다. 인증서 갱신은 문제없지만 CA 자체가 바뀌면 펌웨어도 업데이트해야 한다.

 

수신한 스냅샷을 매번 flash에 저장하지는 않는다. 대신 브로커가 retained 메시지를 보관하고 기기가 재접속했을 때 다시 전달한다. Mac이 잠들거나 사용량 조회가 멈춘 경우에는 마지막 값을 최신처럼 보이지 않도록 STALE 상태를 표시한다.

 

최종적으로 보존한 동작 확인 이미지는 504,448 B, 정적 RAM은 37,268 B다. 상한까지 11,648 B가 남았으므로 새 폰트나 기능을 추가할 때는 빌드 크기를 다시 확인해야 한다. 정적 RAM 사용량만으로는 TLS 연결 가능 여부를 판단할 수 없다는 점도 개발 중에 드러났다. 실제 힙은 기기의 /status에서 heap / heapLow / heapMaxBlock으로 본다.

 

문제가 생겼을 때 복구

복구 모드는 전원을 두 번 짧게(10초 미만) 켰다 끄고, 세 번째에 켜 두면 들어간다. 부팅 중 오류나 파일시스템 mount 실패도 복구 모드로 이어진다. 복구 AP의 192.168.4.1에서 상태를 보고 호환 이미지를 다시 올린다. 복구 모드는 인증 없이 접근되므로 현장에서만 쓴다. 화면이 아예 안 뜨거나 부트로더까지 손상됐다면 UART가 필요하다.

 


다음 편에서는 이미 운영 중인 SWAG를 수정하지 않고 MQTT TLS 서버를 붙인 과정과, 서버 연결이 성공해도 기기가 데이터를 받지 못했던 메모리 문제를 정리한다.

 

다음 글: 2편: MQTT TLS와 안정적인 운영

기반 펌웨어: michaeldale/smartclock-oss, GPL-3.0

 

 

※ 이 글은 AI의 도움을 받아 작성했고, 작성자가 내용을 직접 검토한 뒤 올렸다.