startup: cardless start is a normal state, not an init failure (v2.2.17)

- __main__ catches NoCardError from the startup init (fast and stock paths,
  and the stock fallback) and reports it with one line - 'INIT: no card in
  the reader - server ready; insert a card or press Equip' - without a
  traceback and without the stock-pysim fallback. Any other init failure
  still falls back with its traceback.
- The cardless PysimApp construction no longer prints pySim's 'Waiting for
  card...' or pySim-shell's 'pySim-shell not equipped!': a new _LineFilter
  (server.py) drops whole matching lines while proxying encoding/isatty/
  fileno (the cmd2 Rich console probes them on its stdout).
- Removed the now-duplicate 'INIT: card not initialized' tail line and the
  cat_cla assignment when no card was detected (scc exists but is cardless).
- Tests: tests/test_startup_cardless.py (line filter semantics incl. partial
  lines, one-line cardless report, no fallback call, real failures keep the
  fallback + traceback, muted PysimApp construction). Live-verified against
  an empty SCR 3310: two init lines, no traceback, API serves 2.2.17.
- Docs: READMEs quick start, help EN+RU (Card reader), AGENTS (equip flow).
  SW cache otaman-v184.
This commit is contained in:
2026-09-18 23:41:12 +03:00
parent 6d5d23487a
commit 235388a5d0
10 changed files with 247 additions and 16 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ cd otaman
./start.sh # or start.bat — starts the server (it serves the PWA too) ./start.sh # or start.bat — starts the server (it serves the PWA too)
``` ```
Then open http://127.0.0.1:8080 — the UI and API share one origin, so no CORS or browser-permission setup is needed. Then open http://127.0.0.1:8080 — the UI and API share one origin, so no CORS or browser-permission setup is needed. The server starts fine with an empty reader (no card is a normal state): insert a card and it is initialized automatically (auto-equip), or press **Equip card** in the UI.
## Build ## Build
+1 -1
View File
@@ -17,7 +17,7 @@ cd otaman
./start.sh # или start.bat — запускает сервер (он же раздаёт PWA) ./start.sh # или start.bat — запускает сервер (он же раздаёт PWA)
``` ```
Затем откройте http://127.0.0.1:8080 — интерфейс и API на одном origin, поэтому CORS и разрешения браузера не нужны. Затем откройте http://127.0.0.1:8080 — интерфейс и API на одном origin, поэтому CORS и разрешения браузера не нужны. Сервер запускается и с пустым картридером (отсутствие карты — нормальное состояние): вставьте карту, и она инициализируется автоматически (auto-equip), либо нажмите **Equip card** в интерфейсе.
## Сборка ## Сборка
+1 -1
View File
@@ -517,7 +517,7 @@ start.bat # запускает сервер (PWA + API)</pre>
<li><strong>Резерв сервера</strong> — при запуске без аргументов ридера сервер сам опрашивает PC/SC-ридер при старте (3 попытки с интервалом 2&nbsp;с)</li> <li><strong>Резерв сервера</strong> — при запуске без аргументов ридера сервер сам опрашивает PC/SC-ридер при старте (3 попытки с интервалом 2&nbsp;с)</li>
<li><strong>Serial-ридеры</strong> — запустите сервер вручную с <code class="font-mono text-sm">-d /dev/ttyUSB0</code> (Linux)</li> <li><strong>Serial-ридеры</strong> — запустите сервер вручную с <code class="font-mono text-sm">-d /dev/ttyUSB0</code> (Linux)</li>
</ul> </ul>
<p class="text-sm mb-3">Если карта отсутствует, вкладка &laquo;Картридер&raquo; показывает &laquo;Карта не обнаружена. Вставьте карту и нажмите Подключить карту&raquo;.</p> <p class="text-sm mb-3">Если карта отсутствует, вкладка &laquo;Картридер&raquo; показывает &laquo;Карта не обнаружена. Вставьте карту и нажмите Подключить карту&raquo;. Вставьте карту — сервер инициализирует её автоматически (auto-equip включён по умолчанию) — либо нажмите <strong>Equip card</strong>. Сам сервер запускается и с пустым картридером: отсутствие карты — нормальное состояние, а не ошибка.</p>
<h3 id="manual-install" class="text-lg font-medium mb-2">9.6 Ручная установка</h3> <h3 id="manual-install" class="text-lg font-medium mb-2">9.6 Ручная установка</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Создать и активировать venv <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Создать и активировать venv
+1 -1
View File
@@ -517,7 +517,7 @@ start.bat # starts the server (serves PWA + API)</pre>
<li><strong>Server fallback</strong> — started without reader arguments, the server itself probes for a PC/SC reader at startup (3 attempts, 2&nbsp;s apart)</li> <li><strong>Server fallback</strong> — started without reader arguments, the server itself probes for a PC/SC reader at startup (3 attempts, 2&nbsp;s apart)</li>
<li><strong>Serial readers</strong> — start the server manually with <code class="font-mono text-sm">-d /dev/ttyUSB0</code> (Linux)</li> <li><strong>Serial readers</strong> — start the server manually with <code class="font-mono text-sm">-d /dev/ttyUSB0</code> (Linux)</li>
</ul> </ul>
<p class="text-sm mb-3">If no card is present, the Card reader tab shows &ldquo;No card detected&rdquo;. Insert the card and click <strong>Equip card</strong> to initialize it.</p> <p class="text-sm mb-3">If no card is present, the Card reader tab shows &ldquo;No card detected&rdquo;. Insert the card — the server auto-equips it (auto-equip is on by default) — or click <strong>Equip card</strong>. The server itself starts fine with an empty reader; the absence of a card is a normal state, not an error.</p>
<h3 id="manual-install" class="text-lg font-medium mb-2">9.6 Manual installation</h3> <h3 id="manual-install" class="text-lg font-medium mb-2">9.6 Manual installation</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Create and activate a venv <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Create and activate a venv
+1 -1
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2"> <div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.2.16</span></h1> <h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.2.17</span></h1>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting..."> <span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span> <span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v183'; const CACHE = 'otaman-v184';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "2.2.16" version = "2.2.17"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+34 -8
View File
@@ -7,12 +7,13 @@ import traceback
from http.server import HTTPServer from http.server import HTTPServer
from pySim.card_handler import CardHandler from pySim.card_handler import CardHandler
from pySim.commands import SimCardCommands from pySim.commands import SimCardCommands
from pySim.exceptions import NoCardError
from pySim.log import PySimLogger from pySim.log import PySimLogger
from pySim.cards import UiccCardBase from pySim.cards import UiccCardBase
from .shell import load_pysim_app from .shell import load_pysim_app
from . import fastinit 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, _read_iccid 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, _LineFilter
_server_start = 0 _server_start = 0
@@ -105,22 +106,49 @@ def main():
if opts.fast_init: if opts.fast_init:
try: try:
rs, card = fastinit.init_card_fast(sl, opts.skip_card_init, wait=True) rs, card = fastinit.init_card_fast(sl, opts.skip_card_init, wait=True)
except NoCardError:
# Normal cardless start: there was no card in the reader (the
# 3s wait timed out). The presence monitor auto-equips once a
# card appears; nothing to recover, no traceback.
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
except Exception: except Exception:
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr) print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
traceback.print_exc() traceback.print_exc()
rs, card = mod.init_card(sl, opts.skip_card_init) try:
rs, card = mod.init_card(sl, opts.skip_card_init)
except NoCardError:
# The fallback retried the cardless wait; still a normal
# cardless start, not an initialization failure.
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
else: else:
sl.wait_for_card(3) try:
rs, card = mod.init_card(sl, opts.skip_card_init) sl.wait_for_card(3)
rs, card = mod.init_card(sl, opts.skip_card_init)
except NoCardError:
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000)) _tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0' if card is not None:
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
except Exception: except Exception:
print("Warning: reader/card initialization failed:", file=sys.stderr) print("Warning: reader/card initialization failed:", file=sys.stderr)
traceback.print_exc() traceback.print_exc()
ch = CardHandler(sl) if sl else None ch = CardHandler(sl) if sl else None
t_phase = time.time() t_phase = time.time()
try: try:
app = mod.PysimApp(verbose=opts.verbose, card=card, rs=rs, sl=sl, ch=ch) if card is not None:
app = mod.PysimApp(verbose=opts.verbose, card=card, rs=rs, sl=sl, ch=ch)
else:
# Cardless start: pySim logs 'Waiting for card...' (its own retry
# path) and pySim-shell prints 'pySim-shell not equipped!'; we
# report both cases with our own single line above. A PysimApp
# without a card would also retry the cardless wait, so install
# the filter before constructing it and drop the two internals.
saved_stdout = sys.stdout
sys.stdout = _LineFilter(saved_stdout, ('Waiting for card...', 'pySim-shell not equipped!'))
try:
app = mod.PysimApp(verbose=opts.verbose, card=None, rs=None, sl=sl, ch=ch)
finally:
sys.stdout = saved_stdout
except Exception: except Exception:
print("Warning: PysimApp creation failed:", file=sys.stderr) print("Warning: PysimApp creation failed:", file=sys.stderr)
traceback.print_exc() traceback.print_exc()
@@ -153,8 +181,6 @@ def main():
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000)) _tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
except Exception: except Exception:
traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stderr)
elif scc is not None:
sys.stderr.write('INIT: card not initialized — use Equip once the card is readable\n')
if app is not None and opts.apdu_trace: if app is not None and opts.apdu_trace:
# PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout) # PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
# and drops the root level to INFO. Re-route pysim's own APDU trace logging # and drops the root level to INFO. Re-route pysim's own APDU trace logging
+46 -1
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '2.2.16' VERSION = '2.2.17'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -59,6 +59,51 @@ def _tlog(msg):
sys.stderr.write('TIMING [+%7.3fs] %s\n' % (time.time() - _T0, msg)) sys.stderr.write('TIMING [+%7.3fs] %s\n' % (time.time() - _T0, msg))
class _LineFilter:
"""Text stream that drops whole lines matching any of the given substrings
and forwards everything else to the wrapped stream. Used to mute pySim/
pySim-shell internals we report ourselves (e.g. 'Waiting for card...' or
'pySim-shell not equipped!'). Lines arrive through write(); the dropped
lines are written as one call each by pySim's print/logger and by cmd2's
Rich console, so a substring check per line is reliable here."""
def __init__(self, stream, patterns):
self._stream = stream
self._patterns = list(patterns)
self._pending = ''
def write(self, text):
text = self._pending + text
self._pending = ''
if not text:
return
if not text.endswith('\n'):
# Keep a trailing partial line so a match is not missed when the
# line is completed by the next write().
nl = text.rfind('\n')
if nl < 0:
self._pending = text
return
self._pending = text[nl + 1:]
text = text[:nl + 1]
for line in text.splitlines(True):
if not any(p in line for p in self._patterns):
self._stream.write(line)
self._stream.flush()
def flush(self):
if self._pending:
line, self._pending = self._pending, ''
if not any(p in line for p in self._patterns):
self._stream.write(line)
self._stream.flush()
def __getattr__(self, name):
# encoding/isatty/fileno: the Rich console probes these on the stream
# (cmd2's self.stdout), so they must keep working.
return getattr(self._stream, name)
_APDU_TIMES = [] _APDU_TIMES = []
_APDU_TIME_COLLECT = False _APDU_TIME_COLLECT = False
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Tests for the cardless-startup handling in __main__.
A reader without a card is a normal state, not an initialization failure:
pySim raises NoCardError after its wait, which must be reported with a single
line and no traceback, and the pySim-internal 'Waiting for card...' /
'pySim-shell not equipped!' lines must not reach the console. Any other
failure keeps the stock-pysim fallback and its traceback.
"""
import io
import sys
import unittest
from pathlib import Path
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.exceptions import NoCardError, SwMatchError
from pysim_otaman_server import __main__ as srv_main
from pysim_otaman_server.server import _LineFilter
CARDLESS_LINE = 'INIT: no card in the reader — server ready; insert a card or press Equip'
class LineFilterTest(unittest.TestCase):
def test_drops_matching_lines_and_keeps_the_rest(self):
out = io.StringIO()
f = _LineFilter(out, ('Waiting for card...', 'pySim-shell not equipped!'))
f.write('Waiting for card...\n')
f.write('pySim-shell not equipped!\n')
f.write('INIT: keep me\n')
f.flush()
self.assertEqual(out.getvalue(), 'INIT: keep me\n')
def test_line_completed_by_the_next_write_is_still_dropped(self):
out = io.StringIO()
f = _LineFilter(out, ('Waiting for card...',))
f.write('Waiting for card...')
self.assertEqual(out.getvalue(), '')
f.write('\n')
f.write('ok\n')
self.assertEqual(out.getvalue(), 'ok\n')
def test_partial_line_is_flushed_when_it_does_not_match(self):
out = io.StringIO()
f = _LineFilter(out, ('Waiting for card...',))
f.write('partial')
f.flush()
self.assertEqual(out.getvalue(), 'partial')
def test_stream_attributes_are_proxied(self):
out = io.StringIO()
f = _LineFilter(out, ('x',))
self.assertEqual(f.encoding, out.encoding)
self.assertIs(f.isatty(), out.isatty())
class StartupNoCardTest(unittest.TestCase):
"""Exercise the init branch of main() without a reader or HTTP server."""
def _run(self, init_side_effect, argv=()):
fake_app = mock.Mock()
fake_mod = mock.Mock()
fake_mod.option_parser = _parser()
fake_mod.PysimApp.return_value = fake_app
fake_mod.init_card = mock.Mock(side_effect=init_side_effect)
err = io.StringIO()
out = io.StringIO()
with mock.patch.object(srv_main, 'load_pysim_app', return_value=fake_mod), \
mock.patch.object(srv_main, 'fastinit') as fake_fastinit, \
mock.patch.object(srv_main, 'start_card_monitor'), \
mock.patch.object(srv_main, '_read_iccid', return_value=None), \
mock.patch.object(srv_main, '_send_terminal_profile', return_value=(None, None)), \
mock.patch.object(srv_main, '_send_status', return_value=('', '9000')), \
mock.patch.object(srv_main, 'HTTPServer') as fake_http, \
mock.patch.object(srv_main, 'CardHandler'), \
mock.patch('sys.stderr', err), mock.patch('sys.stdout', out):
fake_fastinit.init_card_fast.side_effect = init_side_effect
fake_fastinit.install = mock.Mock()
fake_http.return_value.serve_forever.side_effect = KeyboardInterrupt
try:
with mock.patch('sys.argv', ['pysim-otaman-server']):
srv_main.main()
except (KeyboardInterrupt, SystemExit):
pass
return err.getvalue(), out.getvalue(), fake_fastinit, fake_mod
def test_no_card_reports_one_line_without_traceback(self):
err, out, fake_fastinit, fake_mod = self._run(NoCardError())
self.assertIn(CARDLESS_LINE, err)
self.assertNotIn('Traceback', err)
self.assertNotIn('falling back to pysim init', err)
# no second (stock) init attempt after the cardless fast init
fake_mod.init_card.assert_not_called()
def test_real_failure_still_falls_back_with_traceback(self):
def boom(*a, **k):
raise SwMatchError('6a82', '9000')
err, out, fake_fastinit, fake_mod = self._run(boom)
self.assertIn('falling back to pysim init', err)
self.assertIn('Traceback', err)
self.assertIn('SwMatchError', err)
self.assertTrue(fake_mod.init_card.called)
def test_cardless_app_construction_mutes_pysim_internals(self):
# PysimApp is constructed without a card: the filter must be active
# around the call, so lines written through sys.stdout are dropped.
seen = {}
def make_app(**kwargs):
seen['kwargs'] = kwargs
sys.stdout.write('Waiting for card...\n')
sys.stdout.write('pySim-shell not equipped!\n')
sys.stdout.write('INIT: something else\n')
return mock.Mock()
fake_mod = mock.Mock()
fake_mod.option_parser = _parser()
fake_mod.PysimApp.side_effect = make_app
fake_mod.init_card = mock.Mock()
err, out = io.StringIO(), io.StringIO()
with mock.patch.object(srv_main, 'load_pysim_app', return_value=fake_mod), \
mock.patch.object(srv_main, 'fastinit') as fake_fastinit, \
mock.patch.object(srv_main, 'start_card_monitor'), \
mock.patch.object(srv_main, 'HTTPServer') as fake_http, \
mock.patch.object(srv_main, 'CardHandler'), \
mock.patch('sys.stderr', err), mock.patch('sys.stdout', out):
fake_fastinit.init_card_fast.side_effect = NoCardError()
fake_fastinit.install = mock.Mock()
fake_http.return_value.serve_forever.side_effect = KeyboardInterrupt
try:
with mock.patch('sys.argv', ['pysim-otaman-server']):
srv_main.main()
except (KeyboardInterrupt, SystemExit):
pass
self.assertIsNone(seen['kwargs']['card'])
self.assertIsNone(seen['kwargs']['rs'])
self.assertNotIn('Waiting for card...', out.getvalue())
self.assertNotIn('pySim-shell not equipped!', out.getvalue())
self.assertIn('INIT: something else', out.getvalue())
def _parser():
import argparse
p = argparse.ArgumentParser()
p.add_argument('-p', '--pcsc-dev', type=int, default=0)
p.add_argument('--pcsc-regex', default=None)
p.add_argument('--apdu-trace', action='store_true', default=False)
p.add_argument('--verbose', action='store_true', default=False)
return p
if __name__ == '__main__':
unittest.main()