Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@ jobs:
node-version: "22"

- name: Run JS tests
run: node --test web/protocol.test.js
run: node --test web/protocol.test.js web/profile-parity.test.js
47 changes: 44 additions & 3 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
.status-msg { padding: 12px 16px; border-radius: var(--radius); margin-top: 12px; font-weight: 600; text-align: center; }
.status-msg.success { background: rgba(158,206,106,0.15); color: var(--green); }
.status-msg.failure { background: rgba(247,118,142,0.15); color: var(--red); }
.notice-warn { margin-top: 10px; padding: 12px 14px; border-radius: var(--radius);
background: rgba(224,175,104,0.12); border: 1px solid rgba(224,175,104,0.35);
font-size: 0.88em; line-height: 1.5; }
.notice-warn pre { margin: 8px 0; padding: 8px 10px; border-radius: 6px;
background: rgba(0,0,0,0.28); overflow-x: auto; font-size: 0.95em; }
.notice-warn code { background: rgba(0,0,0,0.28); padding: 1px 4px; border-radius: 3px; }
.unsupported {
background: var(--red); color: #fff; padding: 16px; border-radius: var(--radius);
text-align: center; margin-bottom: 16px;
Expand All @@ -95,6 +101,7 @@ <h1>⚡ <span>defib</span></h1>
<h2>Recovery Setup</h2>
<label>Chip Model <span class="chip-count" id="chip-count"></span></label>
<select id="chip-select"><option value="">Loading...</option></select>
<div id="chip-unsupported" class="notice-warn" style="display:none"></div>

<label>U-Boot image</label>
<div id="fw-auto" class="btn-row" style="display:none;margin-top:0">
Expand Down Expand Up @@ -599,6 +606,34 @@ <h2 style="color:var(--accent)">Serial Console</h2>
const hint = document.getElementById('fw-hint');
const btn = document.getElementById('fw-download-btn');
const status = document.getElementById('fw-status');
const unsupported = document.getElementById('chip-unsupported');

// Frame-blast parts can't be driven from the browser at all — say so up
// front instead of letting the user download an image and then watch the
// handshake fail for reasons the UI can't explain.
if (chip && needsFrameBlast(chip)) {
unsupported.style.display = '';
unsupported.innerHTML =
`<strong>${chip} needs the command-line tool.</strong> Its bootrom uses a ` +
`frame-blast handshake (an active <code>0xAA</code>+HEAD blast plus a ` +
`PRESTEP0 block) that this browser build does not implement, so the ` +
`upload cannot complete here. Install defib and run:` +
`<pre>uv tool install defib\ndefib burn -c ${chip} -p /dev/ttyUSB0 -t</pre>` +
`Start it before you power-cycle the camera. ` +
`<a href="https://github.com/OpenIPC/defib/issues/121" target="_blank" ` +
`rel="noopener">Tracking issue</a>.`;
autoDiv.style.display = 'none';
hint.textContent = '';
// Deliberately leave firmwareData alone. Start is already blocked by
// needsFrameBlast() below, and clearing it here would strand a locally
// selected file: the file input keeps its selection, so the normal path
// won't reload it (it only clears when no file is selected) and the
// change handler won't re-fire — leaving Start disabled on the way back
// to a supported chip.
updateStartButton();
return;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
unsupported.style.display = 'none';

if (chip && hasFirmware(chip)) {
autoDiv.style.display = '';
Expand Down Expand Up @@ -629,8 +664,13 @@ <h2 style="color:var(--accent)">Serial Console</h2>
];
const unique = [...new Set(allChips)].sort();
sel.innerHTML = '<option value="">— Select chip model —</option>';
for (const c of unique) sel.innerHTML += `<option value="${c}">${c}</option>`;
document.getElementById('chip-count').textContent = `(${unique.length} supported)`;
for (const c of unique) {
const suffix = needsFrameBlast(c) ? ' — CLI only' : '';
sel.innerHTML += `<option value="${c}">${c}${suffix}</option>`;
}
const usable = unique.filter(c => !needsFrameBlast(c)).length;
document.getElementById('chip-count').textContent =
`(${usable} of ${unique.length} supported in browser)`;
sel.onchange = onChipChanged;
// Warm the release index so availability reflects what OpenIPC actually
// publishes today rather than the hardcoded fallback list. Refresh only if
Expand Down Expand Up @@ -658,7 +698,8 @@ <h2 style="color:var(--accent)">Serial Console</h2>

function updateStartButton() {
const chip = document.getElementById('chip-select').value;
document.getElementById('start-btn').disabled = !(chip && firmwareData && selectedPort);
document.getElementById('start-btn').disabled =
!(chip && firmwareData && selectedPort) || needsFrameBlast(chip);
}

function setStage(name) {
Expand Down
106 changes: 106 additions & 0 deletions web/profile-parity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Parity between the browser build's inline PROFILES blob and the CLI's
* profile data under src/defib/profiles/data.
*
* web/index.html carries its own hand-maintained copy of the SoC profiles so
* the page can be deployed as static files. Nothing regenerates it, so it can
* drift from the CLI — which is exactly how every chip in it ended up with its
* PRESTEP0 stripped while staying selectable in the dropdown (defib#121).
*
* These tests fail when that happens again.
*
* Run: node --test web/profile-parity.test.js
*/

const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');

const { FRAME_BLAST_SOCS } = require('./protocol.js');

const REPO_ROOT = path.join(__dirname, '..');
const DATA_DIR = path.join(REPO_ROOT, 'src', 'defib', 'profiles', 'data');
const MAX_ALIAS_DEPTH = 23;

/**
* Resolve a chip to its profile object, following alias files.
*
* An alias is a profile file whose entire contents are a single token ending
* in `.json` — e.g. hi3516ev300.json contains just "hi3516ev200.json". Missing
* this indirection is what made the PRESTEP0 drift invisible: a naive read of
* hi3516ev300.json finds no PRESTEP0 because it holds no fields at all.
* Mirrors load_profile() in src/defib/profiles/loader.py.
*/
function resolveProfile(chip, depth = 0) {
if (depth > MAX_ALIAS_DEPTH) return null;
const file = path.join(DATA_DIR, `${chip}.json`);
if (!fs.existsSync(file)) return null;
const raw = fs.readFileSync(file, 'utf8').trim();
const tokens = raw.split(/\s+/);
if (tokens.length === 1 && tokens[0].endsWith('.json')) {
return resolveProfile(tokens[0].slice(0, -5), depth + 1);
}
try {
return JSON.parse(raw);
} catch {
return null;
}
}

/** Pull the inline `const PROFILES = {...};` blob out of web/index.html. */
function readWebProfiles() {
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
// Tolerate CRLF and trailing whitespace — a checkout with core.autocrlf=true
// would otherwise fail this on a formatting detail rather than a real drift.
const m = html.match(/const PROFILES = (\{[\s\S]*?\});[ \t]*\r?$/m);
assert.ok(m, 'could not locate the PROFILES blob in web/index.html');
return JSON.parse(m[1]);
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}

describe('web PROFILES vs CLI profile data', () => {
const webProfiles = readWebProfiles();

it('every chip in the web dropdown has a CLI profile behind it', () => {
const orphans = Object.keys(webProfiles).filter(c => resolveProfile(c) === null);
assert.deepEqual(orphans, [],
`web-only chips with no resolvable CLI profile: ${orphans.join(', ')}`);
});

it('FRAME_BLAST_SOCS matches the chips whose resolved profile has PRESTEP0', () => {
const expected = Object.keys(webProfiles)
.filter(c => {
const p = resolveProfile(c);
return p && p.PRESTEP0 != null;
})
.sort();
const actual = [...FRAME_BLAST_SOCS].sort();

const missing = expected.filter(c => !FRAME_BLAST_SOCS.has(c));
const extra = actual.filter(c => !expected.includes(c));

assert.deepEqual(missing, [],
`these chips need a frame-blast handshake but are not in FRAME_BLAST_SOCS, ` +
`so the UI will let users start a recovery that cannot succeed: ${missing.join(', ')}`);
assert.deepEqual(extra, [],
`these are listed in FRAME_BLAST_SOCS but their resolved profile has no ` +
`PRESTEP0, so they are being blocked for no reason: ${extra.join(', ')}`);
});

it('the web build does not claim to send PRESTEP0 it has no data for', () => {
// If a future change starts shipping PRESTEP0 in the web blob, the
// frame-blast path must be implemented in protocol.js at the same time —
// otherwise the data is inert and the block list above silently wrong.
const withPrestep = Object.entries(webProfiles)
.filter(([, p]) => p.PRESTEP0 != null)
.map(([c]) => c);
const protocolSrc = fs.readFileSync(path.join(__dirname, 'protocol.js'), 'utf8');
const implemented = /function\s+buildPrestepFrames|sendFrameForStart/.test(protocolSrc);
if (withPrestep.length > 0) {
assert.ok(implemented,
`web PROFILES now carry PRESTEP0 for ${withPrestep.join(', ')} but ` +
`protocol.js still has no frame-blast implementation — either implement ` +
`it or drop the data`);
}
});
});
33 changes: 33 additions & 0 deletions web/protocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,38 @@ function parseCv6xxBoot(data) {
const V500_SOCS = new Set(["gk7205v500","gk7205v510","gk7205v530","xm7205v500","xm7205v510","xm7205v530"]);
const CV6XX_SOCS = new Set(["hi3516cv608","hi3516cv610","hi3516cv613","hi3516dv500","hi3519dv500"]);

// Standard-protocol SoCs whose bootrom needs the frame-blast handshake: an
// active 0xAA+HEAD blast instead of passively waiting for the 0x20 markers,
// followed by a PRESTEP0 block sent ahead of DDRSTEP0. The CLI drives this
// from HiSiliconStandard._send_frame_for_start(); this build implements
// neither, so standardHandshake()/standardSendFirmware() cannot bring these
// parts up. Refuse them in the UI rather than fail silently at the wire.
//
// Derived from the chips whose *resolved* profile under
// src/defib/profiles/data carries a PRESTEP0 — resolution matters, since
// e.g. hi3516ev300.json is a one-line alias pointing at hi3516ev200.json.
// profile-parity.test.js recomputes this and fails if it drifts.
const FRAME_BLAST_SOCS = new Set([
"hi3110ev500", "hi3110ev500-ca", "hi3231v520", "hi3231v530", "hi3251v500",
"hi3251v510", "hi3516a", "hi3516av200", "hi3516cv300", "hi3516cv500",
"hi3516ev200", "hi3516ev300", "hi3518ev200", "hi3519", "hi3519v101",
"hi3520dv400", "hi3521a", "hi3521dv100", "hi3531a", "hi3531dv100",
"hi3536", "hi3536c", "hi3536dv100", "hi3556av100", "hi3559av100",
"hi3559av100es", "hi3559v100", "hi3559v200", "hi3712v100", "hi3716dv100",
"hi3716dv100-ca", "hi3716dv110", "hi3716dv110h", "hi3716mv310", "hi3716mv310-ca",
"hi3716mv320", "hi3716mv330", "hi3716mv330-ca", "hi3716mv410", "hi3716mv410-ca",
"hi3716mv410-ca-n", "hi3716mv420-ca-n", "hi3716mv430", "hi3731v100", "hi3731v101",
"hi3731v201", "hi3731v202", "hi3751v310", "hi3751v320", "hi3751v500-ca",
"hi3751v510", "hi3751v530", "hi3751v551", "hi3751v553", "hi3751v600",
"hi3751v600-ca", "hi3751v620", "hi3751v810", "hi3751v811", "hi3796mv200",
"hi3798cv200", "hi3798mv100", "hi3798mv100-ca", "hi3798mv200", "hi3798mv300",
"hi3798mv310",
]);

function needsFrameBlast(chip) {
return FRAME_BLAST_SOCS.has(chip);
}

// ================================================================
// OpenIPC U-Boot asset resolution
//
Expand Down Expand Up @@ -308,6 +340,7 @@ if (typeof module !== 'undefined' && module.exports) {
CRC_TABLE, calcCrc, appendCrc, appendCrcLE, verifyCrc,
buildHeadFrame, buildDataFrame, buildTailFrame, chunkData,
parseCv6xxBoot, V500_SOCS, CV6XX_SOCS,
FRAME_BLAST_SOCS, needsFrameBlast,
FW_RELEASE_API, FW_DIRECT_BASE, FW_ASSET_RE, FW_PROXIES,
CHIP_FW_ALIAS, fwNameForChip, parseReleaseAssets, parseDigest,
fwSourceUrls, bytesToHex, verifyFirmwareBytes,
Expand Down
Loading