diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f400e8..1581938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ every change carries a descriptive commit message. ## [Unreleased] +## [3.38.1] — 2026-10-24 + +### Performance + +- **Equip/initialization reads no longer select the file twice.** pySim's + `scc.read_binary`/`scc.read_record` call `select_path(ef)` first, but the + ICCID, network-state, EF.ARR and EF.PBR readers had already made a + parent-scoped selection and held its FCP. A new `_read_selected_ef()` reads + the selected file directly — READ BINARY chunked at `max_cmd_len`, READ + RECORD at the FCP record length — removing one SELECT per file read and one + per record (~106 files + 94 records on the bench card: ~0.7 s of the ~3.4 s + traced startup init). The pySim helpers stay as the fallback when the FCP + lacks the size/record length. +- The startup TERMINAL PROFILE drain uses the chain's final SW, so an idle + chain no longer costs one extra STATUS poll (~128 ms). + ## [3.38.0] — 2026-10-24 ### Added diff --git a/frontend/index.html b/frontend/index.html index 661b748..2988b8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1959,7 +1959,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.38.0'; +const SIMPLE_VERSION = '3.38.1'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== diff --git a/frontend/sw.js b/frontend/sw.js index 565ae80..c087251 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v481'; +const CACHE = 'simple-v482'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index acc89f5..47c87cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.38.0" +version = "3.38.1" description = "HTTP REST server wrapping pysim for the SIMple PWA" license = { text = "GPL-2.0-or-later" } requires-python = ">=3.9" diff --git a/pysim_simple_server/__main__.py b/pysim_simple_server/__main__.py index 7dcab22..6282c8f 100644 --- a/pysim_simple_server/__main__.py +++ b/pysim_simple_server/__main__.py @@ -293,7 +293,11 @@ def main(): sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw) if not st_sw.startswith('91'): break - _handle_proactive_chain(scc, st_sw) + chain_sw = _handle_proactive_chain(scc, st_sw) + # the chain's trailing STATUS already proved the card idle; + # polling once more only costs an APDU (v3.38.1) + if chain_sw and not chain_sw.startswith('91'): + break _tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000)) except Exception as e: if _is_pcsc_error(e): diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 9f6177f..51bbf7b 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.38.0' +VERSION = '3.38.1' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -483,6 +483,45 @@ def _decode_iccid(data_hex): return digits +def _read_selected_ef(lchan, kind, rec=None, length=None, offset=0): + """Read the currently selected EF **without selecting it again**. + + pySim's ``scc.read_binary``/``scc.read_record`` call ``select_path(ef)`` + first. Our callers have already made a parent-scoped selection and hold + its FCP, so that second SELECT is redundant (one per file read, one per + record). A transparent file is read in ``max_cmd_len`` chunks from + ``offset`` (the whole file when ``length`` is None, using the FCP file + size); a record file reads record ``rec`` (1-based) at the FCP's record + length. Returns ``(data, sw)`` like the pySim helpers; falls back to them + when the FCP lacks the needed value. + + The caller must select the file first (``_select_path``/``_select_with_parent``) + and must not interleave another selection before the read.""" + scc = getattr(lchan, 'scc', None) + if kind == 'record': + rec_len = lchan.selected_file_record_len() + if scc is not None and rec_len: + pdu = scc.cla_byte + 'b2%02x04%02x' % (int(rec), rec_len) + return scc.send_apdu_checksw(pdu) + return lchan.read_record(rec) + size = lchan.selected_file_size() + if scc is None or size is None: + return lchan.read_binary(length, offset) + if length is None: + length = size - offset + if length <= 0: + return '', '9000' + total, sw = '', '' + chunk = 0 + while chunk < length: + n = min(scc.max_cmd_len, length - chunk) + pdu = scc.cla_byte + 'b0%04x%02x' % (offset + chunk, n) + data, sw = scc.send_apdu_checksw(pdu) + total += data or '' + chunk += n + return total, sw + + def _read_iccid(app): """Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None. @@ -495,7 +534,7 @@ def _read_iccid(app): cleanup = None try: _, cleanup = _select_path(lchan, 'MF/2FE2', app) - data, _sw = lchan.read_binary() + data, _sw = _read_selected_ef(lchan, 'transparent') return _decode_iccid(data) except Exception: return None @@ -536,7 +575,7 @@ def _read_arr(app): n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT) records = [] for i in range(1, n + 1): - rv = lchan.read_record(i) + rv = _read_selected_ef(lchan, 'record', i) data = rv[0] if isinstance(rv, tuple) else rv records.append((data or '').upper()) out.append({'fid': path.rsplit('/', 1)[-1].upper(), @@ -602,7 +641,7 @@ def _read_pbr(app): n = min(lchan.selected_file_num_of_rec() or 1, PBR_RECORD_LIMIT) records = [] for i in range(1, n + 1): - rv = lchan.read_record(i) + rv = _read_selected_ef(lchan, 'record', i) data = rv[0] if isinstance(rv, tuple) else rv records.append((data or '').upper()) out.append({'fid': path.rsplit('/', 1)[-1].upper(), @@ -886,14 +925,14 @@ def _netstate_read(app, keys=None): records = [] n = lchan.selected_file_num_of_rec() or 1 for i in range(1, n + 1): - rv = lchan.read_record(i) + rv = _read_selected_ef(lchan, 'record', i) data = rv[0] if isinstance(rv, tuple) else rv records.append({'num': i, 'data': (data or '').upper()}) entry.update({'present': True, 'kind': 'record', 'records': records}) else: - rv = lchan.read_binary() + rv = _read_selected_ef(lchan, 'transparent') data = rv[0] if isinstance(rv, tuple) else rv entry.update({'present': True, 'kind': 'transparent', 'data': (data or '').upper()}) diff --git a/tests/test_arr.py b/tests/test_arr.py index 5fdea80..5511ee9 100644 --- a/tests/test_arr.py +++ b/tests/test_arr.py @@ -23,13 +23,29 @@ class FakeFile: self.fid = fid +class FakeScc: + """Serves READ RECORD for the selected fake ARR file.""" + + cla_byte = '00' + max_cmd_len = 255 + + def __init__(self, lchan): + self.lchan = lchan + self.sent = [] + + def send_apdu_checksw(self, pdu): + self.sent.append(pdu) + return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000') + + class FakeLchan: - """One selected ARR file: read_record serves its records.""" + """One selected ARR file: READ RECORD serves its records.""" def __init__(self, files): self.files = files # path -> [record hex] self._cur = None self.selected_file = None + self.scc = FakeScc(self) def selected_file_num_of_rec(self): return len(self.files.get(self._cur) or []) @@ -96,13 +112,13 @@ class ReadArrTests(unittest.TestCase): def test_a_failing_read_skips_the_file(self): app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']}) - orig = lchan.read_record + orig = lchan.scc.send_apdu_checksw - def flaky(n): + def flaky(pdu): if lchan._cur == 'MF/2F06': raise RuntimeError('6A82') - return orig(n) - lchan.read_record = flaky + return orig(pdu) + lchan.scc.send_apdu_checksw = flaky arrs = _read(app) self.assertEqual([a['fid'] for a in arrs], ['6F06']) diff --git a/tests/test_iccid.py b/tests/test_iccid.py index bb2aae2..8aa9e91 100644 --- a/tests/test_iccid.py +++ b/tests/test_iccid.py @@ -32,6 +32,21 @@ class FakeFile: self.applications = {} +class FakeScc: + cla_byte = '00' + max_cmd_len = 255 + + def __init__(self, lchan): + self.lchan = lchan + self.sent = [] + + def send_apdu_checksw(self, pdu): + self.sent.append(pdu) + if self.lchan.error: + raise self.lchan.error + return self.lchan.data, '9000' + + class FakeLchan: def __init__(self, mf, data=None, error=None): self.selected_file = mf @@ -39,6 +54,10 @@ class FakeLchan: self.error = error self.selects = [] self.probes = [] + self.scc = FakeScc(self) + + def selected_file_size(self): + return len(self.data or '') // 2 def select_file(self, f, app=None): self.selected_file = f @@ -94,6 +113,8 @@ class ReadIccidTest(unittest.TestCase): self.assertIs(lchan.selected_file, iccid) self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID']) self.assertEqual(lchan.probes, []) + # one READ BINARY, no second SELECT (v3.38.1) + self.assertEqual(lchan.scc.sent, ['00b000000a']) def test_missing_model_entry_is_probed_and_detached(self): app, lchan, mf, _ = make_app(with_iccid=False) diff --git a/tests/test_netstate_server.py b/tests/test_netstate_server.py index 56f3192..f9caeab 100644 --- a/tests/test_netstate_server.py +++ b/tests/test_netstate_server.py @@ -13,6 +13,28 @@ from types import SimpleNamespace from pysim_simple_server import server +class FakeScc: + """Serves the selected fake file for direct READ BINARY/RECORD APDUs.""" + + cla_byte = '00' + max_cmd_len = 255 + + def __init__(self, lchan): + self.lchan = lchan + self.sent = [] + + def send_apdu_checksw(self, pdu): + self.sent.append(pdu) + kind, val = self.lchan.files[self.lchan.selected] + if pdu[2:4] == 'b0': + off = int(pdu[4:8], 16) + ln = int(pdu[8:10], 16) + return val[off * 2:(off + ln) * 2], '9000' + if pdu[2:4] == 'b2': + return val[int(pdu[4:6], 16) - 1], '9000' + raise AssertionError('unexpected APDU %s' % pdu) + + class FakeLchan: def __init__(self, files): self.files = files # path -> ('transparent', hex) | ('record', [hex]) @@ -23,6 +45,9 @@ class FakeLchan: self.selected_file_type = lambda: 'ef' self.selected_file_structure = lambda: self.files[self.selected][0] self.selected_file_num_of_rec = lambda: len(self.files[self.selected][1]) + self.selected_file_record_len = lambda: 2 + self.selected_file_size = lambda: len(self.files[self.selected][1]) // 2 + self.scc = FakeScc(self) self.read_binary = lambda: (self.files[self.selected][1], '9000') self.read_record = lambda i: (self.files[self.selected][1][i - 1], '9000') @@ -68,6 +93,8 @@ class NetStateServerTests(unittest.TestCase): ('select', 'DF.GSM/6F07'), ('select', 'ADF.USIM/6FCD'), ('select', 'ADF.USIM/6FE4')]) + # v3.38.1: the reads use the selected file directly - no second SELECT + self.assertEqual(lchan.scc.sent, ['00b0000003', '00b2010402', '00b2020402']) def test_install_builds_the_state_and_network(self): files = {'imsi': {'name': 'EF.IMSI', 'fid': '6F07', 'present': True, diff --git a/tests/test_pbr.py b/tests/test_pbr.py index 8a627c5..9de5d6a 100644 --- a/tests/test_pbr.py +++ b/tests/test_pbr.py @@ -34,13 +34,29 @@ class FakeFile: self.fid = fid +class FakeScc: + """Serves READ RECORD for the selected fake PBR file.""" + + cla_byte = '00' + max_cmd_len = 255 + + def __init__(self, lchan): + self.lchan = lchan + self.sent = [] + + def send_apdu_checksw(self, pdu): + self.sent.append(pdu) + return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000') + + class FakeLchan: - """One selected PBR file: read_record serves its records.""" + """One selected PBR file: READ RECORD serves its records.""" def __init__(self, files): self.files = files # path -> [record hex] self._cur = None self.selected_file = None + self.scc = FakeScc(self) def selected_file_num_of_rec(self): return len(self.files.get(self._cur) or []) @@ -101,13 +117,13 @@ class ReadPbrTests(unittest.TestCase): def test_a_failing_read_skips_the_file(self): app, lchan = _app({'MF/7F10/5F3A/4F30': ['A80AC0034F3A02'], 'ADF.USIM/5F3A/4F30': ['A80AC0034F3A02']}) - orig = lchan.read_record + orig = lchan.scc.send_apdu_checksw - def flaky(n): + def flaky(pdu): if lchan._cur == 'MF/7F10/5F3A/4F30': raise RuntimeError('6A82') - return orig(n) - lchan.read_record = flaky + return orig(pdu) + lchan.scc.send_apdu_checksw = flaky pbrs = _read(app) self.assertEqual([p['path'] for p in pbrs], ['ADF.USIM/5F3A/4F30']) diff --git a/tests/test_read_selected.py b/tests/test_read_selected.py new file mode 100644 index 0000000..503a817 --- /dev/null +++ b/tests/test_read_selected.py @@ -0,0 +1,93 @@ +"""The direct read of an already-selected EF (v3.38.1). + +pySim's ``scc.read_binary``/``scc.read_record`` select the file first; the +equip/init readers have already made a parent-scoped selection and hold its +FCP, so that second SELECT is pure overhead (one per file, one per record). +These tests pin the direct READ BINARY/READ RECORD form, the chunking and the +fallback when the FCP lacks the size/record length. +""" + +import pathlib +import unittest + +from pysim_simple_server import server as S + + +class FakeScc: + cla_byte = '00' + max_cmd_len = 255 + + def __init__(self, data='AABB'): + self.sent = [] + self.data = data + + def send_apdu_checksw(self, pdu): + self.sent.append(pdu) + return self.data, '9000' + + +class FakeLchan: + def __init__(self, size=2, rec_len=2, data='AABB'): + self.scc = FakeScc(data) + self.size = size + self.rec_len = rec_len + + def selected_file_size(self): + return self.size + + def selected_file_record_len(self): + return self.rec_len + + def read_binary(self, *args): + raise AssertionError('pySim read_binary (re-select) used') + + def read_record(self, *args): + raise AssertionError('pySim read_record (re-select) used') + + +class ReadSelectedTests(unittest.TestCase): + def test_a_transparent_read_is_direct(self): + lchan = FakeLchan(size=2) + self.assertEqual(S._read_selected_ef(lchan, 'transparent'), + ('AABB', '9000')) + self.assertEqual(lchan.scc.sent, ['00b0000002']) + + def test_a_transparent_file_is_chunked_at_max_cmd_len(self): + lchan = FakeLchan(size=300) + S._read_selected_ef(lchan, 'transparent') + # 255 bytes at offset 0, then 45 bytes at offset 0x00ff + self.assertEqual(lchan.scc.sent, ['00b00000ff', '00b000ff2d']) + + def test_an_empty_file_reads_nothing(self): + lchan = FakeLchan(size=0) + self.assertEqual(S._read_selected_ef(lchan, 'transparent'), + ('', '9000')) + self.assertEqual(lchan.scc.sent, []) + + def test_a_record_read_is_direct(self): + lchan = FakeLchan(rec_len=6) + S._read_selected_ef(lchan, 'record', 3) + self.assertEqual(lchan.scc.sent, ['00b2030406']) + + def test_a_missing_fcp_falls_back_to_the_pysim_helpers(self): + lchan = FakeLchan(size=None, rec_len=None) + lchan.read_binary = lambda *a: ('CC', '9000') + lchan.read_record = lambda n: ('DD', '9000') + self.assertEqual(S._read_selected_ef(lchan, 'transparent'), + ('CC', '9000')) + self.assertEqual(S._read_selected_ef(lchan, 'record', 1), + ('DD', '9000')) + + +class StartupDrainPin(unittest.TestCase): + """The startup drain must not poll STATUS again after the chain already + returned an idle SW (v3.38.1).""" + + def test_the_drain_uses_the_chain_result(self): + src = (pathlib.Path(S.__file__).parent / '__main__.py').read_text() + self.assertIn('chain_sw = _handle_proactive_chain(scc, st_sw)', src) + self.assertIn("if chain_sw and not chain_sw.startswith('91'):", src) + + +if __name__ == '__main__': + unittest.main()