equip/init: read the selected EF directly, bound the startup drain
pySim's scc.read_binary/read_record call select_path(ef) first, so the ICCID, network-state, EF.ARR and EF.PBR readers - which had already made a parent-scoped selection and hold its FCP - sent a second SELECT per file and per record. _read_selected_ef() issues READ BINARY (chunked at max_cmd_len from the FCP file size) / READ RECORD (b2<rec>04<len> at the FCP record length) directly; the pySim helpers stay as the fallback when the FCP lacks the value. The startup TERMINAL PROFILE drain now breaks on the chain's idle SW instead of polling STATUS once more. Measured on the last startup: ~3.4 s traced init (netstate/ARR/PBR 271 APDUs / 1.26 s incl. 169 SELECTs; the card's own TP processing 1.58 s); the change removes ~0.7-0.8 s of the read phase without touching any data or the TP. v3.38.1; tests: tests/test_read_selected.py (direct reads, chunking, the fallback and the drain source pin) and the ARR/PBR/ICCID/netstate fakes. 1067 Python + 1075 frontend. sw cache simple-v482.
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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) =====
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v481';
|
||||
const CACHE = 'simple-v482';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()})
|
||||
|
||||
+21
-5
@@ -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'])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+21
-5
@@ -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'])
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user