diff --git a/README.md b/README.md
index 68fb7d9..4c8c0bc 100644
--- a/README.md
+++ b/README.md
@@ -449,7 +449,7 @@ Stores saved card configurations (presets) in `localStorage`. A preset holds the
The **SCP81** column shows whether the preset supplies a usable PSK pair: **✓** (identity and key), **⚠** (only one of the two — the listener ignores such a preset), **—** (no PSK). Identity and key must be set together.
-**Add a card:** fill in the name, ICCID (optional), SPI1/SPI2, KIc/KID keys and indices, TAR, the SCP81 PSK pair (optional) and click **Add**. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown.
+**Add a card:** fill in the name, ICCID (optional — **From card** fills it from the equipped card's EF.ICCID), SPI1/SPI2, KIc/KID keys and indices, TAR, the SCP81 PSK pair (optional) and click **Add**. A duplicate ICCID (compared ignoring spaces and the raw-hex form) is refused, naming the conflicting preset. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown.
**Edit / remove:** **Edit** loads a preset into the form (the Add button becomes **Save**; **Cancel** clears the form); **Remove** deletes the row from `localStorage`. A successful SCP80 send advances and stores the replay counter, and edits are pushed into a running SCP81 listener automatically.
diff --git a/README_RUS.md b/README_RUS.md
index e1c7545..201dc4c 100644
--- a/README_RUS.md
+++ b/README_RUS.md
@@ -412,7 +412,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
| Поле | Описание |
|---|---|
| Name | Понятная метка (обязательна) |
-| ICCID | Опциональный идентификатор карты |
+| ICCID | Опциональный идентификатор карты; кнопка **С карты** подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается) |
| SPI1 / SPI2 | Уровень безопасности и настройки PoR |
| Индекс KIc / KID | Номер версии ключа (задаётся вместе с ключами) |
| Ключ KIc / KID | Hex-ключи шифрования и MAC |
@@ -423,7 +423,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Столбец **SCP81** показывает, задана ли в пресете рабочая пара PSK: **✓** (идентификатор и ключ), **⚠** (только одно из двух — слушатель такой пресет игнорирует), **—** (PSK нет). Идентификатор и ключ задаются вместе.
-**Добавить карту:** заполните имя, ICCID (опционально), SPI1/SPI2, ключи и индексы KIc/KID, TAR, при необходимости пару PSK и нажмите **Add**. Карта появится в списке и станет доступна в выпадающем списке **Card preset** на вкладке RAM.
+**Добавить карту:** заполните имя, ICCID (опционально — кнопка **С карты** подставляет EF.ICCID подключённой карты), SPI1/SPI2, ключи и индексы KIc/KID, TAR, при необходимости пару PSK и нажмите **Add**. Дубликат ICCID (сравнение без пробелов и с учётом сырой hex-формы) отклоняется с указанием конфликтующего пресета. Карта появится в списке и станет доступна в выпадающем списке **Card preset** на вкладке RAM.
**Изменить/удалить:** **Edit** загружает пресет в форму (кнопка Add становится **Save**; **Cancel** очищает форму); **Remove** удаляет строку из `localStorage`. Успешная отправка SCP80 увеличивает и сохраняет счётчик повторов, а изменения сразу передаются работающему слушателю SCP81.
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index dcc4ac7..ab19d06 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -303,7 +303,7 @@
Поле Описание
Name Понятная метка (обязательна)
- ICCID Опциональный идентификатор карты
+ ICCID Опциональный идентификатор карты. Кнопка С карты подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается); при сохранении ICCID, уже присутствующий в списке, отклоняется (сравнение без пробелов и с учётом сырой hex-формы, конфликтующий пресет называется)
KIc / KID Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами
SPI1 / SPI2 Security Parameter Indicators
TAR Toolkit Application Reference
diff --git a/frontend/help.html b/frontend/help.html
index 3f9b010..393ef91 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -303,7 +303,7 @@
Field Description
Name Human-readable label (required)
- ICCID Optional card identifier
+ ICCID Optional card identifier. From card fills it with the equipped card's EF.ICCID (enabled only while a readable ICCID is known); saving refuses an ICCID that already exists in the list (compared ignoring spaces and the raw-hex form, and the conflicting preset is named)
KIc / KID Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys
SPI1 / SPI2 Security Parameter Indicators
TAR Toolkit Application Reference
diff --git a/frontend/index.html b/frontend/index.html
index f4270c7..a0945d6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -18,7 +18,7 @@
-
OTAMan SIM OTA with a Human Face v2.2.17
+
OTAMan SIM OTA with a Human Face v2.2.18
●
@@ -1137,6 +1137,7 @@
+ From card
Add
Cancel
@@ -4711,6 +4712,7 @@ let _pysimServerAvailable = null; // null until probed
let _pysimCardEquipped = false;
let _pysimEquipping = false;
let _pysimAdmVerified = null; // null = no card session
+let _pysimCardIccid = null; // EF.ICCID digits of the equipped card (null = unknown)
let _pysimAvailabilityTimer = null;
function pysimAvailabilityState() {
@@ -4725,7 +4727,9 @@ function pysimControlDisabled(needs, state) {
}
function pysimNeedsHint(needs, state) {
- return state === 'server-down' ? t('Connect to server') : t('Insert and equip a card');
+ if (state === 'server-down') return t('Connect to server');
+ if (needs === 'card-iccid' && state === 'card') return t('Card equipped but its ICCID is not readable');
+ return t('Insert and equip a card');
}
function pysimUpdateStateIndicator() {
@@ -4756,7 +4760,11 @@ function pysimApplyAvailability() {
const state = pysimAvailabilityState();
document.querySelectorAll('[data-needs]').forEach(el => {
const needs = el.getAttribute('data-needs');
- const disabled = pysimControlDisabled(needs, state);
+ let disabled = pysimControlDisabled(needs, state);
+ if (needs === 'card-iccid') {
+ // From-card buttons need a readable ICCID, not just a session.
+ disabled = pysimControlDisabled('card', state) || !_pysimCardIccid;
+ }
el.disabled = disabled;
if (disabled) el.setAttribute('title', pysimNeedsHint(needs, state));
else el.removeAttribute('title');
@@ -6015,8 +6023,10 @@ function cardsAdd() {
alert(t('PSK key must be 32 hex characters'));
return;
}
- if (v.iccid && cards.some((c, i) => c.iccid === v.iccid && i !== _cardsEditIdx)) {
- alert('Card with this ICCID already exists'); return;
+ const dupIdx = cardsFindDuplicateIccid(v.iccid, _cardsEditIdx);
+ if (dupIdx >= 0) {
+ alert(t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name || ('Card ' + dupIdx)));
+ return;
}
if (_cardsEditIdx !== null && cards[_cardsEditIdx]) {
cards[_cardsEditIdx] = Object.assign({}, cards[_cardsEditIdx], v);
@@ -6133,6 +6143,23 @@ function cardsFindByIccid(iccid) {
return cards.findIndex(c => cardsNormIccid(c.iccid) === norm);
}
+function cardsFindDuplicateIccid(iccid, skipIdx) {
+ // Duplicate detection ignores the edited row and compares normalized
+ // values (digits, spaced digits or raw EF hex all mean the same card).
+ const norm = cardsNormIccid(iccid);
+ if (!norm) return -1;
+ return cards.findIndex((c, i) => i !== skipIdx && cardsNormIccid(c.iccid) === norm);
+}
+
+function cardsIccidFromCard() {
+ // Fill the form's ICCID from the equipped card (status.iccid). The button
+ // is only enabled while a readable ICCID is known; alert just in case the
+ // state changed between the last poll and the click.
+ const iccid = _pysimCardIccid;
+ if (!iccid) { alert(t('Card equipped but its ICCID is not readable')); return; }
+ document.getElementById('cards-iccid').value = iccid;
+}
+
let _cardsAutoIccid = null;
function cardsAutoSelectByIccid(iccid) {
@@ -7646,6 +7673,7 @@ function pysimCardStateUpdate(status) {
_pysimServerAvailable = true;
_pysimCardEquipped = !!status.connected;
_pysimEquipping = !!status.equipping;
+ _pysimCardIccid = status.connected ? (status.iccid || null) : null;
pysimUpdateAdmIndicator(status);
pysimApplyAvailability();
if (pysimProactiveSeqChanged(status.proactive_seq)
@@ -11637,6 +11665,9 @@ const LANG_RU = {
'executed': 'выполнено',
'pending': 'ожидает',
'Name is required': 'Укажите имя',
+ 'From card': 'С карты',
+ 'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке',
+ 'Card equipped but its ICCID is not readable': 'Карта подключена, но её ICCID не читается',
'Saved.': 'Сохранено.',
'Invalid APDU': 'Некорректный APDU',
'Add at least one APDU': 'Добавьте хотя бы один APDU',
diff --git a/frontend/sw.js b/frontend/sw.js
index dc974c8..5b4f48a 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v184';
+const CACHE = 'otaman-v185';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/card_state.test.js b/frontend/tests/card_state.test.js
index c820824..85c0b58 100644
--- a/frontend/tests/card_state.test.js
+++ b/frontend/tests/card_state.test.js
@@ -24,7 +24,7 @@ function extractFunc(src, name) {
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n'
- + 'var _cardsAutoIccid = null;\n';
+ + 'var _cardsAutoIccid = null;\nvar _pysimCardIccid = null;\n';
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n';
@@ -61,6 +61,7 @@ function setup() {
_pysimAdmVerified = null;
_pysimServerAvailable = null;
_cardsAutoIccid = null;
+ _pysimCardIccid = null;
globalThis.document = {
getElementById: id => id === 'state-indicator-adm' ? adm : el,
querySelectorAll: () => [],
@@ -122,6 +123,19 @@ test('a disconnect clears the ICCID auto-selection guard', () => {
assert.strictEqual(_cardsAutoIccid, null);
});
+test('the last status ICCID is kept for the From card button', () => {
+ setup();
+ pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
+ assert.strictEqual(_pysimCardIccid, '8970119000004600098');
+ // equipped but unreadable -> null (button disabled)
+ pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: null }));
+ assert.strictEqual(_pysimCardIccid, null);
+ // disconnect clears it as well
+ pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
+ pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 6 }));
+ assert.strictEqual(_pysimCardIccid, null);
+});
+
test('card session change triggers a data reset', () => {
const { calls } = setup();
pysimCardStateUpdate(status({ card_session: 3 }));
diff --git a/frontend/tests/cards_iccid.test.js b/frontend/tests/cards_iccid.test.js
index 3de5449..41b35d2 100644
--- a/frontend/tests/cards_iccid.test.js
+++ b/frontend/tests/cards_iccid.test.js
@@ -23,7 +23,8 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
- 'cardsNormIccid', 'cardsFindByIccid', 'cardsAutoSelectByIccid']) {
+ 'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
+ 'cardsIccidFromCard', 'cardsAutoSelectByIccid']) {
code += extractFunc(html, fn) + '\n';
}
eval(code);
@@ -33,11 +34,18 @@ const RAW_HEX = '980711090000640090F8';
const DIGITS = '8970119000004600098';
function fakeDoc() {
- const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' } };
+ const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' }, 'cards-iccid': { value: '' } };
globalThis.document = { getElementById: id => els[id] || null };
return els;
}
+function fakeAlert() {
+ const seen = [];
+ globalThis.alert = msg => seen.push(msg);
+ globalThis.t = s => s;
+ return seen;
+}
+
test('cardsNormIccid accepts digits, separators and raw EF hex', () => {
assert.strictEqual(cardsNormIccid(DIGITS), DIGITS);
assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS);
@@ -104,6 +112,53 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', ()
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
});
+test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
+ globalThis.cards = [
+ { name: 'A', iccid: '1111111111111111111' },
+ { name: 'B', iccid: RAW_HEX },
+ { name: 'C', iccid: '' },
+ ];
+ assert.strictEqual(cardsFindDuplicateIccid(DIGITS), 1);
+ assert.strictEqual(cardsFindDuplicateIccid('89 70 1190-0000 4600 098'), 1);
+ assert.strictEqual(cardsFindDuplicateIccid('1111111111111111111'), 0);
+ assert.strictEqual(cardsFindDuplicateIccid('2222222222222222222'), -1);
+ // empty ICCID is never a duplicate (the field is optional)
+ assert.strictEqual(cardsFindDuplicateIccid(''), -1);
+ assert.strictEqual(cardsFindDuplicateIccid(' '), -1);
+});
+
+test('cardsFindDuplicateIccid skips the row being edited', () => {
+ globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
+ assert.strictEqual(cardsFindDuplicateIccid(DIGITS, 0), -1);
+ assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 0), -1);
+ assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 1), 0);
+});
+
+test('cardsIccidFromCard fills the field from the equipped card', () => {
+ const els = fakeDoc();
+ const seen = fakeAlert();
+ globalThis._pysimCardIccid = DIGITS;
+ cardsIccidFromCard();
+ assert.strictEqual(els['cards-iccid'].value, DIGITS);
+ assert.deepStrictEqual(seen, []);
+ // no readable ICCID (state raced the click): alert, leave the field alone
+ els['cards-iccid'].value = 'keep-me';
+ globalThis._pysimCardIccid = null;
+ cardsIccidFromCard();
+ assert.strictEqual(els['cards-iccid'].value, 'keep-me');
+ assert.deepStrictEqual(seen, ['Card equipped but its ICCID is not readable']);
+});
+
+test('the From card button and the duplicate refusal are wired into the form', () => {
+ assert.match(html, / {
assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);'));
assert.ok(html.includes('_cardsAutoIccid = null;'));
diff --git a/frontend/tests/indicator.test.js b/frontend/tests/indicator.test.js
index 448b054..2803cf3 100644
--- a/frontend/tests/indicator.test.js
+++ b/frontend/tests/indicator.test.js
@@ -21,8 +21,11 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1);
}
-let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n';
+let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\nvar _pysimCardIccid = null;\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
+code += extractFunc(html, 'pysimControlDisabled') + '\n';
+code += extractFunc(html, 'pysimNeedsHint') + '\n';
+code += extractFunc(html, 'pysimApplyAvailability') + '\n';
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
code += 'globalThis.t = s => s;\n';
eval(code);
@@ -38,6 +41,7 @@ function fakeEl() {
contains: c => classes.has(c),
},
setAttribute(k, v) { this.attrs[k] = v; },
+ getAttribute(k) { return this.attrs[k]; },
removeAttribute(k) { delete this.attrs[k]; },
};
}
@@ -49,10 +53,11 @@ function setup() {
'state-indicator-img': fakeEl(),
};
els['state-indicator-img'].src = '';
- globalThis.document = { getElementById: id => els[id] || null };
+ globalThis.document = { getElementById: id => els[id] || null, querySelectorAll: () => [] };
_pysimServerAvailable = null;
_pysimCardEquipped = false;
_pysimEquipping = false;
+ _pysimCardIccid = null;
return els;
}
@@ -127,6 +132,34 @@ test('indicator markup carries the dot and image elements', () => {
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
});
+test('card-iccid controls need an equipped card with a readable ICCID', () => {
+ const check = (state, iccid) => {
+ const el = fakeEl();
+ el.setAttribute('data-needs', 'card-iccid');
+ globalThis.document = { querySelectorAll: () => [el], getElementById: () => null };
+ _pysimServerAvailable = state !== 'server-down';
+ _pysimCardEquipped = state === 'card';
+ _pysimCardIccid = iccid;
+ pysimApplyAvailability();
+ return el;
+ };
+ // no card -> disabled
+ let el = check('no-card', null);
+ assert.strictEqual(el.disabled, true);
+ assert.strictEqual(el.attrs.title, 'Insert and equip a card');
+ // equipped but unreadable ICCID -> disabled with its own hint
+ el = check('card', null);
+ assert.strictEqual(el.disabled, true);
+ assert.strictEqual(el.attrs.title, 'Card equipped but its ICCID is not readable');
+ // equipped with an ICCID -> enabled
+ el = check('card', '8970119000004600098');
+ assert.strictEqual(el.disabled, false);
+ assert.strictEqual(el.attrs.title, undefined);
+ // server down -> disabled
+ el = check('server-down', '8970119000004600098');
+ assert.strictEqual(el.disabled, true);
+});
+
test('indicator image stays within the 32px header row budget', () => {
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
assert.ok(m, 'inline image size not found');
diff --git a/pyproject.toml b/pyproject.toml
index cbcfa76..a2a2378 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
-version = "2.2.17"
+version = "2.2.18"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py
index dbe5019..e6346c6 100644
--- a/pysim_otaman_server/server.py
+++ b/pysim_otaman_server/server.py
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
-VERSION = '2.2.17'
+VERSION = '2.2.18'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE