PoseLab Logo
  • 제품
  • 머신러닝
  • 문서
  • 시스템
  • 소개
PoseLab Logo(주)마블덱스

PoseLab은 체압센서로 구동되는 인터랙티브 머신러닝 학습 플랫폼입니다. 실제 센서 데이터를 통해 ML 알고리즘을 직접 체험하고, 나만의 앱을 만들어보세요.

© Copyright 2026 PoseLab. All Rights Reserved.

고객지원
  • 문의하기
  • 문서
약관
  • 서비스 약관
  • 개인정보처리방침
  • 쿠키 정책

프로토콜 — Advanced

Advanced 펌웨어 — 96바이트 바이너리 패킷과 온디바이스 추론

시리얼 프로토콜 — Advanced

📄 프로토콜 정의 파일: serial-protocol-seat-anzdF.json — 이 문서의 원본(SOT)입니다. 파서를 직접 짤 때 이 파일을 기준으로 하세요.

Advanced 펌웨어는 값을 96바이트 고정 길이 바이너리로 보냅니다. 센서값에 더해 압력중심·최대값·변화량이 함께 오고, 장치가 직접 돌린 추론 결과도 실립니다.

속도(2,000,000 baud)와 주기(초당 50회)는 Lite와 같습니다. 시리얼 모니터로 열면 깨진 문자가 쏟아지는 것이 정상입니다.

패킷 구조

20

오프셋필드크기타입
0–1sync 0xFF 0xFF2—
2–3rows(3) · cols(14)2u8
4boardId1u8
5rollingIdx (0~199)1u8
6–9timestamp4u32 LE
10–73sensorData64u8 × 64
74–77cofX · cofY4s16 LE
78–83maxVal · velocity · avg6u16/s16 LE
84–93infer (추론 결과)10아래 참조
94–95tail 0x00 0xFE2—

여러 바이트 값은 전부 리틀 엔디언입니다.

rows × cols 는 셀 개수가 아닙니다

rows=3, cols=14는 셀이 걸친 범위일 뿐입니다. 3 × 14 = 42는 아무 의미 없는 숫자입니다. 셀 개수는 항상 32입니다. → 셀 배치 보기

값 읽기와 프레임 자르기

21

function findFrame(buf) {
  for (let i = 0; i + 96 <= buf.length; i++) {
    if (buf[i] !== 0xFF || buf[i + 1] !== 0xFF) continue;
    if (buf[i + 94] !== 0x00 || buf[i + 95] !== 0xFE) continue;  // tail 검증 필수
    return buf.subarray(i, i + 96);
  }
  return null;
}

const values = [];
for (let i = 0; i < 32; i++) {
  values.push(buf[10 + i * 2] * 100 + buf[10 + i * 2 + 1]);
}

rollingIdx가 건너뛰면 프레임을 놓친 것입니다.

압력중심과 통계

22 22

위: 바로 앉았을 때 압력중심이 원점(0, 0) 근처 · 아래: 오른쪽으로 기울이면 점이 +x 쪽으로 이동하고 왼쪽 셀 값이 함께 내려간다 (꼬리는 최근 이동 경로)

필드내용
cofX · cofY무게가 실린 평균 위치. 0.0~1.0을 10000배 한 정수 (5000 = 한가운데)
maxVal32셀 중 최대값
velocity이번 maxVal − 직전 maxVal. 누르는 중이면 양수
avg값이 5를 넘는 셀들의 평균

Advanced는 값이 5보다 작으면 0으로 바꿔서 내보냅니다. Lite(20 미만 컷)보다 문턱이 낮은 것은, 압력중심·avg 계산에 쓰이는 원시값을 덜 잘라내기 위해서입니다.

boardId의 최상위 비트가 켜져 있으면 교정되지 않은 원시 값이라는 표시입니다.

const uncalibrated = (buf[4] & 0x80) !== 0;

추론 결과 10바이트

tail 바로 앞 10바이트에는 장치가 직접 돌린 추론 결과가 들어갑니다. 브라우저에서 학습한 모델을 장치에 올려두면, PC 없이도 방석이 스스로 자세를 판정해 이 자리에 실어 보냅니다.

오프셋필드크기내용
84appId10x00 추론 안 함 · 0x01~0xEF 앱 번호 · 0xF0 ASCII 모드
85modelId1앱 안에서의 모델 번호
86–87flags2예약 (0x0000)
88label011등 라벨
89conf011등 확신도
90label112등 라벨
91conf112등 확신도
92label213등 라벨
93conf213등 확신도
const appId = buf[84];
const top1  = { label: buf[88], conf: buf[89] / 200 };  // 0.0~1.0

확신도는 0–200입니다. 0–255가 아닌 이유는 0xFE·0xFF를 피하기 위해서입니다. 200으로 나누면 0.0–1.0이 됩니다.

라벨은 이름이 아니라 번호입니다

label0에 들어오는 것은 "왼쪽" 같은 문자열이 아니라 번호입니다. 이름은 모델 파일 안(classes 배열)에 이미 있으므로, 같은 문자열을 초당 50번씩 다시 보낼 이유가 없습니다.

값뜻
0x00~0xDF클래스 번호 (최대 224개). classes[번호]로 이름을 찾습니다
0xE0empty — 아무도 안 앉음 (확신도는 항상 200)
0xE1~0xEE예약
0xEF빈 슬롯 (2·3등이 없을 때)

전부 0xEF 이하입니다. 프레임이 어긋나 다시 동기를 잡는 중일 때 라벨 바이트를 sync(0xFF)나 tail(0xFE)로 오인하면 안 되기 때문입니다 — 센서값이 /100, %100 인코딩을 쓰는 것과 같은 이유입니다.

0xE0(empty)은 왜 고정값인가: classes 배열 끝에 붙이면 라벨이 3개인 모델에선 3번, 30개인 모델에선 30번이 됩니다. 파서가 매번 라벨 개수를 먼저 알아야 해석할 수 있게 되죠. 0xE0으로 고정하면 어떤 모델이든 뜻이 같습니다.

empty 게이트

32채널 합이 문턱값 이하이면 모델을 아예 호출하지 않고 label0 = 0xE0을 내보냅니다.

이건 신경망의 일부가 아닙니다. 자세로 학습한 모델에는 "아무도 없음"이라는 출력이 없어서, 방석이 텅 비어 있어도 softmax가 어느 자세든 하나를 고릅니다. 게이트가 그 경우를 보고할 수 있게 만들어 줍니다.

ASCII 모드

appId가 0xF0이면 뒤 9바이트가 라벨 문자열입니다 (null 패딩).

f0 4c 65 66 74 00 00 00 00 00   →  "Left"

터미널에서 눈으로 확인할 때만 씁니다. 기본은 바이너리입니다.

모델 올리기

장치가 추론을 하려면 먼저 모델을 넣어야 합니다. 브라우저에서 학습한 .mlp.json을 변환기가 .plmodel로 바꾸고, 이것을 Web Serial로 장치에 전송합니다.

브라우저 학습 → .mlp.json → [변환] → .plmodel → 장치로 전송 → SPIFFS 저장
명령동작
MDLWR <바이트수> <crc32>전송 시작. 이후 청크를 받습니다
MDLRD저장된 모델 정보 조회 (없으면 MDLRD NONE)
MDLCLR삭제

청크는 [0xFD 0xFD][len u16][seq u16][데이터] 형식이고 한 청크당 최대 1KB입니다. 장치가 청크마다 ACK <seq>로 답하며, 보내는 쪽은 그 응답을 기다렸다가 다음 청크를 보내야 합니다. 이걸 안 하면 7~11KB 근처에서 수신 버퍼가 넘칩니다 (버퍼를 32KB로 키워도 마찬가지였습니다 — 버퍼 크기 문제가 아니라 흐름 제어 문제입니다).

전송이 끝나면 CRC32를 맞춰보고, 맞을 때만 기존 모델을 덮어씁니다. 실패하면 원래 모델이 그대로 남습니다.

올린 모델은 재부팅 후에 적용됩니다. 인터프리터를 부팅 때 한 번 만들기 때문입니다.

현재 온디바이스 추론은 MLP만 지원합니다. KNN은 변환·전송까지는 되지만 장치 안에서 추론하는 코드가 아직 없습니다.

펌웨어 받기

→ Advanced 소스코드 받기 (Arduino IDE)

PlatformIO를 쓴다면 pio 소스코드를 받으세요. 두 소스는 동일한 펌웨어이며 빌드 방식만 다릅니다.

문서 목록으로