Compare commits

...
21 Commits
Author SHA1 Message Date
ZXCLI 456285c71b docs: refresh handoff for next model - final layout conventions + next-round prompt 2026-09-09 17:42:41 +08:00
ZXCLI add3d06713 android: include safe-drawing top inset in uiTopPx (image top clipped on punch-hole phones) + smaller shutter row 2026-09-07 20:16:39 +08:00
ZXCLI 7437ce5e85 android: camera-style controls - 4-item top bar (ffc/zoom/trace/palette), shutter row (gallery/photo/record) above nav 2026-09-07 19:22:49 +08:00
ZXCLI fabea4c664 android: fix inverted device-orientation mapping (real accelerometer reads world-up; emulator uses gravity convention) 2026-09-07 19:09:57 +08:00
ZXCLI b1a3c38a69 android: rotate bar icons/text + OSD labels by physical grip angle (accelerometer), image region stays glued 2026-09-07 13:37:34 +08:00
ZXCLI d0b133a895 android: lock portrait - UI composition glued to phone frame (top/bottom bars + image area absolutely fixed) 2026-09-07 13:17:19 +08:00
ZXCLI 2c38537303 android: 顶栏/底栏位置固定+图像区域永不旋转(只转文字)+挖孔屏安全区适配+完整交接文档 2026-09-07 12:59:26 +08:00
ZXCLI 798e180683 android: 顶栏控制项+图像世界固定旋转+状态栏沉浸+inset感知渲染+乱码二次修复 2026-09-07 12:43:35 +08:00
ZXCLI 96b92a8d26 android: 心跳更新(第二轮UI反馈修复) 2026-09-07 00:30:09 +08:00
ZXCLI 8d22969f6d android: 竖屏旋转热像图填满屏宽/横屏原生方向+控制栏横屏移至右侧+大号圆圈标记与色标+fullSensor方向跟随 2026-09-07 00:29:16 +08:00
ZXCLI 17fcb7b94c android: 心跳更新(用户反馈修复完成) 2026-09-06 23:15:57 +08:00
ZXCLI f8eab28857 android: 相册页接线+演示模式(真实管线渲染合成帧+demo温度显示映射) 2026-09-06 23:15:05 +08:00
ZXCLI 430bf373e4 android: 修复源码GBK编码导致的中文乱码+重绘图标+USB插入自动授权+媒体权限+多点测温/色标条/追踪开关 2026-09-06 21:56:34 +08:00
ZXCLI 6d0805af90 android: 恢复脚本+APK产物 2026-09-06 20:41:41 +08:00
ZXCLI 4fd2e5bf79 android: 图标+预测性返回+心跳更新 阶段7预验收 2026-09-06 20:41:16 +08:00
ZXCLI a07a49b437 android: 设置页+任务巡检解析器(sqlite/xml) 阶段6a 2026-09-06 20:34:21 +08:00
ZXCLI 3bca426c99 android: PDF巡检报告生成(PdfDocument) 阶段5b 2026-09-06 20:04:06 +08:00
ZXCLI 29a9b85cbe android: 媒体库+MDT离线分析查看器(缩放/调色板/备注) 阶段4-5a 2026-09-06 19:55:22 +08:00
ZXCLI ed0a0882d8 android: 拍照MDT容器保存(MediaStore DCIM/MAG160C)+MP4录像(Surface编码) 阶段3b 2026-09-06 19:40:56 +08:00
ZXCLI 6297a06b97 android: USB层+实时画面+UI骨架(阶段3a) 2026-09-06 19:16:35 +08:00
ZXCLI 5b41a6aa9b android: 统一APP工程骨架 + 官方渲染管线Kotlin移植(与C参考逐字节一致) 2026-09-06 18:44:02 +08:00
89 changed files with 5773 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generate Kotlin tables from csdk C headers (palette256, t2e)."""
import re, sys, io
CSDK = r"C:\Project\MAG160C\csdk\src"
OUT = r"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt"
def read(fn):
with open(fn, encoding="utf-8") as f:
return f.read()
def parse_matrix(text, rows=256):
vals = []
for m in re.finditer(r"\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}", text):
vals.append((int(m.group(3)), int(m.group(2)), int(m.group(1)))) # R,G,B from (B,G,R,0)
if len(vals) == rows:
break
return vals
def parse_int32(text):
body = text[text.index("{"):]
return [int(x) for x in re.findall(r"-?\d+", body)]
pal_txt = read(rf"{CSDK}\mag160c_official_palette256.h")
t2e_txt = read(rf"{CSDK}\mag160c_official_t2e.h")
tables_txt = read(rf"{CSDK}\mag160c_tables.h")
pal = parse_matrix(pal_txt)
assert len(pal) == 256, f"palette entries: {len(pal)}"
t2e = parse_int32(t2e_txt)
assert len(t2e) == 646, f"t2e entries: {len(t2e)}"
# mag160c_t2e (274 entries, hex) + mag160c_e2t_acc_q10 (274 entries)
def parse_hex_array(text, name):
m = re.search(rf"static const uint32_t {name}\[MAG160C_TEMP_CURVE_ENTRIES\] = \{{(.*?)\}};", text, re.S)
assert m, name
return [int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", m.group(1))]
t2e274 = parse_hex_array(tables_txt, "mag160c_t2e")
assert len(t2e274) == 274, f"t2e274: {len(t2e274)}"
e2t = parse_hex_array(tables_txt, "mag160c_e2t_acc_q10")
assert len(e2t) == 274, f"e2t: {len(e2t)}"
# palette ARGB int per gray index
lines = []
lines.append("package com.mag160c.thermal.core")
lines.append("")
lines.append("/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */")
lines.append("object OfficialTables {")
lines.append(" /** Official default palette (256 gray levels -> ARGB int), source: CoreSDKLib dev+0xb18. */")
lines.append(" val PALETTE256_ARGB = intArrayBuilder {")
for i, (r, g, b) in enumerate(pal):
argb = (0xFF << 24) | (r << 16) | (g << 8) | b
lines.append(f" add({argb}) // {i}")
lines.append(" }")
lines.append("")
lines.append(" /** Official T2E table (646 int32), temp = slope*diff>>12 + (i<<12) - 0x249f0. */")
lines.append(" val T2E = intArrayBuilder {")
for i in range(0, len(t2e), 8):
chunk = t2e[i:i+8]
lines.append(" add(" + ", ".join(str(v) for v in chunk) + (")" if i + 8 >= len(t2e) else ""))
lines.append(" }")
lines.append("}")
kt = "\n".join(lines) + "\n"
# intArrayBuilder trick is overkill; use direct arrays with chunked lines instead.
argb_lines = []
def signed(v):
return v if v < 2**31 else v - 2**32
for i in range(0, len(pal), 6):
row = ", ".join(str(signed((0xFF << 24) | (r << 16) | (g << 8) | b)) for (r, g, b) in pal[i:i+6])
argb_lines.append(" " + row + ",")
t2e_lines = []
for i in range(0, len(t2e), 8):
row = ", ".join(str(v) for v in t2e[i:i+8])
t2e_lines.append(" " + row + ("," if i + 8 < len(t2e) else ""))
t2e274_lines = []
for i in range(0, len(t2e274), 8):
row = ", ".join(f"0x{v:08x}" for v in t2e274[i:i+8])
t2e274_lines.append(" " + row + ("," if i + 8 < len(t2e274) else ""))
e2t_lines = []
for i in range(0, len(e2t), 8):
row = ", ".join(f"0x{v:08x}" for v in e2t[i:i+8])
e2t_lines.append(" " + row + ("," if i + 8 < len(e2t) else ""))
kt = f"""package com.mag160c.thermal.core
/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */
object OfficialTables {{
/** Official default palette: 256 gray levels -> ARGB int (from CoreSDKLib dev+0xb18, B,G,R order swapped). */
val PALETTE256_ARGB = intArrayOf(
{chr(10).join(argb_lines)}
)
/** Official T2E table (646 int32): temp = slope*diff>>12 + (i<<12) - 0x249f0. */
val T2E = intArrayOf(
{chr(10).join(t2e_lines)}
)
/** Vendor T2E curve from libcoresdk.so ARM64 @0x402010 (274 uint32 entries). */
val T2E274 = intArrayOf(
{chr(10).join(t2e274_lines)}
)
/** Vendor E2TAccQ10 curve from libcoresdk.so ARM64 @0x40245c (274 uint32 entries). */
val E2T_ACC_Q10 = intArrayOf(
{chr(10).join(e2t_lines)}
)
}}
"""
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
f.write(kt)
print(f"written {OUT}: palette {len(pal)} entries, t2e {len(t2e)} entries")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69
View File
@@ -0,0 +1,69 @@
/* render_offline.c - PC reference runner for the official mag160c pipeline.
* Usage: render_offline.exe <ddt> <frames.bin> <out.rgb> <stats.txt>
* frames.bin = sequence of complete USB frames (0x38 + 38400 bytes each).
* Writes per-frame 320x240x3 RGB24 + one stats line per frame.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "mag160c/mag160c_render.h"
static unsigned rd32(const unsigned char *p) {
return p[0] | (p[1] << 8) | (p[2] << 16) | ((unsigned)p[3] << 24);
}
static void ffc_cb(void *user, int param) { (void)user; (void)param; }
int main(int argc, char **argv) {
if (argc < 5) { fprintf(stderr, "usage: %s ddt frames out.rgb stats\n", argv[0]); return 1; }
FILE *ff = fopen(argv[2], "rb");
if (!ff) { fprintf(stderr, "open frames fail\n"); return 1; }
fseek(ff, 0, SEEK_END);
long fsz = ftell(ff);
fseek(ff, 0, SEEK_SET);
unsigned char *buf = malloc(fsz);
if (fread(buf, 1, fsz, ff) != (size_t)fsz) { fclose(ff); return 1; }
fclose(ff);
const unsigned frame_len = 38400;
const unsigned total = 0x38 + frame_len;
if (fsz < (long)total) { fprintf(stderr, "frames too small\n"); return 1; }
FILE *out = fopen(argv[3], "wb");
FILE *st = fopen(argv[4], "w");
if (!out || !st) { fprintf(stderr, "open out fail\n"); return 1; }
mag160c_render_t *r = NULL;
mag160c_render_cfg_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.width = 160; cfg.height = 120;
cfg.ddt_path = argv[1];
cfg.ffc_cb = ffc_cb;
cfg.startup_force_ffc = 1;
cfg.cdf_pivot_75 = 1;
if (mag160c_render_init(&r, &cfg) != MAG160C_OK) {
fprintf(stderr, "render init fail\n");
return 1;
}
unsigned char *rgb = malloc(320 * 240 * 3);
int nframes = fsz / total;
for (int f = 0; f < nframes; ++f) {
unsigned char *fr = buf + (long)f * total;
/* sanity: marker + len */
if (rd32(fr) != 0x1bb1b11b) { fprintf(stderr, "bad marker at frame %d\n", f); return 1; }
if (rd32(fr + 8) != frame_len) { fprintf(stderr, "bad len at frame %d\n", f); return 1; }
if (rd32(fr + 0x1c + frame_len) != 0x1bb1b11c) { fprintf(stderr, "bad trailer at frame %d\n", f); return 1; }
mag160c_error_t rc = mag160c_render_frame(r, fr, 1, rgb);
if (rc == MAG160C_OK) {
fwrite(rgb, 1, 320 * 240 * 3, out);
int lo, hi, mn, mx, mean, sd;
mag160c_render_get_stats(r, &lo, &hi, &mn, &mx, &mean, &sd);
fprintf(st, "%d %d %d %d %d %d %d\n", f, lo, hi, mn, mx, mean, sd);
} else {
fprintf(st, "%d HIDDEN\n", f);
}
}
fclose(out); fclose(st);
return 0;
}
+60
View File
@@ -0,0 +1,60 @@
0 HIDDEN
1 HIDDEN
2 HIDDEN
3 HIDDEN
4 HIDDEN
5 HIDDEN
6 HIDDEN
7 HIDDEN
8 HIDDEN
9 HIDDEN
10 HIDDEN
11 HIDDEN
12 HIDDEN
13 HIDDEN
14 HIDDEN
15 HIDDEN
16 HIDDEN
17 HIDDEN
18 HIDDEN
19 HIDDEN
20 HIDDEN
21 HIDDEN
22 HIDDEN
23 HIDDEN
24 HIDDEN
25 HIDDEN
26 HIDDEN
27 HIDDEN
28 HIDDEN
29 HIDDEN
30 HIDDEN
31 HIDDEN
32 6961 7961 7118 7668 7461 103
33 6962 7962 7098 7663 7462 103
34 6962 7962 7116 7675 7462 103
35 6961 7961 7106 7672 7461 103
36 6961 7961 7106 7659 7461 103
37 6961 7961 7116 7664 7461 103
38 6961 7961 7121 7671 7461 103
39 6962 7962 7117 7673 7462 103
40 6962 7962 7132 7667 7462 103
41 6962 7962 7105 7664 7462 103
42 6961 7961 7118 7671 7461 103
43 6961 7961 7130 7671 7461 103
44 6962 7962 7122 7661 7462 103
45 6961 7961 7096 7669 7461 103
46 6961 7961 7096 7671 7461 103
47 6962 7962 7122 7675 7462 103
48 6961 7961 7094 7667 7461 103
49 6962 7962 7099 7664 7462 103
50 6962 7962 7134 7669 7462 103
51 6962 7962 7133 7662 7462 103
52 6961 7961 7129 7671 7461 103
53 6962 7962 7126 7671 7462 103
54 6961 7961 7119 7669 7461 103
55 6961 7961 7096 7673 7461 103
56 6961 7961 7135 7661 7461 103
57 6962 7962 7099 7662 7462 103
58 6961 7961 7122 7659 7461 103
59 6962 7962 7116 7669 7462 103
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
captures/
+53
View File
@@ -0,0 +1,53 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.mag160c.thermal"
compileSdk = 36
defaultConfig {
applicationId = "com.mag160c.thermal"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.activity.compose)
implementation(libs.core.ktx)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.lifecycle.viewmodel.compose)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
}
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<application
android:label="MAG160C"
android:icon="@drawable/ic_launcher"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:supportsRtl="true"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Auto-launch / system permission dialog when the camera is plugged in -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
</application>
</manifest>
Binary file not shown.
@@ -0,0 +1,26 @@
package com.mag160c.thermal
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.mag160c.thermal.ui.AppRoot
import com.mag160c.thermal.ui.theme.Mag160cTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// immersive: hide the status bar (swipe to reveal)
window.insetsController?.let { c ->
c.hide(android.view.WindowInsets.Type.statusBars())
c.systemBarsBehavior =
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
setContent {
Mag160cTheme {
AppRoot()
}
}
}
}
@@ -0,0 +1,20 @@
package com.mag160c.thermal.core
/** Little-endian byte helpers used by the recovered vendor parsers. */
object ByteReader {
fun rd32(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
fun rd16(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
/** Decode `count` little-endian uint16 values into an IntArray. */
fun decodeU16(b: ByteArray, off: Int, count: Int, out: IntArray, outOff: Int = 0) {
for (i in 0 until count) {
out[outOff + i] = rd16(b, off + i * 2)
}
}
}
@@ -0,0 +1,92 @@
package com.mag160c.thermal.core
/**
* Streaming frame assembler, ported from csdk/src/mag160c_frame.c (vendor
* libmagcore reader thread). Feed every bulk read from EP 0x81 into
* [push]; when a complete valid frame is buffered it is copied to
* [outFrame] and the returned length is > 0.
*/
class FrameStream(maxFrameLen: Int) {
private val cap = maxFrameLen * 2 + 0x470
private val buf = ByteArray(cap)
private var len = 0
private var aligned = false
/**
* Append bulk-read data. Returns the total frame size when a complete
* frame has been copied into [outFrame] (capacity must be >= 0x38 + len),
* otherwise 0.
*/
fun push(data: ByteArray, size: Int, outFrame: ByteArray): Int {
if (cap == 0 || size > cap - len) {
len = 0
aligned = false
}
if (size > cap - len) {
len = 0
}
System.arraycopy(data, 0, buf, len, size)
len += size
if (!aligned) {
alignToMarker()
if (!aligned) return 0
}
if (len < FRAME_OVERHEAD) return 0
val dataLen = ByteReader.rd32(buf, 8)
if (dataLen < 0 || dataLen > cap - FRAME_OVERHEAD) {
len = 0 // bogus length; resync
return 0
}
if (len < FRAME_OVERHEAD + dataLen) return 0
if (!isValidFrame(buf, len)) {
len = 0
aligned = false
return 0
}
val total = FRAME_OVERHEAD + dataLen
System.arraycopy(buf, 0, outFrame, 0, total)
// consume frame
System.arraycopy(buf, total, buf, 0, len - total)
len -= total
if (len == 0) aligned = false
return total
}
private fun alignToMarker() {
var i = 0
while (i + 4 <= len) {
if (ByteReader.rd32(buf, i) == FRAME_MARKER) break
i += 4
}
if (i + 4 > len) {
len = 0
return
}
if (i != 0) {
System.arraycopy(buf, i, buf, 0, len - i)
len -= i
}
aligned = true
}
companion object {
const val FRAME_MARKER = 0x1BB1B11B
const val FRAME_TRAILING_MARKER = 0x1BB1B11C
const val DATA_OFFSET = 0x1C
const val FRAME_OVERHEAD = 0x38
/** Validate a buffered frame: marker, type <= 1, length and trailer. */
fun isValidFrame(buf: ByteArray, size: Int): Boolean {
if (size < DATA_OFFSET + 4) return false
if (ByteReader.rd32(buf, 0) != FRAME_MARKER) return false
if (ByteReader.rd32(buf, 12) > 1) return false
val dataLen = ByteReader.rd32(buf, 8)
if (size < FRAME_OVERHEAD + dataLen) return false
return ByteReader.rd32(buf, DATA_OFFSET + dataLen) == FRAME_TRAILING_MARKER
}
}
}
@@ -0,0 +1,214 @@
package com.mag160c.thermal.core
/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */
object OfficialTables {
/** Official default palette: 256 gray levels -> ARGB int (from CoreSDKLib dev+0xb18, B,G,R order swapped). */
val PALETTE256_ARGB = intArrayOf(
-16777216, -16777211, -16777206, -16777201, -16777195, -16777190,
-16777185, -16777179, -16777174, -16777169, -16777163, -16777158,
-16777153, -16777147, -16777142, -16777137, -16777131, -16777126,
-16777121, -16777115, -16777110, -16777105, -16777099, -16646027,
-16449418, -16318346, -16121737, -15925129, -15794056, -15597448,
-15400839, -15269767, -15073158, -14876550, -14745477, -14548869,
-14352260, -14221188, -14024579, -13827971, -13696898, -13500290,
-13369217, -13172609, -12976000, -12844928, -12648319, -12451710,
-12320638, -12124029, -11927421, -11796348, -11599740, -11403131,
-11272059, -11075450, -10878842, -10747769, -10551161, -10420088,
-10223480, -10026871, -9895799, -9699190, -9502582, -9371509,
-9174901, -8978292, -8847220, -8650611, -8454002, -8322930,
-8126321, -7929713, -7798640, -7602032, -7470959, -7274351,
-7077742, -6946670, -6750061, -6553453, -6422380, -6225772,
-6029163, -5898091, -5701482, -5504874, -5373801, -5177193,
-4980584, -4849512, -4652903, -4456294, -4390504, -4324458,
-4192876, -4127086, -4061040, -3929458, -3863412, -3797622,
-3666040, -3599994, -3534204, -3402622, -3336576, -3270530,
-3139204, -3073158, -3007112, -2875530, -2809740, -2743694,
-2612112, -2546322, -2480276, -2348694, -2282648, -2216858,
-2085276, -2019230, -1887905, -1887397, -1821353, -1755310,
-1689522, -1623479, -1557435, -1491392, -1425604, -1359561,
-1293517, -1227729, -1161686, -1095642, -1029599, -963811,
-963304, -897260, -831473, -765425, -699377, -633329,
-567537, -501489, -435441, -369393, -303601, -237553,
-171505, -105713, -39665, -39153, -38641, -38385,
-37873, -37361, -37105, -36593, -36081, -35569,
-35313, -34801, -34289, -33777, -33521, -33009,
-32497, -32241, -31729, -31217, -30705, -30449,
-29937, -29425, -28913, -28657, -28145, -27633,
-27377, -26865, -26353, -25841, -25585, -25073,
-24561, -24305, -23793, -23281, -22769, -22513,
-22001, -21489, -20977, -20721, -20209, -19697,
-19441, -18929, -18417, -17905, -17649, -17137,
-16625, -16369, -15857, -15345, -14833, -14577,
-14065, -13553, -13041, -12785, -12273, -11761,
-11505, -10993, -10481, -9969, -9713, -9201,
-8689, -8433, -7914, -7394, -6875, -6611,
-6092, -5572, -5053, -4789, -4270, -3750,
-3487, -2967, -2448, -1928, -1665, -1145,
-626, -106, -99, -91, -84, -76,
-69, -61, -54, -46, -39, -31,
-24, -16, -9, -1,
)
/** Official T2E table (646 int32): temp = slope*diff>>12 + (i<<12) - 0x249f0. */
val T2E = intArrayOf(
51, 70, 94, 125, 162, 208, 264, 331,
410, 503, 612, 737, 881, 1045, 1231, 1440,
1675, 1937, 2227, 2547, 2899, 3285, 3705, 4162,
4658, 5192, 5768, 6386, 7047, 7754, 8506, 9305,
10153, 11050, 11997, 12995, 14045, 15147, 16303, 17513,
18778, 20098, 21473, 22905, 24393, 25938, 27540, 29199,
30916, 32690, 34522, 36412, 38360, 40366, 42430, 44552,
46731, 48968, 51262, 53613, 56022, 58487, 61008, 63586,
66220, 68910, 71654, 74454, 77308, 80217, 83179, 86195,
89263, 92385, 95558, 98783, 102059, 105387, 108764, 112191,
115668, 119194, 122768, 126391, 130060, 133777, 137541, 141350,
145205, 149105, 153050, 157039, 161072, 165147, 169266, 173426,
177629, 181873, 186157, 190482, 194847, 199251, 203694, 208175,
212695, 217252, 221847, 226478, 231145, 235849, 240587, 245361,
250169, 255012, 259888, 264797, 269740, 274715, 279722, 284761,
289831, 294933, 300064, 305227, 310419, 315640, 320891, 326170,
331478, 336814, 342178, 347569, 352988, 358433, 363904, 369402,
374925, 380474, 386048, 391647, 397271, 402919, 408591, 414286,
420005, 425747, 431512, 437300, 443109, 448941, 454795, 460670,
466566, 472484, 478422, 484381, 490360, 496359, 502378, 508416,
514474, 520551, 526647, 532761, 538894, 545045, 551215, 557402,
563606, 569828, 576068, 582324, 588597, 594887, 601193, 607516,
613854, 620209, 626579, 632965, 639366, 645782, 652213, 658660,
665120, 671596, 678086, 684589, 691108, 697639, 704185, 710744,
717317, 723903, 730502, 737114, 743740, 750377, 757028, 763691,
770366, 777053, 783753, 790464, 797187, 803922, 810669, 817427,
824196, 830977, 837768, 844571, 851384, 858209, 865043, 871889,
878745, 885611, 892487, 899374, 906270, 913177, 920093, 927019,
933954, 940899, 947854, 954818, 961791, 968773, 975764, 982764,
989773, 996791, 1003818, 1010853, 1017897, 1024949, 1032009, 1039078,
1046155, 1053240, 1060333, 1067435, 1074544, 1081661, 1088785, 1095918,
1103058, 1110205, 1117360, 1124523, 1131692, 1138869, 1146053, 1153245,
1160443, 1167648, 1174860, 1182080, 1189305, 1196538, 1203777, 1211023,
1218276, 1225535, 1232800, 1240072, 1247350, 1254635, 1261925, 1269222,
1276525, 1283834, 1291149, 1298470, 1305797, 1313129, 1320468, 1327812,
1335162, 1342517, 1349878, 1357245, 1364617, 1371995, 1379378, 1386766,
1394160, 1401559, 1408963, 1416372, 1423786, 1431206, 1438631, 1446060,
1453495, 1460934, 1468379, 1475828, 1483282, 1490741, 1498204, 1505672,
1513145, 1520622, 1528104, 1535591, 1543082, 1550578, 1558077, 1565582,
1573090, 1580603, 1588121, 1595642, 1603168, 1610698, 1618232, 1625770,
1633312, 1640859, 1648409, 1655963, 1663522, 1671084, 1678650, 1686220,
1693794, 1701371, 1708953, 1716538, 1724127, 1731719, 1739315, 1746915,
1754519, 1762126, 1769736, 1777350, 1784968, 1792589, 1800214, 1807842,
1815473, 1823108, 1830746, 1838387, 1846032, 1853680, 1861331, 1868986,
1876643, 1884304, 1891968, 1899635, 1907306, 1914979, 1922655, 1930335,
1938017, 1945703, 1953391, 1961082, 1968777, 1976474, 1984174, 1991877,
1999583, 2007292, 2015003, 2022718, 2030435, 2038155, 2045877, 2053602,
2061330, 2069061, 2076794, 2084530, 2092269, 2100010, 2107754, 2115500,
2123249, 2131001, 2138755, 2146511, 2154270, 2162031, 2169795, 2177562,
2185330, 2193101, 2200875, 2208651, 2216429, 2224210, 2231993, 2239778,
2247565, 2255355, 2263147, 2270941, 2278738, 2286537, 2294338, 2302141,
2309946, 2317754, 2325563, 2333375, 2341189, 2349005, 2356823, 2364643,
2372465, 2380290, 2388116, 2395944, 2403775, 2411607, 2419441, 2427278,
2435116, 2442956, 2450798, 2458642, 2466488, 2474336, 2482186, 2490038,
2497891, 2505747, 2513604, 2521463, 2529324, 2537187, 2545051, 2552918,
2560786, 2568656, 2576527, 2584401, 2592276, 2600153, 2608031, 2615911,
2623793, 2631677, 2639562, 2647449, 2655338, 2663228, 2671120, 2679014,
2686909, 2694806, 2702704, 2710604, 2718505, 2726408, 2734313, 2742219,
2750127, 2758036, 2765947, 2773859, 2781773, 2789688, 2797605, 2805524,
2813443, 2821364, 2829287, 2837211, 2845137, 2853064, 2860992, 2868922,
2876853, 2884786, 2892720, 2900655, 2908592, 2916530, 2924470, 2932411,
2940353, 2948296, 2956241, 2964187, 2972135, 2980084, 2988034, 2995985,
3003938, 3011892, 3019847, 3027803, 3035761, 3043720, 3051680, 3059642,
3067605, 3075569, 3083534, 3091500, 3099468, 3107436, 3115406, 3123378,
3131350, 3139323, 3147298, 3155274, 3163251, 3171229, 3179208, 3187189,
3195170, 3203153, 3211137, 3219121, 3227107, 3235095, 3243083, 3251072,
3259062, 3267054, 3275046, 3283040, 3291035, 3299030, 3307027, 3315025,
3323024, 3331023, 3339024, 3347026, 3355029, 3363033, 3371038, 3379044,
3387051, 3395059, 3403068, 3411078, 3419089, 3427101, 3435113, 3443127,
3451142, 3459158, 3467174, 3475192, 3483211, 3491230, 3499250, 3507272,
3515294, 3523317, 3531341, 3539366, 3547392, 3555419, 3563447, 3571475,
3579505, 3587535, 3595566, 3603598, 3611631, 3619665, 3627700, 3635735,
3643772, 3651809, 3659847, 3667886, 3675926, 3683966, 3692008, 3700050,
3708093, 3716137, 3724182, 3732227, 3740273, 3748321, 3756368, 3764417,
3772467, 3780517, 3788568, 3796620, 3804672, 3812726, 3820780, 3828835,
3836891, 3844947, 3853004, 3861062, 3869121, 3877180, 3885240, 3893301,
3901363, 3909425, 3917488, 3925552, 3933617, 3941682, 3949748, 3957814,
3965882, 3973950, 3982019, 3990088, 3998158, 4006229, 4014301, 4022373,
4030446, 4038519, 4046594, 4054669, 4062744, 4070820, 4078897, 4086975,
4095053, 4103132, 4111212, 4119292, 4127373, 4135454, 4143536, 4151619,
4159703, 4167787, 4175871, 4183957, 4192042, 4200129
)
/** Vendor T2E curve from libcoresdk.so ARM64 @0x402010 (274 uint32 entries). */
val T2E274 = intArrayOf(
0x000003e8, 0x000004d3, 0x000005e1, 0x00000714, 0x0000086e, 0x000009f1, 0x00000b9e, 0x00000d76,
0x00000f7b, 0x000011ae, 0x0000140e, 0x0000169d, 0x0000195c, 0x00001c49, 0x00001f67, 0x000022b3,
0x0000262f, 0x000029db, 0x00002db5, 0x000031bd, 0x000035f4, 0x00003a58, 0x00003ee9, 0x000043a7,
0x00004890, 0x00004da4, 0x000052e2, 0x0000584a, 0x00005ddb, 0x00006394, 0x00006974, 0x00006f7a,
0x000075a6, 0x00007bf7, 0x0000826c, 0x00008904, 0x00008fbf, 0x0000969b, 0x00009d98, 0x0000a4b6,
0x0000abf3, 0x0000b34e, 0x0000bac8, 0x0000c25f, 0x0000ca12, 0x0000d1e2, 0x0000d9cc, 0x0000e1d1,
0x0000e9f0, 0x0000f229, 0x0000fa7a, 0x000102e3, 0x00010b64, 0x000113fb, 0x00011ca9, 0x0001256d,
0x00012e46, 0x00013734, 0x00014036, 0x0001494c, 0x00015276, 0x00015bb2, 0x00016501, 0x00016e62,
0x000177d4, 0x00018158, 0x00018aec, 0x00019490, 0x00019e45, 0x0001a809, 0x0001b1dd, 0x0001bbbf,
0x0001c5b0, 0x0001cfaf, 0x0001d9bc, 0x0001e3d7, 0x0001edff, 0x0001f833, 0x00020275, 0x00020cc3,
0x0002171d, 0x00022183, 0x00022bf5, 0x00023672, 0x000240fa, 0x00024b8c, 0x0002562a, 0x000260d2,
0x00026b84, 0x00027640, 0x00028106, 0x00028bd5, 0x000296ae, 0x0002a190, 0x0002ac7b, 0x0002b76f,
0x0002c26b, 0x0002cd70, 0x0002d87d, 0x0002e392, 0x0002eeaf, 0x0002f9d4, 0x00030500, 0x00031034,
0x00031b6f, 0x000326b1, 0x000331fb, 0x00033d4b, 0x000348a2, 0x00035400, 0x00035f64, 0x00036acf,
0x00037640, 0x000381b7, 0x00038d34, 0x000398b7, 0x0003a440, 0x0003afcf, 0x0003bb63, 0x0003c6fd,
0x0003d29c, 0x0003de40, 0x0003e9ea, 0x0003f599, 0x0004014d, 0x00040d05, 0x000418c3, 0x00042486,
0x0004304d, 0x00043c18, 0x000447e9, 0x000453bd, 0x00045f96, 0x00046b74, 0x00047756, 0x0004833b,
0x00048f25, 0x00049b13, 0x0004a705, 0x0004b2fb, 0x0004bef4, 0x0004caf2, 0x0004d6f3, 0x0004e2f8,
0x0004ef00, 0x0004fb0c, 0x0005071b, 0x0005132e, 0x00051f44, 0x00052b5d, 0x0005377a, 0x0005439a,
0x00054fbd, 0x00055be3, 0x0005680c, 0x00057439, 0x00058068, 0x00058c9a, 0x000598cf, 0x0005a507,
0x0005b142, 0x0005bd7f, 0x0005c9bf, 0x0005d602, 0x0005e247, 0x0005ee90, 0x0005fada, 0x00060727,
0x00061377, 0x00061fc9, 0x00062c1e, 0x00063875, 0x000644ce, 0x00065129, 0x00065d87, 0x000669e7,
0x0006764a, 0x000682ae, 0x00068f15, 0x00069b7e, 0x0006a7e9, 0x0006b456, 0x0006c0c5, 0x0006cd36,
0x0006d9a9, 0x0006e61e, 0x0006f295, 0x0006ff0e, 0x00070b89, 0x00071805, 0x00072484, 0x00073104,
0x00073d86, 0x00074a0a, 0x0007568f, 0x00076317, 0x00076fa0, 0x00077c2a, 0x000788b7, 0x00079545,
0x0007a1d4, 0x0007ae66, 0x0007baf8, 0x0007c78d, 0x0007d423, 0x0007e0ba, 0x0007ed53, 0x0007f9ed,
0x00080689, 0x00081327, 0x00081fc5, 0x00082c65, 0x00083907, 0x000845aa, 0x0008524e, 0x00085ef4,
0x00086b9b, 0x00087843, 0x000884ed, 0x00089197, 0x00089e44, 0x0008aaf1, 0x0008b7a0, 0x0008c44f,
0x0008d100, 0x0008ddb3, 0x0008ea66, 0x0008f71b, 0x000903d0, 0x00091087, 0x00091d3f, 0x000929f9,
0x000936b3, 0x0009436e, 0x0009502b, 0x00095ce8, 0x000969a7, 0x00097666, 0x00098327, 0x00098fe9,
0x00099cab, 0x0009a96f, 0x0009b634, 0x0009c2f9, 0x0009cfc0, 0x0009dc88, 0x0009e950, 0x0009f61a,
0x000a02e4, 0x000a0faf, 0x000a1c7c, 0x000a2949, 0x000a3617, 0x000a42e6, 0x000a4fb5, 0x000a5c86,
0x000a6958, 0x000a762a, 0x000a82fd, 0x000a8fd1, 0x000a9ca6, 0x000aa97c, 0x000ab652, 0x000ac329,
0x000ad001, 0x000adcda, 0x000ae9b4, 0x000af68e, 0x000b0369, 0x000b1045, 0x000b1d22, 0x000b29ff,
0x000b36dd, 0x000b43bc
)
/** Vendor E2TAccQ10 curve from libcoresdk.so ARM64 @0x40245c (274 uint32 entries). */
val E2T_ACC_Q10 = intArrayOf(
0x00008b70, 0x0000795d, 0x00006abc, 0x00005eb5, 0x000054ac, 0x00004c62, 0x0000456c, 0x00003f62,
0x00003a34, 0x000035e5, 0x00003207, 0x00002e9d, 0x00002bc0, 0x00002910, 0x000026d3, 0x000024bc,
0x000022dc, 0x0000213c, 0x00001fc0, 0x00001e5e, 0x00001d27, 0x00001c08, 0x00001afe, 0x00001a12,
0x00001935, 0x0000186b, 0x000017ad, 0x000016ff, 0x0000165e, 0x000015ca, 0x00001540, 0x000014bd,
0x00001444, 0x000013d3, 0x0000136a, 0x00001305, 0x000012a9, 0x00001251, 0x000011fc, 0x000011af,
0x00001167, 0x0000111f, 0x000010dd, 0x000010a0, 0x00001062, 0x0000102c, 0x00000ff6, 0x00000fc3,
0x00000f91, 0x00000f64, 0x00000f38, 0x00000f0d, 0x00000ee7, 0x00000ebf, 0x00000e9a, 0x00000e78,
0x00000e56, 0x00000e36, 0x00000e16, 0x00000df8, 0x00000ddc, 0x00000dc0, 0x00000da6, 0x00000d8d,
0x00000d74, 0x00000d5d, 0x00000d47, 0x00000d30, 0x00000d1b, 0x00000d06, 0x00000cf4, 0x00000ce0,
0x00000cce, 0x00000cbc, 0x00000cab, 0x00000c9a, 0x00000c8c, 0x00000c7a, 0x00000c6c, 0x00000c5e,
0x00000c4f, 0x00000c41, 0x00000c34, 0x00000c28, 0x00000c1c, 0x00000c0e, 0x00000c03, 0x00000bf8,
0x00000bed, 0x00000be2, 0x00000bd8, 0x00000bcd, 0x00000bc3, 0x00000bb9, 0x00000bb0, 0x00000ba7,
0x00000b9e, 0x00000b95, 0x00000b8d, 0x00000b85, 0x00000b7c, 0x00000b75, 0x00000b6d, 0x00000b66,
0x00000b5f, 0x00000b57, 0x00000b51, 0x00000b4a, 0x00000b43, 0x00000b3d, 0x00000b36, 0x00000b30,
0x00000b2a, 0x00000b24, 0x00000b1e, 0x00000b19, 0x00000b13, 0x00000b0e, 0x00000b08, 0x00000b04,
0x00000aff, 0x00000af9, 0x00000af5, 0x00000af0, 0x00000aec, 0x00000ae7, 0x00000ae2, 0x00000ade,
0x00000adb, 0x00000ad5, 0x00000ad2, 0x00000ace, 0x00000ac9, 0x00000ac6, 0x00000ac3, 0x00000abe,
0x00000abb, 0x00000ab7, 0x00000ab4, 0x00000ab1, 0x00000aac, 0x00000aaa, 0x00000aa6, 0x00000aa4,
0x00000aa0, 0x00000a9d, 0x00000a9a, 0x00000a97, 0x00000a95, 0x00000a91, 0x00000a8f, 0x00000a8c,
0x00000a89, 0x00000a87, 0x00000a83, 0x00000a82, 0x00000a7f, 0x00000a7c, 0x00000a7a, 0x00000a77,
0x00000a76, 0x00000a73, 0x00000a70, 0x00000a6f, 0x00000a6b, 0x00000a6a, 0x00000a68, 0x00000a65,
0x00000a64, 0x00000a61, 0x00000a5f, 0x00000a5e, 0x00000a5c, 0x00000a5a, 0x00000a58, 0x00000a55,
0x00000a55, 0x00000a52, 0x00000a50, 0x00000a4f, 0x00000a4d, 0x00000a4b, 0x00000a4a, 0x00000a48,
0x00000a46, 0x00000a45, 0x00000a43, 0x00000a42, 0x00000a41, 0x00000a3e, 0x00000a3d, 0x00000a3c,
0x00000a3a, 0x00000a39, 0x00000a37, 0x00000a36, 0x00000a35, 0x00000a33, 0x00000a32, 0x00000a31,
0x00000a2f, 0x00000a2f, 0x00000a2c, 0x00000a2c, 0x00000a2b, 0x00000a29, 0x00000a28, 0x00000a27,
0x00000a25, 0x00000a25, 0x00000a23, 0x00000a22, 0x00000a21, 0x00000a20, 0x00000a1f, 0x00000a1e,
0x00000a1d, 0x00000a1b, 0x00000a1b, 0x00000a19, 0x00000a19, 0x00000a17, 0x00000a17, 0x00000a16,
0x00000a14, 0x00000a14, 0x00000a13, 0x00000a13, 0x00000a11, 0x00000a10, 0x00000a0f, 0x00000a0f,
0x00000a0e, 0x00000a0c, 0x00000a0c, 0x00000a0b, 0x00000a0b, 0x00000a09, 0x00000a08, 0x00000a08,
0x00000a07, 0x00000a06, 0x00000a06, 0x00000a05, 0x00000a04, 0x00000a04, 0x00000a02, 0x00000a02,
0x00000a01, 0x00000a00, 0x00000a00, 0x000009ff, 0x000009fe, 0x000009fe, 0x000009fd, 0x000009fc,
0x000009fc, 0x000009fb, 0x000009fa, 0x000009fa, 0x000009f9, 0x000009f9, 0x000009f8, 0x000009f7,
0x000009f7, 0x000009f6, 0x000009f6, 0x000009f5, 0x000009f4, 0x000009f3, 0x000009f3, 0x000009f3,
0x000009f2, 0x000009f1
)
}
@@ -0,0 +1,105 @@
package com.mag160c.thermal.core
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.sin
import kotlin.math.sqrt
/**
* The 12 display palettes of the official apps. Index 2 (ironbow) uses the
* palette extracted from CoreSDKLib; the rest are standard thermal curves.
*/
object Palettes {
val NAMES = listOf(
"白热", "黑热", "铁虹", "彩虹", "琥珀", "金秋",
"寒冬", "热金属", "喷射", "红饱和", "高对比度", "红热",
)
val NAMES_EN = listOf(
"White hot", "Black hot", "Ironbow", "Rainbow", "Amber", "Autumn",
"Winter", "Hot metal", "Jet", "Red saturation", "High contrast", "Red hot",
)
/** Build all palettes as ARGB int arrays (256 entries each). */
fun buildAll(): List<IntArray> = listOf(
ramp(255, 255, 255, 0, 0, 0), // 0 white hot
ramp(0, 0, 0, 255, 255, 255), // 1 black hot
officialIronbow(), // 2 ironbow (vendor)
rainbow(), // 3 rainbow
ramp(0, 0, 0, 255, 183, 74), // 4 amber
ramp(0, 0, 0, 255, 220, 120), // 5 autumn
ramp(0, 0, 0, 200, 230, 255), // 6 winter
hotMetal(), // 7 hot metal
jet(), // 8 jet
ramp(0, 0, 0, 255, 0, 0), // 9 red saturation
highContrast(), // 10 high contrast
ramp(0, 0, 0, 128, 0, 0), // 11 red hot
)
private fun argb(r: Int, g: Int, b: Int): Int =
(0xFF shl 24) or (r.coerceIn(0, 255) shl 16) or (g.coerceIn(0, 255) shl 8) or b.coerceIn(0, 255)
private fun ramp(r0: Int, g0: Int, b0: Int, r1: Int, g1: Int, b1: Int): IntArray {
val out = IntArray(256)
for (i in 0 until 256) {
val t = i / 255f
out[i] = 0xFF000000.toInt() or
((r0 + (r1 - r0) * t).toInt() shl 16) or
((g0 + (g1 - g0) * t).toInt() shl 8) or
(b0 + (b1 - b0) * t).toInt()
}
return out
}
/** Vendor ironbow: reuse the generated official table. */
private fun officialIronbow(): IntArray = OfficialTables.PALETTE256_ARGB
/** Classic ironbow-style fallback used by index 3 rainbow curve. */
private fun rainbow(): IntArray {
val out = IntArray(256)
for (i in 0 until 256) {
val t = i / 255.0
val r = (255 * clamp(1.5 - t.coerceIn(0.0, 1.0) * 4)).toInt()
val g = (255 * clamp(1.5 - kotlin.math.abs(t - 0.5) * 4)).toInt()
val b = (255 * clamp(t * 2.2)).toInt()
out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b
}
return out
}
private fun highContrast(): IntArray {
val out = IntArray(256)
for (i in 0 until 256) {
val v = if (i < 128) (i * 2) else 255
out[i] = 0xFF000000.toInt() or (v shl 16) or (v shl 8) or v
}
return out
}
private fun hotMetal(): IntArray {
val out = IntArray(256)
for (i in 0 until 256) {
val t = i / 255.0
val r = (255 * clamp(t * 1.6)).toInt()
val g = (255 * clamp(t * t * 1.9 - 0.25)).toInt()
val b = (255 * clamp(t * t * t * 1.6 - 0.6)).toInt()
out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b
}
return out
}
private fun jet(): IntArray {
val out = IntArray(256)
for (i in 0 until 256) {
val t = i / 255.0
val r = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 3.0))).toInt()
val g = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 2.0))).toInt()
val b = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 1.0))).toInt()
out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b
}
return out
}
private fun clamp(v: Double): Double = if (v < 0) 0.0 else if (v > 1) 1.0 else v
}
@@ -0,0 +1,692 @@
package com.mag160c.thermal.core
import kotlin.math.sqrt
/**
* Official MAG160C rendering pipeline, ported from csdk/src/mag160c_render.c
* (pixel-verified against CoreSDKLib.dll).
*
* raw frame -> shutter extract -> FFC state machine -> endpoint select
* + Q12 table interp -> ref (4x type-1 mean) -> NUC lookup -> blind
* compensation -> stats/window -> LUT1024 rebuild -> gray -> 2x upscale
* -> palette -> ARGB output.
*
* Feed USB frames; get a 320x240 ARGB IntArray ready for Bitmap.setPixels.
* FFC commands are emitted through [onFfc] so the caller owns the transport.
*/
class RenderPipeline(
private val w: Int = 160,
private val h: Int = 120,
private val ffcPeriod: Int = 1800,
private val ffcDrift: Int = 250,
private val warmFrames: Int = 20,
private val cdfPivot75: Boolean = true,
private val force75: Boolean = true,
/** FFC command callback: param 0 = FFC(0), 1 = FFC(1). */
private val onFfc: ((param: Int) -> Unit)? = null,
) {
private val npix = w * h
// ---- DDT state ----
private var epCount = 0
private var nsegs = 0
private var c5c = 0
private var c60 = 0
private var dev24 = 5
private var dev4cShift = 3
private var dev48 = 0
private val epTemps = IntArray(64)
private val blindCnt = IntArray(64)
private val epThr = arrayOfNulls<ShortArray>(64)
private val epGain = arrayOfNulls<IntArray>(64)
private val epBlind = arrayOfNulls<IntArray>(64)
private var sel = 0
private var thrWork = ShortArray(0)
private var gainWork = IntArray(0)
private var blindWork: IntArray? = null
private var blindCount = 0
// ---- frame/FFC state ----
private var ffcIdx = -1
private var globalFrames = 0
private var shutter = 0
private var shutterAtFfc = 0
private var manualFfc = false
private var hasRef = false
private var refCnt = 0
private var startupFfcDone = false
private val ref = IntArray(npix)
private val refAcc = IntArray(npix)
private val nuc = IntArray(npix)
private val gray160 = ByteArray(npix)
private val gray320 = ByteArray(npix * 4)
// ---- render state ----
private val hist1024 = IntArray(1024)
private val cdf = IntArray(1024)
private val lut = ByteArray(1024)
private val curve = IntArray(1024)
private var pal: IntArray = OfficialTables.PALETTE256_ARGB
private var winLo = 0
private var winHi = 0
private var statMin = 0
private var statMax = 0
private var statMean = 0
private var statStd = 0
private var centerPrev = 341
private var meanPrev = 0
// ---- output ----
val outWidth: Int = w * 2
val outHeight: Int = h * 2
private val lock = Any()
/** Load the official DDT calibration data (raw file bytes). */
fun loadDdt(bytes: ByteArray): Boolean = synchronized(lock) {
if (bytes.size < 0x98) return false
val magic = ByteReader.rd32(bytes, 0)
if (magic != 0x5AA50003 && magic != 0x5AA50004) return false
if (ByteReader.rd32(bytes, 4) != w || ByteReader.rd32(bytes, 8) != h) return false
epCount = ByteReader.rd32(bytes, 12)
nsegs = ByteReader.rd32(bytes, 16)
if (epCount < 2 || epCount > 64 || nsegs < 1 || nsegs > 8) return false
val v4 = magic == 0x5AA50004
dev24 = ByteReader.rd32(bytes, 20)
dev4cShift = 3
if (!v4) {
c5c = ByteReader.rd32(bytes, 28)
c60 = ByteReader.rd32(bytes, 32)
if (c5c > 0x10000) c5c = 0
} else {
c5c = ByteReader.rd32(bytes, 0x2C + 0x120)
c60 = ByteReader.rd32(bytes, 0x30 + 0x120)
}
if (c5c and 1 == 1) c5c = c5c and 1.inv()
val arro = if (v4) 0x120 + 36 else 36
var off = arro
if (off + epCount * 12 > bytes.size) return false
for (e in 0 until epCount) {
epTemps[e] = ByteReader.rd32(bytes, off + e * 4)
}
off += epCount * 4
off += epCount * 4 // skip array 2
off += epCount * 4 // skip array 3
if (off + (epCount - 1) * 4 > bytes.size) return false
for (e in 0 until epCount - 1) {
blindCnt[e] = ByteReader.rd32(bytes, off + e * 4)
if (blindCnt[e] > 4096) blindCnt[e] = 4096
}
blindCnt[epCount - 1] = 0
off += (epCount - 1) * 4
val thrSz = (nsegs - 1) * npix + c5c / 2
val gainSz = nsegs * npix * 2
for (e in 0 until epCount) {
if (off + thrSz * 2 + gainSz * 2 > bytes.size) return false
val thr = ShortArray(thrSz)
for (i in 0 until thrSz) {
thr[i] = ByteReader.rd16(bytes, off + i * 2).toShort()
}
off += thrSz * 2
val gain = IntArray(gainSz)
ByteReader.decodeU16(bytes, off, gainSz, gain)
off += gainSz * 2
epThr[e] = thr
epGain[e] = gain
}
for (e in 0 until epCount - 1) {
val n = blindCnt[e]
if (n > 0) {
if (off + n * 40 > bytes.size) return false
val rec = IntArray(n * 10)
for (i in 0 until n * 10) {
rec[i] = ByteReader.rd32(bytes, off + i * 4)
}
off += n * 40
epBlind[e] = rec
} else {
epBlind[e] = null
}
}
thrWork = ShortArray(thrSz)
gainWork = IntArray(gainSz)
return true
}
private fun selectEndpoint(shutter: Int): Int {
var s = 0
if (epCount != 2) {
while (s < epCount - 2 && shutter > epTemps[s + 1]) s++
}
return s
}
private fun rebuildTables(shutter: Int) {
sel = selectEndpoint(shutter)
val t = (((shutter - epTemps[sel]) shl 12) / (epTemps[sel + 1] - epTemps[sel]))
.coerceIn(-0x3FFF, 0x3FFF)
val a = epThr[sel]!!
val b = epThr[sel + 1]!!
val nThr = (nsegs - 1) * npix + c5c / 2
for (i in 0 until nThr) {
val av = a[i].toInt()
thrWork[i] = (av + (((b[i].toInt() - av) * t) shr 12)).toShort()
}
val ga = epGain[sel]!!
val gb = epGain[sel + 1]!!
val nGain = nsegs * npix * 2
for (i in 0 until nGain) {
gainWork[i] = ga[i] + (((gb[i] - ga[i]) * t) shr 12)
}
blindWork = epBlind[sel]
blindCount = blindCnt[sel]
}
private fun refPush(frame: IntArray) {
if (refCnt == 0) {
for (i in 0 until npix) refAcc[i] = frame[i]
} else {
for (i in 0 until npix) refAcc[i] += frame[i]
}
refCnt++
if (refCnt >= 4) {
for (i in 0 until npix) ref[i] = refAcc[i] shr 2
refCnt = 0
hasRef = true
}
}
private fun nucAndBlind(f20: IntArray, out: IntArray) {
val thr = thrWork
val gg = gainWork
for (i in 0 until npix) {
val d2 = (f20[i] - ref[i]) shr 1
var seg = 0
if (d2 > thr[i * 2].toInt()) {
seg = 1
if (d2 > thr[i * 2 + 1].toInt()) seg = 2
}
val base = (seg * npix + i) * 2
var v = gg[base + 1] + ((gg[base] * d2) shr 12)
if (v < 0) v = 0 else if (v > 65535) v = 65535
out[i] = v
}
val bw = blindWork
if (bw != null && blindCount > 0) {
for (k in 0 until blindCount) {
val base = k * 10
val target = bw[base]
val typ = bw[base + 1]
if (target >= npix || typ < 3 || typ > 8) continue
var sum = 0L
for (j in 0 until typ) sum += out[bw[base + 2 + j]]
when (typ) {
8 -> out[target] = (sum shr 3).toInt()
4 -> out[target] = (sum shr 2).toInt()
else -> out[target] = (sum / typ).toInt()
}
}
}
}
private fun statsWindow() {
var ssum = 0L
var s2sum = 0L
var mn = 0xFFFF
var mx = 0
for (i in 0 until npix) {
val v = nuc[i]
ssum += v
s2sum += v.toLong() * v
if (v < mn) mn = v
if (v > mx) mx = v
}
statMin = mn
statMax = mx
statMean = (ssum / npix).toInt()
val dmean = ssum.toDouble() / npix
val variance = s2sum.toDouble() / npix - dmean * dmean
statStd = sqrt(if (variance > 0) variance else 0.0).toInt()
if (mn == mx) {
winLo = mn
winHi = mn
lut.fill(0x80.toByte())
gray160.fill(0x80.toByte())
return
}
var base = (dev24 * 1000) shr dev4cShift
if (base < 0x80) base = 0x80
val half = base shr 1
var lo = statMean - half
if (lo > statMin) lo = statMin
if (lo < 0) lo = 0
var hi = statMean + half
if (hi < statMax) hi = statMax
if (hi > 65535) hi = 65535
winLo = lo
winHi = hi
if (winHi <= winLo) winHi = winLo + 1
}
private fun lutRebuild() {
// The vendor code is 32-bit unsigned arithmetic; products/subtractions
// wrap mod 2^32. u32() replicates that so results match bit-for-bit.
val span = (winHi - winLo).coerceAtLeast(1)
val s = 0xFFC00000L / span
hist1024.fill(0)
for (i in 0 until npix) {
val idx = u32((nuc[i] - winLo).toLong() * s) shr 0x16
if (idx < 1024) hist1024[idx.toInt()]++
}
var i = 0
while (i < 1023) {
hist1024[i] = (hist1024[i + 1] + hist1024[i]) shr 1
hist1024[i + 1] = (hist1024[i + 2] + hist1024[i + 1]) shr 1
hist1024[i + 2] = (hist1024[i + 3] + hist1024[i + 2]) shr 1
i += 3
}
var total = 0L
for (k in 0 until 1024) total += hist1024[k]
var u21 = total shr 12
if (u21 == 0L) u21 = 1
var lres = ((statMean - winLo).toLong() * 0x3FF / span).toInt()
if (lres < 0) lres = 0
if (lres > 1023) lres = 1023
if (cdfPivot75) {
var acc = 0L
var k = 0
while (k < 1024) {
acc += hist1024[k]
if (acc >= (total * 3 shr 2)) break
k++
}
if (k < 1024 && k > lres) lres = k
}
var u11 = 0L
var u13 = 0L
var k = lres
while (k >= 0) {
u11 += hist1024[k]
if (u21 <= u11) {
u13 += 0x100
u11 = 0
}
cdf[k] = (((0x10000L / u21) * u11 shr 8) + u13).toInt()
k--
}
u11 = 0
u13 = 0
k = lres + 1
while (k < 1024) {
u11 += hist1024[k]
if (u11 < u21 * 8) {
if (u21 <= u11) {
u13 += 0x100
u11 = 0
}
} else {
u13 += 0x200
u11 = 0
}
cdf[k] = (((0x10000L / u21) * u11 shr 8) + u13).toInt()
k++
}
for (j in 0 until 1024) {
val v = ((0x300000L + j * 0x800L) shr 0xD).toInt()
curve[j] = if (v != 0) v else 1
}
var iv6 = dev48 * 0x155 / 200
var iv7 = iv6 + 0x155
var iv26 = iv7
if (iv6 + 0x156 > 0x331) iv26 = 0x330
var iv10 = 0x332
var b3 = true
val iv22 = 0xFFC00000L / span
while (iv26 + 1 < iv10) {
iv7 = (iv10 + iv26) / 2
if (b3) {
b3 = false
iv7 = iv26
}
var u5 = cdf[0].toLong()
if (u5 != 0L) {
val denom = (iv7 * 0x40000L + 0x40000L) / u5
val base = iv7 * 0x10L + 0x10L
var u21v = base
var j = lres
while (j >= 0) {
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
var u12 = u32(base - shifted)
val over = u32(u21v - u12)
if (curve[j] < over) u12 = u32(u21v - curve[j])
lut[j] = (u12 shr 6).toByte()
u21v = u12
j--
}
}
u5 = cdf[1023].toLong()
if (u5 != 0L) {
val denom = u32(0xFFC0000L - iv7 * 0x40000L) / u5
val base = iv7 * 0x10L
var u21v = base
var j = lres + 1
while (j < 1024) {
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
var u12 = u32(shifted + base)
val over = u32(u12 - u21v)
if (curve[j] < over) u12 = u32(curve[j] + u21v)
lut[j] = (u12 shr 6).toByte()
u21v = u12
j++
}
}
var iv4 = iv7
val bfmin = u32(u32((statMin - winLo).toLong()) * iv22) shr 0x16
val bfmax = u32(u32((statMax - winLo).toLong()) * iv22) shr 0x16
if (bfmin < 1024 && bfmax < 1024 &&
(lut[bfmin.toInt()].toInt() and 0xFF) == 0 && (lut[bfmax.toInt()].toInt() and 0xFF) != 0xFF
) {
iv4 = iv10
iv26 = iv7
}
iv10 = iv4
}
var dm = statMean - meanPrev
if (dm < 0) dm = -dm
meanPrev = statMean
var iv18 = iv10 + ((dm and 0xFFFF) shl dev4cShift) * 2 + statMean
if (iv18 < 0x9C4) {
if (iv18 < 100) iv18 = 100
iv7 = (iv18 * iv7 + centerPrev * 500) / (iv18 + 500)
centerPrev = iv7
var u5 = cdf[0].toLong()
if (u5 != 0L) {
val denom = (iv7 * 0x40000L + 0x40000L) / u5
val base = iv7 * 0x10L + 0x10L
var u21v = base
var j = lres
while (j >= 0) {
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
var u12 = u32(base - shifted)
val over = u32(u21v - u12)
if (curve[j] < over) u12 = u32(u21v - curve[j])
lut[j] = (u12 shr 6).toByte()
u21v = u12
j--
}
}
u5 = cdf[1023].toLong()
if (u5 != 0L) {
val denom = u32(0xFFC0000L - iv7 * 0x40000L) / u5
val base = iv7 * 0x10L
var u21v = base
var j = lres + 1
while (j < 1024) {
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
var u12 = u32(shifted + base)
val over = u32(u12 - u21v)
if (curve[j] < over) u12 = u32(curve[j] + u21v)
lut[j] = (u12 shr 6).toByte()
u21v = u12
j++
}
}
}
}
private fun grayMap() {
val s = 0xFFC00000L / (winHi - winLo).coerceAtLeast(1)
for (i in 0 until npix) {
val v = nuc[i]
val idx = when {
v <= winLo -> 0L
v >= winHi -> 1023L
else -> u32((v - winLo).toLong() * s) shr 0x16
}
gray160[i] = lut[idx.coerceAtMost(1023L).toInt()]
}
}
/** Set the display palette (0..11, see [Palettes]); default = official ironbow. */
fun setPalette(index: Int) = synchronized(lock) {
pal = Palettes.buildAll()[index.coerceIn(0, Palettes.NAMES.size - 1)]
}
/** 32-bit unsigned wrap (C unsigned int semantics). */
private fun u32(x: Long): Long = x and 0xFFFFFFFFL
private fun upscale2x() {
val W = w
val H = h
val W2 = W * 2
val s = gray160
val o = gray320
for (y in 0 until H - 1) {
val r0 = y * W
val r1 = r0 + W
val o0 = y * W2 * 2
val o1 = o0 + W2
var x = 0
while (x < W - 2) {
val a0 = s[r0 + x].toInt() and 0xFF
val a1 = s[r0 + x + 1].toInt() and 0xFF
val a2 = s[r0 + x + 2].toInt() and 0xFF
val b0 = s[r1 + x].toInt() and 0xFF
val b1 = s[r1 + x + 1].toInt() and 0xFF
val b2 = s[r1 + x + 2].toInt() and 0xFF
val c = x * 2
o[o0 + c] = a0.toByte()
o[o0 + c + 1] = ((a0 + a1) shr 1).toByte()
o[o0 + c + 2] = a1.toByte()
o[o0 + c + 3] = ((a2 + a1) shr 1).toByte()
o[o1 + c] = ((b0 + a0) shr 1).toByte()
o[o1 + c + 1] = ((b0 + b1 + a0 + a1) shr 2).toByte()
o[o1 + c + 2] = ((b1 + a1) shr 1).toByte()
o[o1 + c + 3] = ((b2 + b1 + a2 + a1) shr 2).toByte()
x += 2
}
val a0 = s[r0 + W - 2].toInt() and 0xFF
val a1 = s[r0 + W - 1].toInt() and 0xFF
val b0 = s[r1 + W - 2].toInt() and 0xFF
val b1 = s[r1 + W - 1].toInt() and 0xFF
var c = (W - 2) * 2
o[o0 + c] = a0.toByte()
o[o0 + c + 1] = ((a1 + a0) shr 1).toByte()
o[o1 + c] = ((b0 + a0) shr 1).toByte()
o[o1 + c + 1] = ((a1 + b1 + b0 + a0) shr 2).toByte()
c = (W - 1) * 2
o[o0 + c] = a1.toByte()
o[o0 + c + 1] = (((3 * a1) shr 2) + (a0 shr 2)).toByte()
o[o1 + c] = ((b1 + a1) shr 1).toByte()
o[o1 + c + 1] = ((3 * (b1 + a1) + b0 + a0) shr 3).toByte()
}
val y = H - 1
val r0 = y * W
val r1 = r0 - W
val o0 = y * W2 * 2
val o1 = o0 + W2
var x = 0
while (x < W - 2) {
val a0 = s[r0 + x].toInt() and 0xFF
val a1 = s[r0 + x + 1].toInt() and 0xFF
val a2 = s[r0 + x + 2].toInt() and 0xFF
val u0 = s[r1 + x].toInt() and 0xFF
val u1 = s[r1 + x + 1].toInt() and 0xFF
val u2 = s[r1 + x + 2].toInt() and 0xFF
val c = x * 2
o[o0 + c] = a0.toByte()
o[o0 + c + 1] = ((a0 + a1) shr 1).toByte()
o[o0 + c + 2] = a1.toByte()
o[o0 + c + 3] = ((a2 + a1) shr 1).toByte()
o[o1 + c] = (((3 * a0) shr 2) + (u0 shr 2)).toByte()
o[o1 + c + 1] = ((3 * (a0 + a1) + u0 + u1) shr 3).toByte()
o[o1 + c + 2] = (((3 * a1) shr 2) + (u1 shr 2)).toByte()
o[o1 + c + 3] = ((3 * (a2 + a1) + u2 + u1) shr 3).toByte()
x += 2
}
val a0 = s[r0 + W - 2].toInt() and 0xFF
val a1 = s[r0 + W - 1].toInt() and 0xFF
val u0 = s[r1 + W - 2].toInt() and 0xFF
val u1 = s[r1 + W - 1].toInt() and 0xFF
var c = (W - 2) * 2
o[o0 + c] = a0.toByte()
o[o0 + c + 1] = ((a1 + a0) shr 1).toByte()
o[o1 + c] = (((3 * a0) shr 2) + (u0 shr 2)).toByte()
o[o1 + c + 1] = ((3 * (a0 + a1) + u0 + u1) shr 3).toByte()
c = (W - 1) * 2
o[o0 + c] = a1.toByte()
o[o0 + c + 1] = ((3 * a1 + a0) shr 2).toByte()
o[o1 + c] = ((3 * a1 + u1) shr 2).toByte()
o[o1 + c + 1] = (((o[o0 + c + 1].toInt() and 0xFF) + (o[o1 + c].toInt() and 0xFF) + a1) / 3).toByte()
}
private fun ffcStep(shutterIn: Int): Int {
if (ffcIdx < 0) {
onFfc?.invoke(0)
ffcIdx = 0
shutterAtFfc = shutterIn
}
ffcIdx++
val idx = ffcIdx
if (idx <= 5) {
if (idx == 1) rebuildTables(shutterAtFfc)
return 1
}
if (idx <= 9) {
if (idx == 9) onFfc?.invoke(1)
if (idx == 6) {
refCnt = 0
hasRef = false
}
return 2 // ref collection window
}
if (idx <= 13) return 1
// normal frame: FFC condition
var drift = shutterIn - shutterAtFfc
if (drift < 0) drift = -drift
val cool = 13
var doFfc = false
if (manualFfc) {
manualFfc = false
doFfc = true
}
if (force75 && !startupFfcDone && idx == 75) {
startupFfcDone = true
doFfc = true
}
if (idx >= 4 + ffcPeriod) {
doFfc = true
} else if (idx >= cool + 30 && drift > ffcDrift) {
doFfc = true
}
if (doFfc) {
onFfc?.invoke(0)
ffcIdx = 0
shutterAtFfc = shutterIn
return 1
}
return 0
}
/**
* Process one raw frame.
* @param frame USB frame buffer (with or without the 0x38-byte header)
* @param hasHdr true when the 28-byte header is at frame[0]
* @param outArgb 320x240 ARGB IntArray output (outW*outH entries)
* @return true when outArgb was written; false while in the startup/FFC window.
*/
fun frame(frame: ByteArray, hasHdr: Boolean, outArgb: IntArray): Boolean = synchronized(lock) {
globalFrames++
if (globalFrames < warmFrames) return false
var shutterValue = shutter
if (hasHdr) {
val len = ByteReader.rd32(frame, 8)
if (len == npix * 2) {
shutterValue = ByteReader.rd32(frame, 0x1C + len + 8)
}
}
shutter = shutterValue
val st = ffcStep(shutterValue)
if (st != 0) {
if (st == 2) {
val off = if (hasHdr) 0x1C else 0
ByteReader.decodeU16(frame, off, npix, nuc)
refPush(nuc)
}
return false
}
val off = if (hasHdr) 0x1C else 0
ByteReader.decodeU16(frame, off, npix, nuc)
if (!hasRef) return false
nucAndBlind(nuc, nuc)
statsWindow()
lutRebuild()
grayMap()
upscale2x()
// palette colorize 320x240 -> ARGB
val pal = this.pal
var i = 0
while (i < npix * 4) {
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
i++
}
return true
}
fun setShutter(value: Int) {
synchronized(lock) { shutter = value }
}
/** Manual FFC trigger (official FFC button). */
fun requestFfc() {
synchronized(lock) { manualFfc = true }
}
data class Stats(
val winLo: Int, val winHi: Int,
val min: Int, val max: Int,
val mean: Int, val std: Int,
)
fun stats(): Stats = synchronized(lock) {
Stats(winLo, winHi, statMin, statMax, statMean, statStd)
}
fun hasReference(): Boolean = synchronized(lock) { hasRef }
fun frameIndex(): Int = synchronized(lock) { ffcIdx }
fun endpoint(): Int = synchronized(lock) { sel }
/** Temperature probe on the current NUC output, in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int = synchronized(lock) {
TempMath.countsToTempMc(nuc[y * w + x])
}
/** Read the current NUC counts buffer (already blind-compensated). */
fun copyNuc(out: IntArray) = synchronized(lock) {
System.arraycopy(nuc, 0, out, 0, minOf(npix, out.size))
}
/** Debug: full intermediate state, same layout as analysis/debug_lut.c. */
internal fun debugState(): String = synchronized(lock) {
val sb = StringBuilder()
sb.append("win $winLo $winHi\n")
sb.append("hist")
for (v in hist1024) sb.append(' ').append(v)
sb.append("\ncdf")
for (v in cdf) sb.append(' ').append(v)
sb.append("\nlut")
for (v in lut) sb.append(' ').append(v.toInt() and 0xFF)
sb.append("\ngray")
for (v in gray160) sb.append(' ').append(v.toInt() and 0xFF)
sb.append("\ngray320")
for (v in gray320) sb.append(' ').append(v.toInt() and 0xFF)
sb.append('\n')
sb.toString()
}
}
@@ -0,0 +1,52 @@
package com.mag160c.thermal.core
/**
* Temperature math ported from csdk/src/mag160c_render.c counts_to_temp_mc
* and csdk/src/mag160c_temp.c t2e_interp. All temperatures are integer
* millidegrees Celsius (0.001 C units).
*/
object TempMath {
private const val T2E_OFFSET = 0x249F0
private const val TEMP_C = 5797
private val T2E = OfficialTables.T2E
private val T2E274 = OfficialTables.T2E274
/**
* Convert NUC counts to millidegrees C via the official T2E curve
* (646-entry inverse map, Q12 interpolation).
*/
fun countsToTempMc(counts: Int): Int {
var x = counts.toLong() * 3 - TEMP_C
if (x < 0) x = 0
var lo = 0
var hi = 645
while (lo < hi) {
val mid = (lo + hi + 1) shr 1
if (T2E[mid] <= x) lo = mid else hi = mid - 1
}
var i = lo
if (i > 644) i = 644
val diff = x - T2E[i]
val t2 = (T2E[i + 1] - T2E[i]).toLong()
val slope = if (t2 != 0L) (0x1000000L + t2 / 2) / t2 else 0L
val temp = ((slope * diff) shr 12) + (i.toLong() shl 12) - T2E_OFFSET
return temp.toInt()
}
/**
* T2E piecewise-linear evaluation with Q13 band selection
* (vendor ReviseTemperature/CorrectTemperature core, 274-entry curve).
*/
fun t2eInterp(value: Int): Int {
val v = value + 0xC350
var idx = v shr 13
if (idx < 0) idx = 0 else if (idx > T2E274.size - 2) idx = T2E274.size - 2
val t0 = T2E274[idx]
val t1 = T2E274[idx + 1]
val d = v - (idx shl 13)
val prod = d * (t1 - t0)
var interp = prod shr 13
if (interp < 0 && (prod and 0x1FFF) != 0) interp += 1
return t0 + interp
}
}
@@ -0,0 +1,133 @@
package com.mag160c.thermal.media
import java.io.ByteArrayOutputStream
/**
* MDT thermal file container, mirroring the vendor ThermoScope layout
* (docs/android_app/reverse_apk_features.md §4, all little-endian):
*
* [JPEG section: raw jpg bytes at offset 0, padded to 4]
* [DDT section: 136B header (code 0x5BB5B55B + size + reserved 128) + body]
* [Tail: 152B — code 0x5BB5B57B + ddtOffset + reserved]
*
* DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
* 0x5BB5B55B camera info (0x38B from command 66b)
* 0x5BB5B55C second info block (0x38B from 66c, optional)
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
* 0x5BB5B55E text note (UTF-8, optional)
*/
object Mdt {
const val SECTION_DDT = 0x5BB5B55B
const val SECTION_TAIL = 0x5BB5B57B
const val BLOCK_INFO0 = 0x5BB5B55B
const val BLOCK_INFO1 = 0x5BB5B55C
const val BLOCK_FRAME = 0x5BB5B55D
const val BLOCK_TXT = 0x5BB5B55E
private fun align4(n: Int): Int = (n + 3) / 4 * 4
fun u32(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
fun put32(dst: ByteArray, off: Int, v: Int) {
dst[off] = (v and 0xFF).toByte()
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
}
/**
* Compose an MDT file.
* @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base)
* @param info0 camera info block from command 0x6BB6B66B (0x38B)
* @param info1 second cached info block (0x38B) or null
* @param framePixels raw measurement frame (38400 bytes, 19200 x uint16 LE) or null
* @param text UTF-8 note bytes or null
*/
fun compose(
jpg: ByteArray,
info0: ByteArray?,
info1: ByteArray?,
framePixels: ByteArray?,
text: ByteArray? = null,
): ByteArray {
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
out.write(jpg, 0, jpg.size)
repeat(align4(jpg.size) - jpg.size) { out.write(0) }
// --- DDT section ---
val ddtOffset = out.size()
val body = ByteArrayOutputStream()
fun emit(magic: Int, data: ByteArray) {
val n = align4(data.size)
val b = ByteArray(8 + n)
put32(b, 0, magic)
put32(b, 4, n)
System.arraycopy(data, 0, b, 8, data.size)
body.write(b, 0, b.size)
}
info0?.let { emit(BLOCK_INFO0, it) }
info1?.let { emit(BLOCK_INFO1, it) }
if (framePixels != null && framePixels.size == 38400) {
emit(BLOCK_FRAME, framePixels)
}
text?.let { emit(BLOCK_TXT, it) }
val bodyBytes = body.toByteArray()
val header = ByteArray(0x88)
put32(header, 0, SECTION_DDT)
put32(header, 4, bodyBytes.size)
out.write(header, 0, 0x88)
out.write(bodyBytes, 0, bodyBytes.size)
// --- Tail ---
val tail = ByteArray(152)
put32(tail, 0, SECTION_TAIL)
put32(tail, 4, ddtOffset)
out.write(tail, 0, 152)
return out.toByteArray()
}
/** Parse an MDT file produced by [compose] (or any file whose last 152
* bytes carry a valid tail). Returns the sections or null. */
fun parse(bytes: ByteArray): Parsed? {
if (bytes.size < 152) return null
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
if (u32(tail, 0) != SECTION_TAIL) return null
val ddtOffset = u32(tail, 4)
if (ddtOffset <= 0 || ddtOffset + 0x88 > bytes.size - 152) return null
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
val bodySize = u32(bytes, ddtOffset + 4)
val bodyStart = ddtOffset + 0x88
if (bodyStart + bodySize > bytes.size - 152) return null
val blocks = HashMap<Int, ByteArray>()
var p = bodyStart
val end = bodyStart + bodySize
while (p + 8 <= end) {
val magic = u32(bytes, p)
val len = u32(bytes, p + 4)
if (len < 0 || p + 8 + len > end) break
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
p += 8 + len
}
return Parsed(
jpg = bytes.copyOfRange(0, ddtOffset),
info0 = blocks[BLOCK_INFO0],
info1 = blocks[BLOCK_INFO1],
frame = blocks[BLOCK_FRAME],
text = blocks[BLOCK_TXT],
)
}
class Parsed(
val jpg: ByteArray,
val info0: ByteArray?,
val info1: ByteArray?,
val frame: ByteArray?,
val text: ByteArray?,
)
}
@@ -0,0 +1,138 @@
package com.mag160c.thermal.media
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/**
* MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor
* .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec
* handles color conversion; frames arrive as ARGB bitmaps.
*/
class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
private val fps = 15
private val bitRate = 2_000_000
private var encoder: MediaCodec? = null
private var inputSurface: android.view.Surface? = null
private var muxer: MediaMuxer? = null
private var trackIndex = -1
private var muxerStarted = false
private val active = AtomicBoolean(false)
private val canvas = Canvas()
private val paint = Paint()
@Volatile
var outPath: File? = null
private set
fun start(): Boolean {
if (active.get()) return true
return try {
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
)
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
}
val enc = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val surface = enc.createInputSurface()
enc.start()
encoder = enc
inputSurface = surface
val tmp = File.createTempFile("mag160c", ".mp4")
muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
outPath = tmp
active.set(true)
true
} catch (e: Exception) {
releaseAll()
false
}
}
fun isRecording(): Boolean = active.get()
/** Push one frame bitmap (called from the frame callback thread). */
fun offerFrame(bmp: Bitmap) {
val surface = inputSurface ?: return
if (!active.get()) return
val c = surface.lockCanvas(null) ?: return
try {
c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
} finally {
surface.unlockCanvasAndPost(c)
}
drain(false)
}
/** Stop recording and finalize. Returns the output file. */
fun stop(): File? {
if (!active.getAndSet(false)) return null
val enc = encoder ?: return null
// drain with EOS
val idx = enc.dequeueInputBuffer(10_000)
if (idx >= 0) enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
drain(true)
val f = outPath
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
runCatching { enc.stop() }
runCatching { enc.release() }
encoder = null
inputSurface?.release()
inputSurface = null
return f
}
private fun releaseAll() {
runCatching { encoder?.stop() }
runCatching { encoder?.release() }
encoder = null
runCatching { inputSurface?.release() }
inputSurface = null
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
}
private fun drain(end: Boolean) {
val enc = encoder ?: return
val mux = muxer ?: return
val info = MediaCodec.BufferInfo()
while (true) {
val outIdx = enc.dequeueOutputBuffer(info, if (end) 10_000 else 0)
when {
outIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!end) return
outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
trackIndex = mux.addTrack(enc.outputFormat)
mux.start()
muxerStarted = true
}
outIdx >= 0 -> {
if (info.size > 0 && muxerStarted &&
info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0
) {
val ob = enc.getOutputBuffer(outIdx)
if (ob != null) mux.writeSampleData(trackIndex, ob, info)
}
enc.releaseOutputBuffer(outIdx, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
}
else -> return
}
}
}
}
@@ -0,0 +1,129 @@
package com.mag160c.thermal.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import android.os.Environment
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* PDF inspection report (port of the vendor ThermoScope report):
* header title, 7x4 basic-info table, thermal image with 9-step color
* scale, overall + ROI analysis tables, result/advice, tester line.
*/
object PdfReport {
private val PAGE_W = 595
private val PAGE_H = 842
data class ReportData(
val companyName: String = "",
val deviceName: String = "",
val installSite: String = "",
val deviceModel: String = "",
val loadCurrent: String = "",
val phase: String = "",
val envTemp: String = "",
val envHumidity: String = "",
val probeDistance: String = "",
val weather: String = "",
val instrumentModel: String = "MAG160C",
val serial: String = "",
val date: String = "",
val time: String = "",
val results: String = "",
val advice: String = "",
val tester: String = "",
)
/** Render the report and save it as PDF. Returns the saved file name. */
fun generate(context: Context, image: Bitmap, data: ReportData): String? {
val doc = android.graphics.pdf.PdfDocument()
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
typeface = Typeface.SANS_SERIF
}
val title = Paint(paint).apply {
textSize = 20f
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
val label = Paint(paint).apply { textSize = 11f }
val value = Paint(paint).apply { textSize = 11f }
val page = doc.startPage(
android.graphics.pdf.PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, 1).create(),
)
val canvas = page.canvas
var y = 60f
canvas.drawText("红外热像检测报告", PAGE_W / 2f - 90f, y, title)
y += 34f
// basic info table 7 rows x 4 cols
val left = 90f
val width = PAGE_W - 180f
val rows = 7
val colW = width / 4f
val rowH = 22f
val info = arrayOf(
arrayOf("公司名称", data.companyName, "设备名称", data.deviceName),
arrayOf("安装地点", data.installSite, "设备型号", data.deviceModel),
arrayOf("负载电流", data.loadCurrent + " A", "相序", data.phase),
arrayOf("环境温度", data.envTemp + "", "环境湿度", data.envHumidity + "%"),
arrayOf("探测距离", data.probeDistance + " m", "天气", data.weather),
arrayOf("仪器型号", data.instrumentModel, "序列号", data.serial),
arrayOf("探测日期", data.date, "探测时间", data.time),
)
val box = Paint().apply { style = Paint.Style.STROKE; color = Color.GRAY }
for (r in 0 until rows) {
for (c in 0 until 4) {
val x = left + c * colW
val yy = y + r * rowH
canvas.drawRect(x, yy, x + colW, yy + rowH, box)
if (c % 2 == 0) {
canvas.drawText(info[r][c], x + 4, yy + 15, label)
} else {
canvas.drawText(info[r][c], x + 4, yy + 15, value)
}
}
}
y += rows * rowH + 24f
canvas.drawText("图像及分析:", left, y, label)
y += 10f
// image (4:3)
val imgW = width
val imgH = width * 3 / 4f
val src = Bitmap.createBitmap(image)
canvas.drawBitmap(src, null, android.graphics.RectF(left, y, left + imgW, y + imgH), null)
y += imgH + 14f
if (src != image) src.recycle()
canvas.drawText("最高温:${data.envTemp} ℃(分析温度解码待硬件验证)", left, y, value)
canvas.drawText("结果及建议:${data.results} ${data.advice}", left, y + 20f, value)
canvas.drawText("测试员:${data.tester}", left, y + 44f, value)
doc.finishPage(page)
// save under DCIM/MAG160C/reports
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM),
"MAG160C/reports",
)
if (!dir.exists()) dir.mkdirs()
val name = "report_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH).format(Date())}.pdf"
val out = File(dir, name)
runCatching {
doc.writeTo(out.outputStream())
}.onFailure { doc.close(); return null }
doc.close()
return name
}
}
@@ -0,0 +1,96 @@
package com.mag160c.thermal.media
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Save captured photos into MediaStore under DCIM/MAG160C (system gallery
* visible, no rogue folders). The stored file is a self-contained MDT
* container (JPG + temperature frame + note) named by capture time.
*/
object PhotoSaver {
private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
/** Encode a rendered ARGB frame to JPEG bytes. */
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bmp.setPixels(frame, 0, w, 0, 0, w, h)
return encodeJpeg(bmp, quality)
}
/** Encode an existing bitmap to JPEG bytes. */
fun encodeJpeg(bmp: Bitmap, quality: Int = 92): ByteArray {
val out = ByteArrayOutputStream(bmp.width * bmp.height / 4)
bmp.compress(Bitmap.CompressFormat.JPEG, quality, out)
return out.toByteArray()
}
/** Save an MDT container into MediaStore. Returns the media uri string. */
fun saveMdt(
context: Context,
mdt: ByteArray,
displayName: String,
): String? {
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= 29) {
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val uri = resolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
) ?: return null
try {
resolver.openOutputStream(uri)?.use { it.write(mdt) }
if (Build.VERSION.SDK_INT >= 29) {
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
} catch (e: Exception) {
resolver.delete(uri, null, null)
return null
}
return uri.toString()
}
/** Save a plain JPEG (no MDT wrapper). */
fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= 29) {
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
) ?: return null
try {
context.contentResolver.openOutputStream(uri)?.use { it.write(jpg) }
if (Build.VERSION.SDK_INT >= 29) {
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
context.contentResolver.update(uri, done, null, null)
}
} catch (e: Exception) {
context.contentResolver.delete(uri, null, null)
return null
}
return uri.toString()
}
}
@@ -0,0 +1,127 @@
package com.mag160c.thermal.task
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import java.io.File
data class TaskItem(
val id: Long,
val name: String,
val order: Int,
val captured: Boolean,
)
data class TaskLibrary(
val fileName: String,
val title: String,
val items: List<TaskItem>,
) {
fun toTree(): Map<String, List<TaskItem>> =
items.groupBy { it.name.substringBefore(" - ", it.name) }
}
/**
* Inspection task library parser (port of the vendor SqliteTaskParser /
* XmlTaskParser). All queries are guarded; a missing table just yields an
* empty item list.
*/
object TaskParser {
fun parseSqlite(file: File): TaskLibrary = runCatching {
val db = SQLiteDatabase.openDatabase(
file.absolutePath, null, SQLiteDatabase.OPEN_READONLY, null,
)
try {
var title = file.nameWithoutExtension
val regionNames = LinkedHashMap<Long, String>()
try {
db.rawQuery("SELECT id, name FROM region", null).use { c ->
while (c.moveToNext()) regionNames[c.getLong(0)] = c.getString(1) ?: ""
}
} catch (_: Exception) {
}
title = regionNames.values.firstOrNull() ?: title
val items = ArrayList<TaskItem>()
try {
db.rawQuery(
"SELECT t.id, t.capture_status FROM task t",
null,
).use { c ->
var ord = 0
while (c.moveToNext()) {
val id = c.getLong(0)
val captured = c.getInt(1) == 1
items.add(TaskItem(id, "任务#$id", ord++, captured))
}
}
// join names when the columns exist
try {
db.rawQuery(
"SELECT t.id, d.name, p.name, ph.name FROM task t " +
"LEFT JOIN device d ON t.device_id = d.id " +
"LEFT JOIN part p ON t.part_id = p.id " +
"LEFT JOIN phase ph ON t.phase_id = ph.id",
null,
).use { c2 ->
for ((idx, it) in items.withIndex()) {
if (c2.moveToNext()) {
val d = c2.getString(1) ?: ""
val p = c2.getString(2) ?: ""
val ph = c2.getString(3) ?: ""
val joined = listOf(ph, d, p).filter { it.isNotBlank() }
.joinToString("-")
if (joined.isNotBlank()) items[idx] = items[idx].copy(name = joined)
}
}
}
} catch (_: Exception) {
}
} catch (_: Exception) {
}
TaskLibrary(file.name, title, items)
} finally {
db.close()
}
}.getOrDefault(TaskLibrary(file.name, file.nameWithoutExtension, emptyList()))
/** Parse the legacy XML task format. */
fun parseXml(text: String): TaskLibrary = runCatching {
val parser = org.xmlpull.v1.XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(text.reader())
var ev = parser.eventType
var title = ""
val items = ArrayList<TaskItem>()
var inTarget = false
var id = ""
var name = ""
while (ev != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) {
when (ev) {
org.xmlpull.v1.XmlPullParser.START_TAG -> {
when (parser.name) {
"target" -> {
inTarget = true
id = ""
name = ""
}
"id" -> if (inTarget) {
ev = parser.next()
id = parser.text?.trim() ?: ""
}
"name" -> if (inTarget) {
ev = parser.next()
name = parser.text ?: ""
}
}
}
org.xmlpull.v1.XmlPullParser.END_TAG -> {
if (parser.name == "target") {
items.add(TaskItem(items.size.toLong(), "$id $name", items.size, false))
inTarget = false
}
}
}
ev = parser.next()
}
TaskLibrary(title.ifBlank { "任务" }, title, items)
}.getOrDefault(TaskLibrary("任务", "任务", emptyList()))
}
@@ -0,0 +1,95 @@
package com.mag160c.thermal.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.R
import com.mag160c.thermal.ui.gallery.GalleryScreen
import com.mag160c.thermal.ui.live.LiveScreen
import com.mag160c.thermal.ui.settings.SettingsScreen
private data class Tab(val label: String, val icon: Int)
private val TABS = listOf(
Tab("实时", R.drawable.ic_ffc),
Tab("相册", R.drawable.ic_gallery),
Tab("分析", R.drawable.ic_analysis),
Tab("设置", R.drawable.ic_settings),
)
/**
* App shell. The activity is portrait-locked, so the bottom navigation bar
* is glued to the phone's portrait bottom edge at all times (its absolute
* position never moves, however the phone is physically held). Bar content
* (icons/text) is pre-rotated by the physical device orientation so it stays
* readable in any grip. Tab content lives in a stable slot.
*/
@Composable
fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) }
val phi by DeviceOrientation.deg.collectAsState()
val ctx = LocalContext.current
DisposableEffect(Unit) {
DeviceOrientation.start(ctx)
onDispose { DeviceOrientation.stop() }
}
Box(modifier = Modifier.fillMaxSize()) {
when (tab) {
0 -> LiveScreen(onOpenGallery = { tab = 1 })
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen()
}
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen()
}
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
SettingsScreen()
}
}
// bottom navigation overlay (glued to the portrait bottom edge)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.align(Alignment.BottomCenter)
.onSizeChanged { UiInsets.navPx = it.height },
) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TABS.forEachIndexed { i, t ->
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
// pre-rotate so the item is upright in the current grip
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
}
}
}
}
}
@@ -0,0 +1,67 @@
package com.mag160c.thermal.ui
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlin.math.abs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Physical orientation of the phone relative to its portrait grip, from the
* accelerometer. [deg] is the CLOCKWISE rotation of the device as seen by
* the user: 0 = upright portrait, 90 = turned clockwise (portrait top edge
* points to the user's right, sensor reads -g on X), 180 = upside down
* (sensor reads -g on Y), 270 = turned counter-clockwise (sensor reads +g
* on X).
*
* Android accelerometer convention (REAL devices): at rest the reading is
* "acceleration minus gravity", i.e. it points to world UP in device coords
* — flat on a table screen-up -> z=+9.81, upright portrait -> y=+9.81.
* (Note the emulator's virtual sensor uses the opposite, gravity-vector
* convention; do not "fix" this mapping to match emulator defaults.)
*
* The activity is portrait-locked (composition glued to the phone frame, so
* the thermal image region always matches the lens direction). UI layers use
* [deg] to pre-rotate their icons/text by -deg so labels stay readable in
* whatever grip the phone is currently held.
*/
object DeviceOrientation : SensorEventListener {
private val _deg = MutableStateFlow(0)
/** Physical CW rotation of the phone relative to the portrait grip: 0/90/180/270. */
val deg: StateFlow<Int> = _deg
private var sm: SensorManager? = null
fun start(context: Context) {
if (sm != null) return
val m = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = m.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return
sm = m
m.registerListener(this, sensor, SensorManager.SENSOR_DELAY_UI)
}
fun stop() {
sm?.unregisterListener(this)
sm = null
}
override fun onSensorChanged(event: SensorEvent) {
val gx = event.values[0]
val gy = event.values[1]
// world-up in device coords: (0,+g)=0 (-g,0)=90 (0,-g)=180 (+g,0)=270.
// hysteresis: only switch pose when the dominant axis clearly wins,
// so ~45 deg in-between holds keep the previous reading
val next = when {
abs(gx) > abs(gy) + 2.5f -> if (gx < 0) 90 else 270
abs(gy) > abs(gx) + 2.5f -> if (gy > 0) 0 else 180
else -> _deg.value
}
if (next != _deg.value) _deg.value = next
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
@@ -0,0 +1,8 @@
package com.mag160c.thermal.ui
/** Shared UI inset state (px), written by AppRoot's nav overlay. */
object UiInsets {
/** Bottom navigation overlay height in px (portrait-locked app: constant). */
@Volatile
var navPx: Int = 0
}
@@ -0,0 +1,103 @@
package com.mag160c.thermal.ui.analyze
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.media.Mdt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Offline MDT analysis state: loaded container, palette re-render, probes.
*/
class AnalyzeViewModel(
app: Application,
private val containerBytes: ByteArray,
private val fileUri: android.net.Uri,
) : AndroidViewModel(app) {
val parsed: Mdt.Parsed? = Mdt.parse(containerBytes)
/** Raw measurement frame (19200 uint16) if present. */
val rawFrame: IntArray? by lazy {
parsed?.frame?.let { raw ->
val out = IntArray(19200)
for (i in out.indices) {
out[i] = (raw[i * 2].toInt() and 0xFF) or ((raw[i * 2 + 1].toInt() and 0xFF) shl 8)
}
out
}
}
private val _render = MutableStateFlow<Bitmap?>(null)
val render: StateFlow<Bitmap?> = _render
private val _paletteIndex = MutableStateFlow(2)
val paletteIndex: StateFlow<Int> = _paletteIndex
/** Re-render the raw frame with the given palette + auto window. */
fun render(paletteIdx: Int) {
val raw = rawFrame ?: return
_paletteIndex.value = paletteIdx
viewModelScope.launch(Dispatchers.Default) {
var mn = Int.MAX_VALUE
var mx = -1
for (v in raw) {
if (v < mn) mn = v
if (v > mx) mx = v
}
if (mx <= mn) mx = mn + 1
val pal = com.mag160c.thermal.core.Palettes.buildAll()[paletteIdx.coerceIn(0, 11)]
val argb = IntArray(19200)
val scale = (255 shl 12) / (mx - mn)
for (i in argb.indices) {
var g = ((raw[i] - mn) * scale) shr 8
if (g < 0) g = 0 else if (g > 255) g = 255
argb[i] = pal[g]
}
val bmp = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888)
bmp.setPixels(argb, 0, 160, 0, 0, 160, 120)
withContext(Dispatchers.Main) { _render.value = bmp }
}
}
fun decodeNote(): String? {
val t = parsed?.text ?: return null
return String(t, Charsets.UTF_8)
}
/** Probe temperature approximation at a raw pixel (millidegrees C). */
fun probeTemp(x: Int, y: Int): Int? {
val raw = rawFrame ?: return null
if (x < 0 || y < 0 || x >= 160 || y >= 120) return null
return com.mag160c.thermal.core.TempMath.countsToTempMc(raw[y * 160 + x])
}
/** Save note: rewrite the container in place (jpg = current render). */
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
val bmp = _render.value
if (bmp == null) {
onDone(false)
return
}
viewModelScope.launch(Dispatchers.IO) {
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(bmp)
val mdt = Mdt.compose(
jpg = jpg,
info0 = parsed?.info0,
info1 = parsed?.info1,
framePixels = parsed?.frame,
text = note.toByteArray(Charsets.UTF_8),
)
val ok = runCatching {
val ctx = getApplication<Application>()
ctx.contentResolver.openOutputStream(fileUri, "w")?.use { it.write(mdt) } != null
}.getOrDefault(false)
withContext(Dispatchers.Main) { onDone(ok) }
}
}
}
@@ -0,0 +1,185 @@
package com.mag160c.thermal.ui.analyze
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.gallery.GalleryViewModel
/**
* Single-file MDT analysis viewer: pinch zoom/pan, palette re-render,
* text note editing.
*/
import java.util.Locale
@Composable
fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
val context = LocalContext.current
val vm = remember(item.name) {
val bytes = runCatching {
context.contentResolver.openInputStream(item.uri)?.use { it.readBytes() }
}.getOrNull() ?: ByteArray(0)
AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri)
}
val render by vm.render.collectAsState()
val paletteIdx by vm.paletteIndex.collectAsState()
var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) }
var showNote by remember { mutableStateOf(false) }
var reportName by remember { mutableStateOf<String?>(null) }
var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
LaunchedEffect(Unit) { vm.render(2) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
Canvas(
modifier = Modifier
.aspectRatio(4f / 3f)
.align(Alignment.Center)
.pointerInput(Unit) {
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
pan += gesturePan
if (zoom <= 1.01f) {
zoom = 1f
pan = Offset.Zero
}
}
},
) {
val img = render?.asImageBitmap()
if (img != null) {
val w = size.width * zoom
val h = size.height * zoom
val left = (size.width - w) / 2 + pan.x
val top = (size.height - h) / 2 + pan.y
drawImage(
image = img,
dstOffset = androidx.compose.ui.unit.IntOffset(left.toInt(), top.toInt()),
dstSize = IntSize(w.toInt(), h.toInt()),
)
}
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color(0x66000000))
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Palettes.NAMES.forEachIndexed { idx, name ->
Text(
name,
color = if (paletteIdx == idx) MaterialTheme.colorScheme.primary else Color.White,
modifier = Modifier
.clickable { vm.render(idx) }
.padding(horizontal = 10.dp, vertical = 8.dp),
)
}
}
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp),
) {
Button(onClick = { showNote = true }) { Text("备注") }
Button(
onClick = {
val bmp = render
if (bmp != null) {
reportName = com.mag160c.thermal.media.PdfReport.generate(
context, bmp,
com.mag160c.thermal.media.PdfReport.ReportData(
date = java.text.SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())
.format(java.util.Date()),
time = java.text.SimpleDateFormat("HH:mm:ss", Locale.getDefault())
.format(java.util.Date()),
),
)
}
},
modifier = Modifier.padding(start = 8.dp),
) { Text("报告") }
}
if (reportName != null) {
Text(
"已生成: $reportName",
color = Color.White,
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp),
)
}
if (showNote) {
NoteEditor(
initial = note,
onSave = {
note = it
vm.saveNote(it) { }
showNote = false
},
onDismiss = { showNote = false },
)
}
}
}
private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y)
@Composable
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("文字备注") },
text = {
OutlinedTextField(value = text, onValueChange = { text = it })
},
confirmButton = {
TextButton(onClick = { onSave(text) }) { Text("保存") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
},
)
}
@@ -0,0 +1,111 @@
package com.mag160c.thermal.ui.gallery
import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Button
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.ui.analyze.AnalyzeViewer
@Composable
fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
val items by vm.items.collectAsState()
val context = LocalContext.current
var showViewer by remember { mutableStateOf(false) }
// runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE)
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
android.Manifest.permission.READ_MEDIA_IMAGES
else android.Manifest.permission.READ_EXTERNAL_STORAGE
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted -> vm.refresh() }
LaunchedEffect(Unit) {
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) vm.refresh() else launcher.launch(perm)
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
Button(onClick = { vm.refresh() }) { Text("刷新") }
}
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(items.size) { idx ->
val item = items[idx]
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(item.name) {
vm.thumbnail(item) { b -> bmp = b }
}
Box(
modifier = Modifier
.aspectRatio(4f / 3f)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
vm.select(item)
showViewer = true
},
) {
val b = bmp
if (b != null) {
Image(
bitmap = b.asImageBitmap(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Text(
item.name.removePrefix("MAG160C_"),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(4.dp)
.background(Color(0x88000000)),
)
}
}
}
}
if (showViewer) {
val sel = vm.selected.value
if (sel != null) {
AnalyzeViewer(item = sel, galleryVm = vm)
}
}
}
@@ -0,0 +1,123 @@
package com.mag160c.thermal.ui.gallery
import android.app.Application
import android.content.ContentUris
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.provider.MediaStore
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.media.Mdt
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Media library: lists MDT thermal files under DCIM/MAG160C via MediaStore
* with async embedded-JPEG thumbnails.
*/
class GalleryViewModel(app: Application) : AndroidViewModel(app) {
data class Item(
val id: Long,
val uri: android.net.Uri,
val name: String,
val size: Long,
val dateMs: Long,
)
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items
private val _selected = MutableStateFlow<Item?>(null)
val selected: StateFlow<Item?> = _selected
private val thumbs = ConcurrentHashMap<String, Bitmap>()
init {
refresh()
}
fun refresh() {
viewModelScope.launch(Dispatchers.IO) {
val ctx = getApplication<Application>()
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val proj = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.SIZE,
)
val list = ArrayList<Item>()
ctx.contentResolver.query(
uri, proj,
"${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ?",
arrayOf("%DCIM/MAG160C%"),
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { c ->
while (c.moveToNext()) {
val id = c.getLong(0)
val name = c.getString(1) ?: ""
val date = c.getLong(2) * 1000
val size = c.getLong(3)
if (size < 152 + 136) continue
list.add(Item(id, ContentUris.withAppendedId(uri, id), name, size, date))
}
}
// validate MDT containers
val valid = list.filter { isMdt(it) }
withContext(Dispatchers.Main) { _items.value = valid }
}
}
private fun isMdt(item: Item): Boolean = runCatching {
val ctx = getApplication<Application>()
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
val buf = ByteArray(152)
if (s.skip(item.size - 152) != item.size - 152) return false
var off = 0
while (off < 152) {
val n = s.read(buf, off, 152 - off)
if (n <= 0) return false
off += n
}
Mdt.u32(buf, 0) == Mdt.SECTION_TAIL
} ?: false
}.getOrDefault(false)
/** Embedded-JPEG thumbnail (async). */
fun thumbnail(item: Item, onReady: (Bitmap) -> Unit) {
thumbs[item.name]?.let { onReady(it); return }
viewModelScope.launch(Dispatchers.IO) {
val ctx = getApplication<Application>()
val bmp = runCatching {
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
val all = s.readBytes()
val parsed = Mdt.parse(all)
if (parsed != null) {
BitmapFactory.decodeByteArray(parsed.jpg, 0, parsed.jpg.size)
} else null
}
}.getOrNull()
if (bmp != null) {
thumbs[item.name] = bmp
withContext(Dispatchers.Main) { onReady(bmp) }
}
}
}
fun select(item: Item?) {
_selected.value = item
}
fun delete(item: Item) {
viewModelScope.launch(Dispatchers.IO) {
val ctx = getApplication<Application>()
runCatching { ctx.contentResolver.delete(item.uri, null, null) }
thumbs.remove(item.name)
refresh()
}
}
}
@@ -0,0 +1,213 @@
package com.mag160c.thermal.ui.live
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.view.SurfaceHolder
import android.view.SurfaceView
/**
* Software canvas renderer for the live IR stream.
*
* The activity is portrait-locked, so the composition below is glued to the
* phone's portrait frame: the thermal image is ALWAYS drawn rotated 90 deg
* CW (3:4 vertical) into a fixed fitted rect inside the available area
* (full screen minus the control top bar and the bottom navigation bar).
* The image region does NOT move or rotate however the phone is physically
* held, so the on-screen area always matches the thermal lens direction.
* OSD text (center temp / probe labels / color-bar numbers) is pre-rotated
* by the accelerometer-derived grip angle so labels stay readable in any
* grip; marker dots and the color-bar strip stay glued to the image.
*/
class LiveRenderer(
private val surfaceView: SurfaceView,
private val vm: LiveViewModel,
) : SurfaceHolder.Callback, Runnable {
private var thread: Thread? = null
private val running = java.util.concurrent.atomic.AtomicBoolean(false)
private val density = surfaceView.resources.displayMetrics.density
private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
typeface = Typeface.SANS_SERIF
textSize = 15f * density
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
private val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
private val viewport = android.graphics.RectF()
/** OSD text compensation: pre-rotation so labels are upright in the current grip. */
private val textRot: Float
get() = -com.mag160c.thermal.ui.DeviceOrientation.deg.value.toFloat()
fun attach() {
surfaceView.holder.addCallback(this)
}
fun detach() {
running.set(false)
surfaceView.holder.removeCallback(this)
}
override fun surfaceCreated(holder: SurfaceHolder) {
running.set(true)
thread = Thread(this, "live-render").also { it.start() }
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
running.set(false)
thread?.join(200)
thread = null
}
override fun run() {
val holder = surfaceView.holder
while (running.get()) {
val canvas = holder.lockCanvas() ?: continue
try {
drawFrame(canvas)
} finally {
holder.unlockCanvasAndPost(canvas)
}
try {
Thread.sleep(33)
} catch (_: InterruptedException) {
}
}
}
private fun drawFrame(canvas: Canvas) {
val w = canvas.width.toFloat()
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
val frame = vm.latestFrame ?: return
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
// available area (minus UI overlays)
val top = vm.uiTopPx.toFloat()
val bottom = h - vm.uiBottomPx.toFloat()
val availW = w
val availH = bottom - top
if (availH <= 0) return
// fit the 3:4 (rotated) image into the available rect (fixed orientation)
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val vpTop = top + (availH - dstH) / 2f
viewport.set(left, vpTop, left + dstW, vpTop + dstH)
// draw the source bitmap rotated 90 deg CW around the viewport center
val cx = viewport.centerX()
val cy = viewport.centerY()
val zoom = vm.state.value.zoom
val srcRect = if (zoom > 1) {
val cw = 320 / zoom
val ch = 240 / zoom
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
} else null
canvas.save()
canvas.rotate(90f, cx, cy)
// pre-rotation draw rect (source aspect 4:3 inside the rotated 3:4 viewport)
val w0 = dstH
val h0 = dstW
val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.restore()
drawOsd(canvas, vm.state.value)
}
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
if (sx < 0 || sy < 0 || tempC == null) return
val p = vm.probeToScreen(sx, sy)
val cx = p[0]
val cy = p[1]
val dotR = 4.5f * density
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(cx, cy, dotR, markerPaint)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(cx, cy, dotR + 5f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
val tw = textPaint.measureText(text)
val pad = 6f * density
var tx = cx + 14f * density
var ty = cy + textPaint.textSize
if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * density
// pivot around the marker anchor: the label stays attached while upright
canvas.save()
canvas.rotate(textRot, cx, cy)
canvas.drawText(text, tx, ty, textPaint)
canvas.restore()
}
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
val barW = 20f * density
val barH = viewport.height() * 0.8f
val x = viewport.right - barW - 12f * density
val y0 = viewport.top + (viewport.height() - barH) / 2f
val seg = Paint()
val n = 96
for (i in 0 until n) {
val c = pal[255 - i * 255 / (n - 1)]
val sy = y0 + barH * i / n
val ey = y0 + barH * (i + 1) / n
seg.color = c
canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg)
}
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
val labelMaxX = x + barW / 2f - textPaint.measureText(minT) / 2
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
canvas.restore()
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
canvas.drawText(minT, labelMaxX, y0 + barH + textPaint.textSize, textPaint)
canvas.restore()
}
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
textPaint.color = Color.WHITE
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
canvas.save()
canvas.rotate(textRot, ox, oy)
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
canvas.restore()
}
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
}
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
for (p in state.probes) {
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
}
drawColorBar(canvas, state)
}
}
@@ -0,0 +1,343 @@
package com.mag160c.thermal.ui.live
import android.view.SurfaceView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.DeviceOrientation
import com.mag160c.thermal.ui.UiInsets
/**
* Stable single-branch live view, camera-app style:
* - control TOP bar (glued to the portrait top edge): FFC / zoom / max-temp
* trace / palette, each with a tiny label, pre-rotated by the grip angle;
* - camera shutter row above the bottom navigation: gallery shortcut, big
* photo shutter, record/stop;
* - the image region stays glued to the phone's portrait frame (matches the
* lens direction) and never moves; OSD text compensates the grip angle.
*/
@Composable
fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) {
val state by vm.state.collectAsState()
val context = LocalContext.current
val phi by DeviceOrientation.deg.collectAsState()
val density = LocalDensity.current.density
var showPalette by remember { mutableStateOf(false) }
var navPx by remember { mutableStateOf(UiInsets.navPx) }
var shutterPx by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
vm.connect()
vm.uiBottomPx = navPx + shutterPx
while (true) {
kotlinx.coroutines.delay(400)
navPx = UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
vm.refreshTemps()
}
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidSurface(vm)
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { offset ->
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
)
}
},
)
if (!state.connected) {
Text(
text = statusText(state),
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer { rotationZ = -phi.toFloat() }
.padding(16.dp),
)
}
// ---- control TOP bar (glued to the portrait top edge) ----
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
// onSizeChanged BEFORE the safe-drawing padding: the reported
// height must INCLUDE the cutout/status-bar inset, otherwise
// the renderer viewport starts inside the cutout strip and
// the image top hides behind the bar on punch-hole phones
.onSizeChanged { vm.uiTopPx = it.height }
.windowInsetsPadding(
WindowInsets.safeDrawing
.only(WindowInsetsSides.Top),
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { vm.triggerFfc() }.padding(4.dp),
) {
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
Text("FFC", style = MaterialTheme.typography.labelSmall)
}
}
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
vm.setZoom(vm.state.value.zoom % 4 + 1)
}.padding(4.dp),
) {
Icon(painterResource(R.drawable.ic_zoom), "数码变倍")
Text("${state.zoom}×", style = MaterialTheme.typography.labelSmall)
}
}
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp),
) {
val on = state.maxTraceOn
Icon(
painterResource(R.drawable.ic_target), "最高温追踪",
tint = if (on) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
if (on) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelSmall,
color = if (on) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { showPalette = true }.padding(4.dp),
) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
Text(
Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
// ---- camera shutter row above the bottom navigation (live tab) ----
val recording = state.status == "recording"
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.offset(y = -(navPx / density).dp)
.onSizeChanged {
shutterPx = it.height
vm.uiBottomPx = navPx + shutterPx
}
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(44.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Box(
modifier = Modifier
.size(42.dp)
.background(Color(0x59FFFFFF), CircleShape)
.clickable { onOpenGallery() },
contentAlignment = Alignment.Center,
) {
Icon(painterResource(R.drawable.ic_gallery), null, tint = Color.White)
}
Text(
"相册", color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Box(
modifier = Modifier
.size(60.dp)
.border(4.dp, Color.White, CircleShape)
.padding(5.dp)
.background(Color.White, CircleShape)
.clickable { vm.capturePhoto(context) },
)
Text(
"拍照", color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Box(
modifier = Modifier
.size(42.dp)
.border(3.dp, Color(0xFFFF5252), CircleShape)
.clickable { vm.toggleRecording(context) },
contentAlignment = Alignment.Center,
) {
if (recording) {
Box(
modifier = Modifier
.size(16.dp)
.background(Color(0xFFFF5252), RoundedCornerShape(3.dp)),
)
} else {
Box(
modifier = Modifier
.size(19.dp)
.background(Color(0xFFFF5252), CircleShape),
)
}
}
Text(
if (recording) "停止" else "录像", color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
if (showPalette) {
PaletteDialog(
current = state.paletteIndex,
onSelect = { vm.setPalette(it); showPalette = false },
onDismiss = { showPalette = false },
)
}
}
private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) {
"no_device" -> "未检测到热像仪,请插入MAG160C"
"no_permission" -> "USB权限未授予"
"ddt_fail" -> "标定文件加载失败"
else -> "连接中…"
}
@Composable
private fun PaletteDialog(current: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("调色板") },
text = {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(320.dp),
) {
items((0..11).toList()) { idx ->
Text(
text = Palettes.NAMES[idx],
style = MaterialTheme.typography.bodyLarge,
color = if (idx == current) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable { onSelect(idx) }
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
}
/** Compose host for the SurfaceView renderer. */
@Composable
private fun AndroidSurface(vm: LiveViewModel) {
AndroidView(
factory = { ctx ->
SurfaceView(ctx).also { sv ->
val renderer = LiveRenderer(sv, vm)
renderer.attach()
}
},
modifier = Modifier
.fillMaxSize()
.onSizeChanged {
vm.uiViewW = it.width
vm.uiViewH = it.height
},
)
}
@@ -0,0 +1,410 @@
package com.mag160c.thermal.ui.live
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.core.TempMath
import com.mag160c.thermal.usb.IrSession
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Live view state machine: USB permission -> link -> stream -> OSD stats.
*/
class LiveViewModel(app: Application) : AndroidViewModel(app) {
data class ProbePoint(val x: Int, val y: Int, val label: String, val tempC: Float?)
data class LiveState(
val connected: Boolean = false,
val streaming: Boolean = false,
val status: String = "",
val paletteIndex: Int = 2,
val zoom: Int = 1,
val centerTempC: Float? = null,
val maxTempC: Float? = null,
val minTempC: Float? = null,
val maxPos: Int = -1,
val minPos: Int = -1,
val identity: IrSession.CameraIdentity? = null,
val maxTraceOn: Boolean = true,
val probes: List<ProbePoint> = emptyList(),
)
private val _state = MutableStateFlow(LiveState())
val state: StateFlow<LiveState> = _state
/** Latest rendered frame pushed by the session (320x240 ARGB). */
@Volatile
var latestFrame: IntArray? = null
private set
private val session = IrSession(app)
private var usbReceiver: android.content.BroadcastReceiver? = null
private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
/** UI insets (px) reported from Compose, consumed by the renderer. */
@Volatile
var uiTopPx: Int = 0
@Volatile
var uiBottomPx: Int = 0
private val sessionListener = object : IrSession.Listener {
override fun onStateChanged(state: IrSession.State, message: String?) {
_state.value = _state.value.copy(
connected = state == IrSession.State.STREAMING,
streaming = state == IrSession.State.STREAMING,
status = message ?: "",
)
}
override fun onFrameReady(argb: IntArray) {
latestFrame = argb
}
override fun onIdentity(identity: IrSession.CameraIdentity) {
_state.value = _state.value.copy(identity = identity)
}
}
init {
session.setListener(sessionListener)
// auto permission + start when the camera is plugged in while running
val ctx = getApplication<Application>()
usbReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
connect()
}
}
ctx.registerReceiver(
usbReceiver,
android.content.IntentFilter(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED),
if (android.os.Build.VERSION.SDK_INT >= 33) android.content.Context.RECEIVER_NOT_EXPORTED else 0,
)
// push frames into the MP4 recorder while recording
session.recorderHook = { argb ->
val rec = recorder
if (rec != null && rec.isRecording()) {
val bmp = android.graphics.Bitmap.createBitmap(320, 240, android.graphics.Bitmap.Config.ARGB_8888)
bmp.setPixels(argb, 0, 320, 0, 0, 320, 240)
rec.offerFrame(bmp)
}
}
}
/** Begin USB permission flow, then start streaming. No device -> demo mode. */
fun connect() {
val context = getApplication<Application>()
val transport = com.mag160c.thermal.usb.UsbTransport(context)
val dev = transport.findDevice()
if (dev == null) {
startDemo()
return
}
transport.requestPermission { ok ->
if (ok) {
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
.getOrDefault(ByteArray(0))
session.start(ddt)
} else {
_state.value = _state.value.copy(connected = false, status = "no_permission")
}
}
}
private var demoPipeline: com.mag160c.thermal.core.RenderPipeline? = null
private var demoJob: kotlinx.coroutines.Job? = null
/**
* Demo mode: feed synthetic frames through the REAL render pipeline
* (official DDT bundled) so the UI is fully explorable without hardware.
*/
private fun startDemo() {
if (demoJob?.isActive == true) return
_state.value = _state.value.copy(connected = true, streaming = true, status = "demo")
val frames = buildDemoFrames()
demoJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.Default) {
val ddt = runCatching {
getApplication<Application>().assets.open("mag160c.ddt").readBytes()
}.getOrDefault(ByteArray(0))
val pipe = com.mag160c.thermal.core.RenderPipeline(160, 120, onFfc = {})
if (!pipe.loadDdt(ddt)) return@launch
demoPipeline = pipe
val out = IntArray(320 * 240)
var count = 0
while (demoPipeline != null) {
for (f in frames.indices) {
val p = demoPipeline ?: return@launch
if (p.frame(frames[f], true, out)) {
latestFrame = out.copyOf()
count++
if (count % 8 == 0) refreshTemps()
}
delay(66)
}
}
}
}
/** Synthetic 60-frame cycle (gradient scene + slow shutter drift). */
private fun buildDemoFrames(): ArrayList<ByteArray> {
val frameSize = 0x38 + 38400
val frames = ArrayList<ByteArray>(60)
var seed = 20260906
for (f in 0 until 60) {
val buf = ByteArray(frameSize)
var acc = seed
fun next(): Int {
acc = (acc * 1103515245 + 12345) and 0x7FFFFFFF
return acc % 97 - 48
}
seed = acc
for (i in 0 until 19200) {
val x = i % 160
val y = i / 160
var base = x * 4 + y * 3 + 14000
// warm block (like a hand in frame), upper-right area
if (x in 96..144 && y in 28..76) base += 620
if (x in 104..136 && y in 36..68) base += 180
val v = (base + next()).coerceIn(0, 65535)
val off = 0x1C + i * 2
buf[off] = (v and 0xFF).toByte()
buf[off + 1] = ((v shr 8) and 0xFF).toByte()
}
frames.add(buf)
}
// headers + trailers
for (f in frames.indices) {
val buf = frames[f]
put32(buf, 0, 0x1BB1B11B)
put32(buf, 4, f)
put32(buf, 8, 38400)
put32(buf, 12, if (f in 6..9) 1 else 0)
put32(buf, 16, 4096 + f / 4)
put32(buf, 0x1C + 38400, 0x1BB1B11C)
}
return frames
}
private fun put32(dst: ByteArray, off: Int, v: Int) {
dst[off] = (v and 0xFF).toByte()
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
}
fun disconnect() = session.stop()
fun triggerFfc() = session.triggerFfc()
fun setPalette(index: Int) {
session.setPalette(index)
_state.value = _state.value.copy(paletteIndex = index)
}
fun setZoom(z: Int) {
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
}
/** Toggle max-temperature trace marker. */
fun toggleMaxTrace() {
_state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn)
}
/** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */
fun capturePhoto(context: android.content.Context) {
val frame = latestFrame ?: return
val s = session
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame)
val rawFrame = s.lastRawFrame
val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) {
rawFrame.copyOfRange(0x1C, 0x1C + 38400)
} else null
val mdt = com.mag160c.thermal.media.Mdt.compose(
jpg = jpg,
info0 = s.lastInfo0,
info1 = s.lastInfo1,
framePixels = pixels,
)
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
)
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail")
}
/**
* Tap on the live image: add a probe point, or delete an existing one when
* tapping near it. The image is always displayed rotated 90 deg CW
* (3:4 vertical) and does NOT move with the phone; only OSD text follows
* the screen. Insets carve the available area.
*/
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
val s = _state.value
if (!s.streaming) return
val top = uiTopPx.toFloat()
val bottom = viewH - uiBottomPx.toFloat()
if (screenY < top || screenY > bottom) return
// fit the 3:4 (rotated 90 CW) image into the available rect
val availW = viewW
val availH = bottom - top
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val top2 = top + (availH - dstH) / 2f
if (screenX < left || screenX > left + dstW || screenY < top2 || screenY > top2 + dstH) return
val fx = (screenX - left) / dstW
val fy = (screenY - top2) / dstH
// inverse of the fixed 90 CW mapping: fx = 1 - sy/120, fy = sx/160
val sy = (1f - fx) * 120f
val sx = fy * 160f
// near an existing probe (compare in screen space)? delete it instead
val thr = dstW * 0.06f
val existing = s.probes.firstOrNull { p ->
val scr = probeToScreen(p.x, p.y)
val dx = screenX - scr[0]
val dy = screenY - scr[1]
dx * dx + dy * dy < thr * thr
}
if (existing != null) {
_state.value = _state.value.copy(probes = _state.value.probes - existing)
return
}
val label = "Pt${s.probes.size + 1}"
val p = ProbePoint(
sx.toInt().coerceIn(0, 159),
sy.toInt().coerceIn(0, 119),
label,
null,
)
_state.value = _state.value.copy(probes = _state.value.probes + p)
refreshTemps()
}
/** Screen coords of a sensor point under the fixed 90 CW rotation + insets. */
fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
var dstW = viewW
var dstH = viewW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (viewW - dstW) / 2f
val top = uiTopPx + (availH - dstH) / 2f
val fx = 1f - sy / 120f
val fy = sx / 160f
return floatArrayOf(left + fx * dstW, top + fy * dstH)
}
/** Last known surface size, for probe screen mapping. */
@Volatile
var uiViewW: Int = 1080
@Volatile
var uiViewH: Int = 2280
/** Toggle MP4 recording of the live stream. */
fun toggleRecording(context: android.content.Context) {
val rec = recorder
if (rec == null) {
val r = com.mag160c.thermal.media.Mp4Recorder()
if (r.start()) {
recorder = r
_state.value = _state.value.copy(status = "recording")
}
} else {
val file = rec.stop()
recorder = null
if (file != null) {
val name = "MAG160C_V_${com.mag160c.thermal.media.PhotoSaver.fileName()}"
_state.value = _state.value.copy(status = "rec_done")
}
}
}
/** Update per-frame temperature stats (called on a slow timer). */
fun refreshTemps() {
val demo = demoPipeline
if (demo != null) {
// Demo-only display mapping (true calibration needs hardware).
demo.copyNuc(demoNuc)
var mean = 0L
var mn = Int.MAX_VALUE
var mx = -1
var mnPos = -1
var mxPos = -1
for (i in demoNuc.indices) {
val v = demoNuc[i]
mean += v
if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i }
}
mean /= demoNuc.size
val mv = mean.toFloat()
val probes = _state.value.probes.map { p ->
val v = demoNuc[p.y * 160 + p.x]
p.copy(tempC = 24f + (v - mean) * 0.02f)
}
_state.value = _state.value.copy(
centerTempC = 24f + (demoNuc[60 * 160 + 80] - mean) * 0.02f,
maxTempC = 24f + (mx - mean) * 0.02f,
minTempC = 24f + (mn - mean) * 0.02f,
maxPos = mxPos,
minPos = mnPos,
probes = probes,
)
return
}
if (!session.isStreaming()) return
val center = session.probeTemp(80, 60)
val nuc = IntArray(19200)
if (!session.copyNuc(nuc)) return
updateTemps(center, nuc)
}
private val demoNuc = IntArray(19200)
private fun updateTemps(center: Int?, nuc: IntArray) {
var mn = Int.MAX_VALUE
var mx = -1
var mnPos = -1
var mxPos = -1
for (i in nuc.indices) {
val v = nuc[i]
if (v < mn) {
mn = v
mnPos = i
}
if (v > mx) {
mx = v
mxPos = i
}
}
val probes = _state.value.probes.map { p ->
p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f)
}
_state.value = _state.value.copy(
centerTempC = center?.let { TempMath.countsToTempMc(it) / 1000f },
maxTempC = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null,
minTempC = if (mn <= Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null,
maxPos = mxPos,
minPos = mnPos,
probes = probes,
)
}
override fun onCleared() {
demoPipeline = null
session.destroy()
super.onCleared()
}
}
@@ -0,0 +1,36 @@
package com.mag160c.thermal.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
internal fun AlarmTempDialog(initialC: Int, onDone: (Int) -> Unit) {
var value by remember { mutableStateOf(initialC / 10f) }
androidx.compose.material3.AlertDialog(
onDismissRequest = { onDone(initialC) },
title = { Text("报警温度") },
text = {
Column {
Text("超过 %.1f℃ 报警".format(value))
Slider(
value = value,
onValueChange = { value = it },
valueRange = -20f..500f,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(onClick = { onDone((value * 10).toInt()) }) {
Text("确定")
}
},
)
}
@@ -0,0 +1,24 @@
package com.mag160c.thermal.ui.settings
import android.content.Context
/** App settings backed by SharedPreferences (app-private storage only). */
class AppSettings(context: Context) {
private val sp = context.getSharedPreferences("mag160c_settings", Context.MODE_PRIVATE)
var defaultPaletteIndex: Int
get() = sp.getInt("palette", 2)
set(v) = sp.edit().putInt("palette", v).apply()
var defaultEmissivityPercent: Int
get() = sp.getInt("emissivity", 100)
set(v) = sp.edit().putInt("emissivity", v).apply()
var alarmTempC: Int
get() = sp.getInt("alarm_temp", 200)
set(v) = sp.edit().putInt("alarm_temp", v).apply()
var language: String
get() = sp.getString("locale", "auto") ?: "auto"
set(v) = sp.edit().putString("locale", v).apply()
}
@@ -0,0 +1,111 @@
package com.mag160c.thermal.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.core.Palettes
@Composable
fun SettingsScreen() {
val context = androidx.compose.ui.platform.LocalContext.current
val settings = remember { AppSettings(context) }
var dialog by remember { mutableStateOf<String?>(null) }
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
SettingRow(
"默认发射率",
"%.2f".format(settings.defaultEmissivityPercent / 100f),
) { dialog = "emissivity" }
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
SettingRow("语言", settings.language) { dialog = "language" }
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
}
when (dialog) {
"palette" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("默认调色板") },
text = {
Column {
Palettes.NAMES.forEachIndexed { idx, name ->
Text(
name,
modifier = Modifier
.clickable {
settings.defaultPaletteIndex = idx
dialog = null
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
"emissivity" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("默认发射率") },
text = {
Column {
listOf(
"黑体 1.00" to 100,
"暗光 0.90" to 90,
"半光 0.80" to 80,
"亮光 0.70" to 70,
"金属 0.10" to 10,
).forEach { (label, v) ->
Text(
label,
modifier = Modifier
.clickable {
settings.defaultEmissivityPercent = v
dialog = null
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
"alarm" -> AlarmTempDialog(
initialC = settings.alarmTempC,
onDone = { settings.alarmTempC = it; dialog = null },
)
else -> {}
}
}
@Composable
private fun SettingRow(label: String, value: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
HorizontalDivider()
}
@@ -0,0 +1,38 @@
package com.mag160c.thermal.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkScheme = darkColorScheme(
primary = Color(0xFFFFB59B),
secondary = Color(0xFFB3CAD5),
tertiary = Color(0xFFD5C4A1),
)
private val LightScheme = lightColorScheme(
primary = Color(0xFF8F4C38),
secondary = Color(0xFF4F6269),
tertiary = Color(0xFF6A5B44),
)
@Composable
fun Mag160cTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val scheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = androidx.compose.ui.platform.LocalContext.current
if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
dark -> DarkScheme
else -> LightScheme
}
MaterialTheme(colorScheme = scheme, content = content)
}
@@ -0,0 +1,217 @@
package com.mag160c.thermal.usb
import android.content.Context
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import com.mag160c.thermal.core.FrameStream
import com.mag160c.thermal.core.RenderPipeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Live IR camera session: link -> query info -> start stream -> render.
* Ported from csdk/src/mag160c_ir.c + the demo3 FFC cadence:
* prepare: 66b / 66c / 66f (4B each)
* start: reader thread -> 50 ms -> FFC(0) x2 -> 300 ms -> START(73)
* stop: STOP(74)
* FFC commands are emitted by [RenderPipeline.onFfc] to keep the type=0
* stream alive (official cadence).
*/
class IrSession(context: Context) {
data class CameraIdentity(
val pid: Int,
val serial: Long,
val width: Int,
val height: Int,
val fps: Int,
)
enum class State { IDLE, LINKING, STREAMING, ERROR }
interface Listener {
fun onStateChanged(state: State, message: String?)
fun onFrameReady(argb: IntArray)
fun onIdentity(identity: CameraIdentity)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val transport = UsbTransport(context)
private var listener: Listener? = null
private var pipeline: RenderPipeline? = null
private val running = AtomicBoolean(false)
private var streaming = false
private var identity = CameraIdentity(1, 0, 160, 120, 15)
fun setListener(l: Listener?) {
listener = l
}
/** Optional per-frame hook (MP4 recording), runs on the reader thread. */
@Volatile
var recorderHook: ((IntArray) -> Unit)? = null
fun isStreaming(): Boolean = running.get()
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
@Volatile
var lastRawFrame: ByteArray? = null
private set
/** Cached 66b/66c camera info blocks (0x38B each) for the MDT DDT section. */
@Volatile
var lastInfo0: ByteArray? = null
private set
@Volatile
var lastInfo1: ByteArray? = null
private set
fun identitySnapshot(): CameraIdentity = identity.copy()
/** Connect + start the live stream. Must be called after USB permission. */
fun start(ddtBytes: ByteArray) {
if (running.get()) return
scope.launch {
notify(State.LINKING, null)
val dev = transport.findDevice()
if (dev == null) {
notify(State.ERROR, "no_device")
return@launch
}
transport.useDevice(dev)
if (!transport.open()) {
notify(State.ERROR, "open_fail")
return@launch
}
val (epOut, epResp, epStream) = transport.endpoints()
if (epOut == null || epResp == null || epStream == null) {
transport.close()
notify(State.ERROR, "no_endpoints")
return@launch
}
val pipe = RenderPipeline(
w = identity.width, h = identity.height,
onFfc = { param -> sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp) },
)
if (!pipe.loadDdt(ddtBytes)) {
transport.close()
notify(State.ERROR, "ddt_fail")
return@launch
}
pipeline = pipe
// prepare sequence (verified hardware: 4-byte commands)
sendCmd(cmd4(MagProtocol.CMD_PREPARE1), epOut, epResp)
sendCmd(cmd4(MagProtocol.CMD_PREPARE2), epOut, epResp)
sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp)
notify(State.STREAMING, null)
notifyIdentity()
running.set(true)
streamLoop(pipe, epStream, epOut, epResp)
}
}
private fun notify(state: State, message: String?) {
listener?.onStateChanged(state, message)
}
private fun notifyIdentity() {
listener?.onIdentity(identitySnapshot())
}
private fun cmd4(magic: Int) = MagProtocol.cmd4(magic)
private fun cmd8(magic: Int, param: Int) = MagProtocol.cmd8(magic, param)
private fun sendCmd(packet: ByteArray, out: UsbEndpoint, resp: UsbEndpoint) {
val conn: UsbDeviceConnection = transport.connection() ?: return
val written = conn.bulkTransfer(out, packet, packet.size, 500)
if (written != packet.size) return
val buf = ByteArray(0x1000)
val n = conn.bulkTransfer(resp, buf, buf.size, 2000)
if (n <= 3) return
val magic = MagProtocol.u32(buf, 0)
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
}
if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) {
val payload = buf.copyOfRange(4, n)
lastInfo0 = payload.copyOf(0x38)
val newIdentity = CameraIdentity(
pid = MagProtocol.u32(payload, 0),
serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or
((MagProtocol.u32(payload, 12).toLong() and 0xFFFFFFFFL) shl 32),
width = MagProtocol.u32(payload, 0x10),
height = MagProtocol.u32(payload, 0x14),
fps = if (payload.size >= 0x1C) MagProtocol.u32(payload, 0x18) else identity.fps,
)
identity = newIdentity
}
}
private fun streamLoop(
pipe: RenderPipeline,
epStream: UsbEndpoint,
epOut: UsbEndpoint,
epResp: UsbEndpoint,
) {
val conn: UsbDeviceConnection = transport.connection() ?: return
val stream = FrameStream(38400)
val frameBuf = ByteArray(0x38 + 38400)
val out = IntArray(320 * 240)
val tmp = ByteArray(0x8000)
val noop = ByteArray(0)
while (running.get()) {
val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500)
if (n <= 0) continue
var len = stream.push(tmp, n, frameBuf)
while (len > 0 && running.get()) {
lastRawFrame = frameBuf.copyOf()
val rendered = pipe.frame(frameBuf, true, out)
if (rendered) {
listener?.onFrameReady(out)
recorderHook?.invoke(out)
}
len = stream.push(noop, 0, frameBuf)
}
}
sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp)
}
/** Manual FFC (official shutter button / double tap). */
fun triggerFfc() {
pipeline?.requestFfc()
}
/** Display palette (see [Palettes]); applied to the live pipeline. */
fun setPalette(index: Int) {
pipeline?.setPalette(index)
}
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
/** Snapshot of the current NUC counts (already blind-compensated). */
fun copyNuc(out: IntArray): Boolean {
val p = pipeline ?: return false
if (!running.get()) return false
p.copyNuc(out)
return true
}
fun stop() {
if (!running.getAndSet(false)) return
pipeline = null
transport.close()
notify(State.IDLE, null)
}
fun destroy() {
stop()
scope.cancel()
}
}
@@ -0,0 +1,65 @@
package com.mag160c.thermal.usb
import java.nio.ByteBuffer
/**
* Vendor command/response protocol, recovered in analysis/protocol_spec.md.
* Plain commands are 4-byte {magic}; FFC carries an 8-byte {magic, param}.
* Responses on EP 0x82: 0x5BB5B55B camera info (0x38), 0x5BB5B55C block 2,
* 0x5BB5B55E version pair (0x10).
*/
object MagProtocol {
const val CMD_PREPARE1 = 0x6BB6B66B
const val CMD_PREPARE2 = 0x6BB6B66C
const val CMD_GET_INFO = 0x6BB6B66F
const val CMD_GET_VERSION = 0x6BB6B670
const val CMD_FFC = 0x6BB6B672
const val CMD_START = 0x6BB6B673
const val CMD_STOP = 0x6BB6B674
const val RSP_INFO_0 = 0x5BB5B55B
const val RSP_INFO_1 = 0x5BB5B55C
const val RSP_PAIR = 0x5BB5B55E
fun cmd4(magic: Int): ByteArray {
val b = ByteBuffer.allocate(4)
b.putInt(magic)
return b.array()
}
fun cmd8(magic: Int, param: Int): ByteArray {
val b = ByteBuffer.allocate(8)
b.putInt(magic)
b.putInt(param)
return b.array()
}
/** Camera info block (0x5BB5B55B, 0x38 bytes): +0x00 pid, +0x08 serial,
* +0x10 width, +0x14 height, +0x18 fps. */
class CameraInfo {
var pid = 0
var serial = 0L
var width = 160
var height = 120
var fps = 15
var raw = ByteArray(0)
fun parse(payload: ByteArray) {
raw = payload.copyOf(minOf(0x38, payload.size))
if (payload.size >= 0x1C) {
pid = u32(payload, 0)
serial = u32(payload, 8).toLong() and 0xFFFFFFFFL or
((u32(payload, 12).toLong() and 0xFFFFFFFFL) shl 32)
width = u32(payload, 0x10)
height = u32(payload, 0x14)
if (payload.size >= 0x1C) fps = u32(payload, 0x18)
}
}
}
fun u32(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
}
@@ -0,0 +1,127 @@
package com.mag160c.thermal.usb
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Build
/**
* USB transport for the MAG160C module (VID 0x833C PID 0x0001), ported from
* csdk/src/mag160c_ir.c:
* config 2 (fallback 1), interface 0
* EP OUT 0x03 commands, EP IN 0x82 responses, EP IN 0x81 stream
*/
class UsbTransport(private val context: Context) {
private val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
private var connection: UsbDeviceConnection? = null
private var claimedInterface: UsbInterface? = null
var device: UsbDevice? = null
private set
/** Find the first connected MAG160C device. */
fun findDevice(): UsbDevice? {
for (dev in manager.deviceList.values) {
if (dev.vendorId == VID) return dev
}
return null
}
/** USB permission callback: true after approval. */
fun requestPermission(onDone: (Boolean) -> Unit) {
val dev = findDevice() ?: run {
onDone(false)
return
}
if (manager.hasPermission(dev)) {
device = dev
onDone(true)
return
}
val action = "com.mag160c.thermal.USB_PERMISSION_ACTION"
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
context.unregisterReceiver(this)
if (manager.hasPermission(dev)) device = dev
onDone(manager.hasPermission(dev))
}
}
context.registerReceiver(
receiver,
IntentFilter(action),
if (Build.VERSION.SDK_INT >= 33) Context.RECEIVER_NOT_EXPORTED else 0,
)
val pi = PendingIntent.getBroadcast(
context, 0, Intent(action).setPackage(context.packageName),
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0,
)
manager.requestPermission(dev, pi)
}
/** Select the device to open (called before [open]). */
fun useDevice(dev: UsbDevice) {
device = dev
}
/** Open the device: claim interface 0 and expose endpoints. */
fun open(): Boolean {
val dev = device ?: return false
val conn = manager.openDevice(dev) ?: return false
connection = conn
val intf = dev.getInterface(0) ?: run { conn.close(); return false }
if (!conn.claimInterface(intf, true)) {
conn.close()
return false
}
claimedInterface = intf
// Prefer configuration 2 when the device exposes it (vendor behavior).
if (dev.configurationCount > 1) {
val cfg = dev.getConfiguration(1)
// USB SET_CONFIGURATION request = 9
conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500)
}
return true
}
fun endpoints(): Triple<UsbEndpoint?, UsbEndpoint?, UsbEndpoint?> {
val intf = claimedInterface ?: return Triple(null, null, null)
var out: UsbEndpoint? = null
var resp: UsbEndpoint? = null
var stream: UsbEndpoint? = null
for (i in 0 until intf.endpointCount) {
when (intf.getEndpoint(i).address) {
EP_CMD_OUT -> out = intf.getEndpoint(i)
EP_CMD_IN -> resp = intf.getEndpoint(i)
EP_STREAM_IN -> stream = intf.getEndpoint(i)
}
}
return Triple(out, resp, stream)
}
fun isOpen(): Boolean = connection != null
/** Raw connection handle for bulk transfers. */
fun connection(): UsbDeviceConnection? = connection
fun close() {
claimedInterface?.let { connection?.releaseInterface(it) }
connection?.close()
connection = null
claimedInterface = null
}
companion object {
const val VID = 0x833C
const val EP_CMD_OUT = 0x03
const val EP_CMD_IN = 0x82
const val EP_STREAM_IN = 0x81
const val EP_BULK_IN = 0x84
}
}
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5,20 L5,13" android:strokeColor="#FF000000" android:strokeWidth="4" />
<path android:pathData="M12,4 L12,20" android:strokeColor="#FF000000" android:strokeWidth="4" />
<path android:pathData="M19,9 L19,20" android:strokeColor="#FF000000" android:strokeWidth="4" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M4,7 L4,19 L20,19 L20,7 Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M9,7 L9,5 L15,5 L15,7" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M12,13m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5,12a7,7 0 1,0 14,0a7,7 0 1,0 -14,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M12,3 L12,7 M12,17 L12,21 M3,12 L7,12 M17,12 L21,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
<path android:pathData="M12,12m-2.5,0a2.5,2.5 0 1,1 5,0a2.5,2.5 0 1,1 -5,0" android:fillColor="#FF000000" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M4,5 L4,19 L20,19 L20,7 Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M7,16 L11,11 L14,14 L17,12 L20,16" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M15,10a1.5,1.5 0 1,1 3,0a1.5,1.5 0 1,1 -3,0" android:fillColor="#FF000000" />
</vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z" android:fillColor="#FF1B2B33" />
<path android:pathData="M54,54m-20,0a20,20 0 1,0 40,0a20,20 0 1,0 -40,0" android:fillColor="#FFFFB59B" />
<path android:pathData="M54,54m-12,0a12,12 0 1,0 24,0a12,12 0 1,0 -24,0" android:fillColor="#FF8F4C38" />
<path android:pathData="M54,54m-6,0a6,6 0 1,0 12,0a6,6 0 1,0 -12,0" android:fillColor="#FFFFE0D2" />
</vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M6,4 L6,18" android:strokeColor="#FFB71C1C" android:strokeWidth="3" />
<path android:pathData="M12,8 L12,18" android:strokeColor="#FF2E7D32" android:strokeWidth="3" />
<path android:pathData="M18,4 L18,18" android:strokeColor="#FF1565C0" android:strokeWidth="3" />
<path android:pathData="M3,18 L21,18" android:strokeColor="#FF37474F" android:strokeWidth="2" />
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,8a4,4 0 1,0 8,0a4,4 0 1,0 -8,0z" android:strokeColor="#FFD32F2F" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M12,12m-4,0a4,4 0 1,1 8,0a4,4 0 1,1 -8,0" android:fillColor="#FFD32F2F" />
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M4,6 L20,6" android:strokeColor="#FF000000" android:strokeWidth="2" />
<path android:pathData="M14,6m-2.5,0a2,2 0 1,1 4,0a2,2 0 1,1 -4,0" android:fillColor="#FF000000" />
<path android:pathData="M4,12 L20,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
<path android:pathData="M8,12m-2,0a2,2 0 1,1 4,0a2,2 0 1,1 -4,0" android:fillColor="#FF000000" />
<path android:pathData="M4,18 L20,18" android:strokeColor="#FF000000" android:strokeWidth="2" />
<path android:pathData="M16,18m-2,0a2,2 0 1,1 4,0a2,2 0 1,1 -4,0" android:fillColor="#FF000000" />
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5,12a7,7 0 1,0 14,0a7,7 0 1,0 -14,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M12,1.5 L12,5.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M12,18.5 L12,22.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M1.5,12 L5.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M18.5,12 L22.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M10.9,12a1.1,1.1 0 1,0 2.2,0a1.1,1.1 0 1,0 -2.2,0z" android:fillColor="#FF000000" />
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M3,10.5a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M16,16 L21,21" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- MAG160C thermal module -->
<usb-device vendor-id="33596" />
</resources>
@@ -0,0 +1,105 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Differential test: the Kotlin render pipeline must produce byte-identical
* output to the pixel-verified C implementation (csdk/src/mag160c_render.c),
* which was validated against CoreSDKLib.dll. Reference files were produced
* by analysis/render_offline.c with the official DDT + a synthetic frame
* sequence (warm=20, ffc_period=1800, ffc_drift=250, cdf pivot 75, forced
* frame-75 startup FFC, FFC callback noop).
*/
class RenderPipelineTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
@Test
fun matchesCReferencePixelExact() {
val ddt = res("mag160c_official.ddt")
val frames = res("test_frames.bin")
val refRgb = res("test_ref.rgb")
val refStats = res("test_ref_stats.txt").decodeToString()
val pipeline = RenderPipeline(
w = 160, h = 120,
ffcPeriod = 1800, ffcDrift = 250, warmFrames = 20,
cdfPivot75 = true, force75 = true,
onFfc = {},
)
assertTrue("DDT must load", pipeline.loadDdt(ddt))
val frameSize = 0x38 + 38400
val nframes = frames.size / frameSize
val out = IntArray(320 * 240)
val renderedFrames = ArrayList<IntArray>()
for (f in 0 until nframes) {
val fr = frames.copyOfRange(f * frameSize, (f + 1) * frameSize)
if (pipeline.frame(fr, true, out)) {
renderedFrames.add(out.copyOf())
}
}
// reference: hidden frames end with "HIDDEN", rendered frames have 6 ints
val refRendered = refStats.trim().lines().count { !it.endsWith("HIDDEN") }
assertEquals("rendered frame count", refRendered, renderedFrames.size)
// per-pixel comparison (C writes RGB24; Kotlin produces the same ARGB ints)
val rgb = res("test_ref.rgb")
var off = 0
for (fi in renderedFrames.indices) {
val expect = IntArray(320 * 240)
for (i in 0 until 320 * 240) {
val r = refRgb[off].toInt() and 0xFF
val g = refRgb[off + 1].toInt() and 0xFF
val b = refRgb[off + 2].toInt() and 0xFF
expect[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
off += 3
}
assertArrayEquals("frame $fi pixels", expect, renderedFrames[fi])
}
assertEquals("consumed all reference bytes", refRgb.size, off)
}
@Test
fun tempConversionIsMonotonicAndSane() {
// protocol_spec: hand vs background ~1322 counts delta; 0x1000-ish counts
// are ambient-scale. Sanity: monotonic and plausible millidegrees.
var prev = Int.MIN_VALUE
for (counts in 6000..8000 step 10) {
val t = TempMath.countsToTempMc(counts)
assertTrue("monotonic at $counts", t > prev)
prev = t
}
// ~20-40 C around the 7000-count region (millidegrees)
val t20 = TempMath.countsToTempMc(7000)
assertTrue("temp in plausible range: $t20", t20 in 0..60_000)
}
@Test
fun frameStreamReassemblesSplitReads() {
val frames = res("test_frames.bin")
val frameSize = 0x38 + 38400
val stream = FrameStream(38400)
val out = ByteArray(frameSize)
var got = 0
var pos = 0
var seed = 7
while (pos < frames.size) {
seed = (seed * 1103515245 + 12345) and 0x7FFFFFFF
val n = minOf(1 + (seed % 3000), frames.size - pos)
val len = stream.push(frames.copyOfRange(pos, pos + n), n, out)
if (len > 0) {
for (i in 0 until frameSize) {
assertEquals("byte $i at frame $got", frames[got * frameSize + i], out[i])
}
got++
}
pos += n
}
assertEquals("all frames reassembled", frames.size / frameSize, got)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
0 HIDDEN
1 HIDDEN
2 HIDDEN
3 HIDDEN
4 HIDDEN
5 HIDDEN
6 HIDDEN
7 HIDDEN
8 HIDDEN
9 HIDDEN
10 HIDDEN
11 HIDDEN
12 HIDDEN
13 HIDDEN
14 HIDDEN
15 HIDDEN
16 HIDDEN
17 HIDDEN
18 HIDDEN
19 HIDDEN
20 HIDDEN
21 HIDDEN
22 HIDDEN
23 HIDDEN
24 HIDDEN
25 HIDDEN
26 HIDDEN
27 HIDDEN
28 HIDDEN
29 HIDDEN
30 HIDDEN
31 HIDDEN
32 6961 7961 7118 7668 7461 103
33 6962 7962 7098 7663 7462 103
34 6962 7962 7116 7675 7462 103
35 6961 7961 7106 7672 7461 103
36 6961 7961 7106 7659 7461 103
37 6961 7961 7116 7664 7461 103
38 6961 7961 7121 7671 7461 103
39 6962 7962 7117 7673 7462 103
40 6962 7962 7132 7667 7462 103
41 6962 7962 7105 7664 7462 103
42 6961 7961 7118 7671 7461 103
43 6961 7961 7130 7671 7461 103
44 6962 7962 7122 7661 7462 103
45 6961 7961 7096 7669 7461 103
46 6961 7961 7096 7671 7461 103
47 6962 7962 7122 7675 7462 103
48 6961 7961 7094 7667 7461 103
49 6962 7962 7099 7664 7462 103
50 6962 7962 7134 7669 7462 103
51 6962 7962 7133 7662 7462 103
52 6961 7961 7129 7671 7461 103
53 6962 7962 7126 7671 7462 103
54 6961 7961 7119 7669 7461 103
55 6961 7961 7096 7673 7461 103
56 6961 7961 7135 7661 7461 103
57 6962 7962 7099 7662 7462 103
58 6961 7961 7122 7659 7461 103
59 6962 7962 7116 7669 7462 103
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
kotlin.daemon.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
+27
View File
@@ -0,0 +1,27 @@
[versions]
agp = "8.11.1"
kotlin = "2.2.0"
composeBom = "2025.06.01"
activityCompose = "1.10.1"
coreKtx = "1.16.0"
lifecycle = "2.9.1"
junit = "4.13.2"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MAG160C"
include(":app")
Binary file not shown.
+234
View File
@@ -0,0 +1,234 @@
# MAG160C 统一安卓APP — 完整开发交接文档(换模型用)
> 生成时间:2026-09-07。给接手模型读取本文件即可恢复全部上下文。
> 结合 `docs/android_app/session_state.md`(心跳)与 `docs/android_app/reverse_apk_features.md`(逆向总表)。
---
## 0. 给新模型的读取提示词
> 你是本项目的续任开发者。请先完整阅读 `C:\Project\MAG160C\docs\android_app\HANDOFF_DEVELOPMENT.md`
> 和 `C:\Project\MAG160C\docs\android_app\session_state.md`、`C:\Project\MAG160C\docs\android_app\reverse_apk_features.md`、
> `C:\Project\MAG160C\analysis\protocol_spec.md`,再继续开发。项目根目录 `C:\Project\MAG160C` 是 git 仓库,
> 源码在 `android\` 子目录(Gradle 工程,package `com.mag160c.thermal`)。当前会话状态与待办见 session_state.md。
---
## 1. 项目目标与背景
- 把 MAG160C 热像仪(160×120, 15fps, USB VID 0x833C)的官方 4 个安卓APP
(普通版 MAG-Cx / 专业版 MAG-Mx / ThermoScope 数据分析 / CoreSdkSample)逆向整合
为一款现代化安卓APP。
- 适配 **Android 16API 36**;用户反馈旧专业版在 Android 16 上花屏,重写根治。
- 逆向证据:`docs/android_app/reverse_apk_features.md`4 APP 功能总表)、
`analysis/protocol_spec.md`USB 协议/温度算法全逆向)。
- 用户要求:现代 UI、**构图钉死竖屏框架**(竖屏锁定,见 §4.3)、**不建立流氓文件夹**(媒体→MediaStore DCIM/MAG160C)、
无 32 位库(纯 Kotlin,无 NDK)。
## 2. 技术栈与工具链(已部署)
- **纯 Kotlin + Jetpack Compose (Material 3)**,无 NDK、无 .so、无 native。
- Gradle 8.14.3`C:\Tools\gradle-8.14.3\bin\gradle.bat`),JDK Zulu 21`C:\Program Files\Zulu\zulu-21`)。
- Android SDK`C:\Users\ZXC\AppData\Local\Android\Sdk`platforms/android-36、build-tools 36.1.0、
platform-tools/adb、模拟器 system-images android-36.1 google_apis_playstore x86_64、AVD `Medium_Phone_API_36.1`)。
- 工程:`C:\Project\MAG160C\android\`;版本目录 `gradle\libs.versions.toml`
AGP 8.11.1 / Kotlin 2.2.0 / Compose BOM 2025.06.01 / lifecycle 2.9.1 / junit 4.13.2)。
- **磁盘轻量约定**:不装 NDK/CMake/zig(曾占用 2.4GB+,已卸载);全 Kotlin。
- 模拟器实测命令(无窗口):
```powershell
Start-Process "$env:LOCALAPPDATA\Android\Sdk\emulator\emulator.exe" -ArgumentList @('-avd','Medium_Phone_API_36.1','-no-window','-no-audio','-gpu','swiftshader_indirect') -WindowStyle Hidden
# 安装/启动/截图:
adb -s emulator-5554 install -r build-artifacts\mag160c-app-debug.apk
adb -s emulator-5554 shell am force-stop com.mag160c.thermal
adb -s emulator-5554 shell am start -n com.mag160c.thermal/.MainActivity
adb -s emulator-5554 exec-out screencap -p > analysis\preview\xxx.png # 注意:exec-out 才二进制安全
adb -s emulator-5554 emu rotate # 旋转模拟器(每次90°)
```
## 3. 源码结构与核心模块
```
android/app/src/main/kotlin/com/mag160c/thermal/
├─ MainActivity.kt 启动,enableEdgeToEdge + 隐藏状态栏(沉浸)
├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒贴竖屏底边(Activity 竖屏锁定)
├─ ui/DeviceOrientation.kt 加速度计→手机物理朝向(条栏/OSD 文字补偿旋转用)
├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白)
├─ ui/theme/Theme.kt M3 动态取色(Android12+
├─ ui/live/ ★实时画面(最核心)
│ ├─ LiveViewModel.kt 状态机/演示模式/多点测温/追踪开关/拍照/录像/inset上报
│ ├─ LiveScreen.kt 稳定单分支相机布局:顶栏4控制项+底部快门区(相册/拍照/录像)+SurfaceView
│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)钉在竖屏框架固定区域,
│ 文字恒屏幕水平;色标条/圆圈标记/中心温OSD
├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限)
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/备注回写/PDF报告)
├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/关于,SharedPreferences私有)
├─ media/
│ ├─ Mdt.kt MDT 容器格式(JPG + DDT段[info块+原始帧] + 152B Tail
│ ├─ PhotoSaver.kt MediaStore 保存(DCIM/MAG160C,无权限也可写自有文件)
│ ├─ Mp4Recorder.kt MP4录像(Surface 输入 H.264MediaCodec+MediaMuxer
│ └─ PdfReport.kt PDF 巡检报告(PdfDocument
├─ usb/
│ ├─ UsbTransport.kt UsbManager 枚举(VID 0x833C)/权限/claim/EP
│ ├─ MagProtocol.kt 命令码 0x6BB6B6xx、响应 0x5BB5B5xx
│ └─ IrSession.kt 连接/启动流/读线程→FrameStream→RenderPipeline→回调;FFC回调
├─ core/
│ ├─ OfficialTables.kt 自动生成:PALETTE256_ARGB(官方铁虹)/T2E(646)/T2E274/E2T
│ ├─ Palettes.kt 12调色板(铁虹=官方,其余标准曲线近似)
│ ├─ RenderPipeline.kt ★官方渲染管线 Kotlin 移植(与C参考逐字节一致)
│ ├─ TempMath.kt 温度换算(counts→毫度,T2E)
│ └─ FrameStream.kt 流帧重组(0x1BB1B11B 搜索)
└─ task/TaskParser.kt 任务巡检 sqlite/xml 解析
assets/mag160c.ddt 官方 DDT 标定文件(1.8MB,渲染必需)
res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形)
```
## 4. 核心知识(必须知道)
### 4.1 渲染管线(已逐字节验证)
- `core/RenderPipeline.kt` 移植自 `csdk/src/mag160c_render.c`(官方管线,像素级验证)。
- 输入:USB 帧(0x38 头 + 38400B u16 小端 + 尾部);输出 320×240 ARGB。
- FFC 状态机:暖机20帧内暂停;第75帧强制FFCFFC(0)→5隐藏→FFC(1)→4参考帧→4隐藏→正常。
- **移植陷阱(已解决,改代码时务必保持)**:
1. C 的 32 位无符号回绕:所有 `u32()` 掩码必须保留(cdf*denom、0xffc0000-iv7*0x40000、(v-lo)*S 等)。
2. lutRebuild 里 `u12` 的基址是常量 `iv7*0x10+0x10`**不是**链式 u21v。
3. ByteArray 存 255=-1:判 0xFF 必须 `(b.toInt() and 0xFF)`。
4. 暖机窗口检查在 ffc_step **之前**(顺序不能改)。
- 验证方式:`android/app/src/test/.../RenderPipelineTest.kt`JVM 差分测试,参照 `analysis/render_offline.c` 的 C 输出,60帧序列与官方DDT)。
### 4.2 USB 协议(analysis/protocol_spec.md 全文)
- 命令:普通 4 字节 magicFFC 8 字节 {magic, param}。
- 初始化:66b→66c→66f4B)→FFC(0)×2→300ms→START(73);停止 STOP(74)。
- 帧流:EP 0x81 bulk0x1BB1B11B 头 + 0x1C 起像素 + 尾部 shutter 于 +0x24。
- 相机信息块 0x5BB5B55B+0x00 pid、+0x08 序列号、+0x10 宽、+0x14 高、+0x18 fps。
### 4.3 实时画面渲染(最终约定:构图钉死竖屏框架)
- **Activity 锁定竖屏**manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
几何仍钉死在图像上)。对话框与其他页签暂不补偿。
- **加速度计符号约定(第七轮教训,勿改回)**:真机 TYPE_ACCELEROMETER 静止读数
指向世界上方(竖屏正持 y=+9.81);模拟器虚拟传感器是反的重力约定(y=-9.81)。
DeviceOrientation 映射按**真机约定**写,模拟器测试须用反号值驱动
(φ=0→`adb emu sensor set acceleration 0:9.81:0`)。
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
`fx=1-sy/120, fy=sx/160`)。
- 顶栏=4 个相机控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称),恒在顶部并带小字
标签,`safeDrawing` 顶部inset 适配挖孔屏。
- 底部(仅实时页)导航栏上方为**相机快门区**:相册快捷入口 / 大快门拍照 /
录像-停止;快门区高度并入 `vm.uiBottomPx`= 导航高+快门区高)。
- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`**必须挂在 safeDrawing inset 之前**
即包含挖孔安全区高度,否则真机热像顶端会插进顶栏背后);底导航高度经
`UiInsets.navPx`、快门区高度经其 `onSizeChanged`→`vm.uiBottomPx`= 导航+快门区),
渲染器据此留白。
- 演示模式:无设备时用真实管线+DDT渲染合成帧(热块场景),demo温度用局部线性显示映射
(真机温度走管线数学;绝对温度标定待真机)。
### 4.4 温度
- 温度 = 毫度 int(÷1000 = ℃)。`counts_to_temp_mc`(NUC域→T2E逆映射)用于探针/OSD,
在真机上需按 DDT 标定核对绝对值(待办)。
- 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数)未实现(待真机文件对照)。
## 5. 构建 / 测试 / 提交
```powershell
# 构建(无 --daemon 单次,约1分钟)
cd C:\Project\MAG160C\android
$env:JAVA_HOME="C:\Program Files\Zulu\zulu-21"
& "C:\Tools\gradle-8.14.3\bin\gradle.bat" :app:assembleDebug --no-daemon
# 单元测试(管线差分验证)
& "C:\Tools\gradle-8.14.3\bin\gradle.bat" test --no-daemon
# 产物
Copy-Item app\build\outputs\apk\debug\app-debug.apk C:\Project\MAG160C\build-artifacts\mag160c-app-debug.apk
# 提交(每里程碑一个 commit,不 push
git -C C:\Project\MAG160C add -A
git -C C:\Project\MAG160C commit -m "android: ..."
```
## 6. 严重教训(反复踩坑,务必遵守)
1. **编码**:所有源码必须 UTF-8。本机默认编码是 GBK!
- **python 读写文件必须显式 `encoding='utf-8'`**`open(p,'w')` 裸写会变 GBK→编译出的中文全乱码)。
- 检测/修复脚本:`C:\Users\ZXC\AppData\Local\Temp\opencode\fix_encoding.py`、
`check_mojibake.py`。改完必须跑一遍确认全 OK,再构建。
- gradle.properties 已有 `-Dfile.encoding=UTF-8`gradle + kotlin daemon)。
2. **图标**:手写 pathData 的弧线易坏,一律用双弧圆 `M x,y a r,r 0 1,0 2r,0 a r,r 0 1,0 -2r,0z` + 直线/矩形。
3. **SurfaceView 与旋转**:不要在横竖屏间切换不同布局分支(SurfaceView 会被销毁且 surface 不重建)。
用"稳定单分支 + 覆盖层"布局(当前实现即如此)。
4. **写代码要谨慎**:本会话多次因急于成稿写出语法错误/残留死代码。每写一个文件后立即编译。
## 7. 当前状态与待办
**已完成**:核心管线移植(字节级验证)、USB 层、实时画面(相机式布局:
顶栏4控制项 FFC/变倍/追踪/调色板 + 底部快门区 相册/拍照/录像 + 底导航)、
图标文字按物理持机朝向补偿旋转(加速度计)、演示模式、拍照 MDT 容器→MediaStore、
MP4 录像、媒体库、离线分析查看器、PDF 报告、任务解析、设置页、Android16 沉浸
(状态栏隐藏+竖屏锁定+挖孔安全区)、乱码多次修复。
**布局约定(最终,勿回退)**
- Activity **锁定竖屏**`portrait`),构图钉死手机竖屏框架:顶栏/热像区域/快门区/底导航
绝对位置永不随手机物理旋转变化(屏显方向永远对应镜头方向)。
- 图标/文字持机朝向补偿:`ui/DeviceOrientation`(加速度计,真机约定 y=+9.81=0°;
模拟器是反的重力约定)→ `rotationZ=-φ`Compose/`canvas.rotate(-φ)`Canvas)。
- 顶栏 `uiTopPx` 必须挂在 `safeDrawing` inset **之前**(包含挖孔安全区,否则真机热像
顶端被顶栏盖住);`uiBottomPx` = 底导航高 + 快门区高。
**待办**(记录在 session_state.md):
1. 真机 USB 实测:温度绝对值标定、FFC/录像/MDT 保存端到端。
2. 网络互连远程预览(用户需求:UDP 自动发现 + 手动内网 IP 连另一台插热像仪的手机)。
3. 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数)。
4. 厂商 12 调色板精确提取(运行时抓取或逆向)。
5. 可见光 PIP 融合、云模块(预留结构)。
## 8. 文件路径速查
- 心跳/状态:`docs/android_app/session_state.md`
- 逆向功能总表:`docs/android_app/reverse_apk_features.md`
- USB 协议/温度算法:`analysis/protocol_spec.md`
- C 参考实现(Kotlin 管线的蓝本):`csdk/src/mag160c_render.c`、`mag160c_temp.c`、`mag160c_ir.c`
- PC 参考工具:`analysis/render_offline.c`、`analysis/gen_kotlin_tables.py`
- 预览截图:`analysis/preview/preview_*.png`
- APK 产物:`build-artifacts/mag160c-app-debug.apk`
## 9. 下一轮提示词(交付新模型直接用)
> 你是本项目的续任开发者。请先完整阅读 `C:\Project\MAG160C\docs\android_app\HANDOFF_DEVELOPMENT.md`
> 和 `C:\Project\MAG160C\docs\android_app\session_state.md`、`docs\android_app\reverse_apk_features.md`、
> `C:\Project\MAG160C\analysis\protocol_spec.md`,再继续开发。
> 项目根 `C:\Project\MAG160C` 是 git 仓库,源码在 `android\`Gradle 工程,package
> `com.mag160c.thermal`)。Android 16 已适配,目标是一个 MAG160C 热像仪(160×120, 15fps,
> USB VID 0x833C PID 1)统一 APP。
>
> 当前已完成到第 9 轮反馈修复:核心渲染管线 Kotlin 移植(逐字节验证)、USB 层、
> 相机式实时布局(顶栏4控制项+底部快门区+底导航)、图标文字持机朝向补偿旋转、
> 演示模式(无设备用真实管线+DDT渲染合成帧)、拍照MDT存MediaStore、MP4录像、
> 媒体库、离线分析、PDF报告、任务解析、设置页、Android16沉浸(竖屏锁定+挖孔适配)。
>
> 硬性约定(勿回退):① Activity 竖屏锁定 `portrait`,构图钉死手机竖屏框架,
> 屏显方向必须始终对应镜头方向;图标/文字用 `ui/DeviceOrientation`(加速度计,
> 真机约定 y=+9.81=0°)补偿旋转。② 源码全 UTF-8;python 读写必须显式
> `encoding='utf-8'`(本机默认 GBK,裸写会乱码)。③ 图标用双弧圆+直线/矩形。
>
> 下一步建议按 session_state.md「待办」推进:
> 1. 真机 USB 实测——温度绝对值标定(对 DDT 核对 counts→毫度);
> 2. 网络互连远程预览——UDP 广播自动发现 + 手动内网 IP,主机手机插热像仪、
> 局域网另一台手机远程实时预览(自定义轻量协议:控制通道 JSON + 图像流,
> 不走厂商 33596/33597);
> 3. 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数);
> 4. 厂商 12 调色板精确提取;5. 可见光 PIP 融合、云模块(Retrofit opt-in 预留)。
>
> 构建/提交:
> ```powershell
> cd C:\Project\MAG160C\android; $env:JAVA_HOME="C:\Program Files\Zulu\zulu-21"
> & "C:\Tools\gradle-8.14.3\bin\gradle.bat" :app:assembleDebug --no-daemon
> & "C:\Tools\gradle-8.14.3\bin\gradle.bat" test --no-daemon
> git -C C:\Project\MAG160C add -A; git -C C:\Project\MAG160C commit -m "android: ..."
> ```
> 环境:`C:\Tools\gradle-8.14.3`、JDK Zulu 21、SDK `C:\Users\ZXC\AppData\Local\Android\Sdk`、
> AVD `Medium_Phone_API_36.1`。注意:git 工作树里有大量与本任务无关的脏文件
> `.tools/``IR_Camera_SDK/``csdk/``analysis/` 等),**不要提交它们**,只提交你的改动。
+105
View File
@@ -0,0 +1,105 @@
# MAG160C 官方 APP 逆向功能总表(2026-09-06
> 用 jadx 1.5.1 反编译 4 个 APKIDAMCP)分析 pro 版 libcoresdk.so(v7a)
> Ghidra 12.1.2 headless 分析普通版 libcxsdk.so。反编译工作副本在
> `%TEMP%\opencode\apkwork\jadx_*`(临时目录,已删可重跑 jadx 命令见本文末尾)。
## 1. 四个 APP 身份
| 包名 | 版本 | 名称 | 原生库 | 定位 |
|---|---|---|---|---|
| cn.com.magnity.magnitycx | 1.1.4 (114) | MAG-Cx 普通版 | libcxsdk + FFmpeg (仅 armeabi) | USB 实时拍摄/录像 |
| cn.com.magnity.magnitymx | 2.0.7 (27) | MAG-Mx 专业版 | libcoresdk(新版libusb) + libthermogroupsdk (仅 armeabi-v7a) | USB+网络设备、任务巡检、云 |
| cn.com.magnity.thermoscope | 1.0.2 (2) | Thermo Scope | libthermogroupsdk (仅 armeabi-v7a) | MDT 热像文件离线分析+PDF 报告 |
| cn.com.magnity.coresdksample | 1.0 | CoreSdkSample | libcoresdk (旧) | SDK 示例(无独立功能) |
- 全部 targetSdk 25Android 7.1 时代);专业版/普通版 MainActivity 锁定 landscape。
- USBVID 0x833C。普通版 PID=1;专业版同时支持 PID=1(160Core/coresdk) 与 PID=2(Mx 网络机芯)。
## 2. 普通版 MAG-Cx 功能(jadx_normal/sources/cn/com/magnity/magnitycx
**实时画面**SurfaceView 软件渲染(lockCanvas→drawBitmap),IR 160×120→640×480 合成画布
- 12 调色板(白热/黑热/铁虹/彩虹/琥珀/金秋/寒冬/热金属/喷射/红饱和/高对比/红热)DialogFragmentPalette.java
- 中心点测温、全屏最高/最低温追踪(无/最高/最低/全部)、矩形 ROI(最值+均值,MAX_ROI_NUM=1ImageViewer.java
- PIP 可见光小窗(手机 Camera API 预览,可拖动/三档尺寸)ImageViewerVisible.java;无像素级融合
- 数码变倍 1/2/4/8/16x160C 仅 1/2/4 循环);双击=手动 FFCFling 切拍照/录像
- 录像 MP4FFmpeg avcodec-57 + libcxsdk doRecording640×4802048kbps/20fps
- 拍照 JPEG q100 640×480 含 OSD(十字/最值/水印/激光);仅 C3P 另存 .ddt 伴随文件
- 横竖屏 OrientationEventListener 实时切 native 旋转角;来电自动断 USB;退后台 500ms deinit
**USB/协议**UsbCommunication.java,端点 0x03/0x82/0x81/0x8428B 帧头尾协议同 csdk
- 连接握手:GetParameter1/2 → GetCaliInfo → 无本地标定则 GetCaliFile 下载(others\{产品}.{SN}.{日期}
- 启动:native startProcess(...) + StartTransferImgThreadImgRecv/Process 双线程;16 帧一次查开机时长并按时间表 FFC
- 命令集 P2D_*0x6BB6B6xx)与 D2P_*0x5BB5B5xx),与 csdk/protocol_spec.md 完全一致
**设置项**:可见光尺寸、温度追踪目标、矩形测温开关、H/V 翻转、变倍、辐射率 4 档(1.00/0.90/0.80/0.70)、帧率高/低、移动光标
**媒体库**GridView+LRU、多选分享/删除、ViewPager 浏览、视频外部播放器
**升级**http://www.magnity.com.cn/APPs/UpgradeInfo(产品键 MAG-Cx
## 3. 专业版 MAG-Mx 功能(jadx_pro/sources/cn/com/magnity/magnitymx
**双设备模型**CXDeviceModelUSB coresdkPID1+ DeviceModel(网络/云/USB-Mx thermogroupPID2;命令口 33596、图像口 33597,云中继 121.43.190.114,账密 magnity/any123、admin/admin123
**实时画面**LiveFragment+MainActivity,竖直 LinearLayout
- 工具栏:任务面板、logo 点击=FFC、设置、调参条(tunebar)、PIP 切换(长按=可见光窗口调整)、最高温追踪、缩略图、ROI、拍照(长按=MGS 录像)、暂停、设备列表
- tunebar 8 项:手动拉伸/等温线/发射率/调色板/温度报警/任务/红外基准图/扫码/码流
- 12 调色板;发射率 35 种材料表+自定义(DialogFragmentEmissivity
- ROI:点/线/框,区域 ROI 弹名称+报警上下限(DialogFragmentRoiAlarmTempAddEdit);报警声+红色闪烁
- 可见光 Camera2 融合(alpha 混合、拖动/双指缩放、拍照取 JPEG 写入 MDT);闪光灯
- 图像增强:手动两点拉伸/自动拉伸(range5)/等温线;图像旋转 0/90/180/270
- 参考图叠加(基准图 alpha 调整,拍照时 blendBitmap 融合)
- 码流类型:温度流2/视频流4/混合流6(网络设备)
- 拍照 = 640×480 JPG + saveDDT2Buffer + ROI + 可见光 + GPS(EXIF) → **MDT 容器** saveMDT,文件名 01-yyMMddHHmmss-<任务名/扫码结果>.jpg
- **录像 = SDK .mgs 专有格式**(仅长按触发,视频流禁用);VideoEncoder MP4 通道是死代码
- 拼接全景:仅 CX/USB 通道实现(startStitching
- 云台:WiFi SSID 含 HERO-RC → UDP 8484 协议遥控拍照/模式切换(UDPHelper)
- 扫码:ZXing,结果作文件名标记
- 音量键拍照;GPS 写 EXIF;云设备 10 分钟闲置断流
**任务巡检系统**task/ + assets/tasks
- SqliteTaskParser.sqlite 库(省-市-工区-电压-变电站五级 + task 树 + capture_status/order
- XmlTaskParser:旧 XML 任务;选库后侧滑两级树,点选→参考图叠加→拍照自动跳下一项
- 任务库放 /sdcard/magnity/mx/tasks(首拷自 assets;云下载 v1/account/taskfiles/{taskId}
**媒体库**(media/):宫格、多选(上传/分享/删除/全选)、NetworkManager 上传队列、单张浏览、"设为参考图"、"分析"= 调起 ThermoScopeintent thermoscope://路径)、GPS 跳地图
**云平台**Retrofit RESTcloudapi.magnity.com.cn):login/logout/getDevices/upload(task_id)/taskfiles;本地 magnity_mx_files.db 记 MD5 上传状态;CloudSyncTask 云同步;MagnityNetworkService 前台服务上传
**设置**:账户/相机(仅图像旋转)/语言(自动/简/繁/EN)/主题(占位)/检查更新(magnity.com.cn/APPs/mx)/关于
**数据库**Room cloud_device/local_device + SQLite magnity_mx_files.db
## 4. ThermoScope 功能(jadx_thermoscope/sources/cn/com/magnity/thermoscope
- **MDT 容器格式**types/):尾部 152B Tail{0x5BB5B57B + 5 段绝对偏移};段序 JPG 缩略图→DDT(136B头+38400B 原始 u16 帧)→VIS 可见光→LAB(每 ROI 256Btype/最值/均值/位置/坐标/色/发射率/报警上下限/名字)→TXT(编码 GB18030/GBK/UTF-8)→AUD;段间 4 字节对齐;支持裸 DDT
- 媒体库:扫 DCIM/Magnity/*.jpg(内容实为 MDT),同目录打开模式,按文件名内嵌时间排序
- 单张分析(SingleMediaActivity + ScopeSurfaceView 软件渲染):
- 11 调色板切换(回写 DDT);参数滑杆:环境温度 -40~80℃ / 发射率 0~1 / 窗口透过率 0~1native 重算+回写)
- 点测温 Pt*(3x3 探针+发射率修正)、框测温 Rt*min/max/ave+minPos/maxPos)、垂直滑动切红外/可见光
- 双指缩放 1~4x、平移;max/min 追踪标记;色标条+6 刻度;保存 640×480 PNG 快照+水印
- 相机信息(SN/分辨率/版本/时间/海拔/经纬度);文字备注;语音段透传
- PDF 报告(PdfCreatortejpratapsingh PDFUtil):标题、7×4 基本信息、热像图、9 级色标、整体+ROI 分析表、结果/建议、测试员;输出 DCIM/Magnity/reports/ReportSettingActivity 15 项报告参数
- RelayActivity:外部打开 .mdt/.ddt/.jpg/pdf 分发
## 5. SDKcn/com/magnity/coresdk + sdk
三代演进:coresdk(USB-only, PID1) → thermogroupsdk 基础版(thermoscope: +网络/云/PTZ/人脸) → 完整版(pro: +GetCameraInfoEx/ObjReco/visPlay_no_decode H.264)
- 帧回调只发节拍(newFrame(camState,streamType)),数据用 getOutputImage()/getOutputTempImage(int[])/getTemperatureData(int[]) 拉取
- 温度单位:毫度 int(×0.001℃);位图 ARGB_8888IDA 证实 GetOutputImage |0xFF000000 直写、行倒序)
- nativeCopyBitmap:格式/宽高/stride 校验后 qmemcpy
- libcxsdk.so(普通版图像核心)导出 C++ APICFunctions/CAccumulator/CSmoother(与 ARM64 libcoresdk 同族,JNI_OnLoad 动态注册)
## 6. 花屏(Android 16 上专业版)根因分析
可确认的事实:
1. 专业版仅带 **armeabi-v7a 32 位库**libcoresdk/libthermogroupsdk),无 arm64。
2. targetSdk 25 + MainActivity 锁 landscape + 全部旧版 support/自定义 SurfaceView 渲染。
3. 渲染链:native 位图(ARGB_8888,行倒序)→ Bitmap.createBitmap 拷贝 → lockCanvas/drawBitmap → unlockCanvasAndPostSurfaceHolder 格式 -2(TRANSPARENT),与格式无关的 Canvas 软渲染。
4. 现役 2025~2026 旗舰(多数已停 32 位支持或启用 16KB 页内核)上,32 位 v7a 库+4KB 对齐 ELF+legacy 渲染路径极易出现兼容层渲染异常(花屏/缩放模糊/偶发撕裂)。
结论:不是单一 bug,而是"32 位专属库 + targetSdk25 时代渲染栈"在 Android 16 设备上的整体不兼容。重写为 targetSdk 36 + arm64(16KB 对齐) + 现代渲染即可根治,无需逐条修旧代码。
## 7. 反编译复现命令
```powershell
$w='C:\Users\ZXC\AppData\Local\Temp\opencode\apkwork'
& 'C:\Tools\jadx\bin\jadx.bat' -d "$w\jadx_pro" --show-bad-code -j 8 "$w\orig\pro.apk"
# Ghidra: & 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' C:\Tools\ghidra_proj Mag160C -import <lib> -scriptPath C:\Tools\ghidra_scripts_user -postScript DumpJni.java
```
+244
View File
@@ -0,0 +1,244 @@
# MAG160C 安卓统一 APP — 会话状态与心跳锚点
> 本文件是本任务("整合 4 个官方 APP 重写为 Android 16 现代化 APP")的心跳锚点。
> 每完成一个里程碑更新勾选;会话中断先读本文件 + `reverse_apk_features.md`
> 再按"下一步"继续。约定:不弹询问窗,按计划自主推进,重大分歧点才停下来问。
## 任务目标
1. 逆向 C:\Project\MAG160C\app 4 个官方 APP(普通版/专业版/ThermoScope/demo)→ 已完成
2. 整合全部功能开发一款安卓 APP:适配 Android 16API 36)、现代 UI、横竖屏、
无流氓文件夹、干爽简洁 → **进行中**
3. 背景问题:专业版在用户 Android 16 手机上花屏(根因见 reverse_apk_features.md §6
## 已完成的里程碑
- [x] 工具链:jadx 1.5.1C:\Tools\jadx)、Ghidra 12.1.2C:\Tools)、IDA MCP 可用
- [x] 4 APK 解包 + jadx 全量反编译(%TEMP%\opencode\apkwork\jadx_*
- [x] 普通版/专业版/ThermoScope/demo 功能清单 → docs/android_app/reverse_apk_features.md
- [x] IDApro libcoresdk.so GetOutputImage/copyBitmap 位图格式证实(ARGB_8888
- [x] Ghidralibcxsdk.so 导出 C++ API 清单(CFunctions 族,与 ARM64 coresdk 同源)
- [x] 花屏根因分析(32 位专属库 + targetSdk25 legacy 渲染栈)
## 关键事实速查(开发时直接用)
- USBVID 0x833C PID 1EP 0x03/0x82/0x81/0x84,帧 28B 头+38400B+尾,15fps
- 协议/渲染/温度算法全部已逆向:analysis/protocol_spec.md + csdk/(逐像素验证过的官方管线)
- csdk 的 mag160c_render 无平台依赖:USB 帧 → 320×240 RGB24 + 探针测温(毫度)
- DDT 标定文件:可从设备经 0x6BB6B66D/E 下载(官方 prepareProcessImage 同款),仓库有
build-artifacts/mag160c_official.ddt1.8MBSN 160043865
- MDT 文件格式:reverse_apk_features.md §4152B Tail + 5 段布局 + ROI 256B 记录)
- 温度:毫度 intMDT 解码走 ConvertResponse2Temperature + T2E(Revise) + CorrectTemperature
## 用户已确认的范围(2026-09-06
- 云功能:预留 Retrofit 接口模块,默认关闭
- 扫码打标 + 任务巡检(sqlite/xml) + 红外基准图叠加:v1 全部实现
- **网络互连(用户新增需求)**:主机手机插 USB 热像仪,局域网内另一台手机
远程实时预览——UDP 广播自动发现 + 手动添加内网 IP;自定义轻量协议
(控制通道 JSON + 图像流),不走厂商 33596/33597 协议
## 当前阶段
**阶段 2/8 完成:纯Kotlin核心管线移植并逐字节验证(2026-09-06)**
## 已完成里程碑(本任务)
- [x] 阶段1:轻量工具链定案——NDK/CMake/zig 全部卸载(-2.4GB),纯 Kotlin 方案:
渲染管线+温度算法+帧解析从 C 移植为 KotlinGradle 8.14.3 + SDK 已有组件
- [x] 阶段2:项目骨架(android/AGP 8.11.1 + Kotlin 2.2.0 + Compose BOM
compileSdk/targetSdk 36minSdk 26),最小APK可构建
- [x] 阶段2:表数据自动生成 analysis/gen_kotlin_tables.py → OfficialTables.kt
palette256 ARGB / T2E 646 / T2E274 / E2T_ACC_Q10
- [x] 阶段2:核心移植 FrameStream / RenderPipeline / TempMath(毫度测温)
- [x] 阶段2PC端参考工具 analysis/render_offline.cgcc 编译)+ 差分测试:
**Kotlin 输出与C参考逐字节一致**(60帧序列,首渲染帧32,NUC/LUT/灰度/输出全同)
- [x] 阶段2FrameStream 分块重组测试 + 温度换算单调性测试全过
## 移植陷阱记录(教训)
- C 无符号32位回绕:乘积/减法必须 u32() 掩码(cdf*denom、0xffc0000-iv7*0x40000、
(v-win_lo)*S 等)
- lutRebuild 的 u12 基址是常量 iv7*0x10+0x10**不是**链式 u21vu21v 只用于曲线钳制比较
- Kotlin ByteArray 存 255 = -1lut[bfmax] != 0xFF 判断必须 (b.toInt() and 0xFF)
- 暖机窗口内 FFC 状态机暂停(warm check 在 ffc_step 之前)——移植时保持顺序
## 用户反馈修复(2026-09-07
- [x] **中文乱码根因**:部分源码曾被python以GBK重写(AppRoot.kt),编译出的字符串
mojibake。已写 fix_encoding.py 全量转UTF-8 + kotlin.daemon.jvmargs 锁UTF-8 +
dex字节级验证(classes5/9.dex 中"媒体库"UTF-8正确)
- [x] **权限弹窗**Manifest 加 USB_DEVICE_ATTACHED intent-filter + device_filter
(插热像仪系统自动弹权限/启动);相册页运行时申请 READ_MEDIA_IMAGES
(模拟器实测弹窗正常)
- [x] **图标**:全部重绘为可验证几何图形(双弧圆/直线/矩形),横竖屏截图确认渲染正确
- [x] **专业版三大功能补齐**:多点测温(点击加Pt1..PtN、再点删除)、右侧色标条
(渐变+最高/最低温标注)、最高温追踪开关(追踪·开/关)
- [x] **演示模式**:无设备时用真实管线+DDT渲染合成帧(热块场景),全UI可探索;
demo温度用局部线性显示映射(真机温度走管线数学)
- [x] Android 16.1 模拟器(Medium_Phone AVD)实机验证:中文/图标/布局/横竖屏正常,
预览图在 analysis/preview/preview_*.png
## 用户反馈修复 第二轮(2026-09-07
- [x] 竖屏时热像图旋转90°竖着显示(填满屏宽);横屏时保持传感器原生4:3方向
填满屏高——两个方向下显示区域都最大化(用户要求的官方行为)
- [x] 横屏:控制栏移到屏幕右侧(竖列),竖屏保持底部横排6项等分——切换时
不再跳位
- [x] 追踪/测温点标记改为大号"圆环+实心点"图标,字高随密度缩放(更大)
- [x] 色标条放大(宽20dp、高0.8视口)+更大刻度字号
- [x] ScreenOrientation=fullSensor:跟随传感器旋转(系统旋转锁定时也转,
与官方普通版一致)
- [x] **SurfaceView旋转存活**AppRoot/LiveScreen改为稳定单分支布局(导航/控制
栏做成overlay),SurfaceView不再因横竖屏切换被销毁重建(此前横屏花屏空
白的根因)
- [x] 模拟器实测:竖屏/横屏截图确认全部生效(analysis/preview/
## 用户反馈修复 第三轮(2026-09-07
- [x] **布局重构**:控制项(FFC/变倍/追踪/调色板/拍照/录像/调色板名)移到**顶部横栏**,
横竖屏都显示;底栏导航(实时/相册/分析/设置)保持原位置(竖屏底部栏/横屏左侧栏)
- [x] **图像内容世界固定**:绘制角度 = (90 - 显示器rotation×90) mod 360
四向旋转(竖屏/左横/右横/倒竖屏)图像内容都不随手机转,只转图标文字
(修复"向右横屏图像颠倒"问题)
- [x] **状态栏沉浸**insetsController 隐藏状态栏(下滑临时显示),底栏导航仍可用
- [x] **inset感知渲染**:渲染器 viewport = 全屏减顶栏/底栏insetAppRoot横屏时
导航栏宽度不再误算为底部inset)
- [x] 顶栏通过 onSizeChanged 上报 vm.uiTopPx;导航 overlay 高度经 UiInsets 单例
同步 vm.uiBottomPx
- [x] **(重要教训)python 裸写文件会用 GBK**:所有 python 编辑必须显式
encoding='utf-8';本次 LiveScreen.kt 双重乱码已重写恢复,dex 验证 CJK 正常
- [x] 模拟器验证:顶栏/左导航/图像/色标/标记布局正确,中文正常,状态栏隐藏生效
## 用户反馈修复 第四轮(2026-09-07,最终布局约定)
- [x] **顶栏/底栏位置固定**:顶栏=控制项(FFC/变倍/追踪/调色板/拍照/录像)恒在顶部;
底栏=导航恒在底部。横竖屏都不移动(**取消横屏左栏**),图标文字跟随屏幕方向(恒可读)
- [x] **图像区域永不旋转**:图像恒 90°CW 绘制为 3:4 竖向,填满顶栏~底栏之间可用区,
手机怎么转都不动;只转标记文字(文字恒屏幕水平,位置经 probeToScreen 固定90°映射)
- [x] **顶栏适配挖孔屏**`WindowInsets.safeDrawing.only(Top)` 顶部安全区 padding
- [x] 去掉 display-rotation 依赖(uiRotation/drawRotationDeg 已删,tapImage/probeToScreen
用固定 90° 映射)
- [x] 完整交接文档:docs/android_app/HANDOFF_DEVELOPMENT.md(含给新模型的读取提示词)
## 用户反馈修复 第五轮(2026-09-07,布局约定最终定稿:构图钉死竖屏框架)
- [x] **用户澄清**:"顶栏底栏位置不变"指**绝对位置**——永远贴着手机竖屏的物理顶边
(挖孔侧)与物理底边;热像区域也**完全不动**。不是"横屏后显示区域跟着屏幕转":
那样屏显方向会和镜头实际方向对不上。
- [x] **根因与修复**:此前 fullSensor 下渲染"相对当前屏幕固定 90°CW",手机横过来时
屏幕本身转 90°,整个构图相对手机框架也转 90° → 与镜头方向脱节。修复 =
**Activity 锁定竖屏**manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转,构图(顶栏/热像区/底栏)永远钉在竖屏框架上;手机怎么物理旋转都一样。
- [x] 顺带清理:GalleryScreen 去掉 isLandscape() 死逻辑(恒 3 列);
AppRoot/LiveScreen/LiveRenderer/UiInsets 注释同步更新。
- [x] 模拟器四方向实测(0/90/180/270°,`adb emu rotate`):**四个方向截图逐字节一致**
(MD5 相同)——构图绝对固定;演示画面/顶栏/底栏/色标/标记/中文均正常
analysis/preview/preview_rot*_v5.png)。
- [x] 单元测试全过;APK 已更新 build-artifacts/mag160c-app-debug.apk。
- 注:不要再改回 fullSensor/sensor 横屏;横竖屏适配类需求一律以"竖屏构图恒定"为准。
## 用户反馈修复 第六轮(2026-09-07,横屏持机图标文字可读)
- [x] **用户反馈**:竖屏锁定后,横过来拿手机时顶栏/底栏里的图标和文字不旋转(侧着)。
约定补全:**构图钉死竖屏框架不变**(条栏绝对位置+热像区域不动),但条栏内容
(图标+文字)与 OSD 文字要按**物理持机朝向**补偿旋转,保持可读。
- [x] 新增 `ui/DeviceOrientation.kt`:加速度计→手机相对竖屏的顺时针物理转角
φ∈{0,90,180,270}(主轴判定+2.5m/s² 滞回;竖屏锁定下 Display.rotation 恒 0 不可用)。
- [x] 顶栏 7 控制项/底部导航 4 项:`graphicsLayer rotationZ=-φ` 原位预旋转(布局不动)。
- [x] 渲染器 OSD 文字(中心温/探针标注/色标最高最低数字)`canvas.rotate(-φ)` 绕锚点
旋转;标记圆点、色标条几何仍钉死在图像上。
- [x] 模拟器实测(`adb emu sensor set acceleration` 驱动 4 姿态):四姿态下热像区域
完全一致,图标/文字按姿态正确补偿(analysis/preview/preview_pose*_v6.png);
姿态复位 0° 后输出与第五轮逐字节一致(MD5 相同)。
- [x] 单元测试全过;APK 已更新。
- 注:对话框(调色板等)与其他页签内容未做补偿旋转(保持竖屏可读),如需再加。
## 用户反馈修复 第七轮(2026-09-07,真机朝向全反修复)
- [x] **用户反馈**:真机正持竖屏时屏幕内容倒立,其他方向也全反。
- [x] **根因**:第六轮把加速度计符号约定写反。Android 真机 TYPE_ACCELEROMETER
静止读数 = "加速度减重力",指向世界上方向(平放屏幕朝上 z=+9.81、竖屏正持
y=+9.81);模拟器虚拟传感器却是重力向量约定(正持 y=-9.81),故模拟器测试
"通过"而真机全反(所有姿态差 180°)。
- [x] **修复**DeviceOrientation 映射改为 (0,+g)=0、(-g,0)=90、(0,-g)=180、(+g,0)=270
代码注释明确标注"勿按模拟器默认值改回"。模拟器用反号值复测:姿态 0 恢复正常
正持显示,90/180/270 补偿几何不变(preview_fix_pose*_v7.png)。
- [x] 单元测试全过;APK 已更新。
## 用户反馈修复 第八轮(2026-09-07,相机式按键布局)
- [x] **顶栏精简为 4 个相机控制项**(左→右,带小字标签,随持机朝向补偿旋转):
FFC 校正 / 数码变倍(显示当前 1×/2×/4×,点击循环)/ 最高温追踪开关(追踪·开,
高亮+新 ic_target 准星图标)/ 调色板(显示当前调色板名,点击弹出选择)。
- [x] **底部新增相机快门区**(仅实时页,位于底导航上方,白/红色调):
相册快捷入口(跳转媒体库页签)/ 大圆形快门=拍照 / 录像-停止(红圈,录像中
红圈内容变红色方块)。快门区高度并入渲染器底部 insetvm.uiBottomPx =
导航高+快门区高),测温点点击映射同步排除该区域。
- [x] 底部导航(实时/相册/分析/设置)不变;快门区内容同样按持机朝向补偿旋转。
- [x] 模拟器实测:0°/90° 姿态布局、旋转补偿、图像区域均正确
preview_ui_v8.png / preview_ui90_v8.png);单元测试全过;APK 已更新。
## 用户反馈修复 第九轮(2026-09-07,真机热像顶端被顶栏遮挡)
- [x] **用户反馈**:真机上快门区把热像显示区顶满后,热像最顶端被顶栏盖住看不见。
- [x] **根因**:顶栏 `onSizeChanged` 挂在 `safeDrawing` 挖孔 inset 内侧,`uiTopPx`
不含挖孔安全区高度;模拟器无挖孔看不出来。快门区加入后剩余高度变小、热像
进入"高度受限"铺满视口状态,顶端误差直接表现为热像顶端插入顶栏背后。
- [x] **修复**`onSizeChanged` 移到 `windowInsetsPadding` 之前(`uiTopPx` = 挖孔
安全区 + 顶栏全高,渲染器视口完整跳过顶栏);同时按要求缩小快门区
(相册/录像 42dp、快门 60dp、纵向 padding 6dp、间距 44dp)。
- [x] 模拟器复测布局正常,单元测试全过;APK 已更新。
## 待办
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
- 厂商12调色板精确提取(需运行时抓取)
- 可见光PIP融合、云模块(预留结构)
## 里程碑日志
- 阶段3b 完成(2026-09-06 晚): 拍照 MDT 容器(jpg+info块+raw帧+备注)存入
MediaStore DCIM/MAG160CMP4 录像 = Surface 编码 H.264 @320×240 15fps 2Mbps
- 阶段4 完成: 媒体库(MediaStore 扫描 + 内嵌 JPEG 缩略图 + MDT 尾部校验)
- 阶段5a 完成: 分析查看器(detectTransformGestures 缩放1-4x/平移、12 调色板
重渲染原始帧、自动窗口、备注编辑回写容器、温度探针近似)
- 阶段5b 完成: PDF 巡检报告(PdfDocument:标题/7×4信息表/热像图/结果/建议/测试员)
- 阶段6a 完成: 设置页(默认调色板/发射率/报警温度/关于,SharedPreferences 私有)
+ 任务巡检解析器 TaskParsersqlite task/region/device/part/phase + XML title/target
## 阶段3a 完成(2026-09-06 晚)
- [x] USB传输层 usb/UsbTransport.ktVID 0x833C 枚举/权限/claim/EP查找)
- [x] 命令协议 usb/MagProtocol.kt4B命令+FFC 8B0x5BB5B55B 信息块解析)
- [x] 直播会话 usb/IrSession.kt66b/66c/66f → FFC(0)x2 → 300ms → START(73)
读线程→FrameStream→RenderPipeline→帧回调;STOP(74);手动FFC
- [x] 官方12调色板 core/Palettes.kt(铁虹=官方提取表,其余为标准曲线近似)
- [x] UI骨架:底部导航4区(实时/相册/分析/设置,横屏自动切侧栏 NavigationRail
- [x] 实时画面 ui/live/SurfaceView+软件Canvas渲染(letterbox+数码变倍+OSD
中心温/最高最低温标记),ViewModel驱动,慢速测温定时器
- [x] DDT标定文件打包进 assets/mag160c.ddt
- [x] 全APK assembleDebug 成功(11.7MB,无material-icons膨胀)
- [ ] 待实机验证USB流(需插设备)
## 开发计划(待确认后逐阶段执行)
- 阶段 1:环境(cmdline-tools/NDK r27+/gradle 8.x + 16KB 对齐配置 + AGP 8.x
- 阶段 2:新仓库 app-androidKotlin + Compose M3 + NDK 移植 csdk 渲染/温度 C 代码 + Kotlin USB 层)
- 阶段 3:实时画面(USB 流+官方管线+OSD+ROI+调色板+FFC+增强+PIP 可见光+拍照 MDT+MP4 录像)
- 阶段 4:媒体库(MediaStoreDCIM/MAG160C,无流氓文件夹)
- 阶段 5:MDT 离线分析(缩放/ROI/参数/PDF 报告/备注)
- 阶段 6:设置/任务巡检/可选云(Retrofit opt-in
- 阶段 7Android 16 专项适配验收(16KB 对齐/横竖屏/深色/预测性返回)
- 阶段 8:打包安装到用户手机实测
## 心跳约定
- 每完成一个阶段:更新本文件勾选 + git commit(不 push)。
- 中断恢复:读本文件 → 按"下一步"继续 → 环境检查命令:
```powershell
Test-Path C:\Users\ZXC\AppData\Local\Android\Sdk\ndk\<ver>
Get-Command java; Get-Command gradle
```
+31
View File
@@ -0,0 +1,31 @@
# MAG160C 安卓APP会话恢复脚本
# 用法: powershell -ExecutionPolicy Bypass -File tools\resume_android.ps1
# 作用: 检查环境/构建/设备状态,重新构建APK,列出实机验证清单。
$proj = "C:\Project\MAG160C"
$andr = "$proj\android"
Write-Host "=== 1. 工具链 ==="
Write-Host "Java: $env:JAVA_HOME"
$gradle = "C:\Tools\gradle-8.14.3\bin\gradle.bat"
Write-Host "Gradle: $(Test-Path $gradle)"
Write-Host "SDK: $(Test-Path "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe")"
Write-Host "=== 2. 设备 ==="
cmd /c "C:\Users\ZXC\AppData\Local\Android\Sdk\platform-tools\adb.exe devices"
Write-Host "=== 3. 构建 ==="
Push-Location $andr
$env:JAVA_HOME = "C:\Program Files\Zulu\zulu-21"
& $gradle :app:assembleDebug --no-daemon -q 2>&1 | Out-Null
Pop-Location
$apk = "$andr\app\build\outputs\apk\debug\app-debug.apk"
if (Test-Path $apk) {
Write-Host "构建 OK: $apk ($((Get-Item $apk).Length) 字节)"
Copy-Item $apk "$proj\build-artifacts\mag160c-app-debug.apk" -Force
} else { Write-Host "!!! 构建失败,运行: cd android; gradle :app:assembleDebug" }
Write-Host "=== 4. 实机验证清单 ==="
Write-Host "1. adb install mag160c-app-debug.apk"
Write-Host "2. 插入MAG160C -> 允许USB权限 -> 实时画面出图"
Write-Host "3. FFC/调色板/变倍/拍照/录像/媒体库/分析/PDF报告"
Write-Host "4. 横竖屏+深色模式"
Write-Host "详细状态: docs\android_app\session_state.md"