Reverse-Engineered thermal master p3: complete USB protocol, register map, sub-LSB lock-in & compression

Exhaustive hardware, firmware, and radiometric specification for the Thermal Master P3 (VID = 0x3474, PID = 0x45A2, Raytron RTC2121W 256×192 12 µm VOx microbolometer). Everything needed by an engineer or AI agent starting from a bare USB-C plug to achieve bit-exact 25.00 Hz streaming, USB hub self-healing, -38.62 mK/LSB sub-LSB differential lock-in, and 19.7×–325× temporal-difference compression.

Verified live on hardware unit SN = P3025043DG040200256 · PN = P30-1A00800007 · FW 00.00.02.18 · HW P3-00.04 · Host testbed: Google Pixel 9 Pro XL (Android 16, through a USB-C hub) + macOS bench via ~/git/thermal-android. Reverse-engineered from com.thermalmaster.p2telephoto (libomniircamera020.so, libircmd020.so, libadvirtempac020.so, libadvirparse.so, classes.dex).

0. Which Mode Should I Use? (Visualizing vs. Collecting Raw Signal)

Everything the camera and app do boils down to two workflows: (1) Visualizing live thermal video, or (2) Collecting the highest-quality raw sensor signal over GET /stream.raw.z to process in Python on the host. Crucially, GET /stream.raw.z (and /stream.raw) always streams the 256×192 uint16 plane and 512-word InfoLine straight from USB before any phone-side processing—only the four camera hardware registers (nuct_bypass, TNR, SNR, auto_ffc) change the raw signal.

Preset Name (action=preset&name=…)When to Use ItCamera Registers SetRaw Signal Output on /stream.raw.zLive Video Preview (Spacesheep / MJPEG)
1. visualize
(alias: factory)
Default for live viewing on Spacesheep, LAN browser, or phone screen. nuct_bypass = 0
TNR = 50, SNR = 50
auto_ffc = 1
Calibrated 1/64 K (T_C = raw/64 - 273.15, 62.5 mK LUT step), ISP-smoothed. Smooth, low-noise image; shutter clicks every ~90 s to prevent drift.
2. raw
(alias: raw_quiet)
Highest-sensitivity differential raw capture (fast transients, lock-in, sub-LSB averaging in Python). nuct_bypass = 1
TNR = 0, SNR = 0
auto_ffc = 0
Un-halved 14-bit NUC counts (nuc14 = raw >> 2, -38.62 mK/LSB—1.6× finer than LUT), zero ISP filtering, zero shutter interruptions. Polarity auto-corrected (v1.3.4+): renderer inverts the negative NUC slope so hot spots still appear bright while recording raw counts.
3. raw_calibrated
(alias: calib_quiet)
Calibrated Kelvin raw capture when you want absolute °C without ISP blur or shutter clicks (3.1 mK lock-in / 6.9 mK onset noise floor). nuct_bypass = 0
TNR = 0, SNR = 0
auto_ffc = 0
Unfiltered 1/64 K (T_C = raw/64 - 273.15, 62.5 mK LUT step) + FPA vtempRawAdc in InfoLine[65], zero ISP filtering, zero shutter clicks. Positive calibrated temperature image (unfiltered, shutter locked open).
4. high_precision
(On-Phone ΔT)
Viewing sub-10 mK differential heating directly on the phone or /frame.diff.csv without a host script. Same hardware registers as raw (nuct_bypass=1, NR=0/0, auto_ffc=0) Identical to raw on /stream.raw.z (phone-side 64-frame tare + EMA + IQR drift runs in parallel for /stream.diff.z?submk=1). Displays live differential ΔT (mK) relative to the 64-frame tare baseline.
# Switch presets remotely over Tailscale or LAN (pass Authorization: Bearer $SPACESHEEP_API_KEY):
# 1. Live visualization (default factory settings: 1/64 K, NR 50/50, Auto-FFC ON)
curl -X POST -H "Authorization: Bearer $SPACESHEEP_API_KEY" "http://<phone-ip>:8080/api/control?action=preset&name=visualize"

# 2. Raw 14-bit NUC signal collection (-38.62 mK/LSB, NR 0/0, Auto-FFC OFF) + trigger one clean shutter before recording
curl -X POST -H "Authorization: Bearer $SPACESHEEP_API_KEY" "http://<phone-ip>:8080/api/control?action=preset&name=raw"
curl -X POST -H "Authorization: Bearer $SPACESHEEP_API_KEY" "http://<phone-ip>:8080/api/control?action=shutter"

# 3. Raw calibrated 1/64 K signal collection (62.5 mK/LSB, NR 0/0, Auto-FFC OFF)
curl -X POST -H "Authorization: Bearer $SPACESHEEP_API_KEY" "http://<phone-ip>:8080/api/control?action=preset&name=raw_calibrated"

Summary Metrics & The 11 Hard-Won Reverse-Engineering Lessons

256×192
12 µm VOx @ 25.00 Hz
Raytron RTC2121W, 4.3 mm f/1.0 macro lens (8 mm to ∞)
197,656 B
Dual-Plane Bulk Frame
12B start + 98,304B AGC + 1,024B InfoLine + 98,304B 16-bit + 12B end
-38.62 mK
Raw 14-Bit NUC LSB
Unlocked via 0x0126/0x0048 (1.7× finer than 62.5 mK LUT step)
< 10 mK
Sub-LSB Lock-In Floor
Natural 1.07 LSB Johnson noise dither + Float64 tare + IQR drift cancel
19.7× / 325×
P3DF Stream Compression
2.00 Mb/s bit-exact lossless; 0.12 Mb/s closed-loop DPCM
95.1 Hz
Live EP0 Polling Rate
10.01 ms mean vendor register read while streaming 25 FPS with 0 drops

Every major obstacle below cost hours of binary disassembly, USB packet sniffing, or live hardware debugging. Follow these 11 rules verbatim to avoid every trap:

  1. Trap 1 — Why standard UVC / Camera2 / WebRTC getUserMedia completely fails Although older InfiRay P2 modules use UVC (bInterfaceClass = 0x0E), the Thermal Master P3 (VID=0x3474, PID=0x45A2) declares a proprietary vendor class (bInterfaceClass = 0xFF, bInterfaceSubClass = 0xF0) with raw Bulk IN endpoint 0x81. Neither Linux uvcvideo, Android Camera2, nor Chrome's navigator.mediaDevices.getUserMedia enumerates it. You must open it directly via UsbManager.openDevice() / libusb.
    USB Class 0xFF
  2. Trap 2 — AC020 18-Byte CRC16-CCITT protocol vs. legacy AC010 8-byte commands Sending legacy InfiRay P2 / AC010 8-byte vendor packets is silently ignored or rejected. The P3 runs the Gen-2 AC020 protocol (libircmd020.so) over EP0 (bmRequestType=0x41, bRequest=0x20): every command is an 18-byte header where bytes [0..1] are Big-Endian cmdType, bytes [2..15] have Little-Endian fields, and bytes [16..17] carry a Little-Endian CRC16-CCITT (XMODEM, poly=0x1021, init=0x0000) computed over bytes [0..15].
    CRC16-XMODEM
  3. Trap 3 — The 6-Step Handshake & why calling setInterface twice kills the 2s sensor timer The P3 will not emit bulk frames unless you execute an exact 6-step sequence: (1) send CMD_START_STREAM (0x012F/0x0081), (2) sleep(1000ms), (3) setInterface(iface=1, alt=1) + vendor FIFO reset 0x40/0xEE (wValue=0, wIndex=1), (4) sleep(2000ms) while the ASIC boots its VSYNC pipeline, (5) 150ms flush read on EP 0x81, and (6) final CMD_START_STREAM. Crucial trap: if you call setInterface(alt=1) again at Step 6, the USB bridge resets its 2-second sensor timer and bulk reads time out forever!
    2s Timer Trap
  4. Trap 4 — USB Hub Transaction Translator (TT) recovery via 0x40/0xEE without setInterface When running through a USB-C PD hub (e.g. Anker 332), occasional cable micro-bumps or PD negotiations cause bulkTransfer timeouts. After 2 consecutive 750ms bulk timeouts, recover in-place in <60 ms by sending controlTransfer(0x40, 0xEE, 0, 1) + CMD_START_STREAM + parser.reset() without calling setInterface(alt=1).
    Hub Recovery
  5. Trap 5 — Powered USB hub reconnect leaves P3 in streaming state with wedged EP0 When unplugging and replugging the P3 into a powered USB-C hub, VBUS never drops, so the P3 stays mid-stream while its EP0 state machine rejects initial register reads (CMD_READ_NAME returns empty). Fix: detect empty initial read and invoke Android's hidden UsbDeviceConnection.resetDevice() via Java reflection, sleep 600 ms, re-claim interfaces 0 and 1, and retry.
    EP0 Wedge Fix
  6. Trap 6 — 10 ms EP0 command spacing + 0xC1/0x22 BUSY polling + stale SRAM buffer trap Back-to-back EP0 commands faster than 10 ms overflow the RT-Thread command queue; enforce a 10ms minimum gap and poll 0xC1/0x22 until bit 0 (BUSY = 0x01) clears. Furthermore, if 0xC1/0x22 returns 0x04 (rejected opcode), firmware does not clear its EP0 SRAM buffer—a subsequent 0xC1/0x21 read returns stale bytes from the previous valid command! Never probe class 0x0104 (raw I2C master read), which hangs the internal sensor I2C bus until power cycle.
    I2C & SRAM Trap
  7. Trap 7 — Physical bump sticks mechanical shutter closed or corrupts FFC baseline A physical tap during FFC actuation can leave the mechanical shutter blade stuck closed or corrupt the flat-field baseline (spanC < 0.30°C and stdDevC < 0.05°C, or fpaTempC > 34.5°C with stdDevC < 0.35°C). Auto-detecting this condition for 75 consecutive frames (3.0s) and firing CMD_SHUTTER (0x0136/0x0043) with a 20s cooldown self-heals the image hands-free.
    Auto-Shutter Heal
  8. Trap 8 — 204,800-byte NUC transition frames vs. 197,656-byte normal wire frames Normal frames are exactly 197,656 bytes (12B start marker + 197,632B payload + 12B end marker). However, every time the shutter actuates (0x0136/0x0043), the camera emits a 204,800-byte (200 KiB) bulk transfer containing a 9,204-byte partial aborted frame before restarting the new frame. A naive fixed-size reader permanently loses byte alignment; our bounded resynchronizing parser checks endSync == startSync + 2 and startCnt1 == endCnt1 to discard the 9,204-byte fragment with zero dropped sync.
    Sync Parser
  9. Trap 9 — Why bits 0..1 of every temperature word are always 0 (62.5 mK quantum) & how nuct_bypass=1 unlocks -38.62 mK/LSB Although the wire container is 1/64 K (15.625 mK), 100.000% of pixels in default mode satisfy raw % 4 == 0 (62.50 mK step). Disassembly of remap_temp in libadvirtempac020.so revealed why: the ISP computes NUC_ktbt >> 1 (throwing away 1 bit!) to index an 8,192-entry 1/16 K LUT and shifts the result left by 2 (<< 2). Sending adv_bypass_nuct_set(1) (0x0126/0x0048, reg=1) bypasses both >> 1 and the LUT, outputting un-halved 14-bit NUC counts (raw_u16 = nuc14 << 2) at -38.62 mK / LSB (1.7× finer hardware granularity).
    14-Bit NUC Unlock
  10. Trap 10 — Why naive delta compression fails & how Closed-Loop DPCM + >> 2 + ZigZag + Byte-Shuffle hits 19.7×–325× In two's complement int16, a tiny -1 noise fluctuation is 0xFFFF while +1 is 0x0001, flipping all 8 bits of the MSB on 50% of pixels and destroying zlib compression. By (1) stripping the 2 zero LSB padding bits (v14 = raw >> 2), (2) running a closed-loop DPCM predictor with optional deadband k, (3) ZigZag-folding signs ((d << 1) ^ (d >> 31)) so the entire 49,152-byte high-byte plane becomes 100% zeros, and (4) grouping low bytes and high bytes into separate contiguous planes before zlib, lossless streaming drops from 39.54 Mb/s to 2.00 Mb/s (19.7×) and deadband-3 reaches 0.12 Mb/s (325.1×) with zero drift.
    19.7×–325× Codec
  11. Trap 11 — Solving the 0 FPS headless Android WebView WebRTC bug with MediaStreamTrackGenerator When publishing a 30 FPS WebRTC stream from an Android foreground service using an offscreen WebView (no SurfaceFlinger window token), Chromium's HTML5 canvas compositor never flushes canvas.captureStream(30) (outbound-rtp.framesSent stays 0). Using WebCodecs Insertable Streams (new MediaStreamTrackGenerator({ kind: "video" })) driven by an unthrottled 33ms Web Worker calling writer.write(new VideoFrame(canvas, { timestamp })) bypasses the compositor completely and sustains 30.2–30.6 FPS at 1.8 Mbps RTP.
    30 FPS WebRTC

1. Hardware Identity, Optics & Serial Number (SNInfo) Decoding

USB Descriptor Topology

USB 2.0 High-Speed (480 Mbps)

  • Vendor ID (VID): 0x3474 (13428, InfiRay / Raytron)
  • Product ID (PID): 0x45A2 (17826, Thermal Master P3) · 0x45C2 (P1)
  • Device / Interface Class: 0xFF / 0xF0 (Vendor-Specific, not UVC 0x0E)
  • Interface 0 (id=0, alt=0): Control interface over Endpoint 0 (EP0)
  • Interface 1, Altsetting 0 (id=1, alt=0): Idle state (0 endpoints; stops bulk DMA)
  • Interface 1, Altsetting 1 (id=1, alt=1): Active streaming state with 1 endpoint: EP 0x81 IN (Bulk, 512 B maxPacketSize)
Detector & Macro Optics

Raytron RTC2121W + 4.3 mm f/1.0

  • Microbolometer: Uncooled WLP VOx, 256 × 192 (49,152 pixels), 12 µm pitch, 8–14 µm LWIR, 25.00 Hz VSYNC
  • True Lens Focal Length: 4.3 mm, f/1.0 aperture, manual helicoid focus ring (8 mm macro to ∞, resolves 0201 SMD packages)
  • FOV & IFOV: 40.0° (H) × 30.2° (V), IFOV = 12 µm / 4.3 mm = 2.79 mrad (0.160°/pixel)
  • Measured Frame-Diff NETD: 33.81 mK median across all 49,152 pixels (21.70 mK P5 quietest pixels, meeting the <35 mK spec)
Official APK Copy-Paste Bug (3.2 mm vs. True 4.3 mm): In com.common.baselibrary.util.MyIJpegUtils.getMyIJpegBean (classes.dex @ 0x3be7e8), the official app hardcodes setIr_aperture_value(1.1f) and setIr_focal_len(3.2f) (copied from the older InfiRay P2) into saved JPEG headers. However, the hardware Serial Number register (0x0101 / 0x0081 / sub-ID 0x07) and CommonUtils.getSNInfo (classes.dex @ 0x4344b0) decode characters SN[5..8] = "043" → 4.3 mm, and Rs300CmdData.run() (classes.dex @ 0x57eea2) checks PID == 0x45A2 to load assets/rs300_tau/V303_P3_4.3mm_H.bin and V303_P3_4.3mm_L.bin. Note that the manual focus twist ring is purely mechanical with no electronic position encoder.

1.1 Serial Number (SNInfo) Decoding Table (P3025043DG040200256)

SN SliceLive SubstringSDK Field (CommonUtils.getSNInfo)Decoded Hardware Meaning & Lookup Values
[0..3]"P30"modelCodeThermal Master P3 series (checked by MyIJpegUtils.isP3Image)
[3..5]"25"resolution256 × 192 FPA array ("16"=160×120, "25"=256×192, "32"=320×240, "38"=384×288, "64"=640×512, "12"=1280×1024)
[5..8]"043"cameraLens (Focal Length)4.3 mm macro lens (tenths of mm: "020"=2.0mm, "032"=3.2mm P2, "043"=4.3mm P3, "089"=8.9mm, "091"=9.1mm X2/X3, "130".."500"=13..50mm)
[8..9]"D"gainTypeDual Gain (-20..150°C High Gain + 0..600°C Low Gain; "H"=High Gain only, "S"=Switchable)
[9..10]"G"year2026 ('A'=2020, 'B'=2021, 'C'=2022, 'D'=2023, 'E'=2024, 'F'=2025, 'G'=2026)
[10..12]"04"monthApril (04)
[12..14]"02"day2nd (02) → Manufactured 2026-04-02
[14..19]"00256"streamCode (Unit Serial)Unit #256 of that day's production batch

1.2 Live basic_device_info_get (0x0101 / 0x0081) Sub-IDs 0..20

Every sub-ID below was queried live on SN: P3025043DG040200256 via GET /api/probe while streaming 25 FPS video. When the 10ms artificial safety delay is omitted, individual EP0 register reads complete in 0.54–1.34 ms (9.5–12.1 ms with 10ms pacing enforced; sub-ID 0x0D takes 177.26 ms because firmware reads the SPI flash package header):

Sub-ID (hdr[4])SDK Constant (libircmd020.so)Resp LenStatus (s1/s2)Direct / Paced LatencyLive Decoded ValueLive Raw Hex (Trimmed)
0x00 (0)TYPE_RESERVED_064 B4 / 00.54 / 9.57 msRejected (E/std cmd:basic_device_info_get error)—
0x01 (1)TYPE_DEVICE_NAME30 B2 / 30.88 / 12.14 ms"P3"5033
0x02 (2)TYPE_FIRMWARE_VERSION12 B2 / 30.79 / 11.42 ms"00.00.02.18"30302e30302e30322e3138
0x03 (3)TYPE_CUSTOMER_ID10 B2 / 30.68 / 10.61 ms"01.00.00" (Protocol Version)30312e30302e3030
0x04 (4)TYPE_VENDOR_ID16 B2 / 30.76 / 11.48 ms0x3474 (13428 uint16 LE)7434
0x05 (5)TYPE_PRODUCT_ID16 B2 / 30.71 / 10.72 ms0x45A2 (17826 uint16 LE)a245
0x06 (6)TYPE_DEVICE_PN64 B2 / 30.94 / 11.09 ms"P30-1A00800007"5033302d31413030383030303037
0x07 (7)TYPE_DEVICE_SN64 B2 / 31.12 / 11.63 ms"P3025043DG040200256"50333032353034334447303430323030323536
0x08 (8)TYPE_ISP_VERSION64 B2 / 30.89 / 11.06 ms"P3-00.02"50332d30302e3032
0x09 (9)TYPE_ABSTRACT_VERSION64 B2 / 30.95 / 11.33 ms"P3-00.02"50332d30302e3032
0x0A (10)TYPE_DEVICE_SYSTEM_VERSION64 B2 / 30.91 / 11.15 ms"P3-00.04" (Hardware Version)50332d30302e3034
0x0B (11)TYPE_CALI_VERSION64 B2 / 30.64 / 10.42 ms"P3-00.02"50332d30302e3032
0x0C (12)TYPE_WHOLE_DATA_VERSION64 B2 / 30.82 / 11.00 ms"01.05"30312e3035
0x0D (13)TYPE_WHOLE_PACKAGE_VERSION50 B2 / 3167.2 / 177.26 ms"00.02.01.05" (reads SPI flash header)30302e30322e30312e3035
0x0E (14)TYPE_SENSOR_TYPE64 B2 / 30.84 / 10.90 ms"RTC2121W" (Raytron 256×192 12µm VOx)5254433231323157
0x0F (15)TYPE_SENSOR_ID64 B2 / 30.73 / 10.74 ms"P3"5033
0x10 (16)TYPE_EXT_1664 B2 / 30.81 / 10.89 ms"P3-00.01" (PCBA Revision)50332d30302e3031
0x11..0x14TYPE_EXT_17..2064 B4 / 01.34 / 9.30 msRejected (E/std cmd:basic_device_info_get error)—

2. The 18-Byte CRC16-CCITT Vendor Control Protocol (libircmd020.so)

2.1 Exact 18-Byte Command Header Layout

All register reads and writes use an 18-byte command packet sent over EP0 with bmRequestType = 0x41 (Host-to-Interface, Vendor), bRequest = 0x20, wValue = 0x0000, wIndex = 0x0000. Note the mixed endianness: cmdType at bytes [0..1] is Big-Endian, while param, register, respLen, and crc16 are Little-Endian:

Byte:   0      1      2      3      4      5     6..11    12     13     14     15     16     17
     +------+------+------+------+------+------+--------+------+------+------+------+------+------+
     | cmdType BE  |  param LE   | register LE | 0x00*6 | respLen LE  |0x00*2| CRC16-CCITT (LE)   |
     | [0]  | [1]  | [2]  | [3]  | [4]  | [5]  | [6..11]| [12] | [13] |14..15| [16] | [17]        |
     +------+------+------+------+------+------+--------+------+------+------+------+------+------+
     |<----------------- CRC16-CCITT (poly=0x1021, init=0x0000) over bytes 0..15 ---------------->|

2.2 The 4-Step EP0 Control Transfer Sequence & UART Console Log Extraction

StepbmRequestTypebRequestDir / LenBehavior, Status Bitmask & Firmware UART Log
1. Write Command0x410x20OUT / 18 BSends the 18-byte CRC16-protected header (wValue=0, wIndex=0). Always enforce ≥ 10 ms spacing since the last EP0 transaction.
2. Poll Status0xC10x22IN / 1 B or 192 BPoll up to 8 times (2 ms sleep) until bit 0 (BUSY = 0x01) clears:
• 0x01: Busy (keep polling)
• 0x02: Accepted / Ready for Data Read
• 0x03: Write Complete
• 0x04: Error / Rejected Opcode (do not read 0x21 or you will get stale SRAM bytes!)
Bonus: Reading 192 bytes from 0xC1/0x22 returns the camera's internal RT-Thread UART console log in ASCII at byte offset [64..191] (e.g. I/std cmd:basic_gain_set=1)!
3. Read Payload (reads only)0xC10x21IN / respLenReads the requested respLen bytes (1..64 B) when Step 2 returned 0x02.
4. Status Ack (reads only)0xC10x22IN / 1 BFinal status read acknowledging transfer completion (returns 0x03).
// Kotlin: Exact 18-byte command builder + CRC16-CCITT (XMODEM)
fun crc16Ccitt(data: ByteArray, length: Int = 16): Int {
    var crc = 0x0000
    for (i in 0 until length) {
        crc = crc xor ((data[i].toInt() and 0xFF) shl 8)
        for (bit in 0 until 8) {
            crc = if ((crc and 0x8000) != 0) ((crc shl 1) xor 0x1021) and 0xFFFF
                  else (crc shl 1) and 0xFFFF
        }
    }
    return crc
}

fun buildCommand(cmdType: Int, param: Int, register: Int = 0, respLen: Int = 0): ByteArray {
    val b = ByteArray(18)
    b[0] = ((cmdType ushr 8) and 0xFF).toByte() // Big-Endian
    b[1] = (cmdType and 0xFF).toByte()
    b[2] = (param and 0xFF).toByte()            // Little-Endian
    b[3] = ((param ushr 8) and 0xFF).toByte()
    b[4] = (register and 0xFF).toByte()         // Little-Endian
    b[5] = ((register ushr 8) and 0xFF).toByte()
    b[12] = (respLen and 0xFF).toByte()         // Little-Endian
    b[13] = ((respLen ushr 8) and 0xFF).toByte()
    val crc = crc16Ccitt(b, 16)
    b[16] = (crc and 0xFF).toByte()             // Little-Endian CRC16
    b[17] = ((crc ushr 8) and 0xFF).toByte()
    return b
}

2.3 Complete Reverse-Engineered Register Command Table (libircmd020.so)

cmdType (BE)param (LE)R/Wregister (hdr[4..5])respLenSDK Function Name & Verified Hardware Behavior
0x01010x0081Read0x0001..0x001010..64 Bbasic_device_info_get(subId) — Device Name, FW, PN, SN, Sensor Type (see Table 1.2)
0x01010x0082Read0x00001 Bbasic_device_current_status_get — returns 0x02 when streaming is active
0x01010x0083Read0x00001 Bbasic_long_time_vdcmd_state_get — returns 0x00 when idle
0x010A0x0081 / 0x0082Read0x00002 BLegacy AC010/P2 Vtemp / shutter_vtemp (uint16 LE / 64.0 - 273.15 °C)
0x010C0x0081Read0x00007 Brtc_current_time_get_to_second — [sec, min, hr, day, mon, yr_lo, yr_hi] since boot
0x010F0x0086Read0x00002 Badv_device_realtime_status_get — Raw 14-bit FPA NTC thermistor ADC (Vtemp = 8,469), bit-exact match with InfoLine Word 65
0x010F0x0045Write0=Close, 1=Open0 Badv_shutter_tab_close (0) / adv_shutter_tab_open (1) — direct mechanical shutter blade actuation
0x01260x0081Read0=High, 1=Low6 Badv_tpd_k_b_addr_ca_get_new — returns K_temp = -13,696 (int16), B_temp = 11,464 (uint16), Address_CA = 2,330 (uint16)
0x01260x0048 / 0x0088W / R0=LUT, 1=Bypass0 / 1 Badv_bypass_nuct_set / get — 1 bypasses NUC >> 1 and NUC-T LUT, emitting raw 14-bit NUC counts (-38.62 mK/LSB)!
0x01260x008ARead0x0000 / 0x00016 Badv_tpd_parameters_get — returns [-13732, 11488, 2330] (0) / [-13696, 11464, 2330] (1)
0x012C0x0049 / 0x0083W / R0=Off, 1=On0 / 1 Bmirror_status_set / get — horizontal mirror flip
0x012F0x0081Read0x00001 Bbasic_gain_get & CMD_START_STREAM — returns 1 (High Gain -20..150°C) or 0 (Low Gain 0..600°C); also triggers stream start in Steps 1 & 6!
0x012F0x0041Write1=High, 0=Low0 Bbasic_gain_set — switches hardware gain (1 = High Gain -20..150°C, 0 = Low Gain 0..600°C; ~1.5s switch)
0x01310x0082Read0x00001 Bbasic_center_zoom_get — returns 10 (1.0× zoom × 10)
0x01350x0041 / 0x0081W / R0=Off, 1=On0 / 1 Bflip_status_set / get — vertical image flip
0x01360x0043Write0x00000 Badv_manual_ffc_update (CMD_SHUTTER) — actuates mechanical shutter and updates NUC flat-field table
0x10020x0041 / 0x0081W / R0=Off, 1=On0 / 1 Bbasic_auto_ffc_status_set / get — disables (0) or enables (1) the periodic 90s automatic shutter click
0x10020x0042 / 0x0082W / R0x0100..0x01022 Bbasic_auto_ffc_current_params — 0x0100: ΔVtemp = 25 (0.25°C), 0x0101: min interval 5 s, 0x0102: max interval 90 s
0x10020x0043Write0x00000 Bbasic_ffc_update — standard FFC update
0x10030x0045 / 0x0085W / R0..N0 / 1 Bbasic_palette_idx_set / get — internal AGC Y8 pseudo-color mode (0 = White Hot)
0x10040x0044 / 0x0084W / R0..100 (set) / 1 (get)0 / 1 Bbasic_current_image_noise_reduction_level_set / get (default 50)
0x10040x0045 / 0x0085W / R0..100 (set) / 1 (get)0 / 1 Bbasic_current_detail_enhance_level_set / get — DDE detail enhancement (default 50)
0x10040x0047 / 0x0087W / R0..100 (set) / 1 (get)0 / 1 Bbasic_current_brightness_level_set / get — AGC brightness (default 50)
0x10040x0048 / 0x0088W / R0..100 (set) / 1 (get)0 / 1 Bbasic_current_contrast_level_set / get — AGC contrast (default 50)
0x10040x004B / 0x008BW / R0..100 (set) / 1 (get)0 / 1 Bbasic_space_noise_reduce_level_set / get (SNR) — set to 0 in HIGH_PRECISION mode (default 50)
0x10040x004C / 0x008CW / R0..100 (set) / 1 (get)0 / 1 Bbasic_time_noise_reduce_level_set / get (TNR) — set to 0 in HIGH_PRECISION mode to eliminate integer IIR truncation (default 50)
0x10040x004D / 0x008DW / R0..100 (set) / 1 (get)0 / 1 Bbasic_current_gamma_level_set / get (default 50)
0x10070x0042 / 0x0082W / RKelvin (298 = 24.85°C)0 / 2 Badv_env_correct_tu_set / get — Reflected temperature Tu in Kelvin
0x10070x0043 / 0x0083W / RKelvin (298 = 24.85°C)0 / 2 Badv_env_correct_ta_set / get — Atmospheric temperature Ta in Kelvin
0x10070x0044 / 0x0084W / RQ14 (16384 = 1.000)0 / 2 Badv_env_correct_ems_set / get — Emissivity ε × 16384
0x10070x0045 / 0x0085W / RQ14 (16384 = 1.000)0 / 2 Badv_env_correct_tau_set / get — Optical/atmospheric transmittance τ × 16384
0x10070x0046 / 0x0086W / R0=Off, 1=On0 / 1 Badv_env_correct_switch_set / get — In-camera env correction switch (0 by default; computed in float on host)
0x10100x004DWrite0x00000 BAlternative stream control command (basic_preview_start)
0x10100x0091Read0x00002 Bbasic_device_temp_get (uint16 LE / 100.0 °C)
0x10100x0093Read0x00004 Badv_powered_time_get — uint32 LE uptime in seconds since VBUS power-on
0x10210x0081Read0x00002 Beeprom_system_data_update_flag_read (CMD_STATUS) — returns ASCII "11" (0x3131) when healthy

3. Exact 6-Step Stream Handshake & USB Hub Self-Healing Architecture

3.1 The Mandatory 6-Step Stream Start Sequence

  1. Initial Start CommandClaim Interface 0 (ctrl) and Interface 1 (alt=0). Read identity registers, then send CMD_START_STREAM (0x012F/0x0081, respLen=1).
  2. Wait 1,000 msMandatory Thread.sleep(1000) before switching the streaming interface alternate setting.
  3. Altsetting 1 + 0xEE FIFO ResetClaim Interface 1 (alt=1), call setInterface(iface1_alt1), and send vendor control controlTransfer(0x40, 0xEE, 0, 1, null, 0, 1000).
  4. Wait 2,000 msMandatory Thread.sleep(2000) while the P3 ASIC initializes its VSYNC frame generator and bulk DMA engine.
  5. 150 ms Async Flush ReadPerform one throwaway bulkTransfer(epIn0x81, flushBuf, 16384, 150) to drain any power-on FIFO garbage.
  6. Final Start Command (No setInterface!)Send CMD_START_STREAM (0x012F/0x0081, respLen=1) one final time. Never call setInterface here or the 2s timer resets!
// Copy-Pasteable Kotlin Handshake (from P3UsbDriver.kt):
// Step 1: Initial start_stream command
sendCommandAndReadResponse(P3Protocol.CMD_START_STREAM, 1)

// Step 2: Mandatory 1000ms wait before switching interface altsetting
Thread.sleep(1000)

// Step 3: Enable streaming interface (interface 1, altsetting 1) + 0xEE vendor FIFO reset
synchronized(controlLock) {
    conn.claimInterface(foundActive, true)
    conn.setInterface(foundActive)
    conn.controlTransfer(0x40, 0xEE, 0, 1, null, 0, 1000)
}

// Step 4: Mandatory 2000ms wait for sensor stream ready
Thread.sleep(2000)

// Step 5: Async flush read
val flushBuf = ByteArray(16384)
conn.bulkTransfer(foundEpIn, flushBuf, flushBuf.size, 150)

// Step 6: Final start_stream command (do NOT re-call setInterface here!)
sendCommandAndReadResponse(P3Protocol.CMD_START_STREAM, 1)

3.2 The 4 USB Hub Failure Modes & Self-Healing Recipes

Failure Mode on USB-C HubRoot CauseExact Self-Healing Code Recipe (P3UsbDriver.kt)
1. Powered Hub Replug EP0 Wedge When unplugging the hub from the phone while PD power stays plugged into the hub, the P3 never loses 5V VBUS. On reconnect, CMD_READ_NAME returns "" because EP0 is stuck in its previous session state. If readRegisterString(CMD_READ_NAME, 30) is empty on connect, invoke conn.javaClass.getMethod("resetDevice").invoke(conn) via reflection, Thread.sleep(600), re-claim ctrlIface and initialStreamIface, and re-read registers.
2. Hub TT Glitch / Brief Bulk Stall A USB-C PD voltage renegotiation or slight physical cable wiggle drops 1–2 bulk transfers on EP 0x81. On consecutiveTimeouts == 2 (2 × 750 ms), execute an in-place poke: conn.controlTransfer(0x40, 0xEE, 0, 1, null, 0, 500) + sendCommandAndReadResponse(CMD_START_STREAM, 1) + parser.reset(). Do not call setInterface.
3. Persistent USB Disconnect / Re-Enumeration Longer cable tug causes the kernel to re-enumerate the device at a new /dev/bus/usb/001/00X path (device.deviceName changes). On consecutiveTimeouts >= 6 or prevDeviceName != device.deviceName, break out of runSingleUsbSession, close handles, sleep 350 ms, and let runUsbSupervisor re-open the new UsbDevice and re-apply P3Mode settings.
4. Bump-Induced Stuck Shutter / Flat FFC A physical jolt mid-FFC leaves the mechanical shutter blade partially obstructing the FPA or corrupts the NUC flat-field reference. In STREAMING mode (!nuctBypass), if (spanC < 0.30f && stdDevC < 0.05f) || (fpaTempC > 34.5f && stdDevC < 0.35f) persists for 75 consecutive frames (3.0 s) and >= 20 s have elapsed since the last recovery, automatically fire triggerShutter() (0x0136/0x0043).

4. Bulk Frame Wire Format (197,656 B) & Resynchronizing Parser

4.1 Exact Dual-Plane Wire Layout (197,656 Bytes @ 25.00 Hz)

Every 40.00 ms, the P3 transmits one 197,656-byte frame over EP 0x81 IN (arriving as 12.064 × 16,384-byte bulk reads at 4.94 MB/s):

+-----------------------------------------------------------------------+ Byte 0
| 12-Byte Start Marker:                                                 |
|   [0]      = 0x0C (marker length = 12 bytes)                          |
|   [1]      = 0x8C (even frame) or 0x8D (odd frame)                    |
|   [2..5]   = startCnt1 (uint32 LE, hardware clock stamped at VSYNC)   |
|   [6..9]   = startCnt2 (uint32 LE, hardware clock stamped at DMA)     |
|   [10..11] = startCnt3 (uint16 LE, 1.000 kHz ms clock mod 2048)       |
+-----------------------------------------------------------------------+ Byte 12 (Payload Offset 0)
| Plane 0: Rows 0..191 — 8-Bit Hardware AGC IR Plane (98,304 Bytes)     |
|   256 x 192 pixels x 2 bytes/pixel (YUYV):                            |
|   • Even byte (low): 8-bit hardware AGC display luminance (0..253)    |
|   • Odd byte (high): Constant 0x80 (128 neutral chroma)               |
+-----------------------------------------------------------------------+ Byte 98,316 (Payload Offset 98,304)
| Plane 1: Rows 192..193 — Embedded InfoLine Telemetry (1,024 Bytes)    |
|   512 x uint16 LE words (2 rows x 256 words):                         |
|   • Vtemp raw 14-bit NTC ADC (Word 65), frameCount (Word 64 & 180)    |
|   • Hardware ISP min/max (1/64 K) & pixel coordinates (Bytes 140..152)|
|   • 14-bit (1/16 K) Y14 scene min/max/mean statistics (Words 165..179)|
+-----------------------------------------------------------------------+ Byte 99,340 (Payload Offset 99,328)
| Plane 2: Rows 194..385 — 16-Bit Thermal / NUC Plane (98,304 Bytes)    |
|   256 x 192 pixels x 2 bytes/pixel (uint16 LE):                       |
|   • nuct_bypass=0: Calibrated 1/64 K (T_C = raw / 64.0 - 273.15)      |
|   • nuct_bypass=1: Un-halved 14-bit NUC << 2 (-38.62 mK / NUC LSB)    |
+-----------------------------------------------------------------------+ Byte 197,644 (Payload Offset 197,632)
| 12-Byte End Marker:                                                   |
|   [0]      = 0x0C (marker length = 12 bytes)                          |
|   [1]      = 0x8E (even frame) or 0x8F (odd frame) [= startSync + 2]  |
|   [2..5]   = endCnt1 (uint32 LE, MUST equal startCnt1!)               |
|   [6..9]   = endCnt2 (uint32 LE, hardware clock at frame end)         |
|   [10..11] = endCnt3 (uint16 LE, 1.000 kHz clock mod 2048, +40/frame) |
+-----------------------------------------------------------------------+ Byte 197,656

4.2 Hardware Clock Frequencies & Exact Drop Detection

4.3 Why 204,800-Byte NUC Transition Frames Occur & Bounded Resynchronization

When a manual or automatic shutter FFC (0x0136/0x0043) executes, the ASIC aborts the current frame after 9,204 bytes and immediately starts a new frame inside a single 204,800-byte (200 KiB) bulk transfer (7,144 bytes longer than 197,656). P3FrameParser.feedAndPopAll (in P3Protocol.kt) handles this seamlessly without losing stream lock:

  1. Scan buf from searchOffset for a candidate start marker: buf[i] == 0x0C and buf[i+1] in {0x8C, 0x8D}.
  2. Wait until at least WIRE_SIZE_P3 (197,656) bytes are buffered starting at markerIdx.
  3. Inspect the candidate End Marker at endMarkerOffset = markerIdx + 12 + 197,632: verify that buf[endMarkerOffset] == 0x0C, endSync == startSync + 2 (0x8C -> 0x8E or 0x8D -> 0x8F), and endCnt1 == startCnt1.
  4. On the 204,800-byte NUC transition packet, the aborted 9,204-byte prefix fails the endMarkerOffset validation check (corruptCandidates++), so searchOffset advances by 1 byte, finds the true new frame's 0x0C 0x8C/0x8D marker at offset +9,204, and pops the complete valid frame with zero frame drops!

5. Complete 1,024-Byte InfoLine Memory Map (Rows 192–193)

Rows 192..193 (payload bytes 98,304..99,327, 512 × uint16 LE words) carry real-time hardware ISP telemetry in every frame with zero EP0 polling overhead. Cross-referencing live 100-frame captures against com.energy.ac020library.IrcamEngine.getInfoLineBean([B) reveals a critical alignment trap at byte 140 (frameInfoEn is a 1-byte boolean, shifting the 6 global min/max fields to odd byte offsets 141..152!):

RegionByte Offset (0..1023)Word IndexType & AlignmentLive Value (SN: P3025043...)Field Name & Verified Hardware Meaning
Region Abytes 0..41Words 0..2042 B (even)All 0x00Zeroed header block
byte 42Word 21 louint80 (when Auto-FFC off)ffcCountdown: Countdown timer to next automatic shutter actuation
byte 82Word 41 louint80 (normal)sunProtectFlag: Software sun-burn protection status (0 = normal)
Region Bbytes 128..129Word 64uint16 LE (even)30,078 .. 30,177frameCount: ISP frame counter (Δ = +1 per frame)
bytes 130..131Word 65uint16 LE (even)8,469 -> 8,432 (0x2115)vtempRawAdc: Raw 14-bit FPA NTC thermistor ADC reading. Matches register 0x010F/0x0086 (8,469) to the exact bit! Decreases as FPA warms up.
bytes 132..135Words 66..672 × uint16 LE0x0000Reserved (0)
bytes 136..137Word 68uint16 LE (even)18,696 .. 18,736 (19.2 °C)Scene Mean (1/64 K Y16 scale): Always equals 4 × Word 176 (raw / 64.0 - 273.15)
byte 138Word 69 louint80 (normal)hardwareSunProtectFlag (1 = laser/sun burn detected, 0 = normal)
byte 140Word 70 louint8 (1 byte!)1 (true)frameInfoEn: Global min/max tracking enabled. Shifts bytes 141..152 to odd alignment!
bytes 141..142(odd offset)uint16 LE (& 0xFFF)202 .. 204maxTempX: Hottest pixel X coordinate (0..255). Bit-exact match with argmax(thermalRaw) on 100/100 frames.
bytes 143..144(odd offset)uint16 LE (& 0xFFF)124 .. 129maxTempY: Hottest pixel Y coordinate (0..191). Bit-exact match with argmax(thermalRaw) on 100/100 frames.
bytes 145..146(odd offset)uint16 LE (1/64 K)19,368 .. 19,396 (29.7 °C)maxTemp: Hottest pixel raw 1/64 K value. 0 LSB error vs max(thermalRaw) across 100/100 frames.
bytes 147..148(odd offset)uint16 LE (& 0xFFF)252 .. 255minTempX: Coldest pixel X coordinate (0..255). Bit-exact match with argmin(thermalRaw).
bytes 149..150(odd offset)uint16 LE (& 0xFFF)22 .. 57minTempY: Coldest pixel Y coordinate (0..191). Bit-exact match with argmin(thermalRaw).
bytes 151..152(odd offset)uint16 LE (1/64 K)18,404 .. 18,456 (14.8 °C)minTemp: Coldest pixel raw 1/64 K value. 0 LSB error vs min(thermalRaw) across 100/100 frames.
Region Cbytes 153..159(unaligned)1B + 3×uint16p0InfoEn = 0Point Probe P0: [153]=en, [154..155]=x, [156..157]=y, [158..159]=temp
bytes 160..166(unaligned)1B + 3×uint16p1InfoEn = 0Point Probe P1: [160]=en, [161..162]=x, [163..164]=y, [165..166]=temp
Note: bytes 164..167 (w82_b164) also holds a 100.0 Hz (10 ms) RT-Thread OS tick counter when probes are disabled.
bytes 227..249(unaligned)23 B structr0InfoEn = 0Region ROI Probe R0: [227]=en, [232..237]=max(x,y,T), [238..243]=min(x,y,T), [244..245]=avgTemp
Region Dbytes 304..329Words 152..16426 B (even)All 0x0000Zero padding (0)
Region Ebytes 330..335Words 165..1673 × uint16 LE4,880, (203, 126)14-Bit (1/16 K Y14) Scene Max (Word 165) and (X, Y) coordinates (Words 166, 167)
bytes 336..341Words 168..1703 × uint16 LE4,551, (254, 38)14-Bit (1/16 K Y14) Scene Min (Word 168) and (X, Y) coordinates (Words 169, 170)
bytes 346..351Words 173..1753 × uint16 LE4,644 / 4,882 / 4,54914-Bit (1/16 K Y14) ROI Ref (w173), Unfiltered Max (w174), Unfiltered Min (w175)
bytes 352..353Word 176uint16 LE (even)4,678 (19.23 °C)14-Bit (1/16 K Y14) Scene Mean (T_C = w176 / 16.0 - 273.15; Word 68 == Word 176 × 4)
bytes 358..359Word 179uint16 LE (even)158 .. 18814-Bit (Y14) AGC Span / Contrast Metric (≈ (maxY14 − minY14) / 2)
bytes 360..361Word 180uint16 LE (even)30,257 .. 30,356frameCnt: Hardware VSYNC frame counter (Δ = +1 on 99/99 intervals, zero jitter)
bytes 364..365Word 182uint16 LE (even)0x0A0A ("\n\n")End-of-InfoLine sentinel
Region Fbytes 366..1023Words 183..511658 B (329 words)All 0x00FF (255)Static unused tail filled with 0x00FF

6. Radiometric Pipeline, Calibration Binary & HIGH_PRECISION Sub-LSB Mode

6.1 In-Camera ISP Pipeline & Why Bits 0..1 Are Always Zero (62.50 mK Step)

Disassembly of ktbt_correct_process (0x30d68) and remap_temp (0x27e64) in libadvirtempac020.so revealed the exact fixed-point pipeline running inside the P3 ISP:

Raw 14-Bit Bolometer ADC
   │
   ▼  (Per-pixel shutter OOC / B flat-field offset + factory gain correction)
NUC_raw (14-bit unsigned: 0..16383, where -1 LSB = +38.62 mK hotter)
   │
   ▼  Stage 1: FPA Thermistor Drift Compensation (ktbt_correct_process)
   │  Using Vtemp (InfoLine Word 65 = 8469) & 0x0126/0x0081 (K_temp=-13696, B_temp=11464, Addr_CA=2330):
   │    idx0 = clamp(((Vtemp * K_temp + 8192) >> 14) + B_temp, 0, 4095)
   │    idx  = clamp(idx0 - Address_CA - 1, 0, 1200)
   │    NUC_ktbt = clamp(((NUC_raw * K_T[idx] + 8192) >> 14) + B_T[idx], 0, 16383)
   │
   ├──► If adv_bypass_nuct_set(1) [0x0126 / 0x0048, reg=1]:
   │       Emits raw_u16 = (NUC_ktbt << 2) directly!
   │       • Preserves all 14 bits (0..16383) with ZERO LUT rounding!
   │       • Sensitivity: -38.62 mK / LSB (25.9 LSBs / °C — 1.7x finer!)
   │
   ▼  If adv_bypass_nuct_set(0) [Default STREAMING Mode]:
   │    1. Right-shifts by 1 bit: lut_idx = (NUC_ktbt >> 1)  [0..8191, discards bit 0!]
   │    2. Indexes 8,192-entry NUC-T table in 1/16 K (62.50 mK) units: T_Y14 = NUC_T[lut_idx]
   │    3. Left-shifts by 2 bits into 1/64 K container: raw_u16 = (T_Y14 << 2)
   ▼
Wire Plane 2 (raw_u16 % 4 == 0 for 100% of pixels; T_C = raw_u16 / 64.0 - 273.15)

6.2 Factory Calibration Structures & Stefan-Boltzmann T4 Correction

6.3 HIGH_PRECISION Sub-LSB Differential Mode (< 10 mK Resolution)

To detect sub-10 millikelvin thermal transients (e.g., 1 mA PCB leakage or subtle physiological/material heating) that are normally invisible inside the P3's 62.50 mK LUT steps, HighPrecisionTracker (in P3Protocol.kt) reconfigures four hardware registers and runs a floating-point lock-in pipeline:

Pipeline Stage / RegisterSTREAMING Mode (Default)HIGH_PRECISION Differential Mode
1. NUC-T LUT Bypass (0x0126 / 0x0048)nuct_bypass = 0 (NUC >> 1 → 1/16 K LUT)nuct_bypass = 1 (Un-halved 14-bit NUC, -38.62 mK/LSB)
2. Temporal Noise Filter (0x1004 / 0x004C)TNR = 50 (integer IIR truncates sub-LSB fractional bits)TNR = 0 (Disabled — preserves natural 1.07 LSB Gaussian dither!)
3. Spatial Noise Filter (0x1004 / 0x004B)SNR = 50 (blurs 3×3 neighborhood)SNR = 0 (Disabled — preserves single-pixel point sources)
4. 90s Auto-Shutter (0x1002 / 0x0041)autoFfc = 1 (clicks every 5–90s)autoFfc = 0 (Disabled — uninterrupted baseline lock)
5. Reference Tare N̄ref(x,y)None64-frame (2.56 s) Float64 accumulator (σref ≈ 5.1 mK)
6. Stochastic Dither EMA N̄ema(x,y,t)NoneContinuous Float32 EMA (α = 0.10, zero integer truncation)
7. Common-Mode Drift ΔCM(t)Mechanical shutter every 90s1,024-sample Interquartile Mean (IQR 25%..75%) subtraction
Output Units & Noise FloorAbsolute °C (62.50 mK step, 33.8 mK NETD)Differential mK (ΔTmK = −(N̄ema − N̄ref − ΔCM) × 38.62 mK, ~8–10 mK RMS)

7. Static-Scene Temporal-Difference Compression (P3DF / /stream.diff.z) & API Reference

7.1 Why Naive Delta Fails & The 4-Stage P3DF Algorithm

Uncompressed dual-plane streaming requires 39.54 Mb/s at 25 FPS, which saturates distant Wi-Fi access points (Studio45 AP B is 27–61 Mb/s) and exceeds the 27–36 Mb/s shared internet upload ceiling. P3DiffCompressor (in P3Protocol.kt) (GET /stream.diff.z) achieves 19.7× lossless compression (2.00 Mb/s) and up to 325.1× noise-gated compression (0.12 Mb/s) in 4 stages:

  1. Zero-Padding Bit Strip (v14 = raw_u16 ushr 2): Both nuct_bypass=0 and nuct_bypass=1 left-shift their 14-bit sensor words by 2 bits (bits 0..1 are always 0). Right-shifting by 2 bits before prediction losslessly removes 2 bits of padding. (In submk=1 mode, encodes round(diffMilliK * 2.0) in 0.5 mK = 500 µK steps.)
  2. Closed-Loop DPCM Predictor (d = v14[t] - recon[t-1]): Computes temporal deltas against the decoder's exact reconstructed state recon. When deadband = 0, reconstruction is 100% bit-exact lossless. When deadband = k (1..3 LSBs), noise fluctuations with |d| ≤ k emit 0, while any true thermal drift exceeding k updates recon += d, guaranteeing strictly bounded error (≤ k LSBs) and zero long-term drift accumulation.
  3. ZigZag Sign Folding (zz = (d shl 1) xor (d shr 31)): In two's complement int16, -1 is 0xFFFF while +1 is 0x0001. ZigZag maps 0 -> 0, -1 -> 1, +1 -> 2, -2 -> 3, +2 -> 4, making the high byte 0x00 for all |Δ| ≤ 127.
  4. Plane Byte-Shuffling + InfoLine XOR-Delta + zlib: Packs all 49,152 low bytes contiguously (offset 1088..50239) followed by all 49,152 high bytes (offset 50240..99391, which is 100% zeros on static scenes!), XOR-deltas the 1,024-byte InfoLine (offset 64..1087, 99.4% zeros), and compresses the 99,392-byte buffer with Deflater.

7.2 Exact P3DF 64-Byte Header & Payload Layout (99,392 B Decompressed)

Each frame on /stream.diff.z is preceded by a 4-byte Little-Endian compressed length uint32 LE comp_len, followed by comp_len bytes of zlib data that decompress to 99,392 bytes:

struct P3DF_Packet { // Unpacks in Python via: struct.unpack("<4sBBBBqqqqqihhfBBH", raw[:64])
    char     magic[4];        // [0..3]   "P3DF" (or "P3FZ" on /stream.raw.z, "P3FR" on /stream.raw)
    uint8_t  version;         // [4]      1
    uint8_t  frameType;       // [5]      0 = KEYFRAME (I-frame), 1 = DELTA_PREV (P-frame)
    uint8_t  wireMode;        // [6]      0 = STREAMING_14B (1/16 K), 1 = HIGH_PREC_NUC14 (-38.62 mK), 2 = SUBMK_0.5MK
    uint8_t  deadband;        // [7]      DPCM deadband threshold in LSBs (0 = bit-exact lossless)
    int64_t  sequence;        // [8..15]  Monotonic frame sequence number
    int64_t  monotonicNs;     // [16..23] Host System.nanoTime()
    int64_t  wallNs;          // [24..31] Host wall-clock nanoseconds
    int64_t  cnt1;            // [32..39] P3 hardware startCnt1 (15.0 MHz / 240 MHz prescaled)
    int64_t  cnt2;            // [40..47] P3 hardware startCnt2
    int32_t  cnt3;            // [48..51] P3 hardware endCnt3 (1.000 kHz ms clock mod 2048, +40/frame)
    int16_t  width;           // [52..53] 256
    int16_t  height;          // [54..55] 192
    float    hdrFloat;        // [56..59] fpaTempC (modes 0,1) or commonModeDriftMk (mode 2)
    uint8_t  flags;           // [60]     bit0=gainHigh, bit1=autoFfc, bit2=nuctBypass, bit3=tareReady
    uint8_t  emaAlphaPct;     // [61]     round(emaAlpha * 100)
    uint16_t tareFrames;      // [62..63] Current tare frame count
    uint8_t  infoDelta[1024]; // [64..1087]      Raw InfoLine on I-frame; XOR-delta vs prevInfo on P-frame
    uint8_t  loBytes[49152];  // [1088..50239]   Low bytes of 49,152 uint16 values (ZigZag on P-frame)
    uint8_t  hiBytes[49152];  // [50240..99391]  High bytes of 49,152 uint16 values (100% 0x00 on P-frames!)
};

7.3 Live Studio45 Wi-Fi Benchmarks (25.0 FPS from Pixel 9 Pro XL)

Stream Endpoint & ModeDeadbandMax ErrorAvg FrameP-FrameBitrate @ 25 FPSRatioFits Far AP B (27 Mb/s) & Cloud Upload (6–10 Mb/s)?
GET /stream.raw (Uncompressed Dual-Plane)0 (Lossless)0 mK197,696 B197,696 B39.54 Mb/s1.0×LAN near AP A only (chokes AP B & internet upload)
GET /stream.raw.z (Per-Frame zlib)0 (Lossless)0 mK38,835 B38,835 B7.77 Mb/s5.1×Yes (fits AP B & Studio45 upload budget)
GET /stream.diff.z (STREAMING)0 (100% Lossless)0 mK10,021 B9,843 B2.00 Mb/s19.7×Yes — 100% bit-exact lossless with 19× headroom!
GET /stream.diff.z (STREAMING)1 (±62.5 mK)1 LSB4,184 B3,887 B0.84 Mb/s47.2×Yes — 47× smaller, zero drift accumulation
GET /stream.diff.z (STREAMING)2 (±125 mK)2 LSB1,290 B934 B0.26 Mb/s153.2×Yes — 153× smaller
GET /stream.diff.z (STREAMING)3 (±187 mK)3 LSB608 B238 B0.12 Mb/s325.1×Yes — 325× smaller (238 B P-frames!)
GET /stream.diff.z (HIGH_PRECISION)0 (100% Lossless)0 mK17,857 B17,720 B3.57 Mb/s11.1×Yes — unfiltered 14-bit NUC (TNR=0, SNR=0)
GET /stream.diff.z (HIGH_PRECISION)1 (±38.6 mK)1 LSB11,842 B11,582 B2.37 Mb/s16.7×Yes
GET /stream.diff.z (HIGH_PRECISION)2 (±77.2 mK)2 LSB8,911 B8,591 B1.78 Mb/s22.2×Yes

7.4 Complete HTTP Server API Reference (Port 8080, ThermalHttpServer.kt)

HTTP EndpointFormat / Content-TypeDescription & Query Parameters
GET /stream.diff.zapplication/octet-stream (P3DF)25 FPS temporal-difference stream. Params: gop=50 (0..1000), deadband=0 (0..255 LSBs), level=6 (1..9), submk=0|1 (0.5 mK float diff mode).
GET /frame.diff.zapplication/octet-stream (P3DF)Single keyframe snapshot in P3DF format (level=6, submk=0|1).
GET /stream.raw.zapplication/octet-stream (P3FZ)25 FPS per-frame zlib-compressed stream (64B hdr + 1,024B InfoLine + 98,304B 16-bit plane, ~7.8 Mb/s).
GET /stream.rawapplication/octet-stream (P3FR)25 FPS uncompressed 197,696 B/frame stream (64B hdr + 197,632B full payload, 39.54 Mb/s).
GET /frame.rawapplication/octet-stream (P3FR)Single 197,696-byte uncompressed P3FR frame (X-Frame-Seq, X-Cnt1, X-Cnt3 headers).
GET /frame.csvtext/csv256×192 matrix of calibrated temperatures in °C (or mK if diff=1).
GET /frame.diff.csvtext/csv256×192 sub-LSB differential temperature matrix ΔTmK(x,y) in millikelvin (mK).
GET /stream.mjpegmultipart/x-mixed-replace25 FPS colorized MJPEG video stream (512×384 upscaled with HUD & crosshair).
GET /health (/api/health)application/jsonFast JSON health & version check: ok, connected, streaming, fps, framesRead, mode, nuctBypass, appVersion, versionCode, buildTimeUtc.
GET /api/statusapplication/jsonFull real-time JSON telemetry: preset (visualize, raw, raw_calibrated, high_precision, custom), fps, minC, maxC, meanC, stdDevC, fpaTempC, usb (sessionCount, inPlaceRecoveries, detachCount, kernelResets, lastEvent), highPrecision, compression, device, phone (LAN/Tailscale IPs, battery), spacesheep, logs.
GET /api/probeapplication/jsonExecutes live hardware register probe (0x0101/0x0081 sub-IDs 0..20, sn_info, pn_info, live_registers, control_polling_benchmark, and decoded info_line). Requires ?confirm=1.
GET|POST /api/controlapplication/jsonOne-shot presets & hardware actions:
• ?action=preset&name=visualize — live viewing & °C readout (nuct_bypass=0, TNR=50, SNR=50, auto_ffc=1)
• ?action=preset&name=raw — raw 14-bit NUC counts for offline Python processing (nuct_bypass=1, -38.62 mK/LSB, TNR=0, SNR=0, auto_ffc=0)
• ?action=preset&name=raw_calibrated — unsmoothed 1/64 K LUT output (nuct_bypass=0, 62.5 mK step, TNR=0, SNR=0, auto_ffc=0)
• ?action=preset&name=high_precision — on-phone ΔT lock-in (tare_frames=64, alpha=0.10, cm_correction=true)
• ?action=nuct_bypass&enabled=1|0 · ?action=shutter · ?action=auto_ffc&enabled=1|0
• ?action=gain&high=1|0 · ?action=noise&time=0..100&space=0..100
• ?action=emissivity&value=0.96&reflected=25.0 · ?action=palette&name=IRONBOW
• ?action=reset (fast <150 ms in-place softReset) · ?action=hard_reset (full USB re-handshake)
POST /api/upgradeapplication/jsonBinary APK self-upgrade endpoint over LAN or Tailscale (./deploy-phone.sh --http realme or curl -X POST -H "Authorization: Bearer $SPACESHEEP_API_KEY" --data-binary @app-debug.apk "http://<phone-ip>:8080/api/upgrade?sha256=<64-hex>"). Commits a PackageInstaller session with UPDATE_PACKAGES_WITHOUT_USER_ACTION and BootReceiver auto-restarts ThermalStreamService on MY_PACKAGE_REPLACED with zero screen taps.

7.5 Copy-Pasteable Python Client for /stream.diff.z (P3DF Decoder)

import urllib.request, zlib, struct, numpy as np

def stream_p3_diff(host="127.0.0.1", port=8080, gop=50, deadband=0, submk=0):
    """
    Streams 25 FPS temporal-difference compressed P3 frames (/stream.diff.z).
    - deadband=0: 100% bit-exact lossless (~2.00 Mb/s in STREAMING mode, ~3.57 Mb/s in HIGH_PRECISION mode)
    - deadband=1..3: closed-loop DPCM noise-gated (0.12 - 0.84 Mb/s, up to 325x compression, zero drift)
    - submk=1: streams Float64-accumulated ΔT quantized to 0.5 mK (500 µK) per LSB
    """
    url = f"http://{host}:{port}/stream.diff.z?gop={gop}&deadband={deadband}&submk={submk}"
    req = urllib.request.urlopen(url, timeout=10)
    recon = None
    info_bytes = np.zeros(1024, dtype=np.uint8)

    while True:
        hdr = req.read(4)
        if len(hdr) < 4:
            break
        (comp_len,) = struct.unpack("<I", hdr)
        comp_buf = b""
        while len(comp_buf) < comp_len:
            chunk = req.read(comp_len - len(comp_buf))
            if not chunk:
                return
            comp_buf += chunk

        raw = zlib.decompress(comp_buf)
        magic, ver, ftype, mode, db, seq, mono_ns, wall_ns, cnt1, cnt2, cnt3, w, h, hdr_f, flags, alpha_pct, tare_f = (
            struct.unpack("<4sBBBBqqqqqihhfBBH", raw[:64])
        )
        info_delta = np.frombuffer(raw, dtype=np.uint8, count=1024, offset=64)
        lo = np.frombuffer(raw, dtype=np.uint8, count=w * h, offset=1088).astype(np.uint16)
        hi = np.frombuffer(raw, dtype=np.uint8, count=w * h, offset=1088 + w * h).astype(np.uint16)
        u16 = (lo | (hi << 8)).reshape(h, w)

        if ftype == 0 or recon is None:  # Keyframe (I-frame)
            info_bytes[:] = info_delta
            if mode == 2:
                recon = ((u16 >> 1) ^ -(u16 & 1)).astype(np.int16)
            else:
                recon = u16.astype(np.int16)
        else:  # Delta frame (P-frame)
            info_bytes ^= info_delta
            delta = ((u16 >> 1) ^ -(u16 & 1)).astype(np.int16)
            recon = recon + delta

        if mode == 2:
            # Sub-mK differential mode: 0.5 mK per LSB
            diff_mk = recon.astype(np.float32) * 0.5
            yield seq, mode, comp_len, info_bytes.view("<u2"), recon, diff_mk
        elif mode == 1:
            # High-Precision raw 14-bit NUC mode (-38.62 mK / LSB)
            raw_u16 = (recon.astype(np.uint16) << 2)
            yield seq, mode, comp_len, info_bytes.view("<u2"), raw_u16, recon.astype(np.float64) * -38.62
        else:
            # Standard calibrated 1/64 K mode
            raw_u16 = (recon.astype(np.uint16) << 2)
            temp_c = (raw_u16.astype(np.float64) / 64.0) - 273.15
            yield seq, mode, comp_len, info_bytes.view("<u2"), raw_u16, temp_c

8. Remote Lab Operation, Tailscale OTA & USB Reliability on Realme 14T 5G

The lab camera host is a Realme 14T 5G (RMX5074, Tailscale host realme-14t-5g) acting as USB host for the Thermal Master P3 through a USB-C hub with charging pass-through on its single port. Measured on with the new hub: 1.39 A into the battery (≈5.3 W at 3.86 V), +28%/h with the P3 streaming and the screen off.

USB Disconnect & Microbump Recovery (v1.3.4+): Unplugging and replugging either the camera's USB port or the entire USB-C hub — or bumping the cable — recovers automatically without waking the phone screen (`android:turnScreenOn` is omitted so `keep_screen_on=false` stays dark):

Token-Authenticated Control, AES-256-GCM Telemetry & OTA When Tailscale Is Down (v1.3.9+): If Tailscale is down or its key expires while Wi-Fi or cellular LTE is up, the phone remains 100% controllable and upgradeable using your secret Spacesheep API key (~/.config/spacesheep/config.json) or its SHA-256 digest (token_sha256):

# 1A. Remote unattended OTA when Tailscale IS up (no ADB, zero screen taps, Bearer token + SHA-256 verified)
cd ~/git/thermal-android
./deploy-phone.sh --http realme

# 1B. Remote unattended OTA when Tailscale is DOWN (stages APK to private GH release ota-latest,
#     dispatches token_sha256-authenticated upgrade_url over thermal/p3-control, verifies SHA-256)
./deploy-phone.sh --spacesheep

# 2. Control & inspect the phone when Tailscale is DOWN (via thermal/p3-control & thermal/p3-telemetry)
./deploy-phone.sh --status
./deploy-phone.sh --cmd "action=preset&name=raw"
./deploy-phone.sh --cmd "action=preset&name=visualize"
./deploy-phone.sh --cmd "action=close_bg_apps"

# 3. Direct HTTP over Tailscale or LAN (port 8080)
curl -s http://realme-14t-5g:8080/health | python3 -m json.tool
curl -s -H "Authorization: Bearer $SPACESHEEP_API_KEY" http://realme-14t-5g:8080/api/status | python3 -m json.tool
curl -X POST -H "Authorization: Bearer $SPACESHEEP_API_KEY" "http://realme-14t-5g:8080/api/control?action=preset&name=raw"
curl -X POST -H "Authorization: Bearer $SPACESHEEP_API_KEY" "http://realme-14t-5g:8080/api/control?action=preset&name=visualize"

Part of the Thermal camera hub and AI Foundry hub. Live 30 FPS WebRTC stream & controls: thermal-master-p3-live-camera-stream. Source repository: ~/git/thermal-android (P3Protocol.kt, P3UsbDriver.kt, ThermalHttpServer.kt, BootReceiver.kt, THERMAL_MASTER_P3.md).