Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ that produced them is the durable part.

## Radio images (.img) & metadata

- **image-driver-resolution** (2026-07-23, consolidated and fixed 2026-07-30): `directory.get_radio_by_image()` only searches drivers already in `DRV_TO_RADIO`, and the browser imports drivers lazily — so `handleLoadImage` (`web/js/runtime-rpc.js`) parses the metadata trailer first (`read_image_metadata_base64`, driver-free), resolves the module via the catalog (`findCatalogRadioForImageMetadata`, `web/js/image-metadata.mjs`), imports it, then runs detection. When nothing resolves, `ensureAllDriverModules()` imports every driver so `match_model()` byte-sniffing can run — the fallback desktop CHIRP gets for free from `import_drivers()`. **The governing invariant: a resolved-but-wrong match is worse than no match, because it suppresses the sweep.** The matcher must therefore be at least as precise as the `get_radio_by_image()` it front-runs, which compares VENDOR/MODEL/VARIANT across `rclass.ALIASES + [rclass]` (`chirp/chirp/directory.py:202-204`). Four things it has to get right:
- **image-driver-resolution** (2026-07-23, consolidated and fixed 2026-07-30): `directory.get_radio_by_image()` only searches drivers already in `DRV_TO_RADIO`, and the browser imports drivers lazily — so `handleLoadImage` (`web/js/runtime-rpc.js`) parses the metadata trailer first (`read_image_metadata_base64`, driver-free), resolves the module via the catalog (`findCatalogRadioForImageMetadata`, `web/js/image-metadata.mjs`), imports it, then runs detection. When nothing resolves, `importDriverModulesUntilImageMatches()` imports drivers until one claims the image so `match_model()` byte-sniffing can run — the fallback desktop CHIRP gets for free from `import_drivers()` (see **incremental-detection-is-order-safe** for why stopping early is sound). **The governing invariant: a resolved-but-wrong match is worse than no match, because it suppresses the sweep.** The matcher must therefore be at least as precise as the `get_radio_by_image()` it front-runs, which compares VENDOR/MODEL/VARIANT across `rclass.ALIASES + [rclass]` (`chirp/chirp/directory.py:202-204`). Four things it has to get right:
- **rclass may be unregistered or synthetic, so it cannot be the primary key.** `export_image_base64` from `uv5r.BaofengUV5R` stamps a class name whose registered entry is `BaofengUV5RGeneric`; more importantly, CHIRP stamps `DynamicRadioAlias` — the synthetic subclass it creates in `get_radio_by_image` (directory.py:206) — whenever detection went through an alias. 69 of the 250 trailer-bearing corpus images carry an rclass absent from the catalog (2026-08-01 pin), so identity, not class name, is the load-bearing path. A class-name hit now only wins if the recorded identity agrees with it. Matching also applies `directory.MODEL_COMPAT` remaps (e.g. Retevis RT-5R → RT5R).
- **A class name is not unique across modules.** `Kenwood_TS-480_CloneMode.img` stamps `rclass=TS480Radio`, which exists as both `kenwood_live:TS480Radio` (model `TS-480_LiveMode`) and `ts480:TS480_CRadio` (model `TS-480_CloneMode`). Requiring the identity to agree resolves it to the clone driver directly. The `isLiveRadio` guard in `handleLoadImage` remains as a second line — a live radio can never own a clone image.
- **`variant` is what separates same-vendor/model drivers.** `Quansheng_UV-K5_egzumer.img` stamps `variant=egzumer`; without it, vendor/model alone matched four catalog entries and resolved `uvk5.OSFWUVK5Radio`, which then failed with `Unsupported model Quansheng UV-K5`. The catalog now records `variant` and the full alias identity list, and the matcher compares them exactly as CHIRP does. Note `None` (no variant recorded) and `""` (an explicitly empty variant) are **different**: CHIRP skips the comparison for the former and demands `VARIANT == ""` for the latter, so `read_image_metadata_base64` must not collapse them.
- **Ambiguity must decline, not guess.** 12 vendor/model pairs map to multiple catalog entries. When more than one candidate survives, the matcher returns null and takes the sweep rather than picking whichever sorts first.
Even so the matcher cannot be trusted absolutely, so `loadImageWithDriverFallback()` retries through the sweep when detection *fails* after a fast-path resolve — that backstop, not the matching precision, is what makes a wrong match merely slow instead of fatal. The retry is gated on `ImageDetectionError`, the one failure importing more drivers can fix; every other image failure (not clone-mode, bad payload, a driver raising while reading memories) fails identically after the sweep, so retrying it would only delay the real error by the sweep's ~20 s. That gate reads a Python class name out of the traceback Pyodide hands JS, which nothing else pins — hence the end-to-end assertion in `scripts/test-metadataless-image-load.mjs` that a real detection failure trips it and a bad payload does not.
Even so the matcher cannot be trusted absolutely, so `loadImageWithDriverFallback()` retries through the sweep when detection *fails* after a fast-path resolve — that backstop, not the matching precision, is what makes a wrong match merely slow instead of fatal. The retry is gated on `ImageDetectionError`, the one failure importing more drivers can fix; every other image failure (not clone-mode, bad payload, a driver raising while reading memories) fails identically after the sweep, so retrying it would only delay the real error by the sweep's seconds of CDN fetches. That gate reads a Python class name out of the traceback Pyodide hands JS, which nothing else pins — hence the end-to-end assertion in `scripts/test-metadataless-image-load.mjs` that a real detection failure trips it and a bad payload does not.
- **image-corpus-measurements-need-fresh-runtimes** (2026-07-30): driver imports accumulate in a Pyodide session, so walking `chirp/tests/images/` in one runtime measures "a session where earlier files already imported things", not each image's own path — and the corpus is walked alphabetically, where `Alinco_DJ175.img` (no metadata trailer) triggers the full sweep on the very first file. That artifact produced both a 356/357 and a 357/357 figure for work that actually leaves one image failing. Measure per-path instead: import only what that image's branch would import (its catalog-resolved module, or the sweep), which needs no per-image runtime restart because a fast-path-only pass can only ever *hide* failures, never invent them. A cheaper oracle avoids the question entirely for matching: import every driver once, let CHIRP detect each image, and assert the matcher resolves either the same driver or nothing — a null resolution is correct by construction, because it takes the sweep. Split at the 2026-08-01 pin (CHIRP `b7ae1b6`): 358 `.img` files, 108 with no trailer that sweep, 250 resolved on the fast path, all 250 agreeing with detection — and re-measuring after that submodule bump is what caught the earlier 357/249/68 figures going stale, so treat every count here as pinned to a revision rather than durable.
- **synchronous-python-can-drive-progress** (2026-07-30): a plain `for` loop inside one `runPythonAsync` *can* animate browser UI, which looks impossible and is why the driver sweep shipped silent at first. Every CHIRP import suspends the interpreter on a CDN fetch through `ChirpCdnFinder`'s JSPI `run_sync`, so the JS event loop runs between iterations and anything the loop wrote to the DOM paints — the same mechanism that lets CHIRP's synchronous clone loops drive `#clone-progress` via `serial_progress`. So reach for a callback argument (`import_all_driver_modules(names, progress_cb)`) before restructuring a loop into per-item RPCs; the latter would pay a full call-queue round trip per module for no benefit. Two rules for such callbacks: guard every call in `try/except` so a reporting failure can never abort the work being reported on, and clear the `pyodide.globals` slot afterwards. The reverse also holds — a Python loop that never suspends will not paint, so this is a property of the I/O, not of Pyodide.
- **all-driver-import-mechanics** (2026-07-30): `import_all_driver_modules()` (`runtime_bridge.py`) tolerates per-module failures — each failure is recorded and surfaced as `DRIVERS SKIP <module>: <error>` in the debug log, never aborting the sweep. With the pyserial shim in place (see **pyserial-shim-and-idrp-never-registers**) all 191 driver modules import and 551 radio classes register, so the skip list is empty at this pin; keep reporting it anyway, because importability cannot be judged by grepping for `import serial` (`hf90`/`tmv71_ll` have function-scope imports that are harmless) and the next submodule bump can reintroduce a failure silently. `scripts/build-catalog.mjs` calls the same function so the catalog's "not importable, absent from catalog" warnings and the runtime's skip lines can never diverge. The sweep is one synchronous Python loop in a single `runPythonAsync` (~1.8 s in Node off the local checkout; ~20 s in-browser, where each module is fetched individually from jsDelivr), cached per session as a promise that resets on failure so a later load retries instead of caching the wreck.
- **all-driver-import-mechanics** (2026-07-30): `import_all_driver_modules()` (`runtime_bridge.py`) tolerates per-module failures — each failure is recorded and surfaced as `DRIVERS SKIP <module>: <error>` in the debug log, never aborting the sweep. With the pyserial shim in place (see **pyserial-shim-and-idrp-never-registers**) all 191 driver modules import and 551 radio classes register, so the skip list is empty at this pin; keep reporting it anyway, because importability cannot be judged by grepping for `import serial` (`hf90`/`tmv71_ll` have function-scope imports that are harmless) and the next submodule bump can reintroduce a failure silently. `scripts/build-catalog.mjs` calls the same function so the catalog's "not importable, absent from catalog" warnings and the runtime's skip lines can never diverge. The sweep is one synchronous Python loop in a single `runPythonAsync` (~1.8 s in Node off the local checkout; ~20 s in-browser for the full list, where each module is fetched individually from jsDelivr). The runtime no longer runs the full sweep: `detect_image_driver_incremental()` shares the same loop and failure handling but stops at the first driver that claims the image, and `import_all_driver_modules()` now exists only for the catalog build and the tests. Only an *exhausted* run may be memoised for the session — a boolean, not a promise, because a run that stopped early leaves nothing reusable to cache and a failed one must not be cached at all.
- **incremental-detection-is-order-safe** (2026-07-30): stopping the driver sweep at the first driver that claims the image cannot change *which* driver is chosen, as long as the module list is imported in the same order as before. `directory.get_radio_by_image()` returns the first match in `DRV_TO_RADIO` insertion order, and insertion order is a pure function of import order — so the first hit found incrementally is the hit the full sweep would have returned. Measured over the 108 metadata-less images in `chirp/tests/images/` (191 modules, one CDN round trip each in the browser): all 108 match, min 1 module, p25 30, **median 72.5**, p75 116, max 188 (`Yaesu_VX-8*`) — roughly 60% of the fetches skipped at the median, worst case unchanged. What is **not** safe is reordering the list to try likely drivers first: 111 registered classes inherit the default `match_model`, which is the bare comparison `len(filedata) == cls._memsize`, and several classes share a memory size, so among those the winner is decided by order alone. Two things the implementation has to keep: classes registered *before* the sweep starts (whatever the user's radio selection imported) are checked first, because the full sweep would have seen them first; and detection mirrors *both* branches of `get_radio_by_image`, `match_model` for metadata-less images and the vendor/model/variant alias comparison for images whose trailer the catalog could not match (`Kenwood_TS-480_CloneMode.img`, which matches at module 168). The one deliberate divergence from upstream is that the mirror returns early when there is no metadata instead of falling into the alias comparison with `meta_vendor`/`meta_model` both `None`; no registered class declares a `None` VENDOR or MODEL, so the comparison upstream still runs can never match.

## Repeater directories

Expand Down
14 changes: 7 additions & 7 deletions scripts/test-image-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ test("detection failure after a resolved match retries against all drivers", asy
}
return { module: "uvk5_egzumer" };
},
importAllDrivers: () => {
importDriversForDetection: () => {
calls.push("sweep");
return Promise.resolve();
},
Expand All @@ -429,9 +429,9 @@ test("detection failure after a resolved match retries against all drivers", asy
assert.deepEqual(result, { module: "uvk5_egzumer" });
});

// The sweep is the slowest thing the app does (~20 s in the browser, every
// driver fetched individually from a CDN). Spending it on a failure it cannot
// possibly fix just delays the real error by 20 s.
// The sweep is the slowest thing the app does (every driver fetched
// individually from a CDN, seconds even when it stops early). Spending it on a
// failure it cannot possibly fix just delays the real error.
test("a failure the sweep cannot fix is surfaced without sweeping", async () => {
const calls = [];
await assert.rejects(
Expand All @@ -445,7 +445,7 @@ test("a failure the sweep cannot fix is surfaced without sweeping", async () =>
"Loaded image is not a clone-mode CHIRP image",
);
},
importAllDrivers: () => {
importDriversForDetection: () => {
calls.push("sweep");
return Promise.resolve();
},
Expand All @@ -463,7 +463,7 @@ test("a successful resolved match never imports every driver", async () => {
calls.push("load");
return { module: "uv5r" };
},
importAllDrivers: () => {
importDriversForDetection: () => {
calls.push("sweep");
return Promise.resolve();
},
Expand All @@ -483,7 +483,7 @@ test("an unresolved image sweeps first, and a failure there is surfaced", async
calls.push("load");
throw new Error("Unable to detect radio from image");
},
importAllDrivers: () => {
importDriversForDetection: () => {
calls.push("sweep");
return Promise.resolve();
},
Expand Down
Loading