diff --git a/README.md b/README.md index 0f384b2..68fb7d9 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ cd otaman ./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 diff --git a/README_RUS.md b/README_RUS.md index d718f36..e1c7545 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -17,7 +17,7 @@ cd otaman ./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** в интерфейсе. ## Сборка diff --git a/frontend/help-ru.html b/frontend/help-ru.html index a280439..dcc4ac7 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -517,7 +517,7 @@ start.bat # запускает сервер (PWA + API)
-d /dev/ttyUSB0 (Linux)Если карта отсутствует, вкладка «Картридер» показывает «Карта не обнаружена. Вставьте карту и нажмите Подключить карту».
+Если карта отсутствует, вкладка «Картридер» показывает «Карта не обнаружена. Вставьте карту и нажмите Подключить карту». Вставьте карту — сервер инициализирует её автоматически (auto-equip включён по умолчанию) — либо нажмите Equip card. Сам сервер запускается и с пустым картридером: отсутствие карты — нормальное состояние, а не ошибка.
# Создать и активировать venv diff --git a/frontend/help.html b/frontend/help.html index c77866d..3f9b010 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -517,7 +517,7 @@ start.bat # starts the server (serves PWA + API)
-d /dev/ttyUSB0 (Linux)If no card is present, the Card reader tab shows “No card detected”. Insert the card and click Equip card to initialize it.
+If no card is present, the Card reader tab shows “No card detected”. Insert the card — the server auto-equips it (auto-equip is on by default) — or click Equip card. The server itself starts fine with an empty reader; the absence of a card is a normal state, not an error.
# Create and activate a venv diff --git a/frontend/index.html b/frontend/index.html index 6cc659e..f4270c7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@-OTAMan SIM OTA with a Human Face v2.2.16
+OTAMan SIM OTA with a Human Face v2.2.17
● diff --git a/frontend/sw.js b/frontend/sw.js index 44f7acd..dc974c8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v183'; +const CACHE = 'otaman-v184'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 94d196b..cbcfa76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.2.16" +version = "2.2.17" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/__main__.py b/pysim_otaman_server/__main__.py index 235fa9b..6a1fe76 100644 --- a/pysim_otaman_server/__main__.py +++ b/pysim_otaman_server/__main__.py @@ -7,12 +7,13 @@ import traceback from http.server import HTTPServer from pySim.card_handler import CardHandler from pySim.commands import SimCardCommands +from pySim.exceptions import NoCardError 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, _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 @@ -105,22 +106,49 @@ def main(): if opts.fast_init: try: 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: print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr) 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: - sl.wait_for_card(3) - rs, card = mod.init_card(sl, opts.skip_card_init) + try: + 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)) - 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: print("Warning: reader/card initialization failed:", file=sys.stderr) traceback.print_exc() ch = CardHandler(sl) if sl else None t_phase = time.time() 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: print("Warning: PysimApp creation failed:", file=sys.stderr) traceback.print_exc() @@ -153,8 +181,6 @@ def main(): _tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000)) except Exception: 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: # PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout) # and drops the root level to INFO. Re-route pysim's own APDU trace logging diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 769129a..dbe5019 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter 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 @@ -59,6 +59,51 @@ def _tlog(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_TIME_COLLECT = False diff --git a/tests/test_startup_cardless.py b/tests/test_startup_cardless.py new file mode 100644 index 0000000..346ecf6 --- /dev/null +++ b/tests/test_startup_cardless.py @@ -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()