From b34ade8435c114004a44bd0c0261b7f35f6d0ad0 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Sun, 3 May 2026 11:24:48 +0300 Subject: [PATCH] Add sensor driver extraction toolkit and trace --output= flag ipctool trace already decodes sensor I2C/SPI/MIPI/VI traffic into C-pseudocode, but the output goes to the same stdout the traced child uses, so verbose streamers (Majestic, Sofia) interleave their own log lines with ours and occasionally truncate mid-token. * `--output=PATH` flag: parent freopens stdout after fork; the child keeps its inherited fd 1 untouched so its logs go where they always did. Default behaviour unchanged. * New post-processing pipeline under tools/ (no external deps): trace_segment.py splits a captured trace into pre_sensor/init/ post_init/runtime phases via reset/stream-on detection plus write-frequency heuristics; trace_to_driver.py emits a HiSilicon-shaped C scaffold (sensor_linear_init + post_init_exposure_prime); trace_diff.py compares against a known reference with --gen-scope/--ref-scope so AE-overwritten regs don't pollute the diff. * tools/capture_sensor.sh wraps the camera-side flow for both OpenIPC/Majestic (ssh) and XiongMai/Sofia (telnet + bind-mount over /usr/bin/Sofia, since XmServices_Mgr is not a real supervisor). The build subcommand downloads the canonical OpenIPC toolchain and UPX-packs the binary, which is required for the XiongMai HiLinux kernel to load it (raw musl-static ELFs hit ENOEXEC there). * docs/sensor-driver-extraction.md documents the full workflow, including the non-obvious gotchas (UPX requirement, XmServices_Mgr behaviour, SIGTTIN on backgrounded ptraced chains, fd-1 sharing). Validated end-to-end on SC2315E + HI3516EV200: 100% address / 100% value / 100% LCS sequence match against widgetii/smart_sc2315e reference, identical from both Majestic and Sofia captures. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + README.md | 10 +- docs/sensor-driver-extraction.md | 450 +++++++++++++++++++++++++++++++ src/main.c | 9 +- src/ptrace.c | 26 +- tools/capture_sensor.sh | 222 +++++++++++++++ tools/trace_diff.py | 175 ++++++++++++ tools/trace_segment.py | 219 +++++++++++++++ tools/trace_to_driver.py | 148 ++++++++++ 9 files changed, 1252 insertions(+), 9 deletions(-) create mode 100644 docs/sensor-driver-extraction.md create mode 100755 tools/capture_sensor.sh create mode 100644 tools/trace_diff.py create mode 100644 tools/trace_segment.py create mode 100644 tools/trace_to_driver.py diff --git a/.gitignore b/.gitignore index 6f47e10..7ae26f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .clangd build +build-arm* compile_commands.json +tools/dumps/ diff --git a/README.md b/README.md index 9d2deb6..56f63e9 100644 --- a/README.md +++ b/README.md @@ -310,12 +310,18 @@ sensors: # ipctool --script reginfo ``` -* Advanced replacement of `strace`: +* Advanced replacement of `strace`, with sensor-aware decoding of I2C/SPI + ioctls and HiSilicon MIPI/VI structs: ```console - # ipctool trace /usr/bin/Sofia + # ipctool trace --output=/tmp/sofia-trace.log /usr/bin/Sofia ``` + For a complete recipe — building a portable binary, capturing on + OpenIPC/Majestic and XiongMai/Sofia firmwares, and converting the + trace into a buildable sensor driver scaffold — see + [docs/sensor-driver-extraction.md](docs/sensor-driver-extraction.md). + ### To help the researcher On Ingenic devices, the original Sensor I2C address needs to be right shifted by 1bit, example: diff --git a/docs/sensor-driver-extraction.md b/docs/sensor-driver-extraction.md new file mode 100644 index 0000000..b700e21 --- /dev/null +++ b/docs/sensor-driver-extraction.md @@ -0,0 +1,450 @@ +# Extracting sensor drivers from binary firmware with `ipctool trace` + +A guide for researchers who need to recover an image-sensor init sequence +(and the surrounding ISP/MIPI setup) from a running but source-less +streamer — typically because the camera vendor has stopped shipping +updates and the only remaining trace of the driver lives inside an `.so` +or a statically-linked binary. + +This document walks through the full pipeline end-to-end, using +SmartSens **SC2315E** on a Hisilicon **HI3516EV200** as the worked +example, captured separately from OpenIPC's [Majestic][majestic] streamer +and from XiongMai's proprietary Sofia. Both produce a register-by-register +identical init sequence — and one that matches the public OpenIPC +[smart_sc2315e][smart_sc2315e] reference source exactly. + +[majestic]: https://github.com/openipc +[smart_sc2315e]: https://github.com/widgetii/smart_sc2315e + +## What `ipctool trace` does + +It is a [`ptrace(2)`][ptrace]-based syscall interceptor, similar in spirit +to `strace`, but specialised for camera I/O. It hooks `open`, `read`, +`write`, `ioctl`, and `nanosleep`, and decodes the device-specific +payloads on the fly: + +- `/dev/i2c-*`, `/dev/hi_i2c` → `sensor_write_register(0x..., 0x..)` / + `sensor_read_register(0x...) /* -> 0x.. */` / `sensor_i2c_change_addr(0x..)` +- `/dev/spidev*`, `/dev/ssp` → `ssp_write_register(...)` / SPI message dumps +- `/dev/hi_mipi`, `/dev/vi` → pretty-printed `combo_dev_attr_t SENSOR_ATTR = {...}` +- `/dev/xm_gpio`, `/sys/class/gpio/...` → GPIO request/direction/write events +- `/dev/mtd*` → MTD ioctl dumps + +The output is a stream of C-pseudocode interleaved with bus-banner +markers (`========================== i2c-29 ==========================`). +ARM-only (the syscall-number table is hardcoded for ARM 32-bit EABI). + +[ptrace]: https://man7.org/linux/man-pages/man2/ptrace.2.html + +## Quick worked example + +```console +# build (downloads OpenIPC's CI toolchain, UPX-packs the binary) +$ tools/capture_sensor.sh build + +# capture from a Majestic camera over ssh +$ tools/capture_sensor.sh majestic --host openipc-hi3516ev200.lan \ + --secs 35 --out tools/dumps/cap.log + +# segment, generate, diff against a known-good vendor source +$ python3 tools/trace_segment.py tools/dumps/cap.log +$ python3 tools/trace_to_driver.py tools/dumps/cap.log.segments.json \ + --sensor sc2315e \ + --out tools/dumps/cap.c +$ python3 tools/trace_diff.py tools/dumps/cap.c \ + /path/to/smart_sc2315e/sc2315e_sensor_ctl.c \ + --gen-scope sc2315e_linear_init \ + --ref-scope sc2315e_linear_1080P30_init + +generated: tools/dumps/cap.c (172 writes, 169 unique regs) +reference: smart_sc2315e/sc2315e_sensor_ctl.c (172 writes, 169 unique regs) + +address match: 169 / 169 ref regs present (100.0%) +value match: 169 / 169 matching addrs (100.0%) +sequence (LCS): 100.0% of max(len) +``` + +A 100/100/100 match confirms that what the binary writes to the sensor +is exactly what the published vendor driver writes. If you trust the +reference, the trace gives you a buildable scaffold. + +## Pipeline overview + +``` +camera (ARM) host (x86) +───────────── ────────── +streamer ┌─────────► trace_segment.py + │ │ │ + ▼ │ ▼ +ipctool trace ──► .log file ──► trace_to_driver.py ──► .c scaffold + │ │ + ▼ ▼ + segments.json trace_diff.py vs reference +``` + +Each stage is dependency-free Python (segment / generate / diff) and one +shell wrapper for the camera-side capture (`capture_sensor.sh`). Nothing +needs to be installed on the camera beyond the staged `ipctool` binary. + +## Stage 1 — Build a portable `ipctool` + +You need an ARM static binary that will run on whichever embedded kernel +the target camera ships. Cross-compile flags vary, but two non-obvious +constraints apply: + +### 1. Use the OpenIPC CI toolchain + +Generic Buildroot/musl ARM toolchains often produce binaries with +slightly different code generation than the canonical CI build, which +is what every released `ipctool` tested against. Stick with the toolchain +referenced in [`.github/workflows/release-arm32.yml`](../.github/workflows/release-arm32.yml): + +```bash +wget -qO- \ + https://github.com/OpenIPC/firmware/releases/download/toolchain/toolchain.hisilicon-hi3516cv100.tgz \ + | tar xzf - -C /opt +``` + +Then build with **direct compiler override**, no toolchain file: + +```bash +PATH=/opt/arm-openipc-linux-musleabi_sdk-buildroot/bin:$PATH \ + cmake -H. -Bbuild \ + -DCMAKE_C_COMPILER=arm-openipc-linux-musleabi-gcc \ + -DCMAKE_BUILD_TYPE=Release +cmake --build build +``` + +### 2. UPX-pack the binary + +Some embedded kernels (notably **XiongMai HiLinux** based on Linux 4.9) +will refuse to load a regular musl-static ELF and the busybox shell +falls back to interpreting the file as a script: + +``` +/utils/ipctool: line 1: syntax error: unexpected word (expecting ")") +``` + +That cryptic message is the kernel returning `ENOEXEC` for the binary, +followed by `sh` trying to read the ELF magic as shell. UPX wraps the +binary in a tiny self-decompressing stub with a single `PT_LOAD` +segment and (as a side effect) sets the OS/ABI byte to `UNIX - GNU`. +Both the OpenIPC and XiongMai kernels accept that: + +```bash +upx --best build/ipctool -o build/ipctool-upx +``` + +The released `ipctool` from the OpenIPC GitHub releases is UPX-packed +for exactly this reason. `tools/capture_sensor.sh build` does this +automatically. + +## Stage 2 — Camera-side capture + +`ipctool trace` forks, `ptrace(TRACEME)`s, and `execv`s the target +binary. All clones/forks are followed via `PTRACE_O_TRACECLONE`, so a +multi-threaded streamer is captured as one stream. + +The `--output=PATH` flag (added in this branch) is important: without +it, the trace pseudocode shares the child's `stdout`, and verbose +streamers (Majestic at INFO level, Sofia with its `[31m...[0m` +ANSI-coloured logs) interleave their own log lines with ours, sometimes +truncating mid-token. With `--output=PATH`, the parent `freopen`s its +stdout to PATH after `fork`, so the child's `stdout` keeps pointing at +the original tty. + +### Capturing from Majestic (OpenIPC) + +Majestic is a "good citizen": no watchdog, no path-based supervisors, +restartable via `/etc/init.d/S95majestic`. Capture is straightforward: + +```bash +# uses ssh, expects passwordless key-based access +tools/capture_sensor.sh majestic --host openipc-hi3516ev200.lan \ + --secs 35 +``` + +What it does: + +``` +killall majestic # stop the live stream +ipctool-upx trace --output=/utils/dumps/log \ + /usr/bin/majestic & # run a fresh one under trace +sleep 35 # init + a few seconds AE +killall ipctool-upx majestic # stop the trace +/etc/init.d/S95majestic start # restore camera service +``` + +Output is a clean trace with no log interleaving. About 3000 lines for +40 s of capture (init + a few hundred AE-loop iterations). + +### Capturing from XiongMai Sofia (HiLinux) + +This is harder for three independent reasons. Document each gotcha +separately so you can recognise them on adjacent firmware variants. + +**(a) Sofia is a network client of `XmServices_Mgr`, not a peer.** +Sofia opens a local TCP socket to talk to `XmServices_Mgr` for system +services like `GetWritableDir`. If `XmServices_Mgr` is dead, Sofia +spins forever in `LibXmComm_NetTcp_recvPacket: timeout`, never reaching +sensor init. So the capture flow must keep `XmServices_Mgr` alive. + +**(b) `XmServices_Mgr` is not a supervisor.** Despite the name, it +forks `SofiaRun.sh` exactly once at boot — from `/etc/init.d/rcS` — and +does not restart it if Sofia or `SofiaRun.sh` exits. Killing Sofia +leaves the camera with no running streamer until manual intervention. + +**(c) The watchdog is fed by `XmServices_Mgr`, not Sofia.** Killing +Sofia by itself does not trigger a hardware reboot, so you have time. +But killing `XmServices_Mgr` will reboot the camera within ~30 s. + +The working recipe is a **bind-mount wrapper**: insert ipctool between +`SofiaRun.sh` and `/usr/bin/Sofia` without disturbing `XmServices_Mgr`: + +```bash +# 1. Stage a copy of the real Sofia under a path the wrapper can exec +cp /usr/bin/Sofia /utils/Sofia.real + +# 2. Create a wrapper that runs Sofia under trace, redirecting trace +# output to a file (Sofia's own stdout/stderr go to /dev/null as +# they did under the real SofiaRun.sh anyway) +cat > /utils/sofia.wrap.sh <<'EOF' +#!/bin/sh +exec /utils/ipctool-upx trace --skip=usleep \ + --output=/utils/dumps/sofia.log \ + /utils/Sofia.real +EOF +chmod +x /utils/sofia.wrap.sh + +# 3. Make /usr/bin/Sofia point at the wrapper. Squashfs is read-only, +# so we use a bind mount - the inode of /usr/bin/Sofia is now +# /utils/sofia.wrap.sh. +mount --bind /utils/sofia.wrap.sh /usr/bin/Sofia + +# 4. Kill Sofia and SofiaRun.sh, then restart SofiaRun.sh manually - +# XmServices_Mgr will not do this for us. The < /dev/null is +# important: a backgrounded shell pipeline that contains a +# ptraced-stopped child receives SIGTTIN if any process in it +# tries to read from the controlling tty. /dev/null skips that. +killall Sofia SofiaRun.sh +sleep 1 +/usr/sbin/SofiaRun.sh /dev/null 2>&1 & + +# 5. Wait long enough for sensor init + a few seconds of steady state +sleep 55 + +# 6. Reboot. The bind mount is ephemeral and goes away cleanly. +reboot +``` + +`tools/capture_sensor.sh sofia` automates this through `expect(1)`: + +```bash +tools/capture_sensor.sh sofia --host 10.216.128.106 --secs 55 +``` + +**What `--skip=usleep` is for here:** Sofia spends a lot of time +busy-polling `nanosleep(0)` (yield) while it waits for various subsystems +to come up. With `--skip=usleep`, those entries don't pollute the trace, +and we still keep the structurally meaningful sleeps that matter for +phase segmentation. (Majestic uses real sleeps so `--skip=usleep` would +be lossy there; the script keeps it on by default.) + +### Why these recipes don't transfer wholesale to other firmwares + +The two camera-side recipes encode two different operational models: + +| Concern | OpenIPC / Majestic | XiongMai HiLinux / Sofia | +|---|---|---| +| Streamer restart | `init.d` script | Manual via the supervisor parent | +| Supervisor behaviour | None — `init.d` script is fire-and-forget | Spawns once, doesn't restart | +| Hardware watchdog | Not in play during dev | Fed by supervisor; ~30 s to reboot | +| Streamer log destination | stdout (mixes with trace if not redirected) | Same | +| ELF kernel acceptance | Plain musl-static OK | Requires UPX | + +For a third firmware, work out: + +1. Does the kernel accept your ARM static ELF? If `sh: line 1: syntax error` + on launch, UPX-pack. +2. Does anything respawn the streamer? Try `kill ` and + watch `ps`. If yes, you can let it respawn through your wrapper. If + no, you'll need to start it manually after killing. +3. Is there a watchdog? `ls /dev/watchdog*`. If yes, time-bound your + capture and have a clean exit path. +4. Does the streamer write to stdout? If yes, **always** use + `--output=PATH` to avoid interleaving. + +## Stage 3 — Post-processing + +Three Python scripts, no external dependencies, all read/write under +`tools/dumps/` by convention. + +### `trace_segment.py` + +Splits the raw log into phases: + +| Phase | What it contains | +|---|---| +| `pre_sensor` | Bus probe, MIPI/VI struct dumps, pre-init noise | +| `init` | From `sensor_write_register(0x100, 0x0)` (reset) to `sensor_write_register(0x100, 0x1)` (stream-on) | +| `post_init` | A short burst of AE/exposure prime writes between stream-on and the steady-state loop | +| `runtime` | Per-frame writes during steady-state (e.g. AE updating exposure registers) | + +The init/post-init split exists for diff-friendliness: the AE loop in +`post_init` typically rewrites the same exposure registers (`0x3E00..`, +`0x320E/F`, …) that `init` had set to default values. Comparing +`init`-only against the reference's init function avoids spurious +mismatches. + +```bash +python3 tools/trace_segment.py tools/dumps/cap.log +# wrote tools/dumps/cap.log.segments.json +# pre_sensor 39 events +# init 173 events +# post_init 15 events +# runtime 2585 events +``` + +### `trace_to_driver.py` + +Emits HiSilicon-SDK-shaped C from the segmented JSON. Two functions per +sensor: `_linear_init` and `_post_init_exposure_prime`, +plus a comment block summarising the most-frequently-written runtime +registers (the AE/AGC hot list). + +```bash +python3 tools/trace_to_driver.py tools/dumps/cap.log.segments.json \ + --sensor sc2315e \ + --out tools/dumps/cap.c +``` + +The output is a scaffold, not a finished driver: + +- The `init` function is a register table you can hand-edit into the + vendor's `sensor_init` callback. +- The `post_init_exposure_prime` function hints at what the SDK's + `cmos_inittime_update` sets up. +- The runtime hot-register comment block tells you which registers + belong inside the AE/AGC callback. The values driving them are + **not** in the trace — only the regs touched and how often. + +### `trace_diff.py` + +Diff-by-register-write. Extracts every `sensor_write_register(reg, val)` +call (or the vendor-shaped `_write_register(ViPipe, reg, val)` +variant) from each file and reports: + +- address coverage (how many ref regs the trace actually wrote) +- value match (last value seen per address agrees) +- sequence similarity (LCS, order-aware) +- per-side asymmetric diffs and value mismatches + +`--gen-scope` and `--ref-scope` restrict each side to a named function +body. Critical for the AE-overwrite issue mentioned above: + +```bash +python3 tools/trace_diff.py \ + tools/dumps/cap.c \ + /path/to/smart_sc2315e/sc2315e_sensor_ctl.c \ + --gen-scope sc2315e_linear_init \ + --ref-scope sc2315e_linear_1080P30_init +``` + +## Reading the diff + +For SC2315E captured from Majestic without scoping, the unscoped diff +shows ~5 value mismatches at registers `0x320E`, `0x320F`, `0x3E01`, +`0x3E02`, `0x3E09` — these are VMAX and exposure registers. They appear +in both `init` (defaults) and `post_init` (AE prime). The generator's +"last value seen" is the AE prime value, while the reference's init +function carries the default. This is **expected**, not a bug; scope +the diff to the `linear_init` function on both sides and the mismatches +disappear. + +Other expected sources of mismatch on a real capture: + +- **Registers in the reference source but not in the trace**: these are + often gated by mode (HDR-only registers, sub-pipe configurations, OTP + reads). The reference is the union over all branches; your trace + exercises one. Cross-check with a second reference if you have one + ([`sc_sc2315e`](https://github.com/widgetii/sc_sc2315e) does this for + SC2315E). +- **Registers in the trace but not in the reference source**: these are + binary updates the source missed (vendor patched the closed driver + but not the published one) or runtime tuning that the trace happened + to capture in `init` slot. Inspect manually — these are the + interesting ones. +- **Order differences in LCS but ≥99% address match**: usually + reorderings inside an unordered group of writes (BLC defaults, + test-pattern setup). Not material for sensor function. + +A 100 % triple match like the one in the worked example is the +**ceiling**, not the expected outcome. 90+/85+/80+ is a healthy result +that lets you trust the scaffold as a porting starting point. + +## Troubleshooting + +### `sh: line 1: syntax error: unexpected word (expecting ")")` on `ipctool` launch + +The kernel returned `ENOEXEC` for your binary. Cause is almost always +"binary built with toolchain whose ELF flags this kernel doesn't accept". +Solution: UPX-pack the binary (see Stage 1). The CI toolchain alone is +not sufficient; UPX is the actual fix. + +### Trace contains lines like `sensor_write_register(0x5785xmcap_video_api.c` + +Streamer log lines and trace pseudocode were both written to the same +fd. Use `--output=PATH`. If you can't (older `ipctool` build), strip +ANSI escapes before parsing: + +```bash +sed 's/\x1b\[[0-9;]*m//g' raw.log > clean.log +``` + +…and accept that some lines mid-write may be truncated. + +### Trace shows lots of `error copy_from_process from 0 (I/O error)` + +The exit-side hook tried to read the result buffer for a syscall but +the address was zero or unmapped. This usually means the syscall +returned an error before populating the buffer (e.g. an I2C read at an +unresponsive address during bus probe). Filter these out — they convey +no information. + +### Trace is dominated by `usleep(0)` + +Streamer is busy-polling. Pass `--skip=usleep` to silence them. You +will lose phase boundaries that depend on actual sleep durations, but +the segmenter falls back on the `0x100=0` / `0x100=1` reset/stream-on +markers, which is enough for most sensors. + +### `expect`'d capture session ends with `Connection closed by foreign host` mid-flow + +Either the camera rebooted under you (watchdog or `RebootAbnormal` +trigger after a stream death), or your kill sequence reaped a process +the supervisor depended on. For XiongMai cameras, do not kill +`XmServices_Mgr`; it provides services the streamer needs and feeds the +hardware watchdog. Use the bind-mount approach in the Sofia recipe. + +### Diff shows 100 % address match but only 60 % sequence match + +You're capturing a different SDK build than the reference was generated +from. Some vendors reorder writes between releases without changing +behaviour. The address+value match is what matters; sequence is a +helpful proxy that breaks down across versions. + +## File layout + +``` +ipctool/ +├── src/ +│ ├── ptrace.c # ARM-only ptrace engine, --output flag added +│ └── hal/hisi/ptrace.c # HiSilicon-specific MIPI/VI struct decoders +├── tools/ +│ ├── capture_sensor.sh # build / majestic / sofia subcommands +│ ├── trace_segment.py # parse + phase split +│ ├── trace_to_driver.py # JSON → C scaffold +│ └── trace_diff.py # generated vs reference, scoped +└── docs/ + └── sensor-driver-extraction.md # this file +``` diff --git a/src/main.c b/src/main.c index dba06c4..2f7eebe 100644 --- a/src/main.c +++ b/src/main.c @@ -91,16 +91,19 @@ void print_usage() { " i2cset \n" " spiset \n" " write a value to I2C/SPI device\n" - " i2cdump [--script] [-b, --bus] \n" + " i2cdump [--script] [-b, --bus] \n" " spidump [--script] \n" " dump data from I2C/SPI device\n" " i2cdetect [-b, --bus] attempt to detect devices on I2C bus\n" " reginfo [--script] dump current status of pinmux registers\n" " gpio (scan|mux) GPIO utilities\n" - " trace [--skip=usleep] [program " - "arguments]\n" + " trace [--skip=usleep] [--output=PATH] " + "[program arguments]\n" " dump original firmware calls and data " "structures\n" + " (--output= keeps child's stdout/stderr " + "untouched)\n" " -h, --help this help\n"); } diff --git a/src/ptrace.c b/src/ptrace.c index 5d3ac71..cd4c0aa 100644 --- a/src/ptrace.c +++ b/src/ptrace.c @@ -1121,8 +1121,8 @@ static void do_child(const char *program, char *const argv[]) { } static int help() { - puts("Usage: ipctool trace [--skip=usleep] " - "[program arguments]"); + puts("Usage: ipctool trace [--skip=usleep] [--output=PATH] " + " [program arguments]"); return EXIT_FAILURE; } @@ -1136,8 +1136,10 @@ int ptrace_cmd(int argc, char **argv) { if (argc < 2) return help(); + const char *output_path = NULL; const struct option long_options[] = { {"skip", required_argument, NULL, 's'}, + {"output", required_argument, NULL, 'o'}, {NULL, 0, NULL, 0}, }; int res; @@ -1149,6 +1151,9 @@ int ptrace_cmd(int argc, char **argv) { case 's': parse_skip(optarg); break; + case 'o': + output_path = optarg; + break; case '?': return help(); } @@ -1160,10 +1165,23 @@ int ptrace_cmd(int argc, char **argv) { } pid_t pid = fork(); - if (pid) + if (pid) { + // Parent only: redirect trace output to PATH so the traced child's + // stdout (e.g. streamer log lines) doesn't interleave with our + // pseudocode emissions on the shared fd 1. Child already has its + // own copy of fd 1 from fork() and is unaffected. + if (output_path) { + if (!freopen(output_path, "w", stdout)) { + fprintf(stderr, "freopen(%s) failed: %s\n", output_path, + strerror(errno)); + exit(EXIT_FAILURE); + } + setvbuf(stdout, NULL, _IOLBF, 0); + } do_trace(pid); - else + } else { do_child(argv[optind], &argv[optind]); + } return EXIT_SUCCESS; } #endif diff --git a/tools/capture_sensor.sh b/tools/capture_sensor.sh new file mode 100755 index 0000000..6f7fb42 --- /dev/null +++ b/tools/capture_sensor.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Camera-side sensor trace capture, via NFS-staged ipctool. +# +# Three subcommands: +# +# build Build ipctool for ARM (CI-canonical toolchain) and UPX-pack +# so the binary runs on both OpenIPC and XiongMai HiLinux kernels. +# Stages at $NFS_LOCAL/ipctool-upx. +# +# majestic Capture from an OpenIPC/Majestic camera over ssh. Kills the +# running majestic, starts a fresh one under `ipctool trace +# --output=PATH`, lets it run for $SECS seconds, kills it, and +# restarts majestic via /etc/init.d/S95majestic. +# +# sofia Capture from a XiongMai/Sofia camera over telnet (expect). +# Uses bind-mount over /usr/bin/Sofia (because XmServices_Mgr is +# not a supervisor), then reboots the camera to clean up. +# +# Environment defaults (override on the command line): +# +# NFS_HOST=10.216.128.227 NFS server holding /utils content +# NFS_LOCAL=/mnt/noc local mount point of the NFS share +# NFS_REMOTE=/srv/nfsroot path exported by the NFS server +# mounted as /utils on the camera +# +# After capture, post-process locally with: +# tools/trace_segment.py +# tools/trace_to_driver.py .segments.json --sensor sc2315e +# tools/trace_diff.py \ +# --gen-scope sc2315e_linear_init \ +# --ref-scope sc2315e_linear_1080P30_init + +set -euo pipefail + +NFS_HOST="${NFS_HOST:-10.216.128.227}" +NFS_LOCAL="${NFS_LOCAL:-/mnt/noc}" +NFS_REMOTE="${NFS_REMOTE:-/srv/nfsroot}" + +OPENIPC_TOOLCHAIN_URL="https://github.com/OpenIPC/firmware/releases/download/toolchain/toolchain.hisilicon-hi3516cv100.tgz" +OPENIPC_TOOLCHAIN_DIR="/tmp/openipc-tc" +OPENIPC_GCC="$OPENIPC_TOOLCHAIN_DIR/arm-openipc-linux-musleabi_sdk-buildroot/bin/arm-openipc-linux-musleabi-gcc" + +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +log() { printf '== %s\n' "$*" >&2; } + +usage() { + awk '/^# Camera-side/,/^set -/{ if (/^set -/) exit; sub(/^# ?/,""); print }' "$0" + echo + echo "Usage:" + echo " $0 build" + echo " $0 majestic --host HOST [--user USER] [--secs N] [--out FILE]" + echo " $0 sofia --host HOST [--password P] [--secs N] [--out FILE]" + exit 1 +} + +# ----- build ----- + +cmd_build() { + [ -d "$NFS_LOCAL" ] || die "$NFS_LOCAL not present (NFS not mounted?)" + command -v upx >/dev/null || die "upx not installed" + + if [ ! -x "$OPENIPC_GCC" ]; then + log "downloading OpenIPC CI toolchain" + mkdir -p "$OPENIPC_TOOLCHAIN_DIR" + curl -sL "$OPENIPC_TOOLCHAIN_URL" \ + | tar xzf - -C "$OPENIPC_TOOLCHAIN_DIR" + fi + + local builddir=build-arm-ci + rm -rf "$builddir" + PATH="$(dirname "$OPENIPC_GCC"):$PATH" \ + cmake -H. -B"$builddir" \ + -DCMAKE_C_COMPILER=arm-openipc-linux-musleabi-gcc \ + -DCMAKE_BUILD_TYPE=Release >/dev/null + PATH="$(dirname "$OPENIPC_GCC"):$PATH" \ + cmake --build "$builddir" >/dev/null + + log "UPX-packing" + cp "$builddir/ipctool" "$NFS_LOCAL/ipctool-upx.unpacked" + upx --best -q "$NFS_LOCAL/ipctool-upx.unpacked" \ + -o "$NFS_LOCAL/ipctool-upx" >/dev/null + rm -f "$NFS_LOCAL/ipctool-upx.unpacked" + + ls -la "$NFS_LOCAL/ipctool-upx" + log "staged $NFS_LOCAL/ipctool-upx (=> /utils/ipctool-upx on the camera)" +} + +# ----- majestic (ssh, OpenIPC) ----- + +cmd_majestic() { + local host="" user="root" secs=35 out="" + while [ $# -gt 0 ]; do + case $1 in + --host) host=$2; shift 2 ;; + --user) user=$2; shift 2 ;; + --secs) secs=$2; shift 2 ;; + --out) out=$2; shift 2 ;; + *) die "unknown majestic arg: $1" ;; + esac + done + [ -n "$host" ] || die "--host required" + [ -n "$out" ] || out="tools/dumps/majestic-$(date +%s).log" + + [ -x "$NFS_LOCAL/ipctool-upx" ] \ + || die "$NFS_LOCAL/ipctool-upx missing - run '$0 build' first" + + local remote_log=/utils/dumps/majestic-capture.log + log "capturing $secs s from $host -> $out" + ssh -o StrictHostKeyChecking=accept-new "$user@$host" " + set -e + mount -o nolock,vers=3 $NFS_HOST:$NFS_REMOTE /utils 2>/dev/null || true + mkdir -p /utils/dumps + rm -f $remote_log + killall -q majestic 2>/dev/null || true + sleep 1 + /utils/ipctool-upx trace --output=$remote_log /usr/bin/majestic \ + >/dev/null 2>&1 & + TRACE_PID=\$! + sleep $secs + kill -TERM \$TRACE_PID 2>/dev/null || true + killall -q -TERM ipctool-upx majestic 2>/dev/null || true + sleep 2 + killall -q -KILL ipctool-upx majestic 2>/dev/null || true + /etc/init.d/S95majestic start >/dev/null 2>&1 + wc -l $remote_log + " + cp "$NFS_LOCAL/dumps/majestic-capture.log" "$out" + log "saved $out" + wc -l "$out" +} + +# ----- sofia (telnet, XiongMai HiLinux) ----- + +cmd_sofia() { + command -v expect >/dev/null || die "expect required for sofia mode" + + local host="" password="xmhdipc" secs=55 out="" + while [ $# -gt 0 ]; do + case $1 in + --host) host=$2; shift 2 ;; + --password) password=$2; shift 2 ;; + --secs) secs=$2; shift 2 ;; + --out) out=$2; shift 2 ;; + *) die "unknown sofia arg: $1" ;; + esac + done + [ -n "$host" ] || die "--host required" + [ -n "$out" ] || out="tools/dumps/sofia-$(date +%s).log" + + [ -x "$NFS_LOCAL/ipctool-upx" ] \ + || die "$NFS_LOCAL/ipctool-upx missing - run '$0 build' first" + + local remote_log=/utils/dumps/sofia-capture.log + log "capturing $secs s from $host (telnet, will reboot afterwards)" + + # Generous timeout: 60s setup + capture + cleanup + local total=$((secs + 30)) + + expect </dev/null +set timeout $total +log_user 0 +spawn telnet $host +expect { + -re "login:" { send "root\r"; exp_continue } + -re "Password:" { send "$password\r"; exp_continue } + -re "(#|\\\$) ?\$" {} + timeout { puts "could not connect"; exit 1 } +} + +# Mount NFS, set up bind-mount wrapper +send "mount -o nolock,vers=3 $NFS_HOST:$NFS_REMOTE /utils 2>/dev/null; mkdir -p /utils/dumps; rm -f $remote_log\r" +expect -re "(#|\\\$) ?\$" +send "cp /usr/bin/Sofia /utils/Sofia.real\r" +expect -re "(#|\\\$) ?\$" +send "cat > /utils/sofia.wrap.sh <<'EOF'\r" +expect -re ">" +send "#!/bin/sh\r" +expect -re ">" +send "exec /utils/ipctool-upx trace --skip=usleep --output=$remote_log /utils/Sofia.real\r" +expect -re ">" +send "EOF\r" +expect -re "(#|\\\$) ?\$" +send "chmod +x /utils/sofia.wrap.sh; mount --bind /utils/sofia.wrap.sh /usr/bin/Sofia\r" +expect -re "(#|\\\$) ?\$" + +# Kill chain, manually start SofiaRun.sh through the wrapper. stdin redirect +# avoids SIGTTIN on the backgrounded chain. +send "killall Sofia SofiaRun.sh 2>/dev/null; sleep 1; killall -9 Sofia SofiaRun.sh 2>/dev/null; sleep 1\r" +expect -re "(#|\\\$) ?\$" +send "/usr/sbin/SofiaRun.sh /dev/null 2>&1 &\r" +expect -re "(#|\\\$) ?\$" + +send "sleep $secs; echo capture-done\r" +expect -re "capture-done" +expect -re "(#|\\\$) ?\$" + +# Cleanup via reboot - the bind-mount goes away with it +send "ls -la $remote_log\r" +expect -re "(#|\\\$) ?\$" +send "reboot\r" +EXPECT + + # File arrives on NFS even with the camera mid-reboot + sleep 5 + [ -f "$NFS_LOCAL/dumps/sofia-capture.log" ] \ + || die "no $NFS_LOCAL/dumps/sofia-capture.log produced" + cp "$NFS_LOCAL/dumps/sofia-capture.log" "$out" + log "saved $out (camera is rebooting)" + wc -l "$out" +} + +# ----- main ----- + +[ $# -ge 1 ] || usage +sub=$1; shift +case $sub in + build) cmd_build "$@" ;; + majestic) cmd_majestic "$@" ;; + sofia) cmd_sofia "$@" ;; + -h|--help|help) usage ;; + *) die "unknown subcommand: $sub" ;; +esac diff --git a/tools/trace_diff.py b/tools/trace_diff.py new file mode 100644 index 0000000..6a6fd68 --- /dev/null +++ b/tools/trace_diff.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Diff a generated init register sequence against a reference C source. + +Extracts every `sensor_write_register(0xADDR, 0xVAL)` call from each file, +then compares as ordered sequences of (reg, val) pairs. Reports: + * unique addresses in each + * intersection / asymmetric differences + * value mismatches at common addresses (last value wins per side) + * order similarity (longest common subsequence ratio) + +Usage: + trace_diff.py +""" +import argparse +import re +import sys + + +# Matches both `sensor_write_register(0xABCD, 0xEF)` and +# `sc2315e_write_register(ViPipe, 0xABCD, 0xEF)`. Tolerant of optional +# 0x prefix on either operand and decimal literals (reference uses `1` +# rather than `0x01` in many places). +RE_WRITE = re.compile( + r"\bsensor_write_register\s*\(\s*" # function name (matches *_write_register too via lookbehind below) + r"(?:[^,]+,\s*)?" # optional ViPipe / first arg + r"((?:0[xX])?[0-9a-fA-F]+)\s*,\s*" + r"((?:0[xX])?[0-9a-fA-F]+)\s*\)" +) +# Wider net for things like sc2315e_write_register(...). +RE_ANY_WRITE = re.compile( + r"\b\w*write_register\s*\(\s*" + r"(?:[^,]+,\s*)?" + r"((?:0[xX])?[0-9a-fA-F]+)\s*,\s*" + r"((?:0[xX])?[0-9a-fA-F]+)\s*\)" +) + + +def parse_int(s): + s = s.strip() + if s.lower().startswith("0x"): + return int(s, 16) + # Reference uses bare 0..15 in some lines; treat those as decimal. + return int(s, 10) if not any(c in "abcdefABCDEF" for c in s) else int(s, 16) + + +def extract_function_body(src, fn_name): + """Return text inside `void (...) { ... }`, brace-balanced. + + Strings/comments are not handled — fine for these driver files which + keep everything on one line per write. + """ + pat = re.compile(r"\bvoid\s+" + re.escape(fn_name) + r"\s*\([^)]*\)\s*\{") + m = pat.search(src) + if not m: + return None + start = m.end() + depth = 1 + i = start + while i < len(src) and depth: + c = src[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return src[start:i] + i += 1 + return src[start:] + + +def extract_writes(path, scope=None): + with open(path) as f: + src = f.read() + if scope: + body = extract_function_body(src, scope) + if body is None: + print(f"WARNING: function {scope!r} not found in {path}", file=sys.stderr) + return [] + src = body + writes = [] + for m in RE_ANY_WRITE.finditer(src): + try: + reg = parse_int(m.group(1)) + val = parse_int(m.group(2)) + except ValueError: + continue + if reg > 0xFFFF or val > 0xFFFF: + continue + writes.append((reg, val)) + return writes + + +def lcs_ratio(a, b): + """Length of LCS / max(len). Cheap O(n*m) DP, fine for ~200x200.""" + n, m = len(a), len(b) + if n == 0 or m == 0: + return 0.0 + prev = [0] * (m + 1) + for i in range(1, n + 1): + cur = [0] * (m + 1) + for j in range(1, m + 1): + if a[i - 1] == b[j - 1]: + cur[j] = prev[j - 1] + 1 + else: + cur[j] = max(cur[j - 1], prev[j]) + prev = cur + return prev[m] / max(n, m) + + +def report(gen_path, ref_path, gen_scope=None, ref_scope=None): + gen = extract_writes(gen_path, gen_scope) + ref = extract_writes(ref_path, ref_scope) + + gen_regs = {r for r, _ in gen} + ref_regs = {r for r, _ in ref} + + # Per-register *last* value seen (matches "init complete" state). + gen_last = {} + for r, v in gen: + gen_last[r] = v + ref_last = {} + for r, v in ref: + ref_last[r] = v + + common = gen_regs & ref_regs + only_gen = gen_regs - ref_regs + only_ref = ref_regs - gen_regs + + val_match = sum(1 for r in common if gen_last[r] == ref_last[r]) + val_mismatch = [ + (r, gen_last[r], ref_last[r]) for r in common if gen_last[r] != ref_last[r] + ] + + # Sequence similarity (order-aware). + seq_ratio = lcs_ratio(gen, ref) + + print(f"generated: {gen_path} ({len(gen)} writes, {len(gen_regs)} unique regs)") + print(f"reference: {ref_path} ({len(ref)} writes, {len(ref_regs)} unique regs)") + print() + print(f"address match: {len(common)} / {len(ref_regs)} ref regs present " + f"({100*len(common)/max(1,len(ref_regs)):.1f}%)") + print(f"value match: {val_match} / {len(common)} matching addrs " + f"({100*val_match/max(1,len(common)):.1f}%)") + print(f"sequence (LCS): {seq_ratio*100:.1f}% of max(len)") + print() + + if only_ref: + print(f"-- regs in REF but NOT in GENERATED ({len(only_ref)}) --") + for r in sorted(only_ref): + print(f" 0x{r:04X} = 0x{ref_last[r]:02X}") + print() + if only_gen: + print(f"-- regs in GENERATED but NOT in REF ({len(only_gen)}) --") + for r in sorted(only_gen): + print(f" 0x{r:04X} = 0x{gen_last[r]:02X}") + print() + if val_mismatch: + print(f"-- value mismatches at {len(val_mismatch)} common regs --") + for r, gv, rv in sorted(val_mismatch): + print(f" 0x{r:04X} gen=0x{gv:02X} ref=0x{rv:02X}") + print() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("generated") + ap.add_argument("reference") + ap.add_argument("--gen-scope", help="restrict to this function body in generated") + ap.add_argument("--ref-scope", help="restrict to this function body in reference") + args = ap.parse_args() + report(args.generated, args.reference, args.gen_scope, args.ref_scope) + + +if __name__ == "__main__": + main() diff --git a/tools/trace_segment.py b/tools/trace_segment.py new file mode 100644 index 0000000..1dbfb15 --- /dev/null +++ b/tools/trace_segment.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Parse `ipctool trace` output, drop noise, segment into phases. + +Reads the raw mixed log (ptrace pseudocode + interleaved streamer logs + +ptrace bookkeeping). Emits a JSON file describing phase boundaries and a +per-phase list of register operations. + +Phases identified, in order on a clean Majestic capture: + probe bus scan, lots of failed reads, before any successful write + mipi_vi HiSilicon /dev/hi_mipi + /dev/vi pretty-printed structs + init sensor reset (0x100=0) ... stream-on (0x100=1) + post_init AE/exposure registers right after stream-on, before frame loop + runtime cyclic per-frame writes (same regs repeated > THRESHOLD times) + +Usage: + trace_segment.py [--out segments.json] +""" +import argparse +import json +import re +import sys +from collections import Counter, defaultdict + + +# Pseudocode line patterns produced by src/ptrace.c +RE_WRITE = re.compile(r"^sensor_write_register\(0x([0-9a-fA-F]+),\s*0x([0-9a-fA-F]+)\);") +RE_READ = re.compile( + r"^sensor_read_register\(0x([0-9a-fA-F]+)\);\s*/\*\s*->\s*0x([0-9a-fA-F]+)\s*\*/" +) +RE_READ_ERR = re.compile(r"^sensor_read_register\(0x([0-9a-fA-F]+)\);\s*/\*\s*\[err\]") +RE_ADDR = re.compile(r"^sensor_i2c_change_addr\(0x([0-9a-fA-F]+)\);?") +RE_USLEEP = re.compile(r"^usleep\((\d+)\)") +RE_BANNER = re.compile(r"^=+\s*([a-zA-Z0-9_\-]+)\s*=+") +RE_STRUCT_OPEN = re.compile(r"^[\w]+\s*=\s*\{$|^\.\w+\s*=\s*\{$") + +# Lines we drop on sight (noise, not register ops) +RE_NOISE = re.compile( + r"^(" + r"\s*$" # blank + r"|\d{2}:\d{2}:\d{2}\s+(DEBUG|INFO|WARN|ERROR)" # majestic logs + r"|error copy_from_process" + r"|Cloned \d+ fds" + r"|\[\d+\] child \d+ created" + r"|parent \d+ created child \d+" + r"|child \d+ exited" + r"|child \d+ killed" + r"|open\(" + r"|close\(" + r"|read\(\d+," + r"|write\(\d+," + r"|syscall\d+\(" + r"|\[err\]" + r")" +) + +# A register that gets written N+ times in the runtime tail is treated as +# part of the AE/AGC loop, not the init. +RUNTIME_REPEAT_THRESHOLD = 3 + + +def parse(line): + """Classify one line. Return (kind, payload) or None if dropped.""" + s = line.rstrip("\n") + if RE_NOISE.match(s): + return None + + m = RE_WRITE.match(s) + if m: + return ("write", {"reg": int(m.group(1), 16), "val": int(m.group(2), 16)}) + + m = RE_READ.match(s) + if m: + return ("read", {"reg": int(m.group(1), 16), "val": int(m.group(2), 16)}) + m = RE_READ_ERR.match(s) + if m: + return ("read_err", {"reg": int(m.group(1), 16)}) + + m = RE_ADDR.match(s) + if m: + return ("addr", {"addr": int(m.group(1), 16)}) + + m = RE_USLEEP.match(s) + if m: + return ("sleep", {"us": int(m.group(1))}) + + m = RE_BANNER.match(s) + if m: + return ("banner", {"dev": m.group(1)}) + + if RE_STRUCT_OPEN.match(s): + return ("struct_begin", {"head": s}) + if s.strip() == "};": + return ("struct_end", {}) + + # Anything left is preserved as opaque text (mipi/vi struct bodies, ioctl). + return ("text", {"raw": s}) + + +def collapse_struct(events): + """Merge struct_begin..struct_end runs into a single struct event.""" + out = [] + i = 0 + while i < len(events): + kind, payload = events[i] + if kind == "struct_begin": + buf = [payload["head"]] + j = i + 1 + while j < len(events) and events[j][0] != "struct_end": + if events[j][0] == "text": + buf.append(events[j][1]["raw"]) + j += 1 + buf.append("};") + out.append(("struct", {"text": "\n".join(buf)})) + i = j + 1 + else: + out.append((kind, payload)) + i += 1 + return out + + +def find_init_bounds(events): + """Return (init_start_idx, init_end_idx). + + Heuristic: + - init_start = first 'write' whose reg == 0x100 and val == 0 (reset). + - init_end = first subsequent 'write' whose reg == 0x100 and val == 1 + (stream-on). + """ + start = None + for i, (k, p) in enumerate(events): + if k == "write" and p["reg"] == 0x100 and p["val"] == 0: + start = i + break + if start is None: + return (None, None) + for j in range(start + 1, len(events)): + k, p = events[j] + if k == "write" and p["reg"] == 0x100 and p["val"] == 1: + return (start, j) + return (start, len(events) - 1) + + +def find_runtime_start(events, init_end): + """First index after init_end where cyclic per-frame writes start. + + Walk forward from init_end. Track recent write addrs in a window; + when an addr repeats >= RUNTIME_REPEAT_THRESHOLD times in the post-init + tail, mark the start of runtime as the FIRST occurrence of any such addr. + """ + if init_end is None: + return None + write_addrs = [] + for k, p in events[init_end + 1 :]: + if k == "write": + write_addrs.append(p["reg"]) + repeat_addrs = { + a for a, c in Counter(write_addrs).items() if c >= RUNTIME_REPEAT_THRESHOLD + } + if not repeat_addrs: + return None + for j in range(init_end + 1, len(events)): + k, p = events[j] + if k == "write" and p["reg"] in repeat_addrs: + return j + return None + + +def slice_events(events, start, end): + return events[start : end + 1] if start is not None and end is not None else [] + + +def serialize(events): + """Convert internal event tuples to JSON-serializable dicts.""" + out = [] + for k, p in events: + d = {"kind": k} + d.update(p) + out.append(d) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input") + ap.add_argument("--out", default=None, help="output JSON path") + args = ap.parse_args() + + with open(args.input, errors="replace") as f: + events = [e for e in (parse(line) for line in f) if e is not None] + events = collapse_struct(events) + + # Phase boundaries. + init_s, init_e = find_init_bounds(events) + runtime_s = find_runtime_start(events, init_e) + + phases = {} + if init_s is None: + phases["pre_sensor"] = serialize(events) + else: + phases["pre_sensor"] = serialize(events[:init_s]) + phases["init"] = serialize(events[init_s : init_e + 1]) + if runtime_s is not None: + phases["post_init"] = serialize(events[init_e + 1 : runtime_s]) + phases["runtime"] = serialize(events[runtime_s:]) + else: + phases["post_init"] = serialize(events[init_e + 1 :]) + + summary = {phase: len(events) for phase, events in phases.items()} + out_path = args.out or args.input + ".segments.json" + with open(out_path, "w") as f: + json.dump({"summary": summary, "phases": phases}, f, indent=2) + + print(f"wrote {out_path}", file=sys.stderr) + for phase, count in summary.items(): + print(f" {phase:12s} {count} events", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tools/trace_to_driver.py b/tools/trace_to_driver.py new file mode 100644 index 0000000..69c8b6e --- /dev/null +++ b/tools/trace_to_driver.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Generate a HiSilicon-SDK-shaped sensor init function from a segmented trace. + +Reads the JSON produced by trace_segment.py and emits a C source file that +mirrors the structure of widgetii/smart_sc2315e/sc2315e_sensor_ctl.c. + +Usage: + trace_to_driver.py --sensor sc2315e [--out file.c] +""" +import argparse +import json +import sys + + +HEADER = """\ +/* + * AUTO-GENERATED from ipctool trace via tools/trace_to_driver.py + * Source: {src} + * Sensor: {sensor} + * + * This is a scaffold. Compare against a known-good driver before use. + * Runtime AE/AGC writes are emitted as a comment block, not C code - + * the surrounding logic (gain math, exposure scaling) is not derivable + * from a register trace alone. + */ +#include "hi_comm_video.h" +#include "hi_sns_ctrl.h" + +extern void {sensor}_write_register(VI_PIPE ViPipe, HI_U32 addr, HI_U32 data); +""" + +FN_TEMPLATE = """ +void {sensor}_{suffix}(VI_PIPE ViPipe) {{ +{body}}} +""" + + +def fmt_write(reg, val, indent=" "): + return f"{indent}sensor_write_register(0x{reg:04X}, 0x{val:02X});\n" + + +def fmt_sleep(us, indent=" "): + if us == 0: + # ipctool emits usleep(0) for very short sleeps; reference drivers + # commonly use 10ms here. Flagged as TODO. + return f"{indent}usleep(10000); /* TODO: trace shows 0us, ref uses 10ms */\n" + return f"{indent}usleep({us});\n" + + +def emit_phase(events, indent=" "): + out = [] + for ev in events: + if ev["kind"] == "write": + out.append(fmt_write(ev["reg"], ev["val"], indent)) + elif ev["kind"] == "sleep": + out.append(fmt_sleep(ev["us"], indent)) + elif ev["kind"] == "addr": + out.append(f"{indent}/* sensor_i2c_change_addr(0x{ev['addr']:02X}) */\n") + elif ev["kind"] == "banner": + out.append(f"{indent}/* === bus: {ev['dev']} === */\n") + elif ev["kind"] == "struct": + out.append(f"{indent}/*\n") + for line in ev["text"].splitlines(): + out.append(f"{indent} * {line}\n") + out.append(f"{indent} */\n") + elif ev["kind"] in ("read", "read_err"): + # Reads happen during probe and chip-id checks. Document but + # do not emit as code (they don't belong in init). + r = ev["reg"] + v = ev.get("val") + tag = "read_err" if ev["kind"] == "read_err" else "read" + line = f"{indent}/* {tag} 0x{r:04X}" + if v is not None: + line += f" -> 0x{v:02X}" + line += " */\n" + out.append(line) + return "".join(out) + + +def runtime_summary(events): + """Per-register write count and last value seen.""" + from collections import Counter + + counts = Counter() + last_val = {} + for ev in events: + if ev["kind"] == "write": + counts[ev["reg"]] += 1 + last_val[ev["reg"]] = ev["val"] + items = sorted(counts.items(), key=lambda kv: -kv[1]) + return [(reg, n, last_val[reg]) for reg, n in items] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("segments") + ap.add_argument("--sensor", required=True) + ap.add_argument("--out", default=None) + args = ap.parse_args() + + with open(args.segments) as f: + data = json.load(f) + phases = data["phases"] + + parts = [HEADER.format(src=args.segments, sensor=args.sensor)] + + init = phases.get("init", []) + post_init = phases.get("post_init", []) + if init: + parts.append( + FN_TEMPLATE.format( + sensor=args.sensor, + suffix="linear_init", + body=emit_phase(init, indent=" "), + ) + ) + if post_init: + # Kept separate from init: these are AE/exposure prime writes that + # would otherwise overwrite init values when the diff merges them. + parts.append( + FN_TEMPLATE.format( + sensor=args.sensor, + suffix="post_init_exposure_prime", + body=emit_phase(post_init, indent=" "), + ) + ) + + runtime = phases.get("runtime", []) + if runtime: + parts.append("\n/*\n") + parts.append(" * RUNTIME AE/AGC HOT REGISTERS\n") + parts.append(" * (per-frame writes during steady state - the math\n") + parts.append(" * that drives these values is NOT in the trace)\n") + parts.append(" *\n") + parts.append(" * reg count last value\n") + for reg, n, v in runtime_summary(runtime)[:32]: + parts.append(f" * 0x{reg:04X} {n:5d} 0x{v:02X}\n") + parts.append(" */\n") + + src = "".join(parts) + out_path = args.out or args.segments + ".c" + with open(out_path, "w") as f: + f.write(src) + print(f"wrote {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main()