diff --git a/README.md b/README.md index 058f6a3..de41674 100644 --- a/README.md +++ b/README.md @@ -626,7 +626,9 @@ pysim-otaman-server --http-port 8080 | `--log-requests` | Log request/response payloads to stderr | | `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit | | `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) | -| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s) | +| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s; `0` disables polling) | +| `--fast-init` | Init/equip without redundant card resets (only explicit equip/reset resets the card) | +| `--timing` | Log phase durations, card resets and APDU counters with elapsed timestamps | ### Troubleshooting diff --git a/README_RUS.md b/README_RUS.md index 05aef6f..af51319 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -606,7 +606,9 @@ pysim-otaman-server --http-port 8080 | `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) | | `--apdu-trace` | Лог APDU-трафика в stderr | | `--log-requests` | Лог запросов/ответов в stderr | -| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с) | +| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с; `0` отключает опрос) | +| `--fast-init` | Инициализация/equip без лишних сбросов карты (сброс только по явным equip/reset) | +| `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени | ### Устранение неполадок diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 56bf928..3d3edff 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -362,7 +362,7 @@

Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.

5.5.5 Опрос STATUS

-

Кнопка Отправить STATUS отправляет STATUS (F2) вручную. Переключатель Опрос включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера --poll-interval, 1–255 с, по умолчанию 30 с) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.

+

Кнопка Отправить STATUS отправляет STATUS (F2) вручную. Переключатель Опрос включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера --poll-interval, 1–255 с, по умолчанию 30 с, 0 отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.

5.6 Профайлер

Проверяет соответствие карты именованному профилю — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в localStorage.

diff --git a/frontend/help.html b/frontend/help.html index b7e640b..316dba0 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -362,7 +362,7 @@

Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.

5.5.5 STATUS polling

-

A Send STATUS button issues a manual STATUS (F2). A Polling toggle enables background polling: after a configurable idle interval (server CLI --poll-interval, 1–255 s, default 30 s) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.

+

A Send STATUS button issues a manual STATUS (F2). A Polling toggle enables background polling: after a configurable idle interval (server CLI --poll-interval, 1–255 s, default 30 s, 0 disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.

5.6 Profiler

Verifies that a card matches a named profile — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in localStorage.

diff --git a/frontend/sw.js b/frontend/sw.js index feed8e0..d67ab62 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v88'; +const CACHE = 'otaman-v89'; const URLS = [ 'index.html', 'help.html', diff --git a/pysim_otaman_server/__main__.py b/pysim_otaman_server/__main__.py index 228622c..8f05497 100644 --- a/pysim_otaman_server/__main__.py +++ b/pysim_otaman_server/__main__.py @@ -11,6 +11,7 @@ from pySim.log import PySimLogger 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 @@ -49,6 +50,8 @@ def main(): help='Skip pysim card initialization (preserve CAT session — no file manager)') parser.add_argument('--timing', action='store_true', default=False, help='Log phase durations, card resets and APDU counters with elapsed timestamps') + parser.add_argument('--fast-init', action='store_true', default=False, + help='Init/equip without redundant card resets (reset only on explicit equip/reset)') opts = parser.parse_args() opts.skip_card_init = opts.no_card_init @@ -88,8 +91,11 @@ def main(): scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card scc._tp.proactive_handler = _DefaultProactiveHandler() t_phase = time.time() - sl.wait_for_card(3) - rs, card = mod.init_card(sl, opts.skip_card_init) + if opts.fast_init: + rs, card = fastinit.init_card_fast(sl, opts.skip_card_init, wait=True) + else: + sl.wait_for_card(3) + rs, card = mod.init_card(sl, opts.skip_card_init) _tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000)) scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0' except Exception: @@ -104,6 +110,8 @@ def main(): traceback.print_exc() app = None _tlog('pysim_app: %.0fms' % ((time.time() - t_phase) * 1000)) + if app is not None and opts.fast_init: + fastinit.install(app) if scc and hasattr(scc, '_tp'): scc._tp.apdu_tracer = _LoggingApduTracer() try: diff --git a/pysim_otaman_server/fastinit.py b/pysim_otaman_server/fastinit.py new file mode 100644 index 0000000..7e4baea --- /dev/null +++ b/pysim_otaman_server/fastinit.py @@ -0,0 +1,160 @@ +"""Fast card initialization for pysim-otaman-server. + +pySim's ``init_card()`` performs several physical card resets: one per profile +candidate tried by ``CardProfile.pick()`` plus one at the end of +``RuntimeState.__init__``, and ``PysimApp.equip()`` resets yet again. On common +readers each disconnect/connect costs around a second, so the stock path spends +most of its time re-establishing a clean state (MF selected) that can also be +restored in software. + +This module mirrors ``pySim.app.init_card()`` with those resets removed: all +profile probes run back-to-back on the same connection and the runtime state +uses a software reset. The explicit ``equip`` and ``reset`` commands keep a +real reconnect/physical reset. +""" + +import operator + +from pySim.cards import CardBase, SimCardBase, UiccCardBase, card_detect +from pySim.commands import SimCardCommands +from pySim.exceptions import SwMatchError +from pySim.filesystem import CardApplication, CardModel +from pySim.profile import CardProfile +from pySim.runtime import RuntimeState +from pySim.ts_102_221 import CardProfileUICC +from pySim.utils import all_subclasses + +import pySim.euicc + +from .server import _tlog + + +class FastRuntimeState(RuntimeState): + """RuntimeState whose reset() restores software state (selects MF) instead + of power-cycling the card. Use hard_reset() for an explicit reset.""" + + def reset(self, cmd_app=None): + return self.soft_reset(cmd_app) + + def soft_reset(self, cmd_app=None): + for lchan_nr in list(self.lchan.keys()): + self.lchan[lchan_nr].scc.scp = None + if lchan_nr == 0: + continue + del self.lchan[lchan_nr] + self.adm_verified = False + try: + atr = self.card._scc.get_atr() + except Exception: + atr = None + if cmd_app: + cmd_app.lchan = self.lchan[0] + self.lchan[0].select('MF', cmd_app) + self.lchan[0].selected_adf = None + self.identity['ATR'] = atr + return atr + + def hard_reset(self, cmd_app=None): + return super().reset(cmd_app) + + +def pick_profile_no_reset(scc): + """Like CardProfile.pick(), but without a physical reset between + candidates. Each probe selects its own discriminating file, so a reset only + costs a reconnect without changing the outcome.""" + original_reset = scc.reset_card + scc.reset_card = lambda: None + try: + profiles = sorted(all_subclasses(CardProfile), key=operator.attrgetter('ORDER')) + for p in profiles: + if p.match_with_card(scc): + return p() + return None + finally: + scc.reset_card = original_reset + + +def init_card_fast(sl, skip_card_init=False, wait=True): + """Replacement for pySim.app.init_card() that avoids redundant resets. + + ``wait`` performs the single disconnect/connect of this init (explicit + equip passes True; startup already connects via wait_for_card).""" + scc = SimCardCommands(transport=sl) + if wait: + sl.wait_for_card(3) + if skip_card_init: + return None, CardBase(scc) + + generic_card = False + card = card_detect(scc) + if card is None: + card = SimCardBase(scc) + generic_card = True + + profile = pick_profile_no_reset(scc) + if profile is None: + return None, card + + if generic_card and isinstance(profile, CardProfileUICC): + card._adm_chv_num = 0x0A + + if isinstance(profile, CardProfileUICC): + for app_cls in all_subclasses(CardApplication): + if hasattr(app_cls, '_' + app_cls.__name__ + '__intermediate'): + continue + profile.add_application(app_cls()) + if generic_card: + card = UiccCardBase(scc) + + rs = FastRuntimeState(card, profile) + + CardModel.apply_matching_models(scc, rs) + + sl.set_sw_interpreter(rs) + + isd_r = rs.mf.applications.get(pySim.euicc.AID_ISD_R.lower(), None) + if isd_r: + rs.lchan[0].select_file(isd_r) + try: + rs.identity['EID'] = pySim.euicc.CardApplicationISDR.get_eid(scc) + except SwMatchError: + pass + finally: + rs.soft_reset() + + return rs, card + + +def do_equip_fast(app): + """Explicit equip: one real reconnect (wait_for_card) then reset-free init.""" + if app.rs and app.rs.profile: + for cmd_set in app.rs.profile.shell_cmdsets: + app.unregister_command_set(cmd_set) + rs, card = init_card_fast(app.sl, wait=True) + app.equip(card, rs) + + +def do_reset_fast(app): + """Explicit reset: always a physical card reset.""" + if app.rs is None: + app.card._scc.reset_card() + atr = app.card._scc.get_atr() + else: + atr = app.rs.hard_reset(app) + app.poutput('Card ATR: %s' % atr) + + +def install(app): + """Route the pySim-shell equip/reset commands through the fast paths.""" + def _do_equip(statement): + _tlog('do_equip_fast: start') + do_equip_fast(app) + _tlog('do_equip_fast: done') + + def _do_reset(statement): + _tlog('do_reset_fast: start') + do_reset_fast(app) + _tlog('do_reset_fast: done') + + app.do_equip = _do_equip + app.do_reset = _do_reset diff --git a/tests/test_fastinit.py b/tests/test_fastinit.py new file mode 100644 index 0000000..2f3a738 --- /dev/null +++ b/tests/test_fastinit.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Tests for the reset-free fast initialization helpers.""" + +import sys +import types +import unittest +from pathlib import Path + +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.exceptions import SwMatchError +from pySim.ts_102_221 import CardProfileUICC + +from pysim_otaman_server.fastinit import ( + FastRuntimeState, + do_reset_fast, + pick_profile_no_reset, +) + + +class FakeScc: + def __init__(self): + self.sel_ctrl = '0004' + self.cla_byte = '00' + self.resets = 0 + self.selected = [] + + def reset_card(self): + self.resets += 1 + + def select_file(self, fid): + self.selected.append(fid) + return ('', '9000') + + def select_adf(self, aid): + raise SwMatchError('6a82', '9000') + + +class TestPickProfileNoReset(unittest.TestCase): + def test_uicc_selected_without_any_reset(self): + scc = FakeScc() + profile = pick_profile_no_reset(scc) + self.assertIsInstance(profile, CardProfileUICC) + self.assertEqual(scc.resets, 0) + self.assertIn('3f00', scc.selected) + + def test_reset_card_restored_after_pick(self): + scc = FakeScc() + pick_profile_no_reset(scc) + scc.reset_card() + self.assertEqual(scc.resets, 1) + + +class FakeLchan: + def __init__(self): + self.scc = types.SimpleNamespace(scp=object()) + self.selected_adf = 'SOMETHING' + self.selected = [] + + def select(self, path, cmd_app=None): + self.selected.append(path) + + +class TestFastRuntimeStateSoftReset(unittest.TestCase): + def make_rs(self): + rs = FastRuntimeState.__new__(FastRuntimeState) + rs.lchan = {0: FakeLchan(), 1: FakeLchan()} + rs.adm_verified = True + rs.card = types.SimpleNamespace(_scc=types.SimpleNamespace(get_atr=lambda: 'AABB')) + rs.identity = {} + return rs + + def test_soft_reset_selects_mf_without_physical_reset(self): + rs = self.make_rs() + atr = rs.soft_reset() + self.assertEqual(atr, 'AABB') + self.assertEqual(rs.identity['ATR'], 'AABB') + self.assertEqual(rs.lchan[0].selected, ['MF']) + self.assertIsNone(rs.lchan[0].selected_adf) + self.assertFalse(rs.adm_verified) + self.assertNotIn(1, rs.lchan) + + def test_reset_is_soft(self): + rs = self.make_rs() + rs.card = types.SimpleNamespace(_scc=types.SimpleNamespace(get_atr=lambda: 'EEFF')) + self.assertEqual(rs.reset(), 'EEFF') + self.assertEqual(rs.lchan[0].selected, ['MF']) + + +class FakeCardScc: + def __init__(self): + self.resets = 0 + + def reset_card(self): + self.resets += 1 + return 'ATR' + + def get_atr(self): + return 'AABB' + + +class TestDoResetFast(unittest.TestCase): + def test_explicit_reset_is_physical(self): + scc = FakeCardScc() + out = [] + app = types.SimpleNamespace(rs=None, card=types.SimpleNamespace(_scc=scc), poutput=out.append) + do_reset_fast(app) + self.assertEqual(scc.resets, 1) + self.assertEqual(out, ['Card ATR: AABB']) + + def test_explicit_reset_uses_hard_reset_with_runtime_state(self): + calls = [] + rs = types.SimpleNamespace(hard_reset=lambda cmd_app=None: calls.append(cmd_app) or 'CCDD') + out = [] + app = types.SimpleNamespace(rs=rs, card=None, poutput=out.append) + do_reset_fast(app) + self.assertEqual(calls, [app]) + self.assertEqual(out, ['Card ATR: CCDD']) + + +if __name__ == '__main__': + unittest.main()