server+ui: detect card removal passively and reflect it within 2s

The UI only noticed a removed card when some user action ran a real card
command (e.g. Check status); /api/status is a cached-state read that kept
returning the old card, and _handle_card_disconnect() did not clear
app.card/rs.

- start_card_monitor() registers a pyscard CardObserver for our reader;
  it only polls SCardGetStatusChange (no APDU, no connection, no extra
  process), and on removal sets server.card_present=False and calls
  _handle_card_disconnect() under _CARD_LOCK
- /api/status now exposes connected (session usable) and card_present
  (physically inserted) and masks card/profile/atr/selection when not
  connected; _CARD_CONNECTED is initialized from card presence instead of
  being unconditionally True
- the 2s UI poll includes /api/status; on disconnect it switches to the
  existing 'No card detected. Insert card and click Equip' state, or the
  new 'Card inserted — press Equip' hint when the card is back; the old
  _hadData heuristic is gone

Tests for the observer (filtering, removal, insertion) and the UI state
transitions. SW cache v90 -> v91.
This commit is contained in:
2026-09-12 13:21:33 +03:00
parent 7288c22830
commit 57eb412b6d
6 changed files with 228 additions and 16 deletions
+6 -3
View File
@@ -12,7 +12,7 @@ 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
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
_server_start = 0
@@ -93,6 +93,8 @@ def main():
t_phase = time.time()
sl = mod.init_reader(opts, **kwargs)
_tlog('init_reader: %.0fms' % ((time.time() - t_phase) * 1000))
if getattr(sl, '_reader', None) is not None:
start_card_monitor(str(sl._reader))
scc = SimCardCommands(sl)
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
scc._tp.proactive_handler = _DefaultProactiveHandler()
@@ -179,10 +181,11 @@ def main():
server.event_list = event_list
server.menu_active = False
server.stk_pending = None
# Set server reference for polling timer and mark card as connected
server.card_present = card is not None
# Set server reference for polling timer and mark the card session state
import pysim_otaman_server.server
pysim_otaman_server.server._server_ref = server
pysim_otaman_server.server._CARD_CONNECTED = True
pysim_otaman_server.server._CARD_CONNECTED = card is not None
if opts.poll_interval is not None:
pysim_otaman_server.server._set_poll_interval(opts.poll_interval)
# Auto-enable polling if card initialized successfully (unless interval is 0)
+55 -1
View File
@@ -11,6 +11,7 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
from io import StringIO
from pySim.transport import ApduTracer, ProactiveHandler
from pySim.cards import UiccCardBase
from smartcard.CardMonitoring import CardMonitor, CardObserver
import gsm0338 # registers 'gsm03.38' codec
from construct import GreedyBytes
@@ -1034,6 +1035,48 @@ def _handle_card_disconnect():
_reset_proactive_log()
class _CardPresenceObserver(CardObserver):
"""Passive PC/SC presence watcher: pyscard's CardMonitor only polls
SCardGetStatusChange (no connection, no APDUs), so it can never interleave
with our APDU traffic. We only update flags and tear down card state."""
def __init__(self, reader_name):
self.reader_name = reader_name
def update(self, observable, handlers):
addedcards, removedcards = handlers
try:
for card in removedcards:
if str(getattr(card, 'reader', '')) == self.reader_name:
sys.stderr.write('CARD-WATCH: card removed from %s\n' % self.reader_name)
with _CARD_LOCK:
if _server_ref:
_server_ref.card_present = False
_handle_card_disconnect()
for card in addedcards:
if str(getattr(card, 'reader', '')) == self.reader_name:
sys.stderr.write('CARD-WATCH: card inserted into %s (press Equip)\n' % self.reader_name)
with _CARD_LOCK:
if _server_ref:
_server_ref.card_present = True
except Exception as e:
sys.stderr.write('CARD-WATCH error: %s\n' % e)
_card_presence_observer = None
def start_card_monitor(reader_name):
"""Start the process-wide pyscard monitor (one daemon thread, no process)
and register our reader's presence observer."""
global _card_presence_observer
if not reader_name:
return None
if _card_presence_observer is None:
_card_presence_observer = _CardPresenceObserver(reader_name)
CardMonitor().addObserver(_card_presence_observer)
return _card_presence_observer
def _init_proactive_session():
global _PROACTIVE_SESSION_START
_PROACTIVE_SESSION_START = time.time()
@@ -1526,9 +1569,19 @@ class PysimHandler(BaseHTTPRequestHandler):
lchan = rs.lchan[0] if rs else None
cur_file = lchan.selected_file if lchan else None
scc = app.card._scc if app and app.card else None
card = app.card if app else None
connected = bool(_CARD_CONNECTED and card is not None)
if not connected:
rs = None
lchan = None
cur_file = None
scc = None
card = None
data = {
'reader': str(self.server.sl) if self.server.sl else None,
'card': app.card.name if app and app.card else None,
'connected': connected,
'card_present': bool(getattr(self.server, 'card_present', False)),
'card': card.name if card else None,
'profile': str(rs.profile) if rs and rs.profile else None,
'app_ready': app is not None,
'adm_verified': rs.adm_verified if rs else False,
@@ -1672,6 +1725,7 @@ class PysimHandler(BaseHTTPRequestHandler):
self.server.scc = self.server.app.card._scc
self.server.scc.cat_cla = '80' if isinstance(self.server.card, UiccCardBase) else 'a0'
_CARD_CONNECTED = True
self.server.card_present = True
_poll_enable()
sm, el = _send_terminal_profile(self.server.scc, self.server.terminal_profile)
self.server.sim_menu = sm