This note is the hardware/software yak-shave behind the thermal-camera approach in the radiator note. The camera is a TOPDON TC001 read with the open-source thermal-camera-android app1. Out of the box it did not stream; getting there took peeling back three layers.
The three layers
Diagnosis was driven from adb logcat on the phone (oxenhead).
-
OTG power flapping. On the first plug the camera enumerated, then detached ~0.6 s later, before the app could open it – the USB-C port renegotiated to
no-power/no-dataand only flipped to host+source mode ~36 s later. By then the app had already given up with Device disconnected before open attempt. Re-plugging once the port had settled into host+source mode made it enumerate cleanly. If it ever browns out again under sustained use, the fix is a powered OTG splitter that feeds the camera 5 V independently of the phone’s budget. -
Two-stage USB identity. The camera first appears as
2bdf:0106(“TC001N”, an iAP/vendor device), then mode-switches and re-enumerates as a proper UVC device2bdf:0102(“USB Camera”). The app opens that second node fine: Camera opened successfully. -
Stream-format mismatch (the real bug). With the camera open, format negotiation fails:
I ThermalCamera: Camera opened successfully E ThermalCamera: uvc_get_stream_ctrl_format_size failed: Invalid mode E ThermalCamera: uvc_get_stream_ctrl_format_size (fps=0) failed: Invalid modeThe app hard-codes a request for YUYV 256x384 @ 25fps1 (a 192-row image half + 192-row thermal half, as on the Infiray P2 Pro it was tested against). The TC001N does not advertise 256x384.
What the TC001N actually advertises
Read straight from the device’s UVC descriptors over adb (no root needed):
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
doit () {
desc=""
for d in $(clk android -d "$DEVICE" adb shell ls /sys/bus/usb/devices/)
do
v=$(clk android -d "$DEVICE" adb shell cat /sys/bus/usb/devices/$d/idVendor 2>/dev/null)
if [ "$v" = "2bdf" ]
then
desc=/sys/bus/usb/devices/$d/descriptors
fi
done
if [ -z "$desc" ]
then
echo "camera (2bdf) not on the bus -- replug and wait for 2bdf:0102"
else
clk android -d "$DEVICE" adb shell od -An -tx1 "$desc"
fi
}
doit
12 01 00 02 ef 02 01 40 df 2b 02 01 09 04 01 02
03 01 09 02 12 02 02 01 04 80 32 08 0b 00 02 0e
03 00 05 09 04 00 00 00 0e 01 00 05 0d 24 01 10
01 51 00 00 6c dc 02 01 01 12 24 02 02 01 02 00
00 00 00 00 00 00 00 03 00 00 00 0c 24 05 05 01
00 40 02 00 00 00 09 1d 24 06 0a 41 76 9e a2 04
de e3 47 8b 2b f4 34 1a ff 00 3b 17 01 02 04 ff
ff 7f 00 00 09 24 03 03 01 01 00 02 00 09 04 01
00 01 0e 02 00 06 0e 24 01 01 97 01 81 00 03 00
00 00 01 00 1b 24 04 01 0c 59 55 59 32 00 00 10
00 80 00 00 aa 00 38 9b 71 10 01 00 00 00 00 1e
24 05 01 00 00 01 88 01 00 29 ea 05 00 29 ea 05
20 92 07 00 80 1a 06 00 01 80 1a 06 00 1e 24 05
02 00 08 02 c0 00 00 29 ea 05 00 29 ea 05 20 92
07 00 80 1a 06 00 01 80 1a 06 00 1e 24 05 03 00
00 02 e4 01 00 29 ea 05 00 29 ea 05 20 92 07 00
80 1a 06 00 01 80 1a 06 00 1e 24 05 04 00 84 02
80 01 00 29 ea 05 00 29 ea 05 20 92 07 00 80 1a
06 00 01 80 1a 06 00 1e 24 05 05 00 04 00 11 30
00 29 ea 05 00 29 ea 05 20 92 07 00 80 1a 06 00
01 80 1a 06 00 1e 24 05 06 00 00 01 c4 00 00 29
ea 05 00 29 ea 05 20 92 07 00 80 1a 06 00 01 80
1a 06 00 1e 24 05 07 00 04 00 4d 31 00 29 ea 05
00 29 ea 05 20 92 07 00 80 1a 06 00 01 80 1a 06
00 1e 24 05 08 00 3c 00 e3 0c 00 29 ea 05 00 29
ea 05 20 92 07 00 80 1a 06 00 01 80 1a 06 00 1e
24 05 09 00 08 00 22 31 00 29 ea 05 00 29 ea 05
20 92 07 00 80 1a 06 00 01 80 1a 06 00 1e 24 05
0a 00 10 00 91 3c 00 29 ea 05 00 29 ea 05 20 92
07 00 80 1a 06 00 01 80 1a 06 00 1e 24 05 0b 00
00 01 c0 00 00 29 ea 05 00 29 ea 05 20 92 07 00
80 1a 06 00 01 80 1a 06 00 1e 24 05 0c 00 00 02
80 01 00 29 ea 05 00 29 ea 05 20 92 07 00 80 1a
06 00 01 80 1a 06 00 06 24 0d 01 01 04 07 05 81
02 00 02 00
This block walks the descriptor list by bLength, keeps the
VS_FRAME_UNCOMPRESSED (24 05) entries, and decodes each frame’s width,
height and rate (dwDefaultFrameInterval, 100 ns units, so fps = 1e7 /
interval). Format is YUY2; absurd dimensions are flagged as malformed.
# dump is the od -An -tx1 hex from the `descriptors` block; turn it into bytes.
if isinstance(dump, str):
text = dump
else:
text = " ".join(str(c) for row in dump
for c in (row if isinstance(row, (list, tuple)) else [row]))
b = [int(t, 16) for t in text.split()
if len(t) == 2 and all(c in "0123456789abcdef" for c in t)]
CS_INTERFACE, VS_FRAME_UNCOMPRESSED = 0x24, 0x05
print("| frame | width | height | fps | usable |")
print("|-")
i = 0
while i < len(b):
blen = b[i]
if blen == 0:
break
d = b[i:i + blen]
if len(d) >= 26 and d[1] == CS_INTERFACE and d[2] == VS_FRAME_UNCOMPRESSED:
w = d[5] | (d[6] << 8)
h = d[7] | (d[8] << 8)
interval = d[21] | (d[22] << 8) | (d[23] << 16) | (d[24] << 24)
fps = round(10_000_000 / interval) if interval else 0
usable = "yes" if 16 <= w <= 1024 and 16 <= h <= 1024 else "no (malformed)"
print(f"| {d[3]} | {w} | {h} | {fps} | {usable} |")
i += blen
| frame | width | height | fps | usable |
|---|---|---|---|---|
| 1 | 256 | 392 | 25 | yes |
| 2 | 520 | 192 | 25 | yes |
| 3 | 512 | 484 | 25 | yes |
| 4 | 644 | 384 | 25 | yes |
| 5 | 4 | 12305 | 25 | no (malformed) |
| 6 | 256 | 196 | 25 | yes |
| 7 | 4 | 12621 | 25 | no (malformed) |
| 8 | 60 | 3299 | 25 | no (malformed) |
| 9 | 8 | 12578 | 25 | no (malformed) |
| 10 | 16 | 15505 | 25 | no (malformed) |
| 11 | 256 | 192 | 25 | yes |
| 12 | 512 | 384 | 25 | yes |
So the TC001N uses 196-row halves (196 image + 196 thermal = 392), not the
192+192=384 of the P2 Pro. The app asks for 384, finds no match, and falls
back to fps=0 -> Invalid mode. Nothing is wrong with the camera or the
permissions; it is a one-constant mismatch.
As of writing the app has a single release (v1.0.0, April 2026) that
hard-codes 384, and there is no issue or release mentioning the TC001N – so
this needs a patched build.
The fix: patched fork built by the upstream CI
No local Android toolchain required: the repo ships a GitHub Actions workflow
(build.yml) that runs ./gradlew assembleDebug on every push and uploads an
app-debug artifact. So the plan is: fork, flip the constants, push, let CI
build, download the artifact, install it.
Shared variables, pulled into every block below via noweb:
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
Fork and clone
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
mkdir -p "$WORKDIR"
cd "$WORKDIR"
gh repo fork "$UPSTREAM" --clone --remote
https://github.com/Konubinix/thermal-camera-android
Inspect the constants before touching them
Confirm the real lines first – if IMAGE_HEIGHT=/=THERMAL_HEIGHT turn out to
be derived (e.g. FRAME_HEIGHT / 2) rather than literal 192, only the
FRAME_HEIGHT line needs editing and the patch below should be trimmed.
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
grep -nE 'FRAME_WIDTH|FRAME_HEIGHT|IMAGE_HEIGHT|THERMAL_HEIGHT' \
"$REPO/app/src/main/cpp/native-lib.cpp"
19:static constexpr int FRAME_WIDTH = 256;
20:static constexpr int FRAME_HEIGHT = 384;
21:static constexpr int IMAGE_HEIGHT = 192; // Top half: visual image
22:static constexpr int THERMAL_HEIGHT = 192; // Bottom half: thermal data
78: size_t expected_size = FRAME_WIDTH * FRAME_HEIGHT * 2;
85: size_t stride = FRAME_WIDTH * 2; // YUYV: 2 bytes per pixel
89: uint8_t grayscale[FRAME_WIDTH * IMAGE_HEIGHT];
92: for (int y = 0; y < IMAGE_HEIGHT; y++) {
93: for (int x = 0; x < FRAME_WIDTH; x++) {
95: grayscale[y * FRAME_WIDTH + x] = data[y * stride + x * 2];
104: uint8_t *thermal_data = data + (IMAGE_HEIGHT * stride);
106: for (int y = 0; y < THERMAL_HEIGHT; y++) {
107: for (int x = 0; x < FRAME_WIDTH; x++) {
168: float avgTemp = sumTemp / (THERMAL_HEIGHT * FRAME_WIDTH);
171: int centerY = THERMAL_HEIGHT / 2;
172: int centerX = FRAME_WIDTH / 2;
187: uint8_t thermalLinear[FRAME_WIDTH * THERMAL_HEIGHT];
191: for (int y = 0; y < THERMAL_HEIGHT; y++) {
192: for (int x = 0; x < FRAME_WIDTH; x++) {
198: thermalLinear[y * FRAME_WIDTH + x] = (uint8_t)(norm * 255.0f + 0.5f);
222: jbyteArray imageArray = env->NewByteArray(FRAME_WIDTH * IMAGE_HEIGHT);
223: env->SetByteArrayRegion(imageArray, 0, FRAME_WIDTH * IMAGE_HEIGHT,
226: jbyteArray thermalLinearArray = env->NewByteArray(FRAME_WIDTH * THERMAL_HEIGHT);
227: env->SetByteArrayRegion(thermalLinearArray, 0, FRAME_WIDTH * THERMAL_HEIGHT,
400: FRAME_WIDTH, FRAME_HEIGHT, 25);
408: FRAME_WIDTH, FRAME_HEIGHT, 0);
508: return FRAME_WIDTH;
515: return IMAGE_HEIGHT;
Patch: 384 -> 392, 192 -> 196
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
cd "$REPO"
f=app/src/main/cpp/native-lib.cpp
sed -i -E \
-e 's/(FRAME_HEIGHT[[:space:]]*=[[:space:]]*)384/\1392/' \
-e 's/(IMAGE_HEIGHT[[:space:]]*=[[:space:]]*)192/\1196/' \
-e 's/(THERMAL_HEIGHT[[:space:]]*=[[:space:]]*)192/\1196/' \
"$f"
git --no-pager diff -- "$f"
diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp
index beef9f6..74540ee 100644
--- a/app/src/main/cpp/native-lib.cpp
+++ b/app/src/main/cpp/native-lib.cpp
@@ -17,9 +17,9 @@
// Frame dimensions for Infiray P2 / Topdon TC001
static constexpr int FRAME_WIDTH = 256;
-static constexpr int FRAME_HEIGHT = 384;
-static constexpr int IMAGE_HEIGHT = 192; // Top half: visual image
-static constexpr int THERMAL_HEIGHT = 192; // Bottom half: thermal data
+static constexpr int FRAME_HEIGHT = 392;
+static constexpr int IMAGE_HEIGHT = 196; // Top half: visual image
+static constexpr int THERMAL_HEIGHT = 196; // Bottom half: thermal data
// Global state
static uvc_context_t *g_ctx = nullptr;
Commit and push (this triggers the CI build)
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
cd "$REPO"
git switch -c "$BRANCH"
git commit -am "TC001N: stream 256x392 (196+196 halves) instead of 256x384"
git push -u origin "$BRANCH"
[tc001n-392 5e5988a] TC001N: stream 256x392 (196+196 halves) instead of 256x384
1 file changed, 3 insertions(+), 3 deletions(-)
branch 'tc001n-392' set up to track 'origin/tc001n-392'.
Watch CI and download the APK
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
cd "$REPO"
RUN_ID=$(gh run list -b "$BRANCH" -L1 --json databaseId -q '.[0].databaseId')
gh run watch "$RUN_ID"
rm -rf /tmp/tc001n-apk && mkdir -p /tmp/tc001n-apk
gh run download "$RUN_ID" -n app-debug -D /tmp/tc001n-apk
cd /tmp/tc001n-apk/
ipfa *apk
[[https://ipfs.konubinix.eu/p/bafybeia422b2alkaeey5h7jq7pwvzvtw3axsbxzyl3ogjimrewvx6w3oee?filename=app_debug.apk][app-debug.apk]]
Install on the phone
The CI APK is signed with a debug key, so it will not install over the v1.0.0 release APK (signature mismatch). Uninstall the released one first. The USB-device->app association and permissions are re-granted on first plug.
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
clk android -d "$DEVICE" adb package uninstall "$PKG" || true
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
clk android -d "$DEVICE" adb package install "${cid}"
Saving file(s) to app-debug.apk
Performing Streamed Install
Success
Verify it streams
Re-plug the camera, open the app, then check the log: we want Camera opened successfully with no following Invalid mode.
UPSTREAM=fbreitwieser/thermal-camera-android
WORKDIR=$HOME/test/thermal-camera-android
REPO=$WORKDIR/thermal-camera-android
DEVICE=oxenhead
BRANCH=tc001n-392
PKG=com.breyt.thermalcamera
clk android -d "$DEVICE" adb shell logcat -d \
| grep -E 'ThermalCamera: (Camera opened|uvc_get_stream|Invalid mode|streaming)' \
| tail -20
If the stream comes up but the image and the false-colour/temperature overlay
look mis-split (the 196 vs 192 boundary), the remaining knob is exactly that
IMAGE_HEIGHT=/=THERMAL_HEIGHT split – adjust, push again, CI rebuilds. Once
it works cleanly it is worth a PR upstream making the resolution adaptive
(query the descriptors instead of hard-coding), which would fix every Infiray
variant at once.
Notes linking here
- should I have my radiators flushed?
- The constants are the camera’s own descriptors
- Why a python driver at all
Permalink
-
↩︎ ↩︎thermal-camera-android(packagecom.breyt.thermalcamera), github.com/fbreitwieser/thermal-camera-android. The hard-coded request lives inapp/src/main/cpp/native-lib.cppasFRAME_WIDTH = 256/FRAME_HEIGHT = 384passed touvc_get_stream_ctrl_format_size(..., UVC_FRAME_FORMAT_YUYV, FRAME_WIDTH, FRAME_HEIGHT, 25). README: “Tested: Infiray P2 Pro. Other Infiray/Topdon USB-C cameras likely compatible.”