{
  "doc": "PoseLab Core serial packet protocol — AnzdF B-method + on-device inference (SOT)",
  "sot": true,
  "extractedFrom": "F01-Poselab-96B/PoseLabCore-pio/src/proto/packet.h",
  "mirroredBy": "W05-public/standalone/seat-96B/_shared/graph-heatmap/serial-module.js",
  "updated": "2026-08-26",

  "transport": {
    "baud": 2000000,
    "byteOrder": "little-endian",
    "fps": 50,
    "framePeriodMs": 20,
    "rxBufBytes": 8192,
    "txBufBytes": 2048,
    "txNote": "The stream task checks Serial.availableForWrite() and drops the frame rather than blocking. Serial.write() waits for TX room while holding serialMutex, so a host that stops draining RX (mid-upload, say) would otherwise deadlock the command channel."
  },

  "sync": {
    "header": ["0xFF", "0xFF"],
    "tail": ["0x00", "0xFE"],
    "validValMax": "0xEF"
  },

  "sensorEncoding": {
    "bytesPerCell": 2,
    "hi": "value / 100",
    "lo": "value % 100",
    "reason": "0xF0+ is reserved as sync words; >>8 raw high byte can collide. /100,%100 keeps hi<=40",
    "noiseFloor": "value < 5 -> 0",
    "rangeAdc": [0, 4095]
  },

  "packet": {
    "lengthFormula": "10 + N*2 + 10 + 10 + 2",
    "fields": [
      { "idx": 0,  "name": "sync0",      "size": 1, "type": "u8",    "value": "0xFF" },
      { "idx": 1,  "name": "sync1",      "size": 1, "type": "u8",    "value": "0xFF" },
      { "idx": 2,  "name": "rows",       "size": 1, "type": "u8",    "note": "NUM_REAL_HEIGHT (seat=3). grid boundary rows, not cell count" },
      { "idx": 3,  "name": "cols",       "size": 1, "type": "u8",    "note": "NUM_REAL_WIDTH (seat=14)" },
      { "idx": 4,  "name": "boardId",    "size": 1, "type": "u8",    "note": "bit0-1 = board id (fixed 0, single-board Base-A). bit7 = voltage flag (uncalibrated raw output). Mask with 0x7F before comparing." },
      { "idx": 5,  "name": "rollingIdx", "size": 1, "type": "u8",    "note": "build_count % 200 (0~199)" },
      { "idx": 6,  "name": "timestamp",  "size": 4, "type": "u32le", "note": "millis()" },
      { "idx": 10, "name": "sensorData", "size": "N*2", "type": "u8[]", "note": "N cells x (hi,lo)" },

      { "idx": "10+N*2+0", "name": "cofX",     "size": 2, "type": "s16le", "note": "0.0~1.0 x10000, default 5000 (center) when no active cell" },
      { "idx": "10+N*2+2", "name": "cofY",     "size": 2, "type": "s16le", "note": "0.0~1.0 x10000" },
      { "idx": "10+N*2+4", "name": "maxVal",   "size": 2, "type": "u16le", "note": "raw ADC max" },
      { "idx": "10+N*2+6", "name": "velocity", "size": 2, "type": "s16le", "note": "maxVal - prev_maxVal" },
      { "idx": "10+N*2+8", "name": "avg",      "size": 2, "type": "u16le", "note": "avg of cells above COF_THRESHOLD(5)" },

      { "idx": "10+N*2+10+0", "name": "appId",   "size": 1, "type": "u8",    "note": "0x00 = no inference, 0x01~0xEF = app id, 0xF0 = ASCII mode (see inferBlock.asciiMode)" },
      { "idx": "10+N*2+10+1", "name": "modelId", "size": 1, "type": "u8",    "note": "model number within the app" },
      { "idx": "10+N*2+10+2", "name": "flags",   "size": 2, "type": "u16le", "note": "all bits reserved (0x0000). Kept as 2 bytes for future use — removing them later would break the layout." },
      { "idx": "10+N*2+10+4", "name": "label0",  "size": 1, "type": "u8",    "note": "top-1 label (see inferBlock.labelValues)" },
      { "idx": "10+N*2+10+5", "name": "conf0",   "size": 1, "type": "u8",    "note": "0~200 (0.5% per step)" },
      { "idx": "10+N*2+10+6", "name": "label1",  "size": 1, "type": "u8",    "note": "top-2" },
      { "idx": "10+N*2+10+7", "name": "conf1",   "size": 1, "type": "u8" },
      { "idx": "10+N*2+10+8", "name": "label2",  "size": 1, "type": "u8",    "note": "top-3" },
      { "idx": "10+N*2+10+9", "name": "conf2",   "size": 1, "type": "u8" },

      { "idx": "end-2", "name": "tail0", "size": 1, "type": "u8", "value": "0x00" },
      { "idx": "end-1", "name": "tail1", "size": 1, "type": "u8", "value": "0xFE" }
    ]
  },

  "anzd": {
    "cofScale": 10000,
    "cofThreshold": 5,
    "cofDefaultCenter": 5000
  },

  "inferBlock": {
    "sizeBytes": 10,
    "position": "immediately before the 2-byte tail",
    "rationale": "Label strings are NOT sent per frame. They live in the model file (_lifecycleMeta.classes) and the host looks them up by index — sending the same string 50x/second would just retransmit static metadata.",

    "labelValues": {
      "0x00-0xDF": "class index (224 usable)",
      "0xE0": "empty — the empty gate tripped (conf = 200)",
      "0xE1-0xEE": "reserved",
      "0xEF": "unused slot",
      "note": "Everything stays at or below 0xEF so a resyncing parser can never mistake a label byte for a sync (0xFF) or tail (0xFE) byte — the same reason sensor bytes use the /100,%100 encoding. empty is a FIXED value, not an index appended to classes: appending would make its number depend on how many labels the model has (3 for one model, 30 for another), forcing the parser to know the class count before it can read the field."
    },

    "confidence": {
      "range": [0, 200],
      "scale": "value / 200 = 0.0~1.0",
      "reason": "not 0~255 — keeps the byte clear of 0xFE/0xFF"
    },

    "asciiMode": {
      "trigger": "appId == 0xF0",
      "layout": "bytes +1..+9 carry an ASCII label, null-padded (e.g. \"Left\")",
      "purpose": "reading the stream by eye in a terminal. Binary is the default."
    },

    "emptyGate": {
      "rule": "sum of all channels <= threshold -> label0 = 0xE0, conf0 = 200, inference skipped",
      "note": "This is NOT part of the network — a model trained on postures has no 'nobody there' output, so softmax always picks one of them no matter how empty the mat is. The gate is what makes that case reportable. Settings travel in the .plmodel container (emptyGate.enabled / .threshold), because the browser applies the same filter before calling the model."
    }
  },

  "instances": {
    "seat": { "N": 32, "rows": 3, "cols": 14, "packetBytes": 96, "cellLayout": "10-14-8" }
  },

  "modelTransfer": {
    "doc": "Uploading a .plmodel to the device. Layered on the same ASCII line channel as the calib commands (STOP/FITWR/...).",
    "storage": {
      "partition": "models (SPIFFS, 2MB at 0x490000)",
      "path": "/model.plmodel",
      "maxBytes": 1945600,
      "maxBytesNote": "1900 KB — the partition is 2MB but SPIFFS spends some of it on filesystem metadata",
      "staging": "The whole transfer is buffered in PSRAM and written to SPIFFS once, at commit. Writing per chunk was tried first and lost data: SPIFFS.write() can take tens of ms, and while it runs poll_commands() cannot drain the UART."
    },

    "commands": {
      "MDLWR <bytes> <crc32hex>": "announce a transfer -> 'MDLWR READY'. The device pauses its own sensor stream for the duration (it restores it on every exit path, including timeout — leaving that to the host means a sender that crashes leaves the board mute).",
      "MDLRD": "report stored model metadata as JSON (type/appId/modelId/classes/bytes), or 'MDLRD NONE'",
      "MDLCLR": "delete the stored model"
    },

    "chunkFrame": {
      "layout": "[0xFD][0xFD][len u16le][seq u16le][payload ...]",
      "maxPayload": 1024,
      "syncNote": "0xFD is unused by the sensor stream (sync 0xFF 0xFF, tail 0x00 0xFE), so a chunk frame can never be confused with a packet",
      "seqRule": "must increase by one from zero; a gap aborts the transfer rather than silently storing a hole",
      "flowControl": "The device replies 'ACK <seq>' after each chunk and the sender waits for it before sending the next. Without this the RX buffer overflows on larger models — measured failing at 7-11 KB with both an 8 KB and a 32 KB buffer, which is why the fix is flow control and not a bigger buffer. With ACK, at most one chunk is ever in flight.",
      "completion": "The announced byte count is the end marker — there is no separate 'done' frame. The last chunk is answered with the commit result ('MDLWR OK' / 'MDLWR ERR CRC') instead of an ACK.",
      "timeout": "5000 ms since the last byte -> 'MDLWR ERR TIMEOUT <received>/<announced>'. Checked from loop() unconditionally, not only while bytes are arriving — a dead transfer stops producing bytes by definition."
    },

    "crc32": {
      "polynomial": "0xEDB88320 (zlib-compatible)",
      "note": "Computed incrementally as chunks arrive. Host side can use Python zlib.crc32 directly; verified identical across firmware, Python, and the browser implementation."
    },

    "safety": "Chunks land in a PSRAM buffer and are written to /model.tmp then renamed onto /model.plmodel only after the CRC matches. A failed transfer leaves the previously working model in place."
  },

  "container": {
    "doc": ".plmodel — what the device reads. Produced from a browser-exported .mlp.json by converter/plmodel.py (or the Colab notebook).",
    "format": "plmodel-v1",
    "extension": ".plmodel",
    "encoding": "JSON (the tflite binary is base64 inside it, so ArduinoJson can parse the whole thing)",

    "fields": {
      "format": "plmodel-v1",
      "type": "mlp | knn",
      "appId": "u8, matches the appId in the 96B infer block",
      "modelId": "u8",
      "classes": "string[] — index order fixes the label numbering in the packet. Max 224 (0xE0-0xEF is reserved).",
      "emptyGate": "{ enabled, threshold, label } — copied from the source bundle",
      "preprocess": "{ featureMode, normalization, dataMax } — how to build model input",
      "tfliteB64": "mlp only — the tflite model, base64",
      "samples / k": "knn only — the training samples are the model"
    },

    "whyMetaTravels": "emptyGate and preprocess are not part of the network, but inference is wrong without them. The browser normalizes by dataMax before training and skips the model entirely when the gate trips; firmware that hardcodes either one silently produces wrong answers for a model configured differently.",

    "quantization": {
      "current": "float32 (no quantization)",
      "reason": "Weight-only int8 shrinks the file ~2.5x but the ESP32 returned all -nan on TensorFlowLite_ESP32 1.0.0 (2022), which has no hybrid path to convert int8 weights back to float at runtime. Not retested on Chirale 2.0.0 — float32 is small enough that there is no reason to. It works correctly on a PC, so this only surfaces on real hardware. Measured 2026-08-25: int8 11,248B -> all -nan / float32 28,060B -> 5/5 correct."
    }
  },

  "onDeviceInference": {
    "runtime": "Chirale_TensorFLowLite 2.0.0 (spaziochirale). Builds under both PlatformIO (espressif32@6.10.0) and the Arduino IDE (arduino-esp32 core 3.3.11), which is why it was chosen: the pio and arduino deliverables stay one source. The earlier TensorFlowLite_ESP32 1.0.0 (2022) builds only under pio — its bundled flatbuffers assigns to a const member, which is fine under the C++11 pio uses but a hard error under the C++17 core 3.x defaults to.",
    "arena": "16 KB allocated; a 32->64->64->3 MLP reports ~1.3 KB used",
    "operators": "MicroMutableOpResolver<2> — FullyConnected + Softmax only. Relu is NOT registered: the converter fuses it into FULLY_CONNECTED, so the graph is 3x FC + SOFTMAX.",
    "modelSource": "uploaded .plmodel on the models partition, falling back to a flash-embedded model so a board with nothing uploaded still infers",
    "applyTiming": "the interpreter is built once at boot — a newly uploaded model takes effect after a reboot",
    "knn": "Conversion is supported, but on-device KNN inference is not implemented (decided 2026-08-25). The container and transfer path already carry type='knn'; only the device-side distance/vote code is missing."
  }
}
