ui: the preset list highlights the equipped card's preset

The Cards list now marks the row whose ICCID matches the equipped card
(cardsFindByIccid handles digits / spaced / raw EF hex alike): an emerald row
tint plus a small dot with a "The equipped card's preset" tooltip in the Name
cell.  There is no highlight while the ICCID is unknown (no card, unreadable
EF.ICCID) or when no preset matches - the amber banner already covers that.

pysimCardStateUpdate() re-renders the list when the ICCID actually changes, so
a visible Cards tab follows equip/removal live (opening the tab re-renders
anyway through switchTab).

Tests: cards_iccid.test.js drives cardsRender with a fake tbody - the matching
preset is highlighted (both ICCID forms), the others stay plain and no ICCID
means no highlight; card_state.test.js stubs cardsRender.  Help EN/RU and the
repo AGENTS updated; sw cache simple-v344.
This commit is contained in:
2026-10-01 21:50:29 +03:00
parent 12da7ed593
commit 39723406b4
6 changed files with 45 additions and 6 deletions
+1 -1
View File
@@ -346,7 +346,7 @@
<p class="mb-2">Каждый принятый картой пакет <strong>расходует счётчик</strong>, который сохраняется в keyset пресета &mdash; проба отклоняется, если в SPI1 нет проверки счётчика или в пресете нет такого номера keyset. На живой карте: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> и <code class="font-mono text-sm">B00001</code> зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с <code class="font-mono text-sm">6200</code>.</p>
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Карты&raquo;</h2>
<p class="mb-2">Хранит пресеты карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией &mdash; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> &mdash; номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01&ndash;0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками &mdash; по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). &laquo;Карты&raquo; — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом &mdash; или выбранный пресет принадлежит другой карте &mdash; все три представления SCP80 показывают жёлтое предупреждение над выбором пресета. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в пресете с ICCID подключённой карты эти поля заполнены.</p>
<p class="mb-2">Хранит пресеты карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией &mdash; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> &mdash; номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01&ndash;0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками &mdash; по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). &laquo;Карты&raquo; — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом &mdash; или выбранный пресет принадлежит другой карте &mdash; все три представления SCP80 показывают жёлтое предупреждение над выбором пресета. В списке подсвечивается пресет подключённой карты. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в пресете с ICCID подключённой карты эти поля заполнены.</p>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody>
+1 -1
View File
@@ -345,7 +345,7 @@
<p class="mb-2">Each packet the card accepts <strong>consumes a counter</strong>, which is saved into the preset's keyset &mdash; the probe is refused when SPI1 has no counter check or the named preset does not define the keyset number. Observed on a live card: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> and <code class="font-mono text-sm">B00001</code> registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with <code class="font-mono text-sm">6200</code>.</p>
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2>
<p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself &mdash; a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> &mdash; a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc and KID of a keyset must carry the same number (01&ndash;0F; A.2 rejects a mismatch), and each keyset has its own counter on the card (Annex A.1), so counters are tracked per keyset. In the form the keysets are edited as rows &mdash; one per number, with <strong>Add keyset</strong> / <strong>Remove keyset</strong>; each row shows the number derived from its KIc/KID byte, and <code class="font-mono text-sm">00</code> (no security) is not accepted as a keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches <strong>no</strong> preset &mdash; or the selected preset belongs to another card &mdash; all three SCP80 views show an amber warning above the preset selector. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
<p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself &mdash; a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> &mdash; a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc and KID of a keyset must carry the same number (01&ndash;0F; A.2 rejects a mismatch), and each keyset has its own counter on the card (Annex A.1), so counters are tracked per keyset. In the form the keysets are edited as rows &mdash; one per number, with <strong>Add keyset</strong> / <strong>Remove keyset</strong>; each row shows the number derived from its KIc/KID byte, and <code class="font-mono text-sm">00</code> (no security) is not accepted as a keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches <strong>no</strong> preset &mdash; or the selected preset belongs to another card &mdash; all three SCP80 views show an amber warning above the preset selector. The list highlights the equipped card's preset. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody>
+14 -2
View File
@@ -11741,17 +11741,26 @@ function cardsRender() {
tbody.innerHTML = '<tr><td colspan="10" class="py-2 text-gray-400">' + t('No cards defined.') + '</td></tr>';
return;
}
// The preset of the equipped card (normalised ICCID match): its row is
// highlighted so the list shows which preset belongs to the card in the
// reader (no highlight while the ICCID is unknown).
const equippedIdx = cardsFindByIccid(_pysimCardIccid);
let html = '';
for (let i = 0; i < cards.length; i++) {
const c = cards[i];
const equipped = i === equippedIdx;
let ota = '<span class="text-gray-400">—</span>';
if (cardsScp81Complete(c)) {
ota = '<span class="text-emerald-600 dark:text-emerald-400" title="' + esc(c.pskIdentity) + '">✓</span>';
} else if (c.pskKey || c.pskIdentity) {
ota = '<span class="text-amber-500" title="' + esc(t('PSK identity and PSK key must be set together')) + '">⚠</span>';
}
html += '<tr class="border-b border-gray-100 dark:border-slate-700">';
html += '<td class="py-1 px-2 font-medium">' + esc(c.name) + '</td>';
html += '<tr class="border-b border-gray-100 dark:border-slate-700' +
(equipped ? ' bg-emerald-50 dark:bg-emerald-900/30' : '') + '">';
html += '<td class="py-1 px-2 font-medium">' +
(equipped ? '<span class="mr-1 text-emerald-600 dark:text-emerald-400" title="' +
esc(t("The equipped card's preset")) + '">●</span>' : '') +
esc(c.name) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>';
const ksText = spKeysetList(c).map(ks => {
const kvn = spKeysetKvnOf(ks.kic);
@@ -13794,7 +13803,9 @@ function pysimCardStateUpdate(status) {
_pysimLastStatus = status;
_pysimCardEquipped = !!status.connected;
_pysimEquipping = !!status.equipping;
const prevIccid = _pysimCardIccid;
_pysimCardIccid = status.connected ? (status.iccid || null) : null;
if (prevIccid !== _pysimCardIccid) cardsRender(); // equipped-preset highlight
pysimUpdateAdmIndicator(status);
pysimUpdateIccidIndicator(_pysimCardIccid);
pysimUpdatePresetIndicators(_pysimCardIccid);
@@ -19197,6 +19208,7 @@ const LANG_RU = {
'Snapshot comparison results:': 'Результаты сравнения снимков:',
'RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.': 'RAM-операции выполняют атомарные команды GlobalPlatform через SCP80. Ключи карты берутся из сохранённого пресета.',
'No cards defined.': 'Карты не заданы.',
"The equipped card's preset": 'Пресет подключённой карты',
'Card presets': 'Выбор карты',
'Card preset': 'Пресет карты',
'— Select card —': '— Выберите карту —',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v343';
const CACHE = 'simple-v344';
const URLS = [
'index.html',
'help.html',
+1
View File
@@ -51,6 +51,7 @@ code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'pysimSetServerAvailable') + '\n';
code += '\nglobalThis.esc = s => s;\n';
code += 'globalThis.t = s => s;\n';
code += 'globalThis.cardsRender = () => {};\n'; // equipped-preset highlight
eval(code);
function fakeIndicator() {
+27 -1
View File
@@ -24,7 +24,8 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
'cardsIccidFromCard', 'cardsAutoSelectByIccid']) {
'cardsIccidFromCard', 'cardsAutoSelectByIccid', 'cardsRender',
'esc', 'spKeysetList', 'spKeysetKvnOf', 'cardsTarValue', 'cardsScp81Complete']) {
code += extractFunc(html, fn) + '\n';
}
eval(code);
@@ -169,3 +170,28 @@ test('the card-state update wires the ICCID into the preset selection', () => {
'the auto-selection must cover the TAR probe view');
assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')"));
});
test('cardsRender highlights the equipped card preset', () => {
globalThis.t = s => s;
const els = { 'cards-tbody': { innerHTML: '' } };
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [
{ name: 'other', iccid: '1111111111111111111', spi1: '16', spi2: '01', keysets: [] },
{ name: 'live', iccid: DIGITS, spi1: '16', spi2: '01', keysets: [] },
];
globalThis._pysimCardIccid = DIGITS;
cardsRender();
let rows = els['cards-tbody'].innerHTML.split('</tr>');
assert.ok(!/bg-emerald-50/.test(rows[0]), 'the other preset must stay plain');
assert.match(rows[1], /bg-emerald-50/);
assert.match(rows[1], /The equipped card's preset/);
// the same card stored as raw EF hex matches too
globalThis.cards[1].iccid = RAW_HEX;
cardsRender();
rows = els['cards-tbody'].innerHTML.split('</tr>');
assert.match(rows[1], /bg-emerald-50/);
// no readable ICCID -> no highlight
globalThis._pysimCardIccid = null;
cardsRender();
assert.ok(!/bg-emerald-50/.test(els['cards-tbody'].innerHTML));
});