Files
dw.jang 60b630d3de docs: v7 (2026-07-10) 반영 — msp 제거, 6-stage alignment, mtb queue fix, labdb 자동 재시도
이번 세션 대량 변경 사항을 5개 문서에 반영. 각 문서마다 stale 이던
섹션을 갱신하거나 신규 섹션 추가.

USER_GUIDE.md
  - Sensor Alignment 를 V1 (3-stage) / V2 (6-stage) 로 재구성
  - V2 6-stage 표 + relaxed mode / soft hint 설명
  - GREEN 진입 5초 hold + 10-strike 리셋 완화 명시
  - "최적의 위치입니다!" 문구 반영

docs/BLE_PROTOCOL_REFERENCE.md
  - msp 명령 취소선 처리 + mim 신규 명령 문서화
  - Watchdog timeout 25초 연장 명시 (2.5)
  - §2.6 신규: firmware VBTFW0121 mls mode 0 freeze 취약점 +
    앱 측 3-layer 회피 (isMtbBusy / mtb 3초 timeout / 자동 재연결)

VesiScan_Android_Pipeline_Summary.md
  - v7 (2026-07-10) 섹션 신규 추가 — BLE / Alignment / UI / labdb /
    tools 5개 카테고리로 변경 사항 정리
  - 권장 펌웨어 표기 VBTFW0116 → VBTFW0120+ 로 갱신

labdb.md
  - §12b 신규: 앱 측 Auto Retry Policy — endMeasurement 자동 업로드
    조건 완화, LabdbAutoRetry object, UI 배너, 재시도 안전성

tools/README.md
  - labdb_upload.py 섹션 신규 — 사용법 / 폴더 구조 / 재실행 안전성 /
    buildPayload 로직 / 활용 예 정리

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 11:25:26 +09:00

27 KiB
Raw Permalink Blame History

# labdb REST API Guide

**Server**: https://labdb.medithings.net

**Base URL**: https://labdb.medithings.net/api/v1

**Auth**: X-API-Key: {your\_api\_key} (HTTP header)

**Content-Type**: application/json (UTF-8)

**Version**: 1.1 (2026-06-01)

---

## 0. Quick Start (3 steps)


1\) POST /devices/register          → apiKey 발급 (1회)

2\) Admin이 콘솔에서 Approve         → status: active

3\) POST /upload/json (dataType 포함) → 데이터 전송

가장 간단한 통합 방법은 **POST /upload/json 한 번**으로 전체 세션 + 모든 records를 보내는 것입니다 (Section 3 참조).

### 자료 구분 (필수 개념)

모든 세션은 **dataType 코드**로 구분됩니다 (생략 시 "000"):

| 코드 | 의미 |

|------|------|

| "000" | VesiScan 초음파 6채널 (기본) |

| "100"\~"899" | 관리자 배정 (EMG/PPG/IMU 등) |

| "900"\~"999" | 사용자 정의 자유 형식 |

같은 디바이스가 여러 dataType의 세션을 무제한 보낼 수 있습니다. 자세한 내용은 **Section 2-3** 참조.

---

## 1. 인증 (X-API-Key)

모든 /api/v1/\* 요청에 X-API-Key 헤더 필요 (단, /devices/register만 예외).


X-API-Key: xbk\_live\_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

### 1-1. 디바이스 등록 (앱 첫 실행 1회)


POST /api/v1/devices/register

Content-Type: application/json


{

&#x20; "appName":       "VesiScan",          ← 필수: 앱 식별자

&#x20; "deviceName":    "Galaxy Tab S9",      ← 필수: 표시명

&#x20; "deviceAddress": "AA:BB:CC:DD:EE:FF",  ← BLE MAC (선택)

&#x20; "hwNumber":      "VB2024001",          ← 선택

&#x20; "serialNumber":  "MT20260601",         ← 선택

&#x20; "fwVersion":     "VBTFW0103",          ← 선택

&#x20; "appVersion":    "1.0.0"               ← 선택

}

**Response 201:**


{

&#x20; "deviceId": "dev\_abc123",

&#x20; "apiKey":   "xbk\_live\_...",            ← 안전한 저장소(Keystore/Keychain)에 저장

&#x20; "status":   "pending",                  ← 관리자 승인 대기

&#x20; "message":  "Device registered. Awaiting administrator approval."

}

⚠️ **재등록 금지**: 매 실행마다 호출하면 pending device가 누적됩니다. 첫 실행에만 호출하고 apiKey를 저장하세요.

### 1-2. 디바이스 상태 확인 (앱 시작 시 매번 권장)


GET /api/v1/devices/status

X-API-Key: {apiKey}

**5가지 응답:**

| HTTP | status / code | 의미 | 권장 동작 |

|:----:|---------------|------|----------|

| 200 | active | 정상 사용 가능 | 데이터 업로드 가능 |

| 200 | pending | 승인 대기 | 사용자에게 안내 메시지 |

| 200 | revoked | 비활성화됨 | 관리자 문의 안내 |

| 404 | DEVICE\_DELETED | 서버에서 삭제됨 | 로컬 키 삭제 후 재등록 |

| 401 | INVALID\_API\_KEY | 키 형식 잘못됨 | 로컬 키 삭제 후 재등록 |

**200 응답 예 (active):**


{

&#x20; "deviceId": "dev\_abc123",

&#x20; "status":   "active",

&#x20; "deviceName": "Galaxy Tab S9",

&#x20; "appName":    "VesiScan",

&#x20; "registeredAt": "2026-06-01T08:00:00Z",

&#x20; "approvedAt":   "2026-06-01T09:15:00Z",

&#x20; "revokedAt":    "",

&#x20; "lastSeenAt":   "2026-06-01T10:30:00Z",

&#x20; "sessionCount": 12

}

---

## 2. 데이터 구조 (핵심 개념)

### 2-1. 3단계 계층


Device (1)  ── 여러 Session 보유

&#x20;  └─ Session (1) ── 여러 Record 보유

&#x20;       └─ Record (1) ── 6 Channel 측정값

### 2-2. ID 규칙

| 종류 | 형식 | 발급 |

|------|------|------|

| deviceId | dev\_{16hex} | 서버 자동 |

| apiKey | xbk\_live\_{48hex} | 서버 자동 |

| sessionId (=testId) | ^\[A-Za-z0-9\_-]{1,40}$ | **클라이언트 권장** (idempotent) |

| rowIndex | 0..N 정수 | 클라이언트 |

**권장 sessionId**: LAB\_yyyymmdd\_hhmmss\[\_suffix] (예: LAB\_20260601\_103000)

### 2-3. dataType — 자료 구분 (★ 핵심 개념)

서로 다른 종류의 데이터(초음파 / EMG / PPG / IMU 등)를 **하나의 서버에 보내되 명확히 구분**하기 위해 모든 세션은 반드시 dataType 코드를 가집니다.

#### 왜 필요한가

- **같은 testId 공간을 공유**: 여러 장치/앱이 다른 종류의 데이터를 동일 서버에 업로드

- **시각화 분기**: 관리자 콘솔이 dataType별로 다른 차트/알고리즘 자동 적용 (예: 000은 Wall Detection, 100은 EMG 파형)

- **분석 분리**: dataType별 통계, 필터, 내보내기

- **레코드 구조 자유**: 각 dataType마다 records\[] 내부 스키마가 완전히 달라도 됨

#### dataType 레지스트리

| dataType | 의미 | 일반 records 스키마 | 분석/시각화 |

|----------|------|---------------------|------------|

| "000" (기본) | **VesiScan 초음파 6채널** | {rowIndex, datetime, commandType, sensor:{batteryMv,tempC100,imu:{}}, channels:\[6×{ch,peak,peakIdx,data:\[\~100]}]} | Wall Detection (Raw → Energy → Geometry) |

| "100" | (예약 — EMG 등) | {rowIndex, datetime, channels:\[N×{ch, data:\[sampleRate×duration]}]} | 시계열 그래프 |

| "200" | (예약 — PPG 등) | 자유 | 자유 |

| "900\~999" | **사용자 정의** | 자유 | 기본 표시 (raw JSON) |

**신규 dataType 발급**: admin@medithings.net에 다음 정보 전달 → 등록

  • 코드 (100~899) — 충돌 방지 위해 관리자가 배정
  • 데이터 의미 (장비/측정 종류)
  • records[] 스키마 (JSON Schema 권장)
  • 시각화 요구사항 (있으면)

#### 명시 방법

업로드 시 dataType 필드를 포함:


{

&#x20; "testId":   "LAB\_20260601\_103000",

&#x20; "dataType": "000",              ← 이 필드로 자료 구분

&#x20; "records":  \[ ... ]

}

- **생략 시** → "000" 자동 적용 (기존 호환)

- **잘못 보낸 경우** → admin 콘솔에서 수정 가능 (또는 같은 testId로 재업로드 시 dataType 갱신)

- **형식 제한** — 1~10자 (VARCHAR(10)), 권장 패턴 ^\\d{3}$

#### 같은 디바이스가 여러 유형을 보낼 때

같은 apiKey로 다른 dataType의 세션을 무제한 생성 가능:


POST /upload/json { testId:"LAB\_20260601\_103000", dataType:"000", ... }   ← 초음파

POST /upload/json { testId:"EMG\_20260601\_104500", dataType:"100", ... }   ← EMG

POST /upload/json { testId:"IMU\_20260601\_104530", dataType:"300", ... }   ← IMU

세션ID 접두어는 자유 — dataType이 진짜 구분자입니다.

#### 조회 시 dataType별 필터


GET /api/v1/sessions?dataType=100        # X-API-Key (앱)

GET /api/admin/sessions?dataType=100     # 세션 인증 (관리자)

응답의 모든 세션 객체는 dataType 필드를 포함하므로 클라이언트도 분기 처리 가능:


sessions.forEach(s => {

&#x20;  if (s.dataType === "000") renderUltrasound(s);

&#x20;  else if (s.dataType === "100") renderEmg(s);

&#x20;  else renderRaw(s);

});

#### 유형별 페이로드 예시

**dataType: "000" — VesiScan 초음파 (6채널)**


{

&#x20; "testId": "LAB\_20260601\_103000", "dataType": "000",

&#x20; "records": \[{

&#x20;   "rowIndex": 0, "datetime": "...", "commandType": "MBB",

&#x20;   "sensor": { "batteryMv": 3920, "tempC100": 2530, "imu": {"ax":12,"ay":-3,"az":98,"gx":0,"gy":1,"gz":2} },

&#x20;   "channels": \[

&#x20;     { "ch": 0, "peak": 2202, "peakIdx": 1, "data": \[2202, 2233, 2054, ..., 100개] },

&#x20;     { "ch": 1, ... }, ..., { "ch": 5, ... }

&#x20;   ]

&#x20; }]

}

**dataType: "100" — EMG 단일 채널 시계열 (예시)**


{

&#x20; "testId": "EMG\_20260601\_104500", "dataType": "100",

&#x20; "params": { "sampleRate": 1000, "channels": 1, "duration\_s": 5 },

&#x20; "records": \[{

&#x20;   "rowIndex": 0, "datetime": "...",

&#x20;   "channels": \[

&#x20;     { "ch": 0, "data": \[ 0.12, 0.15, -0.03, ..., 5000개 ] }

&#x20;   ]

&#x20; }]

}

**dataType: "999" — 사용자 정의 자유 형식 (예시)**


{

&#x20; "testId": "CUSTOM\_001", "dataType": "999",

&#x20; "memo": "Experimental payload",

&#x20; "records": \[{

&#x20;   "rowIndex": 0, "datetime": "...",

&#x20;   "myField1": "anything",

&#x20;   "myField2": { "nested": \[1, 2, 3] }

&#x20; }]

}

서버는 records\[] 내부 구조를 검증하지 않고 그대로 PostgreSQL JSONB에 저장 → dataType별 핸들러가 해석.

---

## 3. 단일 호출 업로드 — POST /upload/json (★ 권장)

전체 세션 + 모든 records를 한 번에 보냅니다. **재시도 안전 (idempotent)**.


POST /api/v1/upload/json

X-API-Key: {apiKey}

Content-Type: application/json


{

&#x20; "testId":      "LAB\_20260601\_103000",   ← 필수, sessionId와 동일 의미

&#x20; "dataType":    "000",                   ← 선택 (생략 시 "000")

&#x20; "sessionName": "Morning measurement",   ← 선택

&#x20; "memo":        "물 500ml, 25°C",         ← 선택

&#x20; "savedAt":     "2026-06-01T10:35:00Z",  ← 선택 (있으면 status='completed')

&#x20; "params":      { "frequency": 1, "cycles": 7 },  ← 선택 (JSON 자유)

&#x20; "recordCount": 5,

&#x20; "records": \[

&#x20;   {

&#x20;     "rowIndex":    0,                   ← 필수

&#x20;     "datetime":    "2026-06-01T10:30:00.629Z",

&#x20;     "commandType": "MBB",

&#x20;     "raaStatus":   0,

&#x20;     "sensor": {

&#x20;        "batteryMv":  3920,

&#x20;        "batteryPct": 78,

&#x20;        "tempC100":   2530,              ← 25.30°C × 100

&#x20;        "imu": { "ax": 12, "ay": -3, "az": 98, "gx": 0, "gy": 1, "gz": 2 }

&#x20;     },

&#x20;     "channels": \[

&#x20;        { "ch": 0, "peak": 2202, "peakIdx": 1, "data": \[2202, 2233, 2054, ...] },

&#x20;        { "ch": 1, "peak": 2150, "peakIdx": 2, "data": \[...] },

&#x20;        { "ch": 2, ... },

&#x20;        { "ch": 3, ... },

&#x20;        { "ch": 4, ... },

&#x20;        { "ch": 5, ... }

&#x20;     ]

&#x20;   },

&#x20;   { "rowIndex": 1, ... },

&#x20;   ...

&#x20; ]

}

### 3-1. 응답

| HTTP | 상황 |

|:----:|------|

| 201 Created | 새 세션 생성 |

| 200 OK | 기존 세션에 추가 / 재업로드 (idempotent) |


{

&#x20; "ok":           true,

&#x20; "sessionId":    "LAB\_20260601\_103000",

&#x20; "created":      true,        ← false면 기존 세션 사용

&#x20; "totalRecords": 5,

&#x20; "inserted":     5,           ← 실제 신규 삽입

&#x20; "duplicates":   0,           ← 이미 있어서 무시된 수

&#x20; "errors":       0

}

### 3-2. 제한

| 항목 | 제한 |

|------|:----:|

| 최대 요청 본문 | **20MB** (한 세션 전체) |

| Rate limit | **10 req/min** (디바이스별) |

| testId 길이 | 1~40자 (^\[A-Za-z0-9\_-]+$) |

### 3-3. 자유 형식 규칙

- records\[]의 각 객체는 **rowIndex 외에는 모두 선택** — 내부 구조 자유

- sensor, channels는 JSON 그대로 저장 → 나중에 dataType별 분석에서 해석

- 동일 testId + rowIndex 재전송 → 자동 무시 (중복 안전)

- dataType 다르게 재업로드 → 세션의 dataType이 업데이트됨

### 3-4. 에러


{

&#x20; "error": {

&#x20;   "code":    "INVALID\_SESSION\_ID",

&#x20;   "message": "testId must match ^\[A-Za-z0-9\_-]{1,40}$",

&#x20;   "status":  400

&#x20; }

}

| HTTP | code | 의미 |

|:----:|------|------|

| 400 | INVALID_REQUEST | testId 누락 / 잘못된 JSON |

| 400 | INVALID_SESSION_ID | testId 형식 위반 |

| 401 | UNAUTHORIZED / INVALID_API_KEY | X-API-Key 누락/잘못됨 |

| 403 | PENDING_APPROVAL | 디바이스 승인 대기 |

| 409 | SESSION_ID_CONFLICT | 다른 device의 testId와 충돌 |

| 413 | PAYLOAD_TOO_LARGE | 20MB 초과 |

| 429 | RATE_LIMITED | 분당 10회 초과 |

| 500 | INTERNAL_ERROR | 서버 오류 |

---

## 4. 분할 호출 방식 (대용량/스트리밍)

업로드 한 번에 못 보낼 만큼 records가 많으면 분할:


1\) POST /sessions              → 세션 생성 (idempotent)

2\) POST /sessions/{id}/records/batch × N   ← 100건씩 반복

3\) PATCH /sessions/{id}        → 종료 (endTime, recordCount)

### 4-1. 세션 생성


POST /api/v1/sessions

X-API-Key: {apiKey}

Content-Type: application/json


{

&#x20; "sessionId":   "LAB\_20260601\_103000",   ← 선택 (생략 시 서버가 ses\_xxx 발급)

&#x20; "dataType":    "000",                   ← 선택 (기본 "000")

&#x20; "sessionName": "Morning",

&#x20; "startTime":   "2026-06-01T10:30:00Z",  ← 필수

&#x20; "params":      { ... },                 ← 선택 (JSON 자유)

&#x20; "note":        "..."

}

**Response 201 (신규) / 200 (idempotent):**


{ "sessionId": "LAB\_20260601\_103000", "createdAt": "2026-06-01T10:30:01.123Z" }

### 4-2. Records 일괄 추가


POST /api/v1/sessions/{sessionId}/records/batch

X-API-Key: {apiKey}


{

&#x20; "records": \[

&#x20;   { "rowIndex": 0, "timestamp": "...", "commandType": "MBB", "sensor": {...}, "channels": \[...] },

&#x20;   { "rowIndex": 1, ... },

&#x20;   ...

&#x20; ]

}

**제한:** 최대 100건/요청, 본문 2MB, 10 req/min

**Response 201:**


{ "inserted": 100, "firstRow": 0, "lastRow": 99 }

### 4-3. Records 1건 추가 (실시간 스트리밍용)


POST /api/v1/sessions/{sessionId}/records

X-API-Key: {apiKey}

Body는 위의 records\[] 한 항목과 동일.

### 4-4. 세션 종료


PATCH /api/v1/sessions/{sessionId}

X-API-Key: {apiKey}


{

&#x20; "endTime":     "2026-06-01T11:30:00Z",

&#x20; "recordCount": 720,

&#x20; "note":        "정상 완료"

}

---

## 5. 조회 API

### 5-1. 세션 목록 (해당 디바이스)


GET /api/v1/sessions?from=2026-06-01\&to=2026-06-30\&limit=50\&offset=0

X-API-Key: {apiKey}

**Response:**


{

&#x20; "total": 12,

&#x20; "sessions": \[

&#x20;   {

&#x20;     "sessionId":   "LAB\_20260601\_103000",

&#x20;     "sessionName": "Morning",

&#x20;     "deviceName":  "Galaxy Tab S9",

&#x20;     "dataType":    "000",

&#x20;     "startTime":   "...",

&#x20;     "endTime":     "...",

&#x20;     "recordCount": 720,

&#x20;     "params":      { ... },

&#x20;     "note":        "..."

&#x20;   }

&#x20; ]

}

### 5-2. 세션 상세


GET /api/v1/sessions/{sessionId}

X-API-Key: {apiKey}

### 5-3. Records 목록


GET /api/v1/sessions/{sessionId}/records?from=0\&to=99\&fields=summary

X-API-Key: {apiKey}

| fields | 반환 내용 |

|---------|----------|

| summary (기본) | rowIndex, timestamp, peak, peakIdx (가벼움) |

| full | + sensor 전체 + channels의 ADC data\[] (큰 응답) |

### 5-4. Record 단건 (ADC 포함)


GET /api/v1/sessions/{sessionId}/records/{rowIndex}

X-API-Key: {apiKey}

### 5-5. 통계


GET /api/v1/sessions/{sessionId}/stats

X-API-Key: {apiKey}

6채널 peak 평균/최소/최대/표준편차 + 온도/배터리 집계.

### 5-6. CSV/JSON 내보내기


GET /api/v1/sessions/{sessionId}/export?format=csv

GET /api/v1/sessions/{sessionId}/export?format=json

CSV 형식: 1 record = 6 채널 rows (각 row에 sensor/IMU 컨텍스트 복제 + s0..s99).

---

## 6. 삭제


DELETE /api/v1/sessions/{sessionId}

X-API-Key: {apiKey}

Cascade로 모든 records가 함께 삭제됩니다. (204 No Content)

---

## 7. 에러 코드 전체

| HTTP | code | 권장 처리 |

|:----:|------|----------|

| 400 | INVALID_REQUEST | 필드 누락/타입 오류 → 클라이언트 수정 |

| 400 | INVALID_SESSION_ID | testId 형식 위반 → 재생성 |

| 400 | BATCH_TOO_LARGE | records 100건 초과 → 청크 분할 |

| 401 | UNAUTHORIZED | X-API-Key 누락 → 헤더 추가 |

| 401 | INVALID_API_KEY | 키 형식 잘못됨 → 로컬 키 삭제 + 재등록 |

| 403 | PENDING_APPROVAL | 관리자 승인 대기 → UI 안내 |

| 403 | FORBIDDEN | 권한 없음 → 관리자 문의 |

| 404 | DEVICE_DELETED | 디바이스 삭제됨 → 로컬 키 삭제 + 재등록 |

| 404 | SESSION_NOT_FOUND | 세션 없음 → 세션 먼저 생성 |

| 404 | RECORD_NOT_FOUND | rowIndex 확인 |

| 409 | DUPLICATE_ROW | 동일 rowIndex 중복 (자동 무시) |

| 409 | SESSION_ID_CONFLICT | 다른 device가 같은 testId 사용 중 → 재생성 |

| 413 | PAYLOAD_TOO_LARGE | 본문 크기 초과 |

| 429 | RATE_LIMITED | 분당 한도 초과 → 60초 대기 후 재시도 |

| 500 | INTERNAL_ERROR | 서버 오류 → 잠시 후 재시도 + 로그 보고 |

**에러 응답 형식 (공통):**


{

&#x20; "error": {

&#x20;   "code":    "ERROR\_CODE",

&#x20;   "message": "Human-readable description",

&#x20;   "status":  HTTP\_STATUS

&#x20; }

}

---

## 8. Rate Limit

| 종류 | 한도 (디바이스별) |

|------|:----:|

| 일반 요청 (/sessions, /records, /status 등) | **100 req/min** |

| 일괄 업로드 (/records/batch, /upload/json) | **10 req/min** |

| Export (/export?format=\*) | **5 req/min** |

**응답 헤더로 잔여량 확인:**


X-RateLimit-Remaining: 87

X-RateLimit-Reset: 1780000000

---

## 9. Best Practices

### 9-1. API Key 보관

- **저장 위치**: Android EncryptedSharedPreferences / iOS Keychain / Desktop OS credential store

- 평문 파일/SharedPreferences 사용 금지

- 앱 삭제 시 함께 제거 (재설치 시 재등록 흐름)

### 9-2. 오프라인 우선 (IoT 권장)


\[측정 중]

&#x20; 1) 로컬 DB에 records 저장 (네트워크 불필요)

&#x20; 2) sessionId는 측정 시작 시 로컬에서 생성: LAB\_yyyymmdd\_hhmmss



\[네트워크 복구 시]

&#x20; 방식 A (권장 — 간단):

&#x20;    POST /upload/json   ← 세션 전체 한 번에

&#x20; 방식 B (대용량):

&#x20;    POST /sessions           ← idempotent

&#x20;    POST /records/batch × N  ← 100건씩

&#x20;    PATCH /sessions/{id}     ← 종료

### 9-3. 재시도 안전 (idempotency)

- 동일 testId 재호출 → 기존 세션 그대로 사용 (덮어쓰기 안 함)

- 동일 rowIndex 재전송 → ON CONFLICT DO NOTHING → 자동 무시

- 응답의 inserted 카운트로 실제 신규 삽입 수 확인

### 9-4. 매 실행 흐름


앱 시작

&#x20; ├─ 로컬 키 없음 → POST /devices/register → 키 저장 → "승인 대기" UI

&#x20; └─ 로컬 키 있음 → GET /devices/status

&#x20;       ├─ active  → 데이터 업로드 진행

&#x20;       ├─ pending → "승인 대기" UI

&#x20;       ├─ revoked → "비활성화됨" UI

&#x20;       ├─ 404 DEVICE\_DELETED → 로컬 키 삭제 → 재등록 흐름

&#x20;       └─ 401 INVALID\_KEY    → 로컬 키 삭제 → 재등록 흐름

### 9-5. dataType 활용


// 같은 앱이 여러 종류의 데이터를 보낼 때

sendUltrasoundData(records) {

&#x20;  POST /upload/json { testId: "LAB\_xxx", dataType: "000", records };

}

sendEmgData(records) {

&#x20;  POST /upload/json { testId: "EMG\_xxx", dataType: "100", records };

}

서버는 dataType별로 다른 시각화/분석 알고리즘을 자동 적용합니다.

---

## 10. 클라이언트 예시

### 10-1. cURL (가장 간단)


\# 1. 등록 (1회)

curl -X POST https://labdb.medithings.net/api/v1/devices/register \\

&#x20; -H "Content-Type: application/json" \\

&#x20; -d '{"appName":"VesiScan","deviceName":"VB-001"}'

\# → 응답에서 apiKey 저장



KEY="xbk\_live\_xxx..."



\# 2. 상태 확인

curl -H "X-API-Key: $KEY" https://labdb.medithings.net/api/v1/devices/status



\# 3. 전체 업로드 (권장)

curl -X POST https://labdb.medithings.net/api/v1/upload/json \\

&#x20; -H "X-API-Key: $KEY" -H "Content-Type: application/json" \\

&#x20; --data-binary @session.json

### 10-2. Python


import requests, json

BASE = "https://labdb.medithings.net/api/v1"



def register():

&#x20;   r = requests.post(f"{BASE}/devices/register", json={

&#x20;       "appName": "VesiScan", "deviceName": "VB-Test"})

&#x20;   r.raise\_for\_status()

&#x20;   return r.json()\["apiKey"]



def check\_status(key):

&#x20;   r = requests.get(f"{BASE}/devices/status", headers={"X-API-Key": key})

&#x20;   return r.json()



def upload(key, session\_dict):

&#x20;   r = requests.post(f"{BASE}/upload/json",

&#x20;       headers={"X-API-Key": key, "Content-Type": "application/json"},

&#x20;       data=json.dumps(session\_dict))

&#x20;   if r.status\_code == 403:

&#x20;       raise Exception("Pending admin approval")

&#x20;   r.raise\_for\_status()

&#x20;   return r.json()



\# 사용

key = register()                       # 첫 실행만

status = check\_status(key)

if status\["status"] == "active":

&#x20;   result = upload(key, {

&#x20;       "testId": "LAB\_20260601\_103000",

&#x20;       "dataType": "000",

&#x20;       "memo": "Test",

&#x20;       "records": \[...]

&#x20;   })

&#x20;   print(f"Uploaded: {result\['inserted']} records")

### 10-3. Kotlin (Android)


suspend fun uploadSession(apiKey: String, session: JSONObject): JSONObject {

&#x20;   val req = Request.Builder()

&#x20;       .url("https://labdb.medithings.net/api/v1/upload/json")

&#x20;       .header("X-API-Key", apiKey)

&#x20;       .post(session.toString().toRequestBody("application/json".toMediaType()))

&#x20;       .build()

&#x20;   val res = client.newCall(req).execute()

&#x20;   return when (res.code) {

&#x20;       200, 201 -> JSONObject(res.body!!.string())

&#x20;       403      -> throw PendingApprovalException()

&#x20;       404      -> { secureStore.clear(); throw ReregisterRequiredException() }

&#x20;       else     -> throw IOException("HTTP ${res.code}")

&#x20;   }

}

### 10-4. JavaScript (Node.js)


const fetch = require('node-fetch');

const BASE = 'https://labdb.medithings.net/api/v1';



async function upload(key, session) {

&#x20;  const res = await fetch(`${BASE}/upload/json`, {

&#x20;     method: 'POST',

&#x20;     headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },

&#x20;     body: JSON.stringify(session)

&#x20;  });

&#x20;  if (res.status === 403) throw new Error('Pending approval');

&#x20;  if (!res.ok) throw new Error(`HTTP ${res.status}`);

&#x20;  return res.json();

}

---

## 11. 엔드포인트 요약

| Method | Endpoint | 인증 | 설명 |

|:------:|----------|:----:|------|

| POST | /api/v1/devices/register | — | 디바이스 등록 |

| GET | /api/v1/devices/status | X-API-Key | 상태 확인 (5가지 분기) |

| **POST** | **/api/v1/upload/json** | **X-API-Key** | **★ 전체 세션 업로드 (권장)** |

| POST | /api/v1/sessions | X-API-Key | 세션 생성 (idempotent) |

| GET | /api/v1/sessions?dataType=000 | X-API-Key | 세션 목록 (dataType 필터 가능) |

| GET | /api/v1/sessions/{id} | X-API-Key | 세션 상세 |

| PATCH | /api/v1/sessions/{id} | X-API-Key | 세션 종료/메모 |

| DELETE | /api/v1/sessions/{id} | X-API-Key | 세션 삭제 |

| POST | /api/v1/sessions/{id}/records | X-API-Key | Record 1건 추가 |

| POST | /api/v1/sessions/{id}/records/batch | X-API-Key | Record 일괄 추가 (≤100) |

| GET | /api/v1/sessions/{id}/records | X-API-Key | Record 목록 (summary/full) |

| GET | /api/v1/sessions/{id}/records/{row} | X-API-Key | Record 단건 (ADC 포함) |

| GET | /api/v1/sessions/{id}/stats | X-API-Key | 통계 |

| GET | /api/v1/sessions/{id}/export?format= | X-API-Key | CSV/JSON 내보내기 |

---

## 12. 변경 이력

| 버전 | 날짜 | 변경 |

|:----:|------|------|

| 1.0 | 2026-04-14 | 초안 (sessions/records/devices) |

| 1.1 | 2026-06-01 | POST /upload/json 추가, dataType 필드 추가 |

| 1.2 | 2026-06-01 | dataType 자료 구분 메커니즘 상세화 (레지스트리, 유형별 페이로드, 신규 코드 발급 절차) |

---

## 12b. 앱 측 Auto Retry Policy (Android app, 2026-07-09)

Android 앱 (VesiScan-Basic demo-final / tab-navigation / cloud-mvp) 은 매

session 마다 사용자가 Upload 버튼을 누르지 않아도 되도록 **자동 업로드 +

재시도 큐** 를 내장합니다.

### 12b.1 endMeasurement 자동 업로드

Clinical 세션 종료 시 (ClinicalSessionStore.endMeasurement()) apiKey 가

등록되어 있으면 (LabdbCredentials.isRegistered) 무조건 `LabdbUploader.

uploadAsync(context, folder)` 를 호출. fire-and-forget 이므로 응답 대기

없이 다음 화면으로 진행.

- 이전 정책: lastStatus == "active" 조건 필요 → 상태 갱신 안 된 첫 진입 시

자동 업로드 skip 되던 문제.

- 2026-07-09 이후: isRegistered 만 체크. 서버가 401/403 을 반환하면 상위

로직에서 status 재조회.

### 12b.2 LabdbAutoRetry object (app/src/main/java/.../labdb/LabdbAutoRetry.kt)

Clinical Home 진입 시 (LaunchedEffect(Unit)) retryPending(context) 자동

호출. 로직:


1. LabdbCredentials.isRegistered 확인 → 없으면 no-op

2. ClinicalSessionStore.recentFinalizedFolders (tab-nav/cloud-mvp) 또는

   listOfNotNull(lastFinalizedFolder) (demo-final) 스캔

3. labdb_upload.json 마커 없는 폴더만 pending

4. 순차 (병렬 X) LabdbUploader.uploadBlocking 실행

5. 결과: labdb_upload.json (성공) / labdb_upload_error.json (실패)

### 12b.3 UI 배너

Clinical Home 상단:

- 진행 중 (pendingCount > 0 또는 uploadingName != null):

파란 배너 "자동 업로드 진행 중 · 남은 세션 N · "

- 완료 (lastResultMessage != null):

초록/주황 배너 "N uploaded, M failed" + 확인 버튼

### 12b.4 State 노출


object LabdbAutoRetry {

    val pendingCount: MutableIntState           // UI 배너 카운트

    val uploadingName: MutableState<String?>    // 현재 업로드 중 폴더

    val lastResultMessage: MutableState<String?>  // 완료 후 결과 요약

    fun retryPending(context: Context)          // 트리거

    fun dismissResult()                         // 배너 확인

}

### 12b.5 재시도 안전성

- 이미 업로드된 폴더 (labdb_upload.json 있음) 는 skip

- 실패 폴더는 labdb_upload_error.json 만 남기고 다음 진입 시 재시도

- 네트워크 순간 이슈 → 다음 진입에서 자동 복구

- 앱 종료 후 재실행 → ensureHistoryLoaded 로 recent 폴더 복원 → 재시도

---

## 13. 지원

- **서비스 상태**: https://labdb.medithings.net/api/health

- **관리자**: admin@medithings.net

- **상세 워크플로우**: docs/APP-WORKFLOW-RULES.md 참조

- **디바이스 등록 가이드**: docs/DEVICE-REGISTRATION-GUIDE.md 참조

---

*Copyright (c) 2026 Charles KWON OhJun / MEDiThings Inc.*