server: guarantee a TERMINAL RESPONSE for paused proactive commands

Every FETCHed proactive command must be answered, otherwise the card is
left in an unfinished session and stops issuing commands (e.g. it will
not deliver a PoR for SEND SM). The menu handlers now share
_menu_send_response, and a server-side watchdog (_arm_menu_timeout /
_menu_timeout_fire, --menu-timeout, default 60s, 0 disables) sends the
timeout result (0x12) when the user never answers. The timer is armed
while a command is pending and cancelled on any response, equip, rescue
and card disconnect.

Also fixes docs/api.md, which had back (0x11) and timeout (0x12) codes
swapped. Tests for arm/cancel/clamping and the flat timeout TR. SW cache
v89 -> v90.
This commit is contained in:
2026-09-12 12:31:22 +03:00
parent 6c57ff4fd5
commit b50be83da4
9 changed files with 229 additions and 94 deletions
+1
View File
@@ -628,6 +628,7 @@ pysim-otaman-server --http-port 8080
| `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) | | `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) |
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s; `0` disables polling) | | `--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) | | `--fast-init` | Init/equip without redundant card resets (only explicit equip/reset resets the card) |
| `--menu-timeout` | Auto-answer a paused STK command with a timeout TERMINAL RESPONSE (default 60s; `0` disables) |
| `--timing` | Log phase durations, card resets and APDU counters with elapsed timestamps | | `--timing` | Log phase durations, card resets and APDU counters with elapsed timestamps |
### Troubleshooting ### Troubleshooting
+1
View File
@@ -608,6 +608,7 @@ pysim-otaman-server --http-port 8080
| `--log-requests` | Лог запросов/ответов в stderr | | `--log-requests` | Лог запросов/ответов в stderr |
| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с; `0` отключает опрос) | | `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с; `0` отключает опрос) |
| `--fast-init` | Инициализация/equip без лишних сбросов карты (сброс только по явным equip/reset) | | `--fast-init` | Инициализация/equip без лишних сбросов карты (сброс только по явным equip/reset) |
| `--menu-timeout` | Автоответ timeout TERMINAL RESPONSE на приостановленную STK-команду (по умолчанию 60с; `0` отключает) |
| `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени | | `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени |
### Устранение неполадок ### Устранение неполадок
+6 -4
View File
@@ -245,7 +245,9 @@ or
### `POST /api/menu-respond` ### `POST /api/menu-respond`
Sends `TERMINAL RESPONSE` to the current proactive command with the given result Sends `TERMINAL RESPONSE` to the current proactive command with the given result
code. Continues the proactive chain if the card responds with `91XX`. code. Continues the proactive chain if the card responds with `91XX`. If no
response arrives within `--menu-timeout` seconds (default 60, `0` disables), the
server watchdog sends the `timeout` result itself.
```json ```json
{"result": "ok", "item_id": 1} {"result": "ok", "item_id": 1}
@@ -254,9 +256,9 @@ code. Continues the proactive chain if the card responds with `91XX`.
| `result` | TERMINAL RESPONSE code | Meaning | | `result` | TERMINAL RESPONSE code | Meaning |
|---|---|---| |---|---|---|
| `ok` | `0x00` | Command performed successfully | | `ok` | `0x00` | Command performed successfully |
| `back` | `0x12` | Backward move requested | | `cancel` | `0x10` | Proactive session terminated by the user |
| `cancel` | `0x10` | Proactive session terminated | | `back` | `0x11` | Backward move in the proactive session requested by the user |
| `timeout` | `0x11` | No response from user | | `timeout` | `0x12` | No response from the user |
### `GET /api/stk-status` ### `GET /api/stk-status`
+1 -1
View File
@@ -336,7 +336,7 @@
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit: меню STK, подписанные события, журнал проактивных команд, словарь данных PROVIDE LOCAL INFORMATION и опрос STATUS.</p> <p class="text-sm mb-3">Работа с сессией Card Application Toolkit: меню STK, подписанные события, журнал проактивных команд, словарь данных PROVIDE LOCAL INFORMATION и опрос STATUS.</p>
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 Меню STK</h4> <h4 id="stk-menu" class="font-medium mb-1">5.5.1 Меню STK</h4>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления.</p> <p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает).</p>
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Подписанные события (SET UP EVENT LIST)</h4> <h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Подписанные события (SET UP EVENT LIST)</h4>
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p> <p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
+1 -1
View File
@@ -336,7 +336,7 @@
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session: the STK menu, subscribed events, the proactive command log, the PROVIDE LOCAL INFORMATION data dictionary, and STATUS polling.</p> <p class="text-sm mb-3">Interacts with the Card Application Toolkit session: the STK menu, subscribed events, the proactive command log, the PROVIDE LOCAL INFORMATION data dictionary, and STATUS polling.</p>
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 STK menu</h4> <h4 id="stk-menu" class="font-medium mb-1">5.5.1 STK menu</h4>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened.</p> <p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables).</p>
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Subscribed events (SET UP EVENT LIST)</h4> <h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Subscribed events (SET UP EVENT LIST)</h4>
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p> <p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v89'; const CACHE = 'otaman-v90';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+5 -1
View File
@@ -12,7 +12,7 @@ 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 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
_server_start = 0 _server_start = 0
@@ -52,11 +52,15 @@ def main():
help='Log phase durations, card resets and APDU counters with elapsed timestamps') help='Log phase durations, card resets and APDU counters with elapsed timestamps')
parser.add_argument('--fast-init', action='store_true', default=False, parser.add_argument('--fast-init', action='store_true', default=False,
help='Init/equip without redundant card resets (reset only on explicit equip/reset)') help='Init/equip without redundant card resets (reset only on explicit equip/reset)')
parser.add_argument('--menu-timeout', type=int, default=60, metavar='SECS',
help='Auto-send a timeout TERMINAL RESPONSE if a paused STK command is not answered (default: 60, 0 disables)')
opts = parser.parse_args() opts = parser.parse_args()
opts.skip_card_init = opts.no_card_init opts.skip_card_init = opts.no_card_init
if opts.timing: if opts.timing:
_timing_on() _timing_on()
if opts.menu_timeout is not None:
_set_menu_timeout(opts.menu_timeout)
sl = None sl = None
scc = None scc = None
card = None card = None
+124 -86
View File
@@ -688,6 +688,45 @@ def _poll_enable():
_POLL_ENABLED = True _POLL_ENABLED = True
_reset_poll_timer() _reset_poll_timer()
_MENU_TIMEOUT = 60
_MENU_TIMER = None
def _set_menu_timeout(seconds):
global _MENU_TIMEOUT
_MENU_TIMEOUT = max(0, min(3600, int(seconds)))
def _cancel_menu_timeout():
global _MENU_TIMER
if _MENU_TIMER is not None:
_MENU_TIMER.cancel()
_MENU_TIMER = None
def _arm_menu_timeout():
"""Watchdog: a paused proactive command must always get a TERMINAL RESPONSE,
even if the user never answers. Fires 0x12 ('timeout') via the same path as
an explicit user response."""
global _MENU_TIMER
_cancel_menu_timeout()
if _MENU_TIMEOUT <= 0:
return
_MENU_TIMER = threading.Timer(_MENU_TIMEOUT, _menu_timeout_fire)
_MENU_TIMER.daemon = True
_MENU_TIMER.start()
def _menu_timeout_fire():
global _MENU_TIMER
_MENU_TIMER = None
with _CARD_LOCK:
server = _server_ref
if not server or not getattr(server, 'stk_pending', None) or not getattr(server, 'scc', None):
return
pd = server.stk_pending
sys.stderr.write('MENU-TIMEOUT: auto TR timeout (cmd=%02x type=%02x)\n' % (pd['cmd_num'], pd['cmd_type']))
try:
_menu_send_response(server, 'timeout', None)
except Exception as e:
sys.stderr.write('MENU-TIMEOUT error: %s\n' % e)
def _poll_disable(): def _poll_disable():
global _POLL_ENABLED, _POLL_TIMER global _POLL_ENABLED, _POLL_TIMER
_POLL_ENABLED = False _POLL_ENABLED = False
@@ -983,6 +1022,7 @@ def _record_tr(entry, tr_tlv, tr_sw=None):
def _handle_card_disconnect(): def _handle_card_disconnect():
global _CARD_CONNECTED global _CARD_CONNECTED
_poll_disable() _poll_disable()
_cancel_menu_timeout()
_CARD_CONNECTED = False _CARD_CONNECTED = False
if _server_ref: if _server_ref:
_server_ref.card = None _server_ref.card = None
@@ -1332,6 +1372,79 @@ def _send_terminal_profile(scc, tp_hex):
return sim_menu, event_list return sim_menu, event_list
def _make_menu_fetch_handler(server, resp):
"""on_fetch callback for the menu chain: pauses on user-interactive commands
and stores the pending command so a TERMINAL RESPONSE can be sent later."""
def _on_menu_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst):
if cmd_type == 0x21:
text = _parse_display_text(raw) if raw else None
if text:
server.stk_pending = {'type': 'display_text',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text}
resp.update(type='display_text', text=text)
return 'pause'
elif cmd_type == 0x24:
items = _parse_select_item(raw) if raw else []
server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
elif cmd_type == 0x25:
items = _parse_setup_menu_items(raw) if raw else []
server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
return _on_menu_fetch
def _menu_send_response(server, result, item_id=None):
"""Send the pending command's TERMINAL RESPONSE and continue the chain.
Shared by /api/menu-respond and the user-input timeout watchdog. Returns
(payload, http_status)."""
if not server.stk_pending:
return {'error': 'no pending command'}, 400
scc = server.scc
RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12}
gr = RESULT_MAP.get(result, 0x00)
pd = server.stk_pending
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
tr_data = cd + di
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
tr_data += bytes([0x90, 0x01, item_id])
tr_data += bytes([0x83, 0x02, gr, 0x00])
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
tr_rv = scc._tp.send_apdu(tr_hex)
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
for entry in reversed(_PROACTIVE_LOG):
if (entry.get('cmd_num') == pd['cmd_num']
and entry.get('type_hex') == '%02x' % pd['cmd_type']
and 'tr_hex' not in entry):
_record_tr(entry, tr_data, tr_rv[1])
break
sw = tr_rv[1]
resp = {'sw': sw}
if result == 'cancel':
server.stk_pending = None
server.menu_active = False
else:
server.stk_pending = None
if sw.startswith('91'):
_handle_proactive_chain(scc, sw, _make_menu_fetch_handler(server, resp))
else:
server.menu_active = False
resp['type'] = 'done'
if server.stk_pending:
_arm_menu_timeout()
else:
_cancel_menu_timeout()
return resp, 200
class PysimHandler(BaseHTTPRequestHandler): class PysimHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200): def _send_json(self, data, status=200):
self.send_response(status) self.send_response(status)
@@ -1552,6 +1665,7 @@ class PysimHandler(BaseHTTPRequestHandler):
global _CARD_CONNECTED global _CARD_CONNECTED
self.server.stk_pending = None self.server.stk_pending = None
self.server.menu_active = False self.server.menu_active = False
_cancel_menu_timeout()
self.server.event_list = None self.server.event_list = None
_reset_proactive_log() _reset_proactive_log()
self.server.card = self.server.app.card self.server.card = self.server.app.card
@@ -1624,6 +1738,7 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n') sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n')
self.server.stk_pending = None self.server.stk_pending = None
self.server.menu_active = False self.server.menu_active = False
_cancel_menu_timeout()
self.server.event_list = None self.server.event_list = None
_reset_proactive_log() _reset_proactive_log()
sm, el = _send_terminal_profile(scc, self.server.terminal_profile) sm, el = _send_terminal_profile(scc, self.server.terminal_profile)
@@ -1902,31 +2017,9 @@ class PysimHandler(BaseHTTPRequestHandler):
env_hex = '%sc20000%02x%s' % (scc.cat_cla, len(menu_tlv), menu_tlv.hex()) env_hex = '%sc20000%02x%s' % (scc.cat_cla, len(menu_tlv), menu_tlv.hex())
data, sw = scc._tp.send_apdu(env_hex) data, sw = scc._tp.send_apdu(env_hex)
resp = {'type': 'done', 'sw': sw} resp = {'type': 'done', 'sw': sw}
on_fetch = _make_menu_fetch_handler(self.server, resp)
if sw.startswith('91'): if sw.startswith('91'):
def _on_menu_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst): _handle_proactive_chain(scc, sw, on_fetch)
if cmd_type == 0x21:
text = _parse_display_text(raw) if raw else None
if text:
self.server.stk_pending = {'type': 'display_text',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text}
resp.update(type='display_text', text=text)
return 'pause'
elif cmd_type == 0x24:
items = _parse_select_item(raw) if raw else []
self.server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
elif cmd_type == 0x25:
items = _parse_setup_menu_items(raw) if raw else []
self.server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
_handle_proactive_chain(scc, sw, _on_menu_fetch)
else: else:
self.server.menu_active = False self.server.menu_active = False
self.server.stk_pending = None self.server.stk_pending = None
@@ -1935,73 +2028,18 @@ class PysimHandler(BaseHTTPRequestHandler):
st_data, st_sw = _send_status(scc) st_data, st_sw = _send_status(scc)
sys.stderr.write('STATUS -> %s\n' % st_sw) sys.stderr.write('STATUS -> %s\n' % st_sw)
if st_sw.startswith('91'): if st_sw.startswith('91'):
_handle_proactive_chain(scc, st_sw, _on_menu_fetch) _handle_proactive_chain(scc, st_sw, on_fetch)
if self.server.stk_pending:
_arm_menu_timeout()
else:
_cancel_menu_timeout()
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/menu-respond': elif self.path == '/api/menu-respond':
scc = self.server.scc
if not self.server.stk_pending:
self._send_json({'error': 'no pending command'}, 400)
return
body = self._read_body() body = self._read_body()
self._log_req(body) self._log_req(body)
result = body.get('result', 'ok') resp, code = _menu_send_response(self.server, body.get('result', 'ok'), body.get('item_id'))
item_id = body.get('item_id') self._send_json(resp, code)
RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12}
gr = RESULT_MAP.get(result, 0x00)
pd = self.server.stk_pending
# Build TERMINAL RESPONSE
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
tr_data = cd + di
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
tr_data += bytes([0x90, 0x01, item_id])
tr_data += bytes([0x83, 0x02, gr, 0x00])
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
tr_rv = scc._tp.send_apdu(tr_hex)
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
for entry in reversed(_PROACTIVE_LOG):
if (entry.get('cmd_num') == pd['cmd_num']
and entry.get('type_hex') == '%02x' % pd['cmd_type']
and 'tr_hex' not in entry):
_record_tr(entry, tr_data, tr_rv[1])
break
sw = tr_rv[1]
resp = {'sw': sw}
if result == 'cancel':
self.server.stk_pending = None
self.server.menu_active = False
else:
self.server.stk_pending = None
if sw.startswith('91'):
def _on_menu_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst):
if cmd_type == 0x21:
text = _parse_display_text(raw) if raw else None
if text:
self.server.stk_pending = {'type': 'display_text',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text}
resp.update(type='display_text', text=text)
return 'pause'
elif cmd_type == 0x24:
items = _parse_select_item(raw) if raw else []
self.server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
elif cmd_type == 0x25:
items = _parse_setup_menu_items(raw) if raw else []
self.server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
_handle_proactive_chain(scc, sw, _on_menu_fetch)
else:
self.server.menu_active = False
resp['type'] = 'done'
self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/event-send': elif self.path == '/api/event-send':
scc = self.server.scc scc = self.server.scc
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Tests for the paused-command (STK menu) timeout watchdog."""
import sys
import types
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))
import pysim_otaman_server.server as S
class TestMenuTimeout(unittest.TestCase):
def setUp(self):
self.saved = (S._MENU_TIMEOUT, S._MENU_TIMER)
def tearDown(self):
S._cancel_menu_timeout()
S._MENU_TIMEOUT, S._MENU_TIMER = self.saved
def test_clamped(self):
S._set_menu_timeout(30)
self.assertEqual(S._MENU_TIMEOUT, 30)
S._set_menu_timeout(0)
self.assertEqual(S._MENU_TIMEOUT, 0)
S._set_menu_timeout(-1)
self.assertEqual(S._MENU_TIMEOUT, 0)
S._set_menu_timeout(99999)
self.assertEqual(S._MENU_TIMEOUT, 3600)
def test_arm_starts_timer(self):
S._set_menu_timeout(30)
with mock.patch.object(S.threading, 'Timer') as timer:
S._arm_menu_timeout()
timer.assert_called_once_with(30, S._menu_timeout_fire)
def test_zero_disables_arming(self):
S._set_menu_timeout(0)
with mock.patch.object(S.threading, 'Timer') as timer:
S._arm_menu_timeout()
timer.assert_not_called()
def test_cancel(self):
timer = mock.Mock()
S._MENU_TIMER = timer
S._cancel_menu_timeout()
timer.cancel.assert_called_once()
self.assertIsNone(S._MENU_TIMER)
class TestMenuSendResponse(unittest.TestCase):
def test_timeout_tr_is_flat_with_general_result(self):
sent = []
def send_apdu(hexstr):
sent.append(hexstr)
return ('', '9000')
server = types.SimpleNamespace(
stk_pending={'type': 'display_text', 'cmd_num': 1, 'cmd_type': 0x21,
'dev_src': 0x81, 'dev_dst': 0x83},
menu_active=True,
scc=types.SimpleNamespace(cat_cla='80', _tp=types.SimpleNamespace(send_apdu=send_apdu)),
)
resp, code = S._menu_send_response(server, 'timeout', None)
self.assertEqual(code, 200)
self.assertEqual(resp['sw'], '9000')
self.assertEqual(resp['type'], 'done')
self.assertIsNone(server.stk_pending)
self.assertFalse(server.menu_active)
tr = sent[0]
self.assertTrue(tr.startswith('801400000d'), tr)
self.assertIn('8103012100', tr)
self.assertIn('82028381', tr)
self.assertIn('83021200', tr)
def test_no_pending_returns_400(self):
resp, code = S._menu_send_response(types.SimpleNamespace(stk_pending=None), 'ok')
self.assertEqual(code, 400)
self.assertIn('error', resp)
if __name__ == '__main__':
unittest.main()