이 문서는 InstallerApp에서 블루투스(BLE)로 카메라 JPEG 사진을 받는 기능을 공부하기 위한 정리입니다.
중학생도 따라올 수 있도록 쉬운 말로 설명하고, 마지막에 실제 구현된 소스 파일과 전체 흐름을 연결합니다.
참고 원문:
documents/BT JPEG 고속 수신 모바일 앱 적용 가이드 _ R10WS Docs.html
스마트폰과 R10 장치가 가까운 거리에서 무선으로 데이터를 주고받는 통로입니다.
Wi‑Fi처럼 인터넷 전체가 아니라, 내 폰 ↔ 내 장치 사이 연결에 가깝습니다.
BLE 위에서 “어떤 데이터를 어디로 보낼지” 정해 놓은 약속(규칙) 입니다.
비유:
| BLE/GATT | 비유 |
|---|---|
| GATT 서비스 | 아파트 한 동 |
| Characteristic(특성) | 각 호실 |
| UUID | 호실 번호판 (주소) |
우리 앱은 호실 번호(UUID) 를 보고 “여기는 AT 명령용”, “여기는 JPEG 바이너리용”이라고 구분합니다.
| 이름 | 뜻 | 우리 앱에서 |
|---|---|---|
| TX | 장치 → 앱 (보내기) | notify로 데이터 수신 |
| RX | 앱 → 장치 (받기) | write로 AT 명령 전송 |
| TX/RX | 양방향 | AT 명령 + 텍스트 응답 |
| TX-only | 장치 → 앱만 | JPEG 바이너리만 연속 수신 |
TX-only = “JPEG 전용 수신 호실. 앱은 write 안 하고 받기만 한다.”
앱이 장치에게 보내는 짧은 텍스트 명령입니다.
예:
AT$$JPG=CAPTURE,0,10000
→ “0번 카메라 JPEG 찍어줘. 10초 안에.”
장치도 텍스트로 대답합니다.
$$JPG: 0,O00000001,0,960,540,135564,... OK
→ “찍었어. sessionId는 O00000001, 크기는 135564바이트야.”
한 번 찍은 JPEG 묶음의 주문 번호입니다.
DROP으로 장치 메모리 정리장치가 먼저 데이터를 밀어 넣는 방식입니다.
앱이 “다음 거 줘”라고 매번 묻지 않아도 됩니다. 택배가 문 앞에 쌓이는 느낌.
CAPTURE → 사진 정보(메타데이터) 받기 AT$$XFER=GET,sessionId,0 → 0번 조각 요청 AT$$XFER=GET,sessionId,1 → 1번 조각 요청 EOF → 끝조각마다 Base64 텍스트로 와서, 앱이 decode 해야 합니다.
AT 왕복이 많아서 사진이 클수록 느립니다.
CAPTURE → 메타데이터 (GET과 동일) AT$$XFER=STREAM,sessionId,0,0 → “이제 보내기 시작해” 데이터 구간은 장치 → 앱 단방향 push.
앱은 STREAM 시작 명령 한 번만 보냅니다.
| 방식 | 비유 |
|---|---|
| GET | “1페이지 보내줘” → 받음 → “2페이지 보내줘” → … |
| STREAM | “책 통째로 보내 시작해” → 택배가 페이지를 계속 투입 |
| 구분 | UUID | 역할 |
|---|---|---|
| TX/RX | FEC26EC4-6D71-4442-9F81-55BC21D658D6 | AT 명령 write, 텍스트 notify |
| TX-only | B273BFED-974F-43A7-9F77-D28E9F7C2D41 | JPEG binary stream notify |
연결할 때 두 notify 모두 켜야 STREAM이 동작합니다.
(CCCD descriptor write까지 해야 실제 notify가 켜지는 폰도 많습니다.)
TX-only로 오는 데이터는 AX로 시작하는 작은 상자(frame) 들의 연속입니다.
[헤더 18바이트][JPEG 조각 N바이트]
헤더 안에 들어 있는 것 (쉽게):
| 필드 | 의미 |
|---|---|
magic AX | “올바른 상자야” 표시 |
frameType 1 | 데이터 조각 |
frameType 2 | EOF (끝!) |
| streamId | STREAM 시작할 때 받은 ID와 같은지 확인 |
| seq | 0, 1, 2, … 순서대로 와야 함 |
| chunkCrc32 | 이 조각이 깨지지 않았는지 검사 |
| payload | JPEG raw bytes |
다 받으면 전체 CRC32 + SHA256으로 “사진 전체가 맞는지” 한 번 더 검사합니다.
AT$$JPG=DROP,sessionId = “이 주문(session) 그만. 메모리 비워.”
다음 상황에서 호출됩니다.
DROP 로그 자체는 버그가 아니라 정리 작업입니다.
다만 STREAM 직후 바로 DROP → CAPTURE가 반복되면 1차 수신 실패 후 retry 신호였고, 그건 race/CCCD 문제로 수정했습니다.
sequenceDiagram
participant UI as R10Snapshot 화면
participant VM as R10SnapshotViewModel
participant Repo as BleRepository
participant Gatt as BleGattConnection
participant Dev as R10 장치
UI->>VM: 채널 선택
VM->>Repo: getSnapshot(channel)
Repo->>Gatt: AT$$JPG=CAPTURE
Gatt->>Dev: write TX/RX
Dev-->>Gatt: $$JPG metadata notify
Gatt-->>Repo: sessionId, totalBytes, crc, sha256
Repo-->>VM: SnapshotCaptureInfo
VM->>Repo: receiveTransfer(expectation)
Note over Repo: STREAM 우선, 실패 시 GET fallback
Repo->>Gatt: TX-only collector 시작
Repo->>Gatt: AT$$XFER=STREAM
Dev-->>Gatt: $$XFER STREAMING
loop binary frames
Dev-->>Gatt: TX-only notify
Gatt-->>Repo: ByteArray chunks
end
Repo-->>VM: JPEG ByteArray
VM-->>UI: 화면에 표시
| 파일 | 역할 |
|---|---|
R10SnapshotActivity.kt | UI. 첫 진입 시 채널 선택 → ViewModel 호출. JPEG Bitmap 표시. |
R10SnapshotViewModel.kt | 오케스트레이션. getSnapshot → receiveTransfer 순서 호출. retry, timeout, DROP, 채널 전환 mutex. |
SnapshotBleErrorMapper.kt | BLE 에러 → 사용자에게 보여줄 한글 메시지 |
SnapshotRetryPolicy.kt | 어떤 에러는 재시도할지 규칙 |
ViewModel 핵심 코드 (개념만):
val snapshotResult = bleRepository.getSnapshot(channel = channelId)
// 성공 시 ↓
val transferResult = bleRepository.receiveTransfer(info.toTransferExpectation())
→ ViewModel은 STREAM인지 GET인지 몰라도 됨. Repository가 알아서 선택.
| 파일 | 역할 |
|---|---|
BleRepository.kt | 앱이 쓰는 인터페이스. getSnapshot, receiveTransfer, dropSnapshot 선언. |
BleRepositoryImpl.kt | 실제 구현. receiveTransfer에서 STREAM → GET fallback 분기. |
| 파일 | 역할 |
|---|---|
BleDeviceProfile.kt | 제품별 UUID 정의 인터페이스 |
R9BleProfile.kt | TX/RX, TX-only UUID 실제 값 |
BleGattConnection.kt | GATT 연결, AT write/read, notify 수신. TX/RX → UTF-8 텍스트, TX-only → txOnlyBinaryStream |
BleGattConnection이 하는 일
sendAtCommand() — AT 명령 보내고 텍스트 응답 대기 onCharacteristicChanged — byte[] 그대로 SharedFlow emit | 파일 | 역할 |
|---|---|
SnapshotCaptureInfo.kt | CAPTURE 성공 결과 (sessionId, 크기, crc, sha256 등) |
BleJpgResponseParser.kt | $$JPG: 응답 문자열 파싱 |
BleSnapshotError.kt | 캡처 단계 에러 종류 |
BleSnapshotException.kt | 캡처 예외 클래스 |
SnapshotCaptureInfo.toTransferExpectation() — 수신 단계로 넘길 검증 기준 만들기.
| 파일 | 역할 |
|---|---|
BleXferStreamResponseParser.kt | $$XFER: ... STREAMING ... 응답 파싱, streamId 추출 |
BleBinaryStreamFrameParser.kt | TX-only AX frame 파싱, seq/CRC 검사, notify 분할 reassembly |
BleTransferStreamReceiver.kt | frame 모아 JPEG buffer 조립 → EOF 후 검증 |
| 파일 | 역할 |
|---|---|
BleTransferReceiver.kt | AT$$XFER=GET 반복, Base64 decode, 조립 |
BleXferResponseParser.kt | GET 응답 파싱 |
| 파일 | 역할 |
|---|---|
TransferExpectation.kt | sessionId, totalBytes, crc32, sha256 (받을 때 목표치) |
TransferChecksumVerifier.kt | CRC32 / SHA256 계산·비교 |
TransferBufferVerifier.kt | 다 받은 뒤 전체 크기·checksum 최종 검증 |
BleTransferError.kt | 수신 에러 종류 (STREAM_START_FAILED 포함) |
BleTransferException.kt | 수신 예외 (fallbackEligible — GET fallback 가능 여부) |
BleTransferSender.kt등은 인증서/CAN DB 업로드용 XFER PUT. JPEG 다운로드와는 다른 방향.
BleRepositoryImpl.receiveTransfer() 요약:
1. TX-only notify 사용 가능?
└─ No → GET만 사용
└─ Yes → STREAM 시도
├─ 성공 → JPEG 반환 (Log: transferMode=STREAM)
└─ 시작 실패 & fallbackEligible
→ GET fallback (Log: transferMode=GET_FALLBACK)
└─ 수신 중 CRC/seq 실패
→ fallback 없이 failure (재촬영/retry)
receiveTransferViaStream)1. Channel 열고 txOnlyBinaryStream collector 먼저 시작 ← race 방지
2. AT$$XFER=STREAM,sessionId,0,0
3. STREAMING 응답에서 streamId 파싱
4. BleTransferStreamReceiver가 Channel에서 frame 수신
5. EOF + 전체 검증 → ByteArray 반환
| 메커니즘 | 이유 |
|---|---|
snapshotBleMutex | BLE 명령 동시에 두 개 안 날리기 |
snapshotRequestGeneration | 오래된 요청 결과 버리기 |
activeSnapshotSessionId | 진행 중 session → 필요 시 DROP |
| retry 최대 3회 | 일시적 BLE 오류 복구 |
| 30초 timeout | 무한 로딩 방지 |
| 같은 채널 loading 중 skip | 중복 CAPTURE 방지 |
| 태그 / 키워드 | 의미 |
|---|---|
BleAtCommand | AT 송수신 (CAPTURE, STREAM, DROP) |
BleRepository + transferMode=STREAM | 고속 경로 성공 |
transferMode=GET_FALLBACK | STREAM 시작 실패 → GET |
R10SnapshotPageFlow | ViewModel 타이밍 (getSnapshot ms, receiveTransfer ms) |
정상 한 장:
CAPTURE → STREAM → (TX-only frame들) → snapshot complete
| 문제 | 원인 | 수정 |
|---|---|---|
| 가만히 있는데 CAPTURE 2번 | STREAM 후 frame 못 받음 → retry | STREAM 전 collector 시작 |
| seq mismatch 즉시 실패 | 첫 frame 유실 (race) | Channel 버퍼 |
| binary frame 안 옴 | CCCD 미설정 | descriptor write 추가 |
| 채널 전환 불가 | mutex + loading stuck | 위 fix로 1차 수신 성공 |
BleRepository.kt 의 getSnapshot / receiveTransfer KDoc BleGattConnection.kt (notify 분기, txOnlyBinaryStream) BleRepositoryImpl.receiveTransferViaStream → BleTransferStreamReceiver → BleBinaryStreamFrameParser R10SnapshotViewModel.runSnapshotAttempt blemanager/src/test/.../transfer/ (frame 파싱 예시)getSnapshot + receiveTransfer만 부르고, blemanager가 STREAM/GET/DROP/BLE를 처리한다.작성 기준: 2026-06-29, R10Snapshot STREAM 구현 반영