# Roomfit Device ↔ App 통신/프로토콜 문서

**Created**: 2025-12-12  
**Last Updated**: 2026-04-01

이 문서는 Roomfit 디바이스(STM32F446 기반 펌웨어)와 Roomfit Flutter 앱 간의 구조, 통신 흐름, 그리고 모든 BLE 프로토콜을 한 곳에 정리합니다.  
개발자와 LLM이 코드/펌웨어를 같은 스펙으로 이해하고, 테스트로 스펙을 지속적으로 검증하는 것을 목표로 합니다.

버전 혼선과 앱의 compatibility 처리 상태는 별도 문서 [mcu-compatibility-matrix.md](./mcu-compatibility-matrix.md) 에 정리합니다.

---

## 1. 시스템/아키텍처 개요

### 1.1 디바이스(펌웨어) 측
- MCU는 STM32F446 계열이며, BLE 모듈과 UART3로 연결되어 있습니다.
- 펌웨어의 `Protocol_Receive_Task_Uart3()`가 BLE로부터 들어오는 바이트 스트림을 Roomfit Binary Protocol 포맷으로 파싱합니다.
- 디바이스는 명령을 처리한 뒤 동일한 Protocol 포맷으로 응답하거나(단발성), `START_REPORT`가 활성화된 경우 주기적 Report 스트림을 push합니다.

### 1.2 앱 측
- **BleScanner** (`lib/core/adapters/ble_scanner.dart`)
  - BLE adapter 상태와 scan 결과를 관리
- **BleTransport** (`lib/core/adapters/ble_transport.dart`)
  - `flutter_blue_plus` 기반 BLE 연결/해제, NUS characteristic 탐색
  - MTU 협상, RX notify 구독, TX write 담당
- **DeviceLink** (`packages/roomfit_protocol/lib/src/device_link.dart`)
  - `Transport → FrameCodec → ResponseRegistry` 파이프라인
  - echo filtering, raw frame stream, typed response stream 제공
- **DeviceGatewayImpl** (`lib/core/device/device_gateway_impl.dart`)
  - `DeviceLink`의 response를 `DeviceState`로 투영
  - feature/UI는 `DeviceGateway`만 의존
- **Command/Response 패턴**
  - `Command.buildPacket()`이 request packet을 만든다.
  - 수신 packet은 `FrameCodec` 검증 후 `ResponseRegistry`에서 commandCode 기준으로 typed `Response`로 역직렬화된다.

---

## 2. BLE Transport
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **TX Characteristic UUID**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic UUID**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
- 연결 직후 앱은 MTU를 512로 요청하며(플랫폼이 허용하면 협상), 실제 write는 `MTU-3` 크기로 자동 청킹됩니다.
- 디바이스 응답/Report는 notification으로 push됩니다.

---

## 3. Roomfit Binary Protocol (Control Protocol)

### 3.1 패킷 포맷
모든 request/response는 동일한 기본 구조를 사용합니다.

```
[0xFF, 0xFF, Size, Command, Data..., Checksum]
```

| Offset | Field | Size | Description |
|-------:|-------|------|-------------|
| 0 | Header[0] | 1 | `0xFF` 고정 |
| 1 | Header[1] | 1 | `0xFF` 고정 |
| 2 | Size | 1 | `Command + Data + Checksum`의 바이트 수 |
| 3 | Command | 1 | 명령/응답 코드 |
| 4..n-2 | Data | 가변 | 명령별 payload |
| n-1 | Checksum | 1 | 체크섬 |

- 전체 길이 = `Size + 3` (Header 2 + Size 1).
- 모든 값은 unsigned byte.
- multi‑byte 정수는 **Big Endian** 사용.  
  단, float32 payload는 명령별로 Little Endian을 쓸 수 있으므로(예: `TargetWeightCommand`) 명령 스펙을 따른다.

### 3.2 체크섬
앱(`PacketBuilder`)과 펌웨어(`protocol.c`)가 동일한 규칙을 사용합니다.

1. Header(0~1)를 제외하고 Size(2)부터 Data 마지막까지의 합 `sum`을 계산한다.
2. 표준 2’s complement 체크섬  
   `cs = (256 - (sum % 256)) % 256`
3. Roomfit 프로토콜 특성상 **+2 offset**을 적용한다.  
   `checksum = (cs + 2) % 256`

예시(Get Voltage, `0x05`):
- partial: `[FF, FF, 02, 05]`
- sum(Size..Cmd) = `0x02 + 0x05 = 0x07`
- cs = `0xF9`
- checksum = `(0xF9 + 2) = 0xFB`
- packet: `[FF, FF, 02, 05, FB]`

### 3.3 공통 스케일/단위
- **Weight raw**: 0.5kg 단위. `raw = kg / 0.5`
- **Position raw**
  - `GET_POSITION(0x04)` 응답: raw 단위 0.1mm (`PositionResponse: raw/10`)
  - `REPORT(0x41)` 스트림: raw 단위 0.05mm (`ReportResponse: raw*0.05`)
  - `SET_RANGE_DIGIT(0x72)` 입력: raw 단위 0.05mm
- **Voltage raw**: 0.01V (`VoltageResponse: raw/100`)
- **Report time raw**: 1 tick = 50ms (`ReportResponse.timeInMs`)

---

## 4. 명령/응답 스펙

표기:
- **Dir**: `A→D`(App→Device), `D→A`(Device→App)
- **FW**: `v1`은 RF_v1.0.0_250507 기준, `v2+`는 앱 구조가 가정하는 확장/최신 스펙

### 4.1 조회/상태 명령

#### GET_POSITION (0x04)
- Dir: `A→D` request
- Request Data: 없음
- Dir: `D→A` response (`v2+`)
- Response Data: `[posL_hi, posL_lo, posR_hi, posR_lo]`
  - raw 단위 0.1mm
- App: `PositionCommand`, `PositionResponse`
- Note: 일부 v1 FW에서는 별도 응답 없이 Report로 대체될 수 있음.

#### GET_VOLTAGE (0x05)
- Dir: `A→D` request
- Request Data: 없음
- Dir: `D→A` response (`v1+`)
- Response Data: `[v_hi, v_lo]`
  - voltage = `((v_hi<<8)+v_lo)/100` V
- App: `GetVoltageCommand`, `VoltageResponse`

#### BLE_CONNECT / GET_SYSTEM_INFO (0x06)
- Dir: `A→D` request
- Request Data: 없음
- Dir: `D→A` response
  - **v1 FW(250507)**: 연결 직후 스냅샷을 **여러 패킷으로** push
    - Weight snapshot: `0x66(WEIGHTPLUS)` 또는 `0x67(WEIGHTMINUS)`  
      Data `[leftWeightRaw, leftMode, rightWeightRaw, rightMode]`
    - Voltage snapshot: `0x05` Data `[v_hi, v_lo]`
    - Weight ON/OFF snapshot: `0x65` Data `[onOffStatus]`
  - **v2+ FW**: `0x06` 단일 응답(Data `[leftWeightRaw,leftMode,rightWeightRaw,rightMode]`)을 지원할 수 있음
- App:
  - Request: `GetSystemInfoCommand`
  - Response:
    - `SystemInfoResponse(0x06)` for future FW
    - `WeightResponse(0x61/0x66/0x67)` + `WeightPowerResponse(0x65)` + `VoltageResponse(0x05)` for current FW
- Note:
  - 현재 앱은 `0x66`뿐 아니라 `0x67` snapshot도 파싱한다.
  - 체크인 MCU 소스 기준으로는 `0x06` 단일 응답보다 multi-packet snapshot이 실제 동작에 가깝다.

#### CHECK_FWVER (0x07)
- Dir: `A→D` request
- Request Data: 없음
- Dir: `D→A` response (`v1+`)
- Response Data:
  - v1: `[major, minor, sub, 0x00]`
  - v2+: `[major, minor, sub]`
- App: `GetFirmwareVersionCommand`, `FirmwareVersionResponse`

### 4.2 Report 스트림

#### START_REPORT (0x41) / STOP_REPORT (0x42)
- Dir: `A→D` request
- Request Data: 없음
- Device 동작:
  - `START_REPORT` 수신 시 50ms 주기로 Report packet을 push
  - `STOP_REPORT` 수신 시 중지
- Report packet (`D→A`, current default = v3):
  - Command: `0x41`
  - Data:
    ```
    [time_hi,time_lo,
     posL_hi,posL_lo,
     posR_hi,posR_lo,
     forceL_hi,forceL_lo,
     forceR_hi,forceR_lo,
     voltage_hi,voltage_lo]
    ```
  - time tick = 50ms
  - position raw unit = 0.05mm
  - force raw unit = 0.01kg
  - voltage raw unit = 0.01V
- App:
  - Request: `StartReportCommand`, `StopReportCommand`
  - Response: `ReportResponse`
- Note:
  - 현재 앱은 v3 12-byte payload를 기본으로 사용한다.
  - 더 긴 legacy/dev payload가 와도 첫 12-byte prefix만 의미 있게 사용한다.

### 4.3 무게/모드 제어

#### SET_WEIGHT (0x61)
- Dir: `A→D` request
- Request Data: `[leftWeightRaw, leftMode, rightWeightRaw, rightMode]`
  - Weight raw unit = 0.5kg
  - Mode = `0(Constant) / 1(Negative) / 2(Squeeze)`
- Dir: `D→A` response (`v1+`)
- Response:
  - 체크인 MCU 소스 기준: `0x61` 응답 사용
  - 일부 경로/버전: `0x66` 또는 `0x67` snapshot을 사용할 수 있음
- App:
  - Request: `SetWeightCommand`
  - Response: `WeightResponse`
  - parser는 `0x61/0x66/0x67`을 모두 허용

#### SET_RANGE (0x62)
- Dir: `A→D` request
- Request Data: `[side, isHigh]`
  - side: `0=L`, `1=R`, `2=Both`
  - isHigh: `0=LOW`, `1=HIGH`
- Dir: `D→A` response (`v1`)
- Response Data: `[appliedFlag]` (`1=applied`, `0=fail`)
- App: `SetRangeCommand`, `SetRangeAckResponse`

#### POS_CALIB / CALIBRATION (0x63)
- Dir: `A→D` request
- Request Data(v1): `[side]` (1=L, 2=R, 3=Both)
- Response: v1 FW는 별도 패킷 응답 없음
- App: `ResetCalibrationCommand`은 `[0x03]`(Both)로 보정 초기화

#### EMERGENCY (0x64)
- Dir: `A→D` request
- Request Data: 없음
- Dir: `D→A` response (`v1`)
- Response: `0x64` no‑data packet
- App: `EmergencyStopCommand` (parser 없음)

#### WEIGHTONOFF (0x65)
- Dir: `A→D` request
- Request Data: `[onOff]` (1=ON, 0=OFF)
- Dir: `D→A` response (`v1+`)
- Response Data: `[onOffStatus]`
- App: `SetWeightPowerCommand`, `WeightPowerResponse`

#### WEIGHTPLUS (0x66) / WEIGHTMINUS (0x67)
- Dir: `A→D` request
- Request Data: `[side, step]`
  - side: `0=L`, `1=R`, `2=LR(Both)`
  - step: `1=0.5kg`, `2=5kg`
- Dir: `D→A` response (`v1+`)
- Response:
  - 일부 경로는 `0x66`
  - 연결 직후 snapshot 등 일부 경로는 `0x67`
- App:
  - Request: `AdjustWeightCommand`(현재 side=`2` 고정)
  - Response: `WeightResponse`
  - parser는 `0x66`, `0x67` 모두 허용

#### MODE_CHANGE / WEIGHT_MODE (0x68)
- Dir: `A→D` request
- Request Data(v2+): `[leftMode, rightMode]`
- Dir: `D→A` response
  - v1 FW: `[leftMode, rightMode, errorCode, reserved]` (Return_4x1byte)
  - v2+ FW: `[leftMode, rightMode]` 또는 `[leftMode, rightMode, isSuccess, ignore]`
- App:
  - builder: `SetWeightModeCommand`
  - parser: `WeightModeResponse`
  - 현재 parser는 2-byte 응답과 4-byte legacy 확장 응답 둘 다 처리한다.
  - `errorCode`는 payload 3번째 byte가 있으면 그 값을 사용한다.

#### ECC_LEVEL (0x69)
- Dir: `A→D` request
- Request Data: `[leftEccRaw, rightEccRaw]` (1=0.5kg)
- Dir: `D→A` response
  - v1 FW: `[leftEccRaw, rightEccRaw, errorCode, 0]` (Return_4x1byte)
  - v2+ FW: `[leftEccRaw, rightEccRaw]`
- App:
  - builder: `SetEccLevelCommand`
  - parser: `EccLevelResponse`
  - 현재 parser는 2-byte와 4-byte legacy 응답 둘 다 처리한다.

### 4.4 ROM/위치 설정

#### SET_RANGE_DIGIT / SET_POSITION (0x72)
- Dir: `A→D` request
- Request Data: `[side, height, pos_hi, pos_lo]`
  - side: `0=L`, `1=R`
  - height: `0=LOW`, `1=HIGH`
  - pos raw unit=0.05mm, Big Endian
- Dir: `D→A` response (`v1`)
- Response Data: `[appliedFlag]`
- App: `SetPositionCommand`
- Note:
  - 체크인 MCU 소스의 주석과 실제 반환값 의미가 다소 애매하다.
  - 현재 앱은 `1=applied`, `0=not applied`로 해석한다.

#### SET_RANGE_INIT (0x73)
- Dir: `A→D` request
- Request Data: `[side]` (v1은 `2=Both`만 적용)
- Dir: `D→A` response (`v1+`)
- Response Data: `[isFail]` (0=success, 1=fail)
- App: `ResetRangeCommand`, `ResetRangeResponse`

### 4.5 자동 무게/기타 확장

#### AUTOWEIGHT_ACTIVE (0x81)
- Dir: `A→D` request
- Request Data: `[isActive]` (1=ON, 0=OFF)
- Dir: `D→A` response
- Response Data: `[currentActive]`
- App: `SetAutoWeightCommand`, `AutoWeightResponse`
- FW: v1.0.0_250507 지원

#### AUTOWEIGHT_STATUS (0x82)
- Dir: `D→A` response only (`v2+`)
- Response Data: `[leftStatus, rightStatus]`
  - 0=stop, 1=onStarted, 2=offStarted
- App: `AutoWeightStatusResponse`

#### RELAX_ZONE_ACTIVE (0x83)
- Dir: `D→A` response only (`v2+`)
- App: `RelaxZoneResponse`
- Note: request path(`SetRelaxZoneCommand`)는 코드상 존재하지만 체크인 v1 MCU 소스 기준으로 handler가 명확하지 않다.

#### FORCED_CALIBRATION (0x84)
- Dir: `A→D` request (`v2+`)
- Request Data: 없음
- Dir: `D→A` response
- Response Data:
  - 기본 스펙: `0x84` + `[status]`
  - legacy 체크인 소스 일부: `0x81(AUTOWEIGHT_ACTIVE)` + `[status]`
- App:
  - Request: `ForceCalibrateCommand`
  - Response: `ForceCalibrateResponse`
  - 현재 앱은 command 직후 들어온 legacy `0x81`도 `ForceCalibrateResponse`로 호환 처리한다.

#### SET_TIME (0x08)
- Dir: `A→D` request (`v2+`)
- Request Data: Unix timestamp(초) 4바이트 Big Endian
- App: `SetTimeCommand`
- Note: 체크인 v1 MCU 소스에는 handler 정의가 없어 deployed FW 지원 여부를 별도 검증해야 한다.

#### TARGET_WEIGHT (0x70)
- Dir: `A→D` request (`v2+`)
- Request Data: float32 Little Endian
- App: `SetTargetWeightCommand`
- Note: 체크인 v1 MCU 소스에는 handler 정의가 없어 deployed FW 지원 여부를 별도 검증해야 한다.

### 4.6 메모리/관리

#### SET_DATA / SET_ROM_DATA (0xF0)
- Dir: `A→D` request
- Request Data: `[addr_hi, addr_lo, dataLen, data...]`
  - addr: 0x0000~0x3FFF
- Response: 없음(v1)
- App: `SetRomDataCommand`(dataLen<=4 제한)

#### GET_DATA (0xF1)
- Dir: `A→D` request
- Request Data: `[addr_hi, addr_lo, dataLen]`
- Dir: `D→A` response(v1)
- Response Data: `[data...]` (dataLen bytes)
- App parser 없음(필요 시 추가)

#### SAVE_DATA_TO_FLASH (0xF2)
- Dir: `A→D` request
- Request Data: 없음
- Response: 없음(v1)
- App: `SaveDataCommand`

#### FAIL_SAFE_ERROR (0xF3), FACTORY_RESET (0xFA), SLEEP (0xFE)
- v1 FW에 정의되어 있으나 앱에서 현재 사용/파싱하지 않는다.

---

## 5. DFU / Firmware Update Protocol (YModem over BLE)

Roomfit은 일반 제어 프로토콜과 별개로 DFU(IAP) 모드에서 YModem을 사용합니다.

> Note:
> 현재 repo에는 **앱 측 YModem/DFU 구현 코드와 테스트가 포함되어 있지 않습니다.**
> 아래 내용은 MCU/reference 기준 프로토콜 메모이며, 현재 Flutter 앱 구현 문서가 아닙니다.

### 5.1 모드 진입
- 앱이 DFU 복구 모드로 판단하면 연결 직후 ASCII `'s'`(0x73)을 1회 write하여 IAP 기본 모드 진입을 유도합니다.
- 디바이스가 `command>>` 프롬프트 또는 CAN/NAK 등의 제어문자를 보내면 YModem 핸드셰이크를 시작합니다.

### 5.2 YModem 개요
- 구현 상태: 현재 repo에는 앱 측 YModem 구현 파일이 없음
- CRC: CRC16‑XModem
- Control characters:
  - SOH(0x01) 128‑byte packet
  - STX(0x02) 1024‑byte packet
  - EOT(0x04) end of transmission
  - ACK(0x06), NAK(0x15), CAN(0x18), `'C'`(0x43)

### 5.3 전송 시퀀스(앱 기준)
1. 디바이스가 `'C'`를 보내면 앱이 CRC16 모드로 전송을 시작한다.
2. Header packet(SOH, packetNo=0)을 전송: `filename\\0filesize\\0` payload + padding + CRC.
3. 디바이스 ACK 수신 후 다시 `'C'`를 보내면 데이터 패킷 전송을 시작한다.
4. 데이터 패킷(128‑byte):
   ```
   [SOH, packetNo, ~packetNo, 128 bytes data(padded), CRC_hi, CRC_lo]
   ```
   - ACK 수신 시 다음 패킷
   - NAK 또는 `'C'` 수신 시 재전송
5. 마지막 패킷 이후 EOT 전송.
6. fullProtocolEnd=true이면 ACK/`'C'` 핸드셰이크를 마무리하고 디바이스 재부팅.

### 5.4 DFU 테스트
- 현재 repo에는 DFU/YModem 전용 테스트 디렉터리가 없다.
- 앱 측 DFU 구현이 다시 들어오면 별도 test 경로와 fake transport 시나리오를 함께 정의해야 한다.

---

## 6. 테스트 전략(제어 프로토콜)
- **Command 패킷 테스트**: 모든 `Command.buildPacket()` 결과가 Data/Size/Checksum 규칙을 만족하는지 검증한다.
- **Response 파싱 테스트**: `ResponseRegistry`와 각 `Response.fromFrame()` 구현이 스펙에 맞게 값을 계산하는지 검증한다.
- **링크 계층 테스트**: `DeviceLink`가 echo filtering, packet parsing, legacy compatibility shim을 올바르게 처리하는지 검증한다.
- **Gateway/UI 테스트**: `DeviceGatewayImpl`가 `DeviceState`를 올바르게 갱신하고, dev remote UI가 이를 표시하는지 검증한다.

관련 테스트 위치:

- `packages/roomfit_protocol/test/`
- `test/core/device/`
- `test/features/dev_remote/presentation/widgets/`
