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()