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.
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 It | Camera Registers Set | Raw Signal Output on /stream.raw.z | Live Video Preview (Spacesheep / MJPEG) |
|---|---|---|---|---|
1. visualize(alias: factory) |
Default for live viewing on Spacesheep, LAN browser, or phone screen. | nuct_bypass = 0TNR = 50, SNR = 50auto_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 = 1TNR = 0, SNR = 0auto_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 = 0TNR = 0, SNR = 0auto_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"
RTC2121W, 4.3 mm f/1.0 macro lens (8 mm to ∞)0x0126/0x0048 (1.7× finer than 62.5 mK LUT step)1.07 LSB Johnson noise dither + Float64 tare + IQR drift cancel2.00 Mb/s bit-exact lossless; 0.12 Mb/s closed-loop DPCM10.01 ms mean vendor register read while streaming 25 FPS with 0 dropsEvery major obstacle below cost hours of binary disassembly, USB packet sniffing, or live hardware debugging. Follow these 11 rules verbatim to avoid every trap:
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.
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].
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!
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).
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.
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.
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.
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.
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).
>> 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.
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.
SNInfo) DecodingVID): 0x3474 (13428, InfiRay / Raytron)PID): 0x45A2 (17826, Thermal Master P3) · 0x45C2 (P1)0xFF / 0xF0 (Vendor-Specific, not UVC 0x0E)id=0, alt=0): Control interface over Endpoint 0 (EP0)id=1, alt=0): Idle state (0 endpoints; stops bulk DMA)id=1, alt=1): Active streaming state with 1 endpoint: EP 0x81 IN (Bulk, 512 B maxPacketSize)RTC2121W + 4.3 mm f/1.0256 × 192 (49,152 pixels), 12 µm pitch, 8–14 µm LWIR, 25.00 Hz VSYNC4.3 mm, f/1.0 aperture, manual helicoid focus ring (8 mm macro to ∞, resolves 0201 SMD packages)40.0° (H) × 30.2° (V), IFOV = 12 µm / 4.3 mm = 2.79 mrad (0.160°/pixel)33.81 mK median across all 49,152 pixels (21.70 mK P5 quietest pixels, meeting the <35 mK spec)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.
SNInfo) Decoding Table (P3025043DG040200256)| SN Slice | Live Substring | SDK Field (CommonUtils.getSNInfo) | Decoded Hardware Meaning & Lookup Values |
|---|---|---|---|
[0..3] | "P30" | modelCode | Thermal Master P3 series (checked by MyIJpegUtils.isP3Image) |
[3..5] | "25" | resolution | 256 × 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" | gainType | Dual Gain (-20..150°C High Gain + 0..600°C Low Gain; "H"=High Gain only, "S"=Switchable) |
[9..10] | "G" | year | 2026 ('A'=2020, 'B'=2021, 'C'=2022, 'D'=2023, 'E'=2024, 'F'=2025, 'G'=2026) |
[10..12] | "04" | month | April (04) |
[12..14] | "02" | day | 2nd (02) → Manufactured 2026-04-02 |
[14..19] | "00256" | streamCode (Unit Serial) | Unit #256 of that day's production batch |
basic_device_info_get (0x0101 / 0x0081) Sub-IDs 0..20Every 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 Len | Status (s1/s2) | Direct / Paced Latency | Live Decoded Value | Live Raw Hex (Trimmed) |
|---|---|---|---|---|---|---|
0x00 (0) | TYPE_RESERVED_0 | 64 B | 4 / 0 | 0.54 / 9.57 ms | Rejected (E/std cmd:basic_device_info_get error) | — |
0x01 (1) | TYPE_DEVICE_NAME | 30 B | 2 / 3 | 0.88 / 12.14 ms | "P3" | 5033 |
0x02 (2) | TYPE_FIRMWARE_VERSION | 12 B | 2 / 3 | 0.79 / 11.42 ms | "00.00.02.18" | 30302e30302e30322e3138 |
0x03 (3) | TYPE_CUSTOMER_ID | 10 B | 2 / 3 | 0.68 / 10.61 ms | "01.00.00" (Protocol Version) | 30312e30302e3030 |
0x04 (4) | TYPE_VENDOR_ID | 16 B | 2 / 3 | 0.76 / 11.48 ms | 0x3474 (13428 uint16 LE) | 7434 |
0x05 (5) | TYPE_PRODUCT_ID | 16 B | 2 / 3 | 0.71 / 10.72 ms | 0x45A2 (17826 uint16 LE) | a245 |
0x06 (6) | TYPE_DEVICE_PN | 64 B | 2 / 3 | 0.94 / 11.09 ms | "P30-1A00800007" | 5033302d31413030383030303037 |
0x07 (7) | TYPE_DEVICE_SN | 64 B | 2 / 3 | 1.12 / 11.63 ms | "P3025043DG040200256" | 50333032353034334447303430323030323536 |
0x08 (8) | TYPE_ISP_VERSION | 64 B | 2 / 3 | 0.89 / 11.06 ms | "P3-00.02" | 50332d30302e3032 |
0x09 (9) | TYPE_ABSTRACT_VERSION | 64 B | 2 / 3 | 0.95 / 11.33 ms | "P3-00.02" | 50332d30302e3032 |
0x0A (10) | TYPE_DEVICE_SYSTEM_VERSION | 64 B | 2 / 3 | 0.91 / 11.15 ms | "P3-00.04" (Hardware Version) | 50332d30302e3034 |
0x0B (11) | TYPE_CALI_VERSION | 64 B | 2 / 3 | 0.64 / 10.42 ms | "P3-00.02" | 50332d30302e3032 |
0x0C (12) | TYPE_WHOLE_DATA_VERSION | 64 B | 2 / 3 | 0.82 / 11.00 ms | "01.05" | 30312e3035 |
0x0D (13) | TYPE_WHOLE_PACKAGE_VERSION | 50 B | 2 / 3 | 167.2 / 177.26 ms | "00.02.01.05" (reads SPI flash header) | 30302e30322e30312e3035 |
0x0E (14) | TYPE_SENSOR_TYPE | 64 B | 2 / 3 | 0.84 / 10.90 ms | "RTC2121W" (Raytron 256×192 12µm VOx) | 5254433231323157 |
0x0F (15) | TYPE_SENSOR_ID | 64 B | 2 / 3 | 0.73 / 10.74 ms | "P3" | 5033 |
0x10 (16) | TYPE_EXT_16 | 64 B | 2 / 3 | 0.81 / 10.89 ms | "P3-00.01" (PCBA Revision) | 50332d30302e3031 |
0x11..0x14 | TYPE_EXT_17..20 | 64 B | 4 / 0 | 1.34 / 9.30 ms | Rejected (E/std cmd:basic_device_info_get error) | — |
libircmd020.so)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 ---------------->|
| Step | bmRequestType | bRequest | Dir / Len | Behavior, Status Bitmask & Firmware UART Log |
|---|---|---|---|---|
| 1. Write Command | 0x41 | 0x20 | OUT / 18 B | Sends the 18-byte CRC16-protected header (wValue=0, wIndex=0). Always enforce ≥ 10 ms spacing since the last EP0 transaction. |
| 2. Poll Status | 0xC1 | 0x22 | IN / 1 B or 192 B | Poll 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) | 0xC1 | 0x21 | IN / respLen | Reads the requested respLen bytes (1..64 B) when Step 2 returned 0x02. |
| 4. Status Ack (reads only) | 0xC1 | 0x22 | IN / 1 B | Final 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
}
libircmd020.so)cmdType (BE) | param (LE) | R/W | register (hdr[4..5]) | respLen | SDK Function Name & Verified Hardware Behavior |
|---|---|---|---|---|---|
0x0101 | 0x0081 | Read | 0x0001..0x0010 | 10..64 B | basic_device_info_get(subId) — Device Name, FW, PN, SN, Sensor Type (see Table 1.2) |
0x0101 | 0x0082 | Read | 0x0000 | 1 B | basic_device_current_status_get — returns 0x02 when streaming is active |
0x0101 | 0x0083 | Read | 0x0000 | 1 B | basic_long_time_vdcmd_state_get — returns 0x00 when idle |
0x010A | 0x0081 / 0x0082 | Read | 0x0000 | 2 B | Legacy AC010/P2 Vtemp / shutter_vtemp (uint16 LE / 64.0 - 273.15 °C) |
0x010C | 0x0081 | Read | 0x0000 | 7 B | rtc_current_time_get_to_second — [sec, min, hr, day, mon, yr_lo, yr_hi] since boot |
0x010F | 0x0086 | Read | 0x0000 | 2 B | adv_device_realtime_status_get — Raw 14-bit FPA NTC thermistor ADC (Vtemp = 8,469), bit-exact match with InfoLine Word 65 |
0x010F | 0x0045 | Write | 0=Close, 1=Open | 0 B | adv_shutter_tab_close (0) / adv_shutter_tab_open (1) — direct mechanical shutter blade actuation |
0x0126 | 0x0081 | Read | 0=High, 1=Low | 6 B | adv_tpd_k_b_addr_ca_get_new — returns K_temp = -13,696 (int16), B_temp = 11,464 (uint16), Address_CA = 2,330 (uint16) |
0x0126 | 0x0048 / 0x0088 | W / R | 0=LUT, 1=Bypass | 0 / 1 B | adv_bypass_nuct_set / get — 1 bypasses NUC >> 1 and NUC-T LUT, emitting raw 14-bit NUC counts (-38.62 mK/LSB)! |
0x0126 | 0x008A | Read | 0x0000 / 0x0001 | 6 B | adv_tpd_parameters_get — returns [-13732, 11488, 2330] (0) / [-13696, 11464, 2330] (1) |
0x012C | 0x0049 / 0x0083 | W / R | 0=Off, 1=On | 0 / 1 B | mirror_status_set / get — horizontal mirror flip |
0x012F | 0x0081 | Read | 0x0000 | 1 B | basic_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! |
0x012F | 0x0041 | Write | 1=High, 0=Low | 0 B | basic_gain_set — switches hardware gain (1 = High Gain -20..150°C, 0 = Low Gain 0..600°C; ~1.5s switch) |
0x0131 | 0x0082 | Read | 0x0000 | 1 B | basic_center_zoom_get — returns 10 (1.0× zoom × 10) |
0x0135 | 0x0041 / 0x0081 | W / R | 0=Off, 1=On | 0 / 1 B | flip_status_set / get — vertical image flip |
0x0136 | 0x0043 | Write | 0x0000 | 0 B | adv_manual_ffc_update (CMD_SHUTTER) — actuates mechanical shutter and updates NUC flat-field table |
0x1002 | 0x0041 / 0x0081 | W / R | 0=Off, 1=On | 0 / 1 B | basic_auto_ffc_status_set / get — disables (0) or enables (1) the periodic 90s automatic shutter click |
0x1002 | 0x0042 / 0x0082 | W / R | 0x0100..0x0102 | 2 B | basic_auto_ffc_current_params — 0x0100: ΔVtemp = 25 (0.25°C), 0x0101: min interval 5 s, 0x0102: max interval 90 s |
0x1002 | 0x0043 | Write | 0x0000 | 0 B | basic_ffc_update — standard FFC update |
0x1003 | 0x0045 / 0x0085 | W / R | 0..N | 0 / 1 B | basic_palette_idx_set / get — internal AGC Y8 pseudo-color mode (0 = White Hot) |
0x1004 | 0x0044 / 0x0084 | W / R | 0..100 (set) / 1 (get) | 0 / 1 B | basic_current_image_noise_reduction_level_set / get (default 50) |
0x1004 | 0x0045 / 0x0085 | W / R | 0..100 (set) / 1 (get) | 0 / 1 B | basic_current_detail_enhance_level_set / get — DDE detail enhancement (default 50) |
0x1004 | 0x0047 / 0x0087 | W / R | 0..100 (set) / 1 (get) | 0 / 1 B | basic_current_brightness_level_set / get — AGC brightness (default 50) |
0x1004 | 0x0048 / 0x0088 | W / R | 0..100 (set) / 1 (get) | 0 / 1 B | basic_current_contrast_level_set / get — AGC contrast (default 50) |
0x1004 | 0x004B / 0x008B | W / R | 0..100 (set) / 1 (get) | 0 / 1 B | basic_space_noise_reduce_level_set / get (SNR) — set to 0 in HIGH_PRECISION mode (default 50) |
0x1004 | 0x004C / 0x008C | W / R | 0..100 (set) / 1 (get) | 0 / 1 B | basic_time_noise_reduce_level_set / get (TNR) — set to 0 in HIGH_PRECISION mode to eliminate integer IIR truncation (default 50) |
0x1004 | 0x004D / 0x008D | W / R | 0..100 (set) / 1 (get) | 0 / 1 B | basic_current_gamma_level_set / get (default 50) |
0x1007 | 0x0042 / 0x0082 | W / R | Kelvin (298 = 24.85°C) | 0 / 2 B | adv_env_correct_tu_set / get — Reflected temperature Tu in Kelvin |
0x1007 | 0x0043 / 0x0083 | W / R | Kelvin (298 = 24.85°C) | 0 / 2 B | adv_env_correct_ta_set / get — Atmospheric temperature Ta in Kelvin |
0x1007 | 0x0044 / 0x0084 | W / R | Q14 (16384 = 1.000) | 0 / 2 B | adv_env_correct_ems_set / get — Emissivity ε × 16384 |
0x1007 | 0x0045 / 0x0085 | W / R | Q14 (16384 = 1.000) | 0 / 2 B | adv_env_correct_tau_set / get — Optical/atmospheric transmittance τ × 16384 |
0x1007 | 0x0046 / 0x0086 | W / R | 0=Off, 1=On | 0 / 1 B | adv_env_correct_switch_set / get — In-camera env correction switch (0 by default; computed in float on host) |
0x1010 | 0x004D | Write | 0x0000 | 0 B | Alternative stream control command (basic_preview_start) |
0x1010 | 0x0091 | Read | 0x0000 | 2 B | basic_device_temp_get (uint16 LE / 100.0 °C) |
0x1010 | 0x0093 | Read | 0x0000 | 4 B | adv_powered_time_get — uint32 LE uptime in seconds since VBUS power-on |
0x1021 | 0x0081 | Read | 0x0000 | 2 B | eeprom_system_data_update_flag_read (CMD_STATUS) — returns ASCII "11" (0x3131) when healthy |
ctrl) and Interface 1 (alt=0). Read identity registers, then send CMD_START_STREAM (0x012F/0x0081, respLen=1).Thread.sleep(1000) before switching the streaming interface alternate setting.alt=1), call setInterface(iface1_alt1), and send vendor control controlTransfer(0x40, 0xEE, 0, 1, null, 0, 1000).Thread.sleep(2000) while the P3 ASIC initializes its VSYNC frame generator and bulk DMA engine.bulkTransfer(epIn0x81, flushBuf, 16384, 150) to drain any power-on FIFO garbage.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)
| Failure Mode on USB-C Hub | Root Cause | Exact 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). |
197,656 B) & Resynchronizing Parser197,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
cnt3): endCnt3 is an exact 1.000 kHz (1.00 ms tick) counter wrapping modulo 2048 (0..2047). Because the sensor runs at 25.00 Hz (40.00 ms period), endCnt3 increments by +40 (mod 2048) every frame. Checking gap = (endCnt3 - ((lastEndCnt3 + 40) % 2048) + 2048) % 2048 gives hardware-exact dropped-frame detection (droppedFrames += gap / 40).cnt1, cnt2): The P3 ASIC derives its timestamp counters from its 240.0 MHz master system clock (9,592,704 master cycles per 39.970 ms frame period = 25.019 Hz with a ÷ 16 prescaler to 15.000 MHz = 599,852 ticks/frame in cnt1/cnt2, where startCnt2 - startCnt1 = 7,933 ticks = 528.9 µs DMA header setup delay). Crucially, endCnt1 == startCnt1 on every valid frame.204,800-Byte NUC Transition Frames Occur & Bounded ResynchronizationWhen 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:
buf from searchOffset for a candidate start marker: buf[i] == 0x0C and buf[i+1] in {0x8C, 0x8D}.WIRE_SIZE_P3 (197,656) bytes are buffered starting at markerIdx.endMarkerOffset = markerIdx + 12 + 197,632: verify that buf[endMarkerOffset] == 0x0C, endSync == startSync + 2 (0x8C -> 0x8E or 0x8D -> 0x8F), and endCnt1 == startCnt1.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!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!):
| Region | Byte Offset (0..1023) | Word Index | Type & Alignment | Live Value (SN: P3025043...) | Field Name & Verified Hardware Meaning |
|---|---|---|---|---|---|
| Region A | bytes 0..41 | Words 0..20 | 42 B (even) | All 0x00 | Zeroed header block |
byte 42 | Word 21 lo | uint8 | 0 (when Auto-FFC off) | ffcCountdown: Countdown timer to next automatic shutter actuation | |
byte 82 | Word 41 lo | uint8 | 0 (normal) | sunProtectFlag: Software sun-burn protection status (0 = normal) | |
| Region B | bytes 128..129 | Word 64 | uint16 LE (even) | 30,078 .. 30,177 | frameCount: ISP frame counter (Δ = +1 per frame) |
bytes 130..131 | Word 65 | uint16 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..135 | Words 66..67 | 2 × uint16 LE | 0x0000 | Reserved (0) | |
bytes 136..137 | Word 68 | uint16 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 138 | Word 69 lo | uint8 | 0 (normal) | hardwareSunProtectFlag (1 = laser/sun burn detected, 0 = normal) | |
byte 140 | Word 70 lo | uint8 (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 .. 204 | maxTempX: 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 .. 129 | maxTempY: 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 .. 255 | minTempX: Coldest pixel X coordinate (0..255). Bit-exact match with argmin(thermalRaw). | |
bytes 149..150 | (odd offset) | uint16 LE (& 0xFFF) | 22 .. 57 | minTempY: 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 C | bytes 153..159 | (unaligned) | 1B + 3×uint16 | p0InfoEn = 0 | Point Probe P0: [153]=en, [154..155]=x, [156..157]=y, [158..159]=temp |
bytes 160..166 | (unaligned) | 1B + 3×uint16 | p1InfoEn = 0 | Point Probe P1: [160]=en, [161..162]=x, [163..164]=y, [165..166]=tempNote: 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 struct | r0InfoEn = 0 | Region ROI Probe R0: [227]=en, [232..237]=max(x,y,T), [238..243]=min(x,y,T), [244..245]=avgTemp | |
| Region D | bytes 304..329 | Words 152..164 | 26 B (even) | All 0x0000 | Zero padding (0) |
| Region E | bytes 330..335 | Words 165..167 | 3 × uint16 LE | 4,880, (203, 126) | 14-Bit (1/16 K Y14) Scene Max (Word 165) and (X, Y) coordinates (Words 166, 167) |
bytes 336..341 | Words 168..170 | 3 × uint16 LE | 4,551, (254, 38) | 14-Bit (1/16 K Y14) Scene Min (Word 168) and (X, Y) coordinates (Words 169, 170) | |
bytes 346..351 | Words 173..175 | 3 × uint16 LE | 4,644 / 4,882 / 4,549 | 14-Bit (1/16 K Y14) ROI Ref (w173), Unfiltered Max (w174), Unfiltered Min (w175) | |
bytes 352..353 | Word 176 | uint16 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..359 | Word 179 | uint16 LE (even) | 158 .. 188 | 14-Bit (Y14) AGC Span / Contrast Metric (≈ (maxY14 − minY14) / 2) | |
bytes 360..361 | Word 180 | uint16 LE (even) | 30,257 .. 30,356 | frameCnt: Hardware VSYNC frame counter (Δ = +1 on 99/99 intervals, zero jitter) | |
bytes 364..365 | Word 182 | uint16 LE (even) | 0x0A0A ("\n\n") | End-of-InfoLine sentinel | |
| Region F | bytes 366..1023 | Words 183..511 | 658 B (329 words) | All 0x00FF (255) | Static unused tail filled with 0x00FF |
HIGH_PRECISION Sub-LSB Mode0..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)
libadvirtempac020.so): Consists of header metadata + K_T[1201] (uint16 LE Q14 FPA drift slope array, 2,402 B), B_T[1201] (int16 LE FPA drift offset array, 2,402 B), and NUC_T[8192] (uint16 LE lookup table mapping NUC >> 1 to 1/16 K temperatures, 16,384 B, plus Low-Gain tables).assets/rs300_tau/V303_P3_4.3mm_H.bin & L.bin): Loaded by the host when PID == 0x45A2 to look up transmittance τ(d, RH) ∈ (0, 16384] as a function of target distance d ∈ [0.20, 5.00] m and relative humidity.temp_correct_with_reflected_temp @ 0x2a044): Given apparent Kelvin temperature \(T_{\text{org},K} = \text{raw}_{\text{u16}} / 64.0\), surface emissivity \(\varepsilon \in (0.01, 1.0]\), transmittance \(\tau_f = \tau_{\text{u16}} / 16384.0\), reflected ambient temperature \(T_{u,K} = T_u + 273.15\), and atmospheric temperature \(T_{a,K} = T_a + 273.15\):
\[ T_{\text{obj},K}^4 = \frac{T_{\text{org},K}^4 - (1 - \varepsilon)\,\tau_f\,T_{u,K}^4 - (1 - \tau_f)\,T_{a,K}^4}{\varepsilon\,\tau_f}, \qquad T_{\text{corr},^\circ\text{C}} = \left(\max\left(T_{\text{obj},K}^4, 0\right)\right)^{1/4} - 273.15 \]
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 / Register | STREAMING 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) | None | 64-frame (2.56 s) Float64 accumulator (σref ≈ 5.1 mK) |
| 6. Stochastic Dither EMA N̄ema(x,y,t) | None | Continuous Float32 EMA (α = 0.10, zero integer truncation) |
| 7. Common-Mode Drift ΔCM(t) | Mechanical shutter every 90s | 1,024-sample Interquartile Mean (IQR 25%..75%) subtraction |
| Output Units & Noise Floor | Absolute °C (62.50 mK step, 33.8 mK NETD) | Differential mK (ΔTmK = −(N̄ema − N̄ref − ΔCM) × 38.62 mK, ~8–10 mK RMS) |
P3DF / /stream.diff.z) & API ReferenceP3DF AlgorithmUncompressed 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:
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.)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.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.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.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!)
};
25.0 FPS from Pixel 9 Pro XL)| Stream Endpoint & Mode | Deadband | Max Error | Avg Frame | P-Frame | Bitrate @ 25 FPS | Ratio | Fits Far AP B (27 Mb/s) & Cloud Upload (6–10 Mb/s)? |
|---|---|---|---|---|---|---|---|
GET /stream.raw (Uncompressed Dual-Plane) | 0 (Lossless) | 0 mK | 197,696 B | 197,696 B | 39.54 Mb/s | 1.0× | LAN near AP A only (chokes AP B & internet upload) |
GET /stream.raw.z (Per-Frame zlib) | 0 (Lossless) | 0 mK | 38,835 B | 38,835 B | 7.77 Mb/s | 5.1× | Yes (fits AP B & Studio45 upload budget) |
GET /stream.diff.z (STREAMING) | 0 (100% Lossless) | 0 mK | 10,021 B | 9,843 B | 2.00 Mb/s | 19.7× | Yes — 100% bit-exact lossless with 19× headroom! |
GET /stream.diff.z (STREAMING) | 1 (±62.5 mK) | 1 LSB | 4,184 B | 3,887 B | 0.84 Mb/s | 47.2× | Yes — 47× smaller, zero drift accumulation |
GET /stream.diff.z (STREAMING) | 2 (±125 mK) | 2 LSB | 1,290 B | 934 B | 0.26 Mb/s | 153.2× | Yes — 153× smaller |
GET /stream.diff.z (STREAMING) | 3 (±187 mK) | 3 LSB | 608 B | 238 B | 0.12 Mb/s | 325.1× | Yes — 325× smaller (238 B P-frames!) |
GET /stream.diff.z (HIGH_PRECISION) | 0 (100% Lossless) | 0 mK | 17,857 B | 17,720 B | 3.57 Mb/s | 11.1× | Yes — unfiltered 14-bit NUC (TNR=0, SNR=0) |
GET /stream.diff.z (HIGH_PRECISION) | 1 (±38.6 mK) | 1 LSB | 11,842 B | 11,582 B | 2.37 Mb/s | 16.7× | Yes |
GET /stream.diff.z (HIGH_PRECISION) | 2 (±77.2 mK) | 2 LSB | 8,911 B | 8,591 B | 1.78 Mb/s | 22.2× | Yes |
8080, ThermalHttpServer.kt)| HTTP Endpoint | Format / Content-Type | Description & Query Parameters |
|---|---|---|
GET /stream.diff.z | application/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.z | application/octet-stream (P3DF) | Single keyframe snapshot in P3DF format (level=6, submk=0|1). |
GET /stream.raw.z | application/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.raw | application/octet-stream (P3FR) | 25 FPS uncompressed 197,696 B/frame stream (64B hdr + 197,632B full payload, 39.54 Mb/s). |
GET /frame.raw | application/octet-stream (P3FR) | Single 197,696-byte uncompressed P3FR frame (X-Frame-Seq, X-Cnt1, X-Cnt3 headers). |
GET /frame.csv | text/csv | 256×192 matrix of calibrated temperatures in °C (or mK if diff=1). |
GET /frame.diff.csv | text/csv | 256×192 sub-LSB differential temperature matrix ΔTmK(x,y) in millikelvin (mK). |
GET /stream.mjpeg | multipart/x-mixed-replace | 25 FPS colorized MJPEG video stream (512×384 upscaled with HUD & crosshair). |
GET /health (/api/health) | application/json | Fast JSON health & version check: ok, connected, streaming, fps, framesRead, mode, nuctBypass, appVersion, versionCode, buildTimeUtc. |
GET /api/status | application/json | Full 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/probe | application/json | Executes 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/control | application/json | One-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/upgrade | application/json | Binary 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. |
/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
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):
0x40/0xEE clear + CMD_START_STREAM + parser sync reset) without tearing down the USB session.ACTION_USB_DEVICE_DETACHED immediately closes the active UsbDeviceConnection (waking any blocked bulkTransfer or interruptible handshake sleep within <100 ms), clears stale cnt3 drop tracking on the next session, and re-applies the active preset (nuct_bypass, gain, TNR/SNR, auto_ffc) after the 6-step handshake (~6.6 s).UsbDeviceConnection.resetDevice() (USBDEVFS_RESET) through a hub causes the kernel to disconnect and re-enumerate the P3 at a new bus address (/dev/bus/usb/001/N+1) and throws SecurityException if device.serialNumber is read on the stale handle. v1.3.4 avoids resetDevice() during normal hardReset or timeout recovery (simply releasing interfaces and running a fresh 6-step handshake recovers cleanly on the same node) and reserves kernel resets for dead EP0 pipes with a 15-second cooldown.
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):
thermal/p3-control): The phone's SS-ControlLoop polls GET https://spacesheep.dev/api/streams/thermal/p3-control?after=<cursor> every 2.5 s over outbound HTTPS, verifies token_sha256 in constant time against its configured key, executes any /api/control action or upgrade_url HTTPS APK pull (with mandatory 64-hex SHA-256 verification and HTTPS host allowlist), and publishes AES-256-GCM encrypted execution acknowledgements and network diagnostics (priv_enc) into thermal/p3-telemetry./api/control actions, POST /api/upgrade, /api/spacesheep, and /api/probe require Authorization: Bearer <SPACESHEEP_API_KEY>, X-Auth-Token: <token>, or ?token=<sha256> by default in v1.3.9+, with cellular HTTP socket blocking and per-IP brute-force rate limiting.# 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).