개요

Carpybara Companion PWA(app.carpybara.com)는 사용자가 직접 제작한 GIF를 차량용 ESP32-S3 디스플레이에 전송할 수 있는 기능을 제공합니다. 이 글은 PWA에서 GIF를 업로드하고 BLE로 임베디드 디바이스에 전달하기까지, 두 단계로 구성된 파이프라인의 설계와 구현을 기록합니다.
파이프라인 전체 구조
GIF 전달 파이프라인은 두 흐름으로 분리되어 있습니다.
[1단계 — 클라우드 저장]
사용자 GIF 선택 (파일 입력)
→ Supabase Storage (gifs 버킷) 업로드
→ public URL 발급 + custom_gifs 테이블 insert
→ URL을 localStorage에 저장
[2단계 — 디바이스 전송]
DressTab에서 GIF 카드 선택
→ gifToCpyf(): 브라우저에서 GIF → CPYF 포맷 변환
→ Web Bluetooth GATT, 496바이트 청크 스트리밍
→ ESP32-S3 수신 → LittleFS /custom.gif 저장
→ AnimatedGIF 라이브러리로 TFT 재생
두 흐름은 의도적으로 분리했습니다. Supabase 업로드는 GIF를 클라우드에 영구 보존해 디바이스 없이도 목록을 관리할 수 있게 하고, BLE 전송은 해당 GIF를 디바이스 플래시에 직접 기록해 Wi-Fi 없이도 재생되게 합니다.
기술 스택
- PWA: Vite 6 + React 19 + TypeScript strict
- 클라우드 스토리지: Supabase Storage (gifs 버킷), Supabase JS SDK
- GIF 디코딩: gifuct-js (브라우저 사이드)
- 디바이스 통신: Web Bluetooth API (GATT)
- 펌웨어 BLE 스택: NimBLE-Arduino (h2zero/NimBLE-Arduino)
- 펌웨어 파일시스템: LittleFS
- 펌웨어 GIF 재생: bitbank2/AnimatedGIF
1단계: Supabase Storage 업로드
파일 선택 즉시 Supabase Storage의 gifs 버킷에 업로드하고, 발급된 public URL과 메타데이터를 custom_gifs 테이블에 기록합니다. 경로는 {device_id}/{timestamp}.gif 형식으로 디바이스별로 격리합니다.
// useGifUpload.ts
const path = `${deviceId}/${Date.now()}.gif`;
const { error } = await supabase.storage
.from("gifs")
.upload(path, file, { contentType: "image/gif", upsert: true });
const { data } = supabase.storage.from("gifs").getPublicUrl(path);
await supabase.from("custom_gifs").insert({
device_id: deviceId,
storage_path: path,
public_url: data.publicUrl,
size_bytes: file.size,
});
업로드 제한은 1.5 MB로 설정했습니다. BLE 전송 시 브라우저에서 CPYF로 변환하면 실제 전송 크기는 이보다 작아지지만, 원본 GIF의 프레임 수와 해상도가 클수록 변환 시간이 늘어나기 때문에 입력 단계에서 크기를 제한합니다.
2단계: GIF → CPYF 변환 (브라우저)
ESP32-S3는 GIF를 직접 GATT 스트림으로 받아 LittleFS에 저장하고 AnimatedGIF 라이브러리로 재생합니다. 그러나 임의 GIF를 그대로 전송하면 해상도나 팔레트 처리 방식이 펌웨어와 맞지 않는 경우가 생깁니다. 이 문제를 해결하기 위해 전송 전에 브라우저에서 CPYF(Carpybara Frame)라는 중간 바이너리 포맷으로 변환합니다.
CPYF 포맷 설계
Header (16 bytes):
[0-3] Magic "CPYF"
[4-5] frame_width (uint16 LE) = 128
[6-7] frame_height (uint16 LE) = 72
[8-9] frame_count (uint16 LE, max 6)
[10-15] reserved (0x00)
Per frame (frame_count × CPYF_FRAME_BYTES):
[0-1] delay_ms (uint16 LE, min 16 ms)
[2+] RGB565 pixels, row-major, little-endian uint16
출력 해상도를 128×72로 고정한 이유는 두 가지입니다. 첫째, TFT 캐릭터 표시 영역(320×180)에 정수 배율(2.5×)로 맞아 떨어져 펌웨어 측에서 별도 스케일링 없이 drawRGBBitmap으로 직접 렌더링할 수 있습니다. 둘째, 6프레임 기준 전체 페이로드가 약 108 KB로, BLE 전송 시간이 10초 내외로 수렴합니다.
Delta-encoded GIF 처리
GIF 포맷은 매 프레임이 전체 이미지가 아닌 변경된 영역(patch)만 담을 수 있습니다(delta encoding). gifuct-js로 각 프레임의 patch를 추출한 뒤 누적 캔버스에 합성해야 완전한 프레임이 됩니다. Disposal method 2(이전 영역 지우기)도 별도로 처리합니다.
// gifDecoder.ts
const comp = document.createElement('canvas'); // 누적 캔버스
const compCtx = comp.getContext('2d', { willReadFrequently: true })!;
for (let i = 0; i < frameCount; i++) {
const f = raw[i];
const { left, top, width, height } = f.dims;
// Disposal method 2: 이 프레임의 영역을 지우고 합성
if (f.disposalType === 2) compCtx.clearRect(left, top, width, height);
compCtx.putImageData(new ImageData(f.patch, width, height), left, top);
// 128×72로 다운스케일
dstCtx.drawImage(comp, 0, 0, CPYF_W, CPYF_H);
// RGBA → RGB565 (little-endian, ILI9341 drawRGBBitmap 입력 형식)
for (let p = 0; p < CPYF_W * CPYF_H; p++) {
const r = px[p * 4], g = px[p * 4 + 1], b = px[p * 4 + 2];
view.setUint16(base + 2 + p * 2,
((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3), true);
}
}
2단계: BLE GATT 전송 프로토콜
변환된 CPYF 버퍼는 Web Bluetooth API를 통해 ESP32-S3의 GATT 서버로 전송합니다. GATT 서비스는 두 개의 characteristic으로 구성됩니다.
- WRITE characteristic (
0x...0002): 앱 → 디바이스. 바이너리 프로토콜 커맨드. - STATUS characteristic (
0x...0003): 디바이스 → 앱. Notify로 진행 상황 전달.
커맨드 프로토콜
앱 → 디바이스 (WRITE characteristic)
[0x01][size: 4 bytes LE] START — 전체 파일 크기 예고
[0x02][data: max 496 bytes] DATA — 청크 데이터
[0x03] END — 전송 완료, 파일 저장 요청
[0x04] ABORT — 취소, 부분 파일 삭제
디바이스 → 앱 (STATUS characteristic, Notify)
[0x01] READY — 수신 준비 완료
[0x02][rx: 4 bytes LE] PROGRESS — 수신 바이트 수
[0x03] DONE — /custom.gif 저장 완료
[0x04][err] ERROR — 오류 발생
청크 크기 결정 — 496 bytes
BLE의 기본 MTU는 23 bytes이지만, BLE 4.2 이후 협상을 통해 최대 512 bytes까지 확장됩니다. NimBLE-Arduino의 기본 협상 MTU가 512 bytes이므로, 프로토콜 헤더(커맨드 바이트 1 byte) + ATT 오버헤드(3 bytes)를 제외하고 청크당 최대 유효 데이터를 496 bytes로 설정했습니다.
// useBluetooth.ts
const CHUNK_SIZE = 496;
for (let off = 0; off < total; off += CHUNK_SIZE) {
const slice = bytes.slice(off, Math.min(off + CHUNK_SIZE, total));
const pkt = new Uint8Array(1 + slice.length);
pkt[0] = CMD_DATA; // 0x02
pkt.set(slice, 1);
await wc.writeValueWithoutResponse(pkt);
await new Promise((r) => setTimeout(r, 12)); // 12 ms 간격
}
writeValueWithoutResponse를 사용한 이유는 ACK 대기 없이 연속 전송하여 처리량을 높이기 위해서입니다. 다만 BLE 내부 버퍼가 과부하되지 않도록 청크 사이에 12 ms 간격을 두었습니다.
펌웨어: NimBLE GATT 서버 + LittleFS 수신
ESP32-S3 측은 NimBLE-Arduino 라이브러리로 GATT 서버를 구성합니다. WRITE characteristic에 콜백을 등록해 커맨드를 파싱하고, DATA 청크는 즉시 LittleFS의 /custom.gif에 append 합니다. END 커맨드 수신 시 파일을 닫고 gifReady 플래그를 올립니다.
// pet_ble.h — WRITE 콜백
case 0x01: // START
expected = /* 4바이트 LE 파싱 */;
LittleFS.remove(CUSTOM_GIF_PATH);
gifFile = LittleFS.open(CUSTOM_GIF_PATH, "w");
notify(0x01); // READY
break;
case 0x02: // DATA
gifFile.write(d + 1, n - 1);
received += n - 1;
if ((received % (10 * 512)) < (size_t)(n - 1))
notify(0x02, received); // ~5 KB마다 PROGRESS 알림
break;
case 0x03: // END
gifFile.close();
gifReady = true;
notify(0x03); // DONE
break;
gifReady 플래그는 DisplayTask가 매 프레임 루프에서 확인합니다. 플래그가 올라오면 AnimatedGIF를 해당 파일로 열고 즉시 재생을 시작합니다. 플래그는 읽는 순간 자동으로 초기화됩니다(auto-clear on read).
LiveCanvas 미리보기
전송 전에 GIF를 디바이스 화면과 동일한 비율(4:3, 최대 320px)의 LiveCanvas에 미리보기할 수 있습니다. DressTab에서 GIF 카드를 탭하면 previewGifUrl 상태가 설정되고, LiveCanvas가 해당 URL의 GIF를 인라인 재생합니다. 탭을 전환하거나 카드를 다시 탭하면 미리보기가 초기화됩니다. BLE 전송 동작 자체는 미리보기와 완전히 분리되어 있습니다.
트러블슈팅
1. Web Bluetooth는 Chrome 한정
원인: Web Bluetooth API는 현재 Chrome(데스크톱·Android)에서만 지원됩니다. iOS Safari는 미지원 상태입니다.
대응: useBluetooth.ts의 isSupported 플래그로 지원 여부를 초기화 시점에 감지하고, 미지원 환경에서는 BLE 전송 UI를 비활성화합니다.
2. BLE 연결 중 연결 끊김 처리
원인: 전송 도중 BLE 연결이 끊기면 LittleFS에 부분 파일이 남아 이후 재생 시 AnimatedGIF가 손상된 데이터를 읽게 됩니다.
해결: gattserverdisconnected 이벤트 수신 시 앱 측에서 즉시 상태를 idle로 초기화합니다. 펌웨어 측에서는 ABORT 커맨드(0x04)로 부분 파일을 명시적으로 삭제하는 경로를 별도로 마련했습니다.
3. 대용량 GIF 전송 시간
원인: CPYF 6프레임(128×72) 기준 전송 데이터는 약 108 KB이며, BLE 처리량 한계상 전송에 약 10초가 소요됩니다.
대응: 진행률을 STATUS characteristic의 PROGRESS 알림으로 실시간 표시합니다. 사용자에게 전송 중임을 명확히 피드백하고, 완료 전에 연결을 끊지 않도록 안내합니다.
결과
- PWA에서 GIF 선택부터 디바이스 재생까지 단일 UI 흐름으로 완성했습니다.
- Supabase Storage로 업로드된 GIF는 디바이스 없이도 목록 관리가 가능합니다.
- CPYF 변환으로 GIF 포맷의 delta encoding을 브라우저에서 처리하고, 펌웨어는 고정 포맷의 RGB565 스트림만 소비합니다.
- BLE GATT 양방향 프로토콜(WRITE + Notify)로 전송 진행률과 오류를 실시간 피드백합니다.
마무리
이 파이프라인에서 가장 많은 시간이 든 부분은 GIF 포맷의 delta encoding 처리였습니다. 단순히 각 프레임을 독립적으로 디코딩하면 이전 프레임 위에 겹쳐지는 영역이 누락되어 화면이 깨지는 현상이 발생합니다. 누적 캔버스 패턴으로 이를 해결한 뒤 RGB565 변환까지 브라우저에서 모두 처리함으로써 펌웨어 측 복잡도를 최소화할 수 있었습니다.
'개인 개발 > 자동차 속도 기반 IoT 장식 만들기' 카테고리의 다른 글
| 자동차 속도 기반 IoT 장식 만들기 10 - 생산 단가 정하기 (0) | 2026.06.01 |
|---|---|
| 자동차 속도 기반 IoT 장식 만들기 09 - FreeRTOS 구조 상세 설명 (0) | 2026.06.01 |
| 자동차 속도 기반 IoT 장식 만들기 07 - ESP32-S3 FreeRTOS로 차량용 IoT 펌웨어 재설계하기 (0) | 2026.05.22 |
| 자동차 속도 기반 IoT 장식 만들기 06 - 실차 테스트 결과와 방향 전환, OBD2에서 GPS+가속도 센서로 (0) | 2026.05.01 |
| 자동차 속도 기반 IoT 장식 만들기 05 - ESP32 블루투스 OBD2 연동 구현 (0) | 2026.05.01 |