diff --git a/README.md b/README.md index c2427c8..0f384b2 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ Decodes a raw command response: pick the command that was sent, enter the SW (e. ## SCP80 tab -The **SCP80** top-level tab groups SCP80-related views, switched by two pills: **Secured Packet** and **RAM**. Assembles secured packets per ETSI TS 102 225. +The **SCP80** top-level tab groups SCP80-related views, switched by two pills: **Secured Packet** and **RAM**. Assembles secured packets per ETSI TS 102 225. When a card is equipped the server reads its EF.ICCID (2FE2); if a preset carries the same number (digits, or the raw EF hex), it is selected automatically in both views. ### Secured Packet @@ -433,7 +433,7 @@ Delivery PoR (SPI2 `01`) is simpler — the card returns the PoR directly in the ### Cards -Stores saved card configurations (presets) in `localStorage`. A preset holds the cryptographic keys, SPI settings, TAR and replay counter for SCP80 operations, plus the **PSK identity / PSK key** pair used by the SCP81 HTTP OTA listener. Cards is a **top-level tab**. +Stores saved card configurations (presets) in `localStorage`. A preset holds the cryptographic keys, SPI settings, TAR and replay counter for SCP80 operations, plus the **PSK identity / PSK key** pair used by the SCP81 HTTP OTA listener. 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 both SCP80 views. | Field | Description | |---|---| diff --git a/README_RUS.md b/README_RUS.md index f10ecec..d718f36 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -323,7 +323,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со ## Вкладка SCP80 -Вкладка **SCP80** группирует SCP80-виды, переключаемые двумя пиллами: **Secured Packet** и **RAM**. Сборка защищённых пакетов по ETSI TS 102 225. +Вкладка **SCP80** группирует SCP80-виды, переключаемые двумя пиллами: **Secured Packet** и **RAM**. Сборка защищённых пакетов по ETSI TS 102 225. При подключении карты сервер читает её EF.ICCID (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается в обоих видах. ### Secured Packet @@ -407,7 +407,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на ### Карты -Хранит сохранённые конфигурации карт (пресеты) в `localStorage`. Пресет содержит криптографические ключи, настройки SPI, TAR и счётчик повторов для SCP80-операций, а также пару **PSK identity / PSK key** для слушателя SCP81 HTTP OTA. «Карты» — **верхнеуровневая вкладка**. +Хранит сохранённые конфигурации карт (пресеты) в `localStorage`. Пресет содержит криптографические ключи, настройки SPI, TAR и счётчик повторов для SCP80-операций, а также пару **PSK identity / PSK key** для слушателя SCP81 HTTP OTA. «Карты» — **верхнеуровневая вкладка**. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается в обоих видах SCP80. | Поле | Описание | |---|---| diff --git a/docs/api.md b/docs/api.md index 1639085..3805dcc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -70,7 +70,18 @@ Returns server version for compatibility checking. ### `GET /api/status` -Card reader, card type, current selection, and card state. +Card reader, card type, current selection, and card state. Reported fields: +`reader`, `connected`, `card_present`, `card_session` (increments on every +equip/disconnect), `proactive_seq`, `equipping`, `auto_equip`, `card`, +`profile`, `iccid`, `app_ready`, `adm_verified`, `atr`, `cla_byte`, +`sel_ctrl`, `current_selection`, `channels`. + +`iccid` is the E.118 digit string read from EF.ICCID (MF/2FE2) when the card is +equipped, or `null` when it is not connected / the card does not let the +terminal read it. The equip path reads it **before** the TERMINAL PROFILE, so +no CAT session is active yet; the read is best-effort and never fails an equip +(and a card removal clears it). The PWA uses it to auto-select the card preset +with the same ICCID in both SCP80 views (Secured Packet and RAM). ### `GET /api/commands` diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 2b7297a..a280439 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -241,7 +241,7 @@

3. Вкладка SCP80

-

Верхнеуровневая вкладка SCP80 объединяет разделы, связанные с SCP80. Переключение — двумя переключателями: Secured Packet и RAM. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.

+

Верхнеуровневая вкладка SCP80 объединяет разделы, связанные с SCP80. Переключение — двумя переключателями: Secured Packet и RAM. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её EF.ICCID (2FE2); если в предустановке указан тот же номер (цифрами или сырым hex из EF), она автоматически выбирается в обоих представлениях.

3.1 Secured Packet

Собирает защищённые пакеты SCP80 по ETSI TS 102 225.

@@ -298,7 +298,7 @@

4. Вкладка «Карты»

-

Хранит предустановки карт локально в браузере (localStorage), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, а слушатель SCP81 HTTP OTA — находить PSK-ключи. «Карты» — верхнеуровневая вкладка.

+

Хранит предустановки карт локально в браузере (localStorage), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, а слушатель SCP81 HTTP OTA — находить PSK-ключи. «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80.

diff --git a/frontend/help.html b/frontend/help.html index e548d5f..c77866d 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -241,7 +241,7 @@

3. SCP80 tab

-

The SCP80 top-level tab groups the SCP80-related views. It is switched by two pills: Secured Packet and RAM. Assembles SCP80 secured packets per ETSI TS 102 225.

+

The SCP80 top-level tab groups the SCP80-related views. It is switched by two pills: Secured Packet and RAM. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its EF.ICCID (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in both views.

3.1 Secured Packet

Builds SCP80 secured packets per ETSI TS 102 225.

@@ -298,7 +298,7 @@

4. Cards tab

-

Stores card presets locally in the browser (localStorage) so the Secured Packet view can auto-fill keys and parameters, and so the SCP81 HTTP OTA listener can look up PSK keys. Cards is a top-level tab.

+

Stores card presets locally in the browser (localStorage) so the Secured Packet view can auto-fill keys and parameters, and so the SCP81 HTTP OTA listener can look up PSK keys. 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 both SCP80 views.

ПолеОписание
diff --git a/frontend/index.html b/frontend/index.html index dedeac2..6cc659e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v2.2.15

+

OTAMan SIM OTA with a Human Face v2.2.16

@@ -4859,6 +4859,7 @@ async function pysimRefresh() { (data.sel_ctrl ? ' | SEL: ' + esc(data.sel_ctrl) + '' : '') + ' | Channels: ' + (data.channels && data.channels.length ? data.channels.join(', ') : 'none') + '
' + 'ADM: ' + (data.adm_verified ? 'verified' : 'not verified') + '' + + (data.iccid ? ' | ICCID: ' + esc(data.iccid) + '' : '') + ' | Selection: ' + (data.current_selection ? (data.current_selection.name || data.current_selection.fid || '?') : 'none') + '' + @@ -6114,6 +6115,44 @@ function cardsRebuildSelect() { if (prev !== '' && cards[parseInt(prev, 10)]) sel.value = prev; } +// ===== ICCID-based preset auto-selection ===== +// After every equip /api/status carries the card's EF.ICCID (2FE2); when a +// preset holds the same number it is selected in both SCP80 views (Secured +// packet + RAM). Users may store the ICCID as digits or as the raw EF hex, +// so both forms are normalized to plain digits. +function cardsNormIccid(v) { + let s = String(v == null ? '' : v).trim().toUpperCase().replace(/[\s-]/g, ''); + if (!s) return ''; + if (/[A-F]/.test(s)) s = decIccid(s); // raw EF content (nibble-swapped) + return String(s || '').replace(/\D/g, '').replace(/^0+/, ''); +} + +function cardsFindByIccid(iccid) { + const norm = cardsNormIccid(iccid); + if (!norm) return -1; + return cards.findIndex(c => cardsNormIccid(c.iccid) === norm); +} + +let _cardsAutoIccid = null; + +function cardsAutoSelectByIccid(iccid) { + // Act only when the equip reports a *new* ICCID: a manual preset choice + // for the same card must not be overridden by later status updates. + const norm = cardsNormIccid(iccid); + if (!norm) { _cardsAutoIccid = null; return -1; } + if (norm === _cardsAutoIccid) return -1; + _cardsAutoIccid = norm; + const idx = cardsFindByIccid(norm); + if (idx < 0) return -1; + const sp = document.getElementById('sp-card-sel'); + if (sp) sp.value = String(idx); + const ram = document.getElementById('ram-card-sel'); + if (ram) ram.value = String(idx); + _ramCardIdx = idx; + cardsApply(String(idx)); + return idx; +} + function cardsApply(idx) { const c = cards[parseInt(idx)]; if (!c) return; @@ -7622,9 +7661,11 @@ function pysimCardStateUpdate(status) { if (status.connected) { pysimSetConnected(true); pysimResetCardData(true); + cardsAutoSelectByIccid(status.iccid); return; } pysimSetConnected(false); + _cardsAutoIccid = null; const statusEl = document.getElementById('pysim-status'); if (statusEl && statusEl.textContent.trim() !== '') { if (status.equipping || (status.card_present && status.auto_equip)) { diff --git a/frontend/sw.js b/frontend/sw.js index 92502ea..44f7acd 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v182'; +const CACHE = 'otaman-v183'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/card_state.test.js b/frontend/tests/card_state.test.js index 31c4968..c820824 100644 --- a/frontend/tests/card_state.test.js +++ b/frontend/tests/card_state.test.js @@ -23,7 +23,8 @@ 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 _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n' + + 'var _cardsAutoIccid = null;\n'; code += extractFunc(html, 'pysimCardStateUpdate') + '\n'; code += extractFunc(html, 'pysimAvailabilityState') + '\n'; code += extractFunc(html, 'pysimControlDisabled') + '\n'; @@ -53,12 +54,13 @@ function fakeIndicator() { function setup() { const el = { textContent: 'status line', innerHTML: '' }; const adm = fakeIndicator(); - const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 }; + const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0, autoIccid: [] }; _pysimCardStateKey = null; _pysimCardSession = null; _pysimProactiveSeq = null; _pysimAdmVerified = null; _pysimServerAvailable = null; + _cardsAutoIccid = null; globalThis.document = { getElementById: id => id === 'state-indicator-adm' ? adm : el, querySelectorAll: () => [], @@ -68,6 +70,7 @@ function setup() { globalThis.pysimApplyAvailability = () => {}; globalThis.isViewVisible = () => true; globalThis.pysimProactiveLogRender = () => { calls.proactive++; }; + globalThis.cardsAutoSelectByIccid = iccid => { calls.autoIccid.push(iccid); return -1; }; return { el, adm, calls }; } @@ -106,9 +109,17 @@ test('unchanged state key does not touch the UI again', () => { test('connected restores the UI and reloads card data', () => { const { calls } = setup(); - pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2 })); + pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' })); assert.deepStrictEqual(calls.connected, [true]); assert.deepStrictEqual(calls.resets, [true]); + assert.deepStrictEqual(calls.autoIccid, ['8970119000004600098']); +}); + +test('a disconnect clears the ICCID auto-selection guard', () => { + setup(); + _cardsAutoIccid = '8970119000004600098'; + pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 5 })); + assert.strictEqual(_cardsAutoIccid, null); }); test('card session change triggers a data reset', () => { diff --git a/frontend/tests/cards_iccid.test.js b/frontend/tests/cards_iccid.test.js new file mode 100644 index 0000000..3de5449 --- /dev/null +++ b/frontend/tests/cards_iccid.test.js @@ -0,0 +1,111 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +function extractFunc(src, name) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return src.slice(m.index, i + 1); +} + +let code = ''; +for (const fn of ['swapNibbles', 'encIccid', 'decIccid', + 'cardsNormIccid', 'cardsFindByIccid', 'cardsAutoSelectByIccid']) { + code += extractFunc(html, fn) + '\n'; +} +eval(code); + +// live-card style raw EF.ICCID content (nibble-swapped digits + 'F' pad) +const RAW_HEX = '980711090000640090F8'; +const DIGITS = '8970119000004600098'; + +function fakeDoc() { + const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' } }; + globalThis.document = { getElementById: id => els[id] || null }; + return els; +} + +test('cardsNormIccid accepts digits, separators and raw EF hex', () => { + assert.strictEqual(cardsNormIccid(DIGITS), DIGITS); + assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS); + assert.strictEqual(cardsNormIccid(RAW_HEX), DIGITS); + assert.strictEqual(cardsNormIccid(RAW_HEX.toLowerCase()), DIGITS); + assert.strictEqual(cardsNormIccid(''), ''); + assert.strictEqual(cardsNormIccid(null), ''); +}); + +test('cardsFindByIccid matches regardless of the stored form', () => { + globalThis.cards = [ + { name: 'A', iccid: '1111111111111111111' }, + { name: 'B', iccid: RAW_HEX }, + { name: 'C', iccid: '89 70 1190 0000 4600 098' }, + ]; + assert.strictEqual(cardsFindByIccid(DIGITS), 1); + assert.strictEqual(cardsFindByIccid('1111111111111111111'), 0); + assert.strictEqual(cardsFindByIccid('2222222222222222222'), -1); + assert.strictEqual(cardsFindByIccid(''), -1); +}); + +test('cardsAutoSelectByIccid selects the preset in both SCP80 views', () => { + const els = fakeDoc(); + const applied = []; + globalThis.cardsApply = idx => applied.push(idx); + globalThis.cards = [{ name: 'A', iccid: RAW_HEX }, { name: 'B', iccid: '' }]; + globalThis._ramCardIdx = null; + globalThis._cardsAutoIccid = null; + const idx = cardsAutoSelectByIccid(DIGITS); + assert.strictEqual(idx, 0); + assert.strictEqual(els['sp-card-sel'].value, '0'); + assert.strictEqual(els['ram-card-sel'].value, '0'); + assert.deepStrictEqual(applied, ['0']); + assert.strictEqual(globalThis._ramCardIdx, 0); +}); + +test('cardsAutoSelectByIccid does not override a manual choice for the same card', () => { + const els = fakeDoc(); + const applied = []; + globalThis.cardsApply = idx => applied.push(idx); + globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }]; + globalThis._ramCardIdx = null; + globalThis._cardsAutoIccid = null; + assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0); + // user picks the empty preset B for this card, then status polls repeat + els['sp-card-sel'].value = '1'; + els['ram-card-sel'].value = '1'; + assert.strictEqual(cardsAutoSelectByIccid(DIGITS), -1); + assert.strictEqual(els['sp-card-sel'].value, '1'); + assert.deepStrictEqual(applied, ['0']); +}); + +test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', () => { + fakeDoc(); + globalThis.cardsApply = () => {}; + globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '89390100000129506903' }]; + globalThis._cardsAutoIccid = null; + assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0); + assert.strictEqual(cardsAutoSelectByIccid('89390100000129506903'), 1); + // ICCID not readable: reset the guard, keep the current selection + assert.strictEqual(cardsAutoSelectByIccid(null), -1); + assert.strictEqual(globalThis._cardsAutoIccid, null); + assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0); + assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1); +}); + +test('the card-state update wires the ICCID into the preset selection', () => { + assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);')); + assert.ok(html.includes('_cardsAutoIccid = null;')); + assert.ok(html.includes("(data.iccid ? ' | ICCID: ' + esc(data.iccid) + '' : '')")); +}); diff --git a/pyproject.toml b/pyproject.toml index 3779a4d..94d196b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.2.15" +version = "2.2.16" 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/__main__.py b/pysim_otaman_server/__main__.py index 6066160..235fa9b 100644 --- a/pysim_otaman_server/__main__.py +++ b/pysim_otaman_server/__main__.py @@ -12,7 +12,7 @@ from pySim.cards import UiccCardBase from .shell import load_pysim_app from . import fastinit -from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip +from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid _server_start = 0 @@ -128,10 +128,16 @@ def main(): _tlog('pysim_app: %.0fms' % ((time.time() - t_phase) * 1000)) if app is not None and opts.fast_init: fastinit.install(app) + iccid = None if scc and card is not None and hasattr(scc, '_tp'): scc._tp.apdu_tracer = _LoggingApduTracer() try: _init_proactive_session() + # Read EF.ICCID before the TERMINAL PROFILE starts the CAT session + # (the PWA auto-selects the matching card preset from it). + iccid = _read_iccid(app) + if iccid: + sys.stderr.write('INIT: ICCID %s\n' % iccid) t_phase = time.time() sys.stderr.write('INIT: sending TERMINAL PROFILE %s (CLA=%s)\n' % (opts.terminal_profile, scc.cat_cla)) sm, el = _send_terminal_profile(scc, opts.terminal_profile) @@ -187,6 +193,7 @@ def main(): server.stk_pending = None server.card_present = card is not None server.card_session = 1 if card is not None else 0 + server.iccid = iccid server.equipping = False # Set server reference for polling timer and mark the card session state import pysim_otaman_server.server diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 63f8cb0..769129a 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.15' +VERSION = '2.2.16' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -355,6 +355,41 @@ def _select_path(lchan, path, app): return _select_with_parent(lchan, parts[-1], None, app, parent_path=parts[:-1], allow_probe=True) +def _decode_iccid(data_hex): + """Decode EF.ICCID content: nibble-swapped E.118 digits with an optional + trailing 'F' pad (TS 102 221 13.2 / TS 151 011 10.2). Returns the digit + string, or None when the bytes are not a plausible ICCID.""" + h = re.sub(r'[^0-9a-fA-F]', '', data_hex or '').upper() + if len(h) < 2 or len(h) % 2: + return None + digits = ''.join(h[i + 1] + h[i] for i in range(0, len(h), 2)) + digits = re.sub(r'F+$', '', digits) + if not digits or not digits.isdigit(): + return None + return digits + + +def _read_iccid(app): + """Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None. + + EF.ICCID is a mandatory transparent EF, but a card may protect it or the + generic profile may lack it, so the read is optional and never raises. + The previous selection is restored by the _select_path cleanup.""" + if not app or not getattr(app, 'rs', None): + return None + lchan = app.rs.lchan[0] + cleanup = None + try: + _, cleanup = _select_path(lchan, 'MF/2FE2', app) + data, _sw = lchan.read_binary() + return _decode_iccid(data) + except Exception: + return None + finally: + if cleanup: + cleanup() + + def _parse_tree_output(output): lines = (output or '').split('\n') children = [] @@ -2213,6 +2248,7 @@ def _handle_card_disconnect(): _server_ref.menu_active = False _server_ref.event_list = None _server_ref.sim_menu = None + _server_ref.iccid = None _server_ref.equipping = False _server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1 _reset_proactive_log() @@ -2234,6 +2270,14 @@ def _apply_equipped_card(server): _CARD_CONNECTED = True server.card_present = True server.card_session = getattr(server, 'card_session', 0) + 1 + server.iccid = None + # Read the ICCID before the TERMINAL PROFILE starts a CAT session: the + # PWA auto-selects the matching card preset (SCP80 views) from it. + server.iccid = _read_iccid(server.app) + if server.iccid: + _tlog('equip: ICCID %s' % server.iccid) + else: + _tlog('equip: ICCID not readable') _poll_enable() sm, el = _send_terminal_profile(server.scc, server.terminal_profile) server.sim_menu = sm @@ -3073,6 +3117,7 @@ class PysimHandler(BaseHTTPRequestHandler): 'auto_equip': bool(_AUTO_EQUIP), 'card': card.name if card else None, 'profile': str(rs.profile) if rs and rs.profile else None, + 'iccid': getattr(self.server, 'iccid', None) if connected else None, 'app_ready': app is not None, 'adm_verified': rs.adm_verified if rs else False, 'atr': rs.identity.get('ATR') if rs and rs.identity else None, diff --git a/tests/test_iccid.py b/tests/test_iccid.py new file mode 100644 index 0000000..0c5251b --- /dev/null +++ b/tests/test_iccid.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Tests for the EF.ICCID helpers (decode + best-effort read after equip). + +EF.ICCID (MF/2FE2) is a mandatory transparent EF carrying the nibble-swapped +E.118 digit string with an optional trailing 'F' pad (TS 102 221 13.2 / +TS 151 011 10.2). The equip flow reads it so the PWA can auto-select the +matching card preset; the read is optional and must never break an equip. +""" + +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +PROJECTS = Path(__file__).resolve().parents[2] +PY_SIM = PROJECTS / 'pysim' +if str(PY_SIM) not in sys.path: + sys.path.insert(0, str(PY_SIM)) + +from pysim_otaman_server import server +from pysim_otaman_server.server import _decode_iccid, _read_iccid + + +class FakeFile: + def __init__(self, fid=None, name=None, parent=None): + self.fid = fid + self.name = name + self.parent = parent + self.sfid = None + self.children = {} + self.applications = {} + + +class FakeLchan: + def __init__(self, mf, data=None, error=None): + self.selected_file = mf + self.data = data + self.error = error + self.selects = [] + self.probes = [] + + def select_file(self, f, app=None): + self.selected_file = f + self.selects.append(f) + + def probe_file(self, fid, app=None): + self.probes.append(fid) + f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file) + self.selected_file.children[fid] = f + self.selected_file = f + + def read_binary(self, length=None, offset=0): + if self.error: + raise self.error + return self.data, '9000' + + +def make_app(with_iccid=True, data='980711090000640090F8', error=None): + mf = FakeFile('3f00', 'MF') + iccid = None + if with_iccid: + iccid = FakeFile('2fe2', 'EF.ICCID', mf) + mf.children['2fe2'] = iccid + lchan = FakeLchan(mf, data=data, error=error) + rs = SimpleNamespace(mf=mf, lchan=[lchan]) + app = SimpleNamespace(rs=rs) + return app, lchan, mf, iccid + + +class DecodeIccidTest(unittest.TestCase): + def test_live_card_style_content_with_f_pad(self): + # 98 07 11 09 00 00 64 00 90 F8 -> 8970119000004600098 + self.assertEqual(_decode_iccid('980711090000640090F8'), '8970119000004600098') + + def test_even_length_digit_string_is_kept_whole(self): + self.assertEqual(_decode_iccid('21436587092143658709'), '12345678901234567890') + + def test_odd_length_hex_is_rejected(self): + self.assertIsNone(_decode_iccid('98071109000064009F8')) + + def test_non_hex_and_empty_are_rejected(self): + self.assertIsNone(_decode_iccid('')) + self.assertIsNone(_decode_iccid(None)) + self.assertIsNone(_decode_iccid('zzzz')) + self.assertIsNone(_decode_iccid('abcd')) # swaps to non-digits + self.assertIsNone(_decode_iccid('FFFF')) # only the pad + + +class ReadIccidTest(unittest.TestCase): + def test_reads_and_decodes_the_model_file(self): + app, lchan, mf, iccid = make_app() + self.assertEqual(_read_iccid(app), '8970119000004600098') + self.assertIs(lchan.selected_file, iccid) + self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID']) + self.assertEqual(lchan.probes, []) + + def test_missing_model_entry_is_probed_and_detached(self): + app, lchan, mf, _ = make_app(with_iccid=False) + self.assertEqual(_read_iccid(app), '8970119000004600098') + self.assertEqual(lchan.probes, ['2fe2']) + self.assertIs(lchan.selected_file, mf) # previous selection restored + self.assertNotIn('2fe2', mf.children) # probe entry detached + + def test_card_errors_are_not_fatal(self): + app, _, _, _ = make_app(error=Exception('SW 6A82')) + self.assertIsNone(_read_iccid(app)) + + def test_no_app_or_state(self): + self.assertIsNone(_read_iccid(None)) + self.assertIsNone(_read_iccid(SimpleNamespace(rs=None))) + + +class EquipFlowIccidTest(unittest.TestCase): + def _apply(self, read_result): + scc = SimpleNamespace(cat_cla=None) + card = SimpleNamespace(_scc=scc) + app = SimpleNamespace(card=card) + srv = SimpleNamespace(app=app, terminal_profile='FF', card=None, scc=None) + with mock.patch.object(server, '_cancel_menu_timeout'), \ + mock.patch.object(server, '_timer_cancel'), \ + mock.patch.object(server, '_reset_proactive_log'), \ + mock.patch.object(server, '_poll_enable'), \ + mock.patch.object(server, '_send_terminal_profile', return_value=(None, None)), \ + mock.patch.object(server, '_tlog'), \ + mock.patch.object(server, '_read_iccid', return_value=read_result): + server._apply_equipped_card(srv) + return srv + + def test_equip_records_the_iccid(self): + srv = self._apply('8970119000004600098') + self.assertEqual(srv.iccid, '8970119000004600098') + + def test_unreadable_iccid_leaves_it_none(self): + srv = self._apply(None) + self.assertIsNone(srv.iccid) + + def test_disconnect_clears_the_iccid(self): + old_ref = server._server_ref + old_connected = server._CARD_CONNECTED + ref = SimpleNamespace(iccid='8970119000004600098', card='x', scc='y') + try: + server._server_ref = ref + with mock.patch.object(server, '_poll_disable'), \ + mock.patch.object(server, '_cancel_menu_timeout'), \ + mock.patch.object(server, '_timer_cancel'), \ + mock.patch.object(server, '_reset_proactive_log'): + server._handle_card_disconnect() + self.assertIsNone(ref.iccid) + self.assertFalse(server._CARD_CONNECTED) + finally: + server._server_ref = old_ref + server._CARD_CONNECTED = old_connected + + +if __name__ == '__main__': + unittest.main()
FieldDescription