server+ui: auto-equip on card insertion; reset card views on session change
- _apply_equipped_card() centralizes the post-equip refresh + TERMINAL PROFILE (shared by the /api/command equip branch and auto-equip) - server tracks card_session (bumped on equip and disconnect) and equipping; /api/status exposes connected, card_present, card_session, equipping, auto_equip and is exempt from _CARD_LOCK (pure cached state) - auto-equip is on by default (--no-auto-equip; off with --no-card-init): the presence observer spawns a one-shot worker after insertion, which runs equip under _CARD_LOCK and applies the same refresh; the monitor starts after the startup init so pyscard's initial 'already present' event does not re-equip a fresh session - UI: /api/status polls every 2s (other views stay at 5s); when card_session changes it runs pysimResetCardData() (STK overlay, file tree, events, proactive log, PLI, status) — the same reset as a manual Equip; messages: initializing / press Equip / no card Tests for the observer and auto-equip rules, session bumps, _apply_equipped_card, and the UI state machine. SW cache v91 -> v92.
This commit is contained in:
@@ -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) |
|
||||||
| `--full-pysim-init` | Use pysim's stock init/equip (redundant card resets). The default init/equip is reset-free — only explicit equip/reset reconnect the card |
|
| `--full-pysim-init` | Use pysim's stock init/equip (redundant card resets). The default init/equip is reset-free — only explicit equip/reset reconnect the card |
|
||||||
|
| `--no-auto-equip` | Do not initialize a card automatically right after it is inserted (default: auto-equip on) |
|
||||||
| `--menu-timeout` | Auto-answer a paused STK command with a timeout TERMINAL RESPONSE (default 60s; `0` disables) |
|
| `--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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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` отключает опрос) |
|
||||||
| `--full-pysim-init` | Штатная инициализация/equip из pysim (с лишними сбросами карты). По умолчанию инициализация без лишних сбросов — карта переподключается только по явным equip/reset |
|
| `--full-pysim-init` | Штатная инициализация/equip из pysim (с лишними сбросами карты). По умолчанию инициализация без лишних сбросов — карта переподключается только по явным equip/reset |
|
||||||
|
| `--no-auto-equip` | Не инициализировать карту автоматически сразу после вставки (по умолчанию автоинициализация включена) |
|
||||||
| `--menu-timeout` | Автоответ timeout TERMINAL RESPONSE на приостановленную STK-команду (по умолчанию 60с; `0` отключает) |
|
| `--menu-timeout` | Автоответ timeout TERMINAL RESPONSE на приостановленную STK-команду (по умолчанию 60с; `0` отключает) |
|
||||||
| `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени |
|
| `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени |
|
||||||
|
|
||||||
|
|||||||
+38
-11
@@ -4455,6 +4455,7 @@ async function pysimConnect() {
|
|||||||
|
|
||||||
function pysimDisconnect() {
|
function pysimDisconnect() {
|
||||||
if (_pysimPollTimer) { clearInterval(_pysimPollTimer); _pysimPollTimer = null; }
|
if (_pysimPollTimer) { clearInterval(_pysimPollTimer); _pysimPollTimer = null; }
|
||||||
|
if (_pysimStatusTimer) { clearInterval(_pysimStatusTimer); _pysimStatusTimer = null; }
|
||||||
pysimShowConnected(false);
|
pysimShowConnected(false);
|
||||||
document.getElementById('pysim-status').textContent = '';
|
document.getElementById('pysim-status').textContent = '';
|
||||||
pysimSetConnected(false);
|
pysimSetConnected(false);
|
||||||
@@ -6400,7 +6401,9 @@ async function pysimStatusPoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _pysimPollTimer = null;
|
let _pysimPollTimer = null;
|
||||||
let _pysimLastConnected = null;
|
let _pysimStatusTimer = null;
|
||||||
|
let _pysimCardStateKey = null;
|
||||||
|
let _pysimCardSession = null;
|
||||||
|
|
||||||
async function pysimPollToggle() {
|
async function pysimPollToggle() {
|
||||||
const btn = document.getElementById('pli-pause-btn');
|
const btn = document.getElementById('pli-pause-btn');
|
||||||
@@ -6436,41 +6439,64 @@ async function pysimPollStatusInit() {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pysimResetCardData(refreshStatus) {
|
||||||
|
stkMenuClose();
|
||||||
|
stkMenuStack = [];
|
||||||
|
pysimFsRefresh();
|
||||||
|
stkCheckMenu();
|
||||||
|
pysimEventsRender();
|
||||||
|
pysimProactiveLogRender();
|
||||||
|
pysimPliRender();
|
||||||
|
if (refreshStatus) await pysimRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
function pysimCardStateUpdate(status) {
|
function pysimCardStateUpdate(status) {
|
||||||
if (!status || typeof status.connected !== 'boolean') return;
|
if (!status || typeof status.connected !== 'boolean') return;
|
||||||
const statusEl = document.getElementById('pysim-status');
|
const key = [status.connected, !!status.card_present, !!status.equipping, !!status.auto_equip, status.card_session].join('|');
|
||||||
if (status.connected === _pysimLastConnected) return;
|
if (key === _pysimCardStateKey) return;
|
||||||
_pysimLastConnected = status.connected;
|
_pysimCardStateKey = key;
|
||||||
|
const sessionChanged = _pysimCardSession !== null
|
||||||
|
&& status.card_session !== undefined && status.card_session !== _pysimCardSession;
|
||||||
|
if (status.card_session !== undefined) _pysimCardSession = status.card_session;
|
||||||
if (status.connected) {
|
if (status.connected) {
|
||||||
pysimSetConnected(true);
|
pysimSetConnected(true);
|
||||||
pysimRefresh();
|
pysimResetCardData(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pysimSetConnected(false);
|
pysimSetConnected(false);
|
||||||
|
const statusEl = document.getElementById('pysim-status');
|
||||||
if (statusEl && statusEl.textContent.trim() !== '') {
|
if (statusEl && statusEl.textContent.trim() !== '') {
|
||||||
if (status.card_present) {
|
if (status.equipping || status.auto_equip) {
|
||||||
|
statusEl.innerHTML = '<span class="text-gray-500">' + esc(t('Card inserted — initializing...')) + '</span>';
|
||||||
|
} else if (status.card_present) {
|
||||||
statusEl.innerHTML = '<span class="text-amber-600">' + esc(t('Card inserted — press Equip')) + '</span>';
|
statusEl.innerHTML = '<span class="text-amber-600">' + esc(t('Card inserted — press Equip')) + '</span>';
|
||||||
} else {
|
} else {
|
||||||
statusEl.innerHTML = '<span class="text-red-500">' + esc(t('No card detected. Insert card and click Equip card button')) + '</span>';
|
statusEl.innerHTML = '<span class="text-red-500">' + esc(t('No card detected. Insert card and click Equip card button')) + '</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (sessionChanged) pysimResetCardData(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pysimStartBackendPoll() {
|
function pysimStartBackendPoll() {
|
||||||
if (_pysimPollTimer) clearInterval(_pysimPollTimer);
|
if (_pysimPollTimer) clearInterval(_pysimPollTimer);
|
||||||
_pysimLastConnected = null;
|
if (_pysimStatusTimer) clearInterval(_pysimStatusTimer);
|
||||||
|
_pysimCardStateKey = null;
|
||||||
|
_pysimCardSession = null;
|
||||||
|
_pysimStatusTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
pysimCardStateUpdate(await pysimFetch('/api/status'));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}, 2000);
|
||||||
_pysimPollTimer = setInterval(async () => {
|
_pysimPollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const [log, stk, ps, status] = await Promise.all([
|
const [log, stk, ps] = await Promise.all([
|
||||||
pysimFetch('/api/proactive-log'),
|
pysimFetch('/api/proactive-log'),
|
||||||
pysimFetch('/api/stk-status'),
|
pysimFetch('/api/stk-status'),
|
||||||
pysimFetch('/api/poll-status'),
|
pysimFetch('/api/poll-status'),
|
||||||
pysimFetch('/api/status'),
|
|
||||||
]);
|
]);
|
||||||
pysimUpdatePollUI(ps.enabled, ps.interval);
|
pysimUpdatePollUI(ps.enabled, ps.interval);
|
||||||
pysimCardStateUpdate(status);
|
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}, 2000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== proactive command log =====
|
// ===== proactive command log =====
|
||||||
@@ -8762,6 +8788,7 @@ const LANG_RU = {
|
|||||||
'pysim-unreachable-hint': 'Не удалось подключиться к серверу карт. Проверьте, что pysim-otaman-server запущен и адрес указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить).',
|
'pysim-unreachable-hint': 'Не удалось подключиться к серверу карт. Проверьте, что pysim-otaman-server запущен и адрес указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить).',
|
||||||
'No card detected. Insert card and click Equip card button': 'Карта не обнаружена. Вставьте карту и нажмите Подключить карту',
|
'No card detected. Insert card and click Equip card button': 'Карта не обнаружена. Вставьте карту и нажмите Подключить карту',
|
||||||
'Card inserted — press Equip': 'Карта вставлена — нажмите «Подключить карту»',
|
'Card inserted — press Equip': 'Карта вставлена — нажмите «Подключить карту»',
|
||||||
|
'Card inserted — initializing...': 'Карта вставлена — инициализация...',
|
||||||
'Checking...': 'Проверка...',
|
'Checking...': 'Проверка...',
|
||||||
'Resetting...': 'Сброс...',
|
'Resetting...': 'Сброс...',
|
||||||
'Equipping...': 'Подключение...',
|
'Equipping...': 'Подключение...',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v91';
|
const CACHE = 'otaman-v92';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function extractFunc(src, name) {
|
|||||||
return src.slice(m.index, i + 1);
|
return src.slice(m.index, i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let code = 'var _pysimLastConnected = null;\n';
|
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n';
|
||||||
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
|
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
|
||||||
code += '\nglobalThis.esc = s => s;\n';
|
code += '\nglobalThis.esc = s => s;\n';
|
||||||
code += 'globalThis.t = s => s;\n';
|
code += 'globalThis.t = s => s;\n';
|
||||||
@@ -29,47 +29,70 @@ eval(code);
|
|||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const el = { textContent: 'status line', innerHTML: '' };
|
const el = { textContent: 'status line', innerHTML: '' };
|
||||||
const calls = { connected: [], refresh: 0 };
|
const calls = { connected: [], resets: [], refreshStatus: [] };
|
||||||
|
_pysimCardStateKey = null;
|
||||||
|
_pysimCardSession = null;
|
||||||
globalThis.document = { getElementById: () => el };
|
globalThis.document = { getElementById: () => el };
|
||||||
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
||||||
globalThis.pysimRefresh = () => { calls.refresh++; };
|
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
|
||||||
return { el, calls };
|
return { el, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function status(extra) {
|
||||||
|
return Object.assign({ connected: false, card_present: false, equipping: false, auto_equip: false, card_session: 1 }, extra);
|
||||||
|
}
|
||||||
|
|
||||||
test('disconnect without card shows the no-card message', () => {
|
test('disconnect without card shows the no-card message', () => {
|
||||||
_pysimLastConnected = true;
|
|
||||||
const { el, calls } = setup();
|
const { el, calls } = setup();
|
||||||
pysimCardStateUpdate({ connected: false, card_present: false });
|
pysimCardStateUpdate(status({}));
|
||||||
assert.deepStrictEqual(calls.connected, [false]);
|
assert.deepStrictEqual(calls.connected, [false]);
|
||||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disconnect with card present shows the Equip hint', () => {
|
test('disconnect with card present shows the Equip hint when auto-equip is off', () => {
|
||||||
_pysimLastConnected = true;
|
|
||||||
const { el } = setup();
|
const { el } = setup();
|
||||||
pysimCardStateUpdate({ connected: false, card_present: true });
|
pysimCardStateUpdate(status({ card_present: true }));
|
||||||
assert.ok(el.innerHTML.includes('Card inserted'), el.innerHTML);
|
assert.ok(el.innerHTML.includes('Card inserted — press Equip'), el.innerHTML);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unchanged state does not touch the UI again', () => {
|
test('disconnect with auto-equip shows the initializing message', () => {
|
||||||
_pysimLastConnected = false;
|
const { el } = setup();
|
||||||
|
pysimCardStateUpdate(status({ card_present: true, auto_equip: true }));
|
||||||
|
assert.ok(el.innerHTML.includes('initializing'), el.innerHTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchanged state key does not touch the UI again', () => {
|
||||||
const { el, calls } = setup();
|
const { el, calls } = setup();
|
||||||
|
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||||
el.innerHTML = 'unchanged';
|
el.innerHTML = 'unchanged';
|
||||||
pysimCardStateUpdate({ connected: false, card_present: false });
|
calls.connected.length = 0;
|
||||||
|
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||||
assert.deepStrictEqual(calls.connected, []);
|
assert.deepStrictEqual(calls.connected, []);
|
||||||
assert.strictEqual(el.innerHTML, 'unchanged');
|
assert.strictEqual(el.innerHTML, 'unchanged');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reconnect restores the connected UI and refreshes', () => {
|
test('connected restores the UI and reloads card data', () => {
|
||||||
_pysimLastConnected = false;
|
|
||||||
const { calls } = setup();
|
const { calls } = setup();
|
||||||
pysimCardStateUpdate({ connected: true, card_present: true });
|
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2 }));
|
||||||
assert.deepStrictEqual(calls.connected, [true]);
|
assert.deepStrictEqual(calls.connected, [true]);
|
||||||
assert.strictEqual(calls.refresh, 1);
|
assert.deepStrictEqual(calls.resets, [true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('card session change triggers a data reset', () => {
|
||||||
|
const { calls } = setup();
|
||||||
|
pysimCardStateUpdate(status({ card_session: 3 }));
|
||||||
|
calls.resets.length = 0;
|
||||||
|
pysimCardStateUpdate(status({ card_session: 4 }));
|
||||||
|
assert.deepStrictEqual(calls.resets, [false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first observation does not trigger a reset on its own', () => {
|
||||||
|
const { calls } = setup();
|
||||||
|
pysimCardStateUpdate(status({ card_session: 9 }));
|
||||||
|
assert.deepStrictEqual(calls.resets, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('payload without connected flag is ignored', () => {
|
test('payload without connected flag is ignored', () => {
|
||||||
_pysimLastConnected = null;
|
|
||||||
const { calls } = setup();
|
const { calls } = setup();
|
||||||
pysimCardStateUpdate({ reader: 'x' });
|
pysimCardStateUpdate({ reader: 'x' });
|
||||||
pysimCardStateUpdate(null);
|
pysimCardStateUpdate(null);
|
||||||
|
|||||||
@@ -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, _set_menu_timeout, start_card_monitor
|
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
|
||||||
|
|
||||||
|
|
||||||
_server_start = 0
|
_server_start = 0
|
||||||
@@ -55,6 +55,8 @@ def main():
|
|||||||
help="Use pysim's stock init_card/equip (multiple physical card resets) instead of the default reset-free fast init")
|
help="Use pysim's stock init_card/equip (multiple physical card resets) instead of the default reset-free fast init")
|
||||||
parser.add_argument('--menu-timeout', type=int, default=60, metavar='SECS',
|
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)')
|
help='Auto-send a timeout TERMINAL RESPONSE if a paused STK command is not answered (default: 60, 0 disables)')
|
||||||
|
parser.add_argument('--no-auto-equip', action='store_true', default=False,
|
||||||
|
help='Do not automatically initialize a card right after it is inserted (default: auto-equip on)')
|
||||||
|
|
||||||
opts = parser.parse_args()
|
opts = parser.parse_args()
|
||||||
opts.skip_card_init = opts.no_card_init
|
opts.skip_card_init = opts.no_card_init
|
||||||
@@ -63,6 +65,7 @@ def main():
|
|||||||
_timing_on()
|
_timing_on()
|
||||||
if opts.menu_timeout is not None:
|
if opts.menu_timeout is not None:
|
||||||
_set_menu_timeout(opts.menu_timeout)
|
_set_menu_timeout(opts.menu_timeout)
|
||||||
|
set_auto_equip(not opts.no_auto_equip and not opts.skip_card_init)
|
||||||
sl = None
|
sl = None
|
||||||
scc = None
|
scc = None
|
||||||
card = None
|
card = None
|
||||||
@@ -93,8 +96,6 @@ def main():
|
|||||||
t_phase = time.time()
|
t_phase = time.time()
|
||||||
sl = mod.init_reader(opts, **kwargs)
|
sl = mod.init_reader(opts, **kwargs)
|
||||||
_tlog('init_reader: %.0fms' % ((time.time() - t_phase) * 1000))
|
_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 = SimCardCommands(sl)
|
||||||
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
|
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
|
||||||
scc._tp.proactive_handler = _DefaultProactiveHandler()
|
scc._tp.proactive_handler = _DefaultProactiveHandler()
|
||||||
@@ -182,6 +183,8 @@ def main():
|
|||||||
server.menu_active = False
|
server.menu_active = False
|
||||||
server.stk_pending = None
|
server.stk_pending = None
|
||||||
server.card_present = card is not None
|
server.card_present = card is not None
|
||||||
|
server.card_session = 1 if card is not None else 0
|
||||||
|
server.equipping = False
|
||||||
# Set server reference for polling timer and mark the card session state
|
# Set server reference for polling timer and mark the card session state
|
||||||
import pysim_otaman_server.server
|
import pysim_otaman_server.server
|
||||||
pysim_otaman_server.server._server_ref = server
|
pysim_otaman_server.server._server_ref = server
|
||||||
@@ -191,6 +194,12 @@ def main():
|
|||||||
# Auto-enable polling if card initialized successfully (unless interval is 0)
|
# Auto-enable polling if card initialized successfully (unless interval is 0)
|
||||||
if server.scc and server.card and opts.poll_interval != 0:
|
if server.scc and server.card and opts.poll_interval != 0:
|
||||||
pysim_otaman_server.server._poll_enable()
|
pysim_otaman_server.server._poll_enable()
|
||||||
|
# Start presence monitoring only after the startup init: pyscard reports an
|
||||||
|
# already-present card as "added" on the first pass, and we must not
|
||||||
|
# auto-equip over a session we just initialized. If startup init failed,
|
||||||
|
# that event triggers auto-equip instead — the desired retry.
|
||||||
|
if sl is not None and getattr(sl, '_reader', None) is not None:
|
||||||
|
start_card_monitor(str(sl._reader))
|
||||||
print("─" * 70)
|
print("─" * 70)
|
||||||
print(" pysim-otaman-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
print(" pysim-otaman-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
||||||
print(" Open http://%s:%s in your browser for the OTAMan UI (served by this server)."
|
print(" Open http://%s:%s in your browser for the OTAMan UI (served by this server)."
|
||||||
|
|||||||
@@ -1032,9 +1032,82 @@ def _handle_card_disconnect():
|
|||||||
_server_ref.menu_active = False
|
_server_ref.menu_active = False
|
||||||
_server_ref.event_list = None
|
_server_ref.event_list = None
|
||||||
_server_ref.sim_menu = None
|
_server_ref.sim_menu = None
|
||||||
|
_server_ref.equipping = False
|
||||||
|
_server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1
|
||||||
_reset_proactive_log()
|
_reset_proactive_log()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_equipped_card(server):
|
||||||
|
"""Common post-equip state refresh + TERMINAL PROFILE, shared by the
|
||||||
|
/api/command equip branch and the auto-equip worker."""
|
||||||
|
global _CARD_CONNECTED
|
||||||
|
server.stk_pending = None
|
||||||
|
server.menu_active = False
|
||||||
|
_cancel_menu_timeout()
|
||||||
|
server.event_list = None
|
||||||
|
_reset_proactive_log()
|
||||||
|
server.card = server.app.card
|
||||||
|
server.scc = server.app.card._scc
|
||||||
|
server.scc.cat_cla = '80' if isinstance(server.card, UiccCardBase) else 'a0'
|
||||||
|
_CARD_CONNECTED = True
|
||||||
|
server.card_present = True
|
||||||
|
server.card_session = getattr(server, 'card_session', 0) + 1
|
||||||
|
_poll_enable()
|
||||||
|
sm, el = _send_terminal_profile(server.scc, server.terminal_profile)
|
||||||
|
server.sim_menu = sm
|
||||||
|
server.event_list = el
|
||||||
|
_tlog('equip: terminal profile done')
|
||||||
|
|
||||||
|
|
||||||
|
_AUTO_EQUIP = True
|
||||||
|
_AUTO_EQUIP_BUSY = False
|
||||||
|
|
||||||
|
def set_auto_equip(enabled):
|
||||||
|
global _AUTO_EQUIP
|
||||||
|
_AUTO_EQUIP = bool(enabled)
|
||||||
|
|
||||||
|
def _auto_equip_trigger():
|
||||||
|
"""Spawn a one-shot worker; never run equip in the pyscard monitor thread."""
|
||||||
|
global _AUTO_EQUIP_BUSY
|
||||||
|
if not _AUTO_EQUIP or _AUTO_EQUIP_BUSY:
|
||||||
|
return
|
||||||
|
_AUTO_EQUIP_BUSY = True
|
||||||
|
threading.Thread(target=_auto_equip_worker, name='auto-equip', daemon=True).start()
|
||||||
|
|
||||||
|
def _auto_equip_worker():
|
||||||
|
global _AUTO_EQUIP_BUSY
|
||||||
|
try:
|
||||||
|
with _CARD_LOCK:
|
||||||
|
server = _server_ref
|
||||||
|
if not server or _CARD_CONNECTED or not getattr(server, 'card_present', False):
|
||||||
|
return
|
||||||
|
app = server.app
|
||||||
|
if app is None or not getattr(server, 'terminal_profile', None):
|
||||||
|
return
|
||||||
|
server.equipping = True
|
||||||
|
try:
|
||||||
|
sys.stderr.write('AUTO-EQUIP: card inserted, initializing\n')
|
||||||
|
old_stdout, old_stderr = app.stdout, sys.stderr
|
||||||
|
app.stdout = StringIO()
|
||||||
|
sys.stderr = app.stdout
|
||||||
|
try:
|
||||||
|
app.onecmd_plus_hooks('equip')
|
||||||
|
finally:
|
||||||
|
app.stdout = old_stdout
|
||||||
|
sys.stderr = old_stderr
|
||||||
|
if not getattr(server, 'card_present', False) or server.app.card is None:
|
||||||
|
sys.stderr.write('AUTO-EQUIP: card gone during initialization\n')
|
||||||
|
return
|
||||||
|
_apply_equipped_card(server)
|
||||||
|
sys.stderr.write('AUTO-EQUIP: done\n')
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('AUTO-EQUIP failed: %s\n' % e)
|
||||||
|
finally:
|
||||||
|
server.equipping = False
|
||||||
|
finally:
|
||||||
|
_AUTO_EQUIP_BUSY = False
|
||||||
|
|
||||||
|
|
||||||
class _CardPresenceObserver(CardObserver):
|
class _CardPresenceObserver(CardObserver):
|
||||||
"""Passive PC/SC presence watcher: pyscard's CardMonitor only polls
|
"""Passive PC/SC presence watcher: pyscard's CardMonitor only polls
|
||||||
SCardGetStatusChange (no connection, no APDUs), so it can never interleave
|
SCardGetStatusChange (no connection, no APDUs), so it can never interleave
|
||||||
@@ -1046,6 +1119,7 @@ class _CardPresenceObserver(CardObserver):
|
|||||||
def update(self, observable, handlers):
|
def update(self, observable, handlers):
|
||||||
addedcards, removedcards = handlers
|
addedcards, removedcards = handlers
|
||||||
try:
|
try:
|
||||||
|
trigger_auto = False
|
||||||
for card in removedcards:
|
for card in removedcards:
|
||||||
if str(getattr(card, 'reader', '')) == self.reader_name:
|
if str(getattr(card, 'reader', '')) == self.reader_name:
|
||||||
sys.stderr.write('CARD-WATCH: card removed from %s\n' % self.reader_name)
|
sys.stderr.write('CARD-WATCH: card removed from %s\n' % self.reader_name)
|
||||||
@@ -1055,10 +1129,13 @@ class _CardPresenceObserver(CardObserver):
|
|||||||
_handle_card_disconnect()
|
_handle_card_disconnect()
|
||||||
for card in addedcards:
|
for card in addedcards:
|
||||||
if str(getattr(card, 'reader', '')) == self.reader_name:
|
if str(getattr(card, 'reader', '')) == self.reader_name:
|
||||||
sys.stderr.write('CARD-WATCH: card inserted into %s (press Equip)\n' % self.reader_name)
|
sys.stderr.write('CARD-WATCH: card inserted into %s\n' % self.reader_name)
|
||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
if _server_ref:
|
if _server_ref:
|
||||||
_server_ref.card_present = True
|
_server_ref.card_present = True
|
||||||
|
trigger_auto = True
|
||||||
|
if trigger_auto and _AUTO_EQUIP:
|
||||||
|
_auto_equip_trigger()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write('CARD-WATCH error: %s\n' % e)
|
sys.stderr.write('CARD-WATCH error: %s\n' % e)
|
||||||
|
|
||||||
@@ -1551,6 +1628,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(data)
|
self.wfile.write(data)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
# /api/status is pure cached state (no card I/O); keeping it out of the
|
||||||
|
# lock lets the UI report 'initializing' while a long equip holds the
|
||||||
|
# card lock. Result-shaping masks everything card-derived when the
|
||||||
|
# session is not connected.
|
||||||
|
if self.path == '/api/status':
|
||||||
|
self._do_GET()
|
||||||
|
return
|
||||||
# Serialize all card access: the background STATUS poll runs in its own
|
# Serialize all card access: the background STATUS poll runs in its own
|
||||||
# thread and must never interleave with a FETCH/TERMINAL RESPONSE pair.
|
# thread and must never interleave with a FETCH/TERMINAL RESPONSE pair.
|
||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
@@ -1581,6 +1665,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'reader': str(self.server.sl) if self.server.sl else None,
|
'reader': str(self.server.sl) if self.server.sl else None,
|
||||||
'connected': connected,
|
'connected': connected,
|
||||||
'card_present': bool(getattr(self.server, 'card_present', False)),
|
'card_present': bool(getattr(self.server, 'card_present', False)),
|
||||||
|
'card_session': int(getattr(self.server, 'card_session', 0)),
|
||||||
|
'equipping': bool(getattr(self.server, 'equipping', False)),
|
||||||
|
'auto_equip': bool(_AUTO_EQUIP),
|
||||||
'card': card.name if card else None,
|
'card': card.name if card else None,
|
||||||
'profile': str(rs.profile) if rs and rs.profile else None,
|
'profile': str(rs.profile) if rs and rs.profile else None,
|
||||||
'app_ready': app is not None,
|
'app_ready': app is not None,
|
||||||
@@ -1715,22 +1802,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
if is_equip:
|
if is_equip:
|
||||||
_tlog('equip: onecmd_plus_hooks %dms' % elapsed)
|
_tlog('equip: onecmd_plus_hooks %dms' % elapsed)
|
||||||
if is_equip and self.server.app and self.server.app.card and self.server.terminal_profile:
|
if is_equip and self.server.app and self.server.app.card and self.server.terminal_profile:
|
||||||
global _CARD_CONNECTED
|
_apply_equipped_card(self.server)
|
||||||
self.server.stk_pending = None
|
|
||||||
self.server.menu_active = False
|
|
||||||
_cancel_menu_timeout()
|
|
||||||
self.server.event_list = None
|
|
||||||
_reset_proactive_log()
|
|
||||||
self.server.card = self.server.app.card
|
|
||||||
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
|
|
||||||
self.server.event_list = el
|
|
||||||
_tlog('equip: terminal profile done')
|
|
||||||
sys.stderr.write("CMD: %s → %s (%dms)\n" % (cmd, status, elapsed))
|
sys.stderr.write("CMD: %s → %s (%dms)\n" % (cmd, status, elapsed))
|
||||||
resp = {'output': output, 'stop': bool(stop)}
|
resp = {'output': output, 'stop': bool(stop)}
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Tests for the passive PC/SC card-presence observer."""
|
"""Tests for the passive PC/SC card-presence observer and auto-equip state."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
@@ -31,8 +31,14 @@ class TestCardPresenceObserver(unittest.TestCase):
|
|||||||
S, '_handle_card_disconnect',
|
S, '_handle_card_disconnect',
|
||||||
side_effect=lambda: self.disconnects.append(True))
|
side_effect=lambda: self.disconnects.append(True))
|
||||||
self.patcher.start()
|
self.patcher.start()
|
||||||
|
self.trigger = mock.patch.object(S, '_auto_equip_trigger')
|
||||||
|
self.trigger_mock = self.trigger.start()
|
||||||
|
self.saved_auto = S._AUTO_EQUIP
|
||||||
|
S._AUTO_EQUIP = True
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
S._AUTO_EQUIP = self.saved_auto
|
||||||
|
self.trigger.stop()
|
||||||
self.patcher.stop()
|
self.patcher.stop()
|
||||||
S._server_ref = self.saved_ref
|
S._server_ref = self.saved_ref
|
||||||
|
|
||||||
@@ -40,22 +46,104 @@ class TestCardPresenceObserver(unittest.TestCase):
|
|||||||
self.observer.update(None, ([], [FakeCard('Test Reader 00 00')]))
|
self.observer.update(None, ([], [FakeCard('Test Reader 00 00')]))
|
||||||
self.assertFalse(self.server.card_present)
|
self.assertFalse(self.server.card_present)
|
||||||
self.assertEqual(len(self.disconnects), 1)
|
self.assertEqual(len(self.disconnects), 1)
|
||||||
|
self.trigger_mock.assert_not_called()
|
||||||
|
|
||||||
def test_removal_of_other_reader_ignored(self):
|
def test_removal_of_other_reader_ignored(self):
|
||||||
self.observer.update(None, ([], [FakeCard('Other Reader 00 00')]))
|
self.observer.update(None, ([], [FakeCard('Other Reader 00 00')]))
|
||||||
self.assertTrue(self.server.card_present)
|
self.assertTrue(self.server.card_present)
|
||||||
self.assertEqual(self.disconnects, [])
|
self.assertEqual(self.disconnects, [])
|
||||||
|
self.trigger_mock.assert_not_called()
|
||||||
|
|
||||||
def test_insertion_sets_card_present(self):
|
def test_insertion_sets_card_present_and_triggers_auto_equip(self):
|
||||||
self.server.card_present = False
|
self.server.card_present = False
|
||||||
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
||||||
self.assertTrue(self.server.card_present)
|
self.assertTrue(self.server.card_present)
|
||||||
self.assertEqual(self.disconnects, [])
|
self.assertEqual(self.disconnects, [])
|
||||||
|
self.trigger_mock.assert_called_once()
|
||||||
|
|
||||||
|
def test_insertion_does_not_trigger_when_disabled(self):
|
||||||
|
S._AUTO_EQUIP = False
|
||||||
|
self.server.card_present = False
|
||||||
|
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
||||||
|
self.assertTrue(self.server.card_present)
|
||||||
|
self.trigger_mock.assert_not_called()
|
||||||
|
|
||||||
def test_missing_reader_attribute_is_ignored(self):
|
def test_missing_reader_attribute_is_ignored(self):
|
||||||
self.observer.update(None, ([], [types.SimpleNamespace()]))
|
self.observer.update(None, ([], [types.SimpleNamespace()]))
|
||||||
self.assertTrue(self.server.card_present)
|
self.assertTrue(self.server.card_present)
|
||||||
self.assertEqual(self.disconnects, [])
|
self.assertEqual(self.disconnects, [])
|
||||||
|
self.trigger_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoEquipTrigger(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
S._AUTO_EQUIP = True
|
||||||
|
S._AUTO_EQUIP_BUSY = False
|
||||||
|
|
||||||
|
def test_disabled_does_not_spawn(self):
|
||||||
|
S._AUTO_EQUIP = False
|
||||||
|
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||||
|
S._auto_equip_trigger()
|
||||||
|
thread.assert_not_called()
|
||||||
|
|
||||||
|
def test_busy_does_not_spawn_twice(self):
|
||||||
|
S._AUTO_EQUIP = True
|
||||||
|
S._AUTO_EQUIP_BUSY = True
|
||||||
|
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||||
|
S._auto_equip_trigger()
|
||||||
|
thread.assert_not_called()
|
||||||
|
|
||||||
|
def test_spawns_worker_once(self):
|
||||||
|
S._AUTO_EQUIP = True
|
||||||
|
S._AUTO_EQUIP_BUSY = False
|
||||||
|
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||||
|
S._auto_equip_trigger()
|
||||||
|
thread.assert_called_once_with(target=S._auto_equip_worker, name='auto-equip', daemon=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCardSession(unittest.TestCase):
|
||||||
|
def test_disconnect_bumps_session_and_clears_equipping(self):
|
||||||
|
server = types.SimpleNamespace(
|
||||||
|
card_session=5, card=object(), scc=object(), stk_pending=object(),
|
||||||
|
menu_active=True, event_list=[1], sim_menu={}, equipping=True)
|
||||||
|
saved = S._server_ref
|
||||||
|
S._server_ref = server
|
||||||
|
try:
|
||||||
|
S._handle_card_disconnect()
|
||||||
|
finally:
|
||||||
|
S._server_ref = saved
|
||||||
|
self.assertEqual(server.card_session, 6)
|
||||||
|
self.assertFalse(server.equipping)
|
||||||
|
self.assertIsNone(server.card)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyEquippedCard(unittest.TestCase):
|
||||||
|
def test_updates_state_and_bumps_session(self):
|
||||||
|
scc = types.SimpleNamespace(cat_cla=None)
|
||||||
|
card = types.SimpleNamespace(_scc=scc, name='Card')
|
||||||
|
app = types.SimpleNamespace(card=card)
|
||||||
|
server = types.SimpleNamespace(
|
||||||
|
app=app, card=None, scc=None, stk_pending=object(), menu_active=True,
|
||||||
|
event_list=[1], sim_menu={}, card_session=2, card_present=False,
|
||||||
|
equipping=False, terminal_profile='7F')
|
||||||
|
saved_ref, saved_conn = S._server_ref, S._CARD_CONNECTED
|
||||||
|
S._server_ref = server
|
||||||
|
S._CARD_CONNECTED = False
|
||||||
|
try:
|
||||||
|
with mock.patch.object(S, '_send_terminal_profile', return_value=('menu', ['ev'])):
|
||||||
|
with mock.patch.object(S, '_poll_enable'):
|
||||||
|
S._apply_equipped_card(server)
|
||||||
|
connected_after = S._CARD_CONNECTED
|
||||||
|
finally:
|
||||||
|
S._server_ref = saved_ref
|
||||||
|
S._CARD_CONNECTED = saved_conn
|
||||||
|
self.assertTrue(connected_after)
|
||||||
|
self.assertIs(server.card, card)
|
||||||
|
self.assertIs(server.scc, scc)
|
||||||
|
self.assertEqual(server.card_session, 3)
|
||||||
|
self.assertTrue(server.card_present)
|
||||||
|
self.assertEqual(server.sim_menu, 'menu')
|
||||||
|
self.assertEqual(server.event_list, ['ev'])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Reference in New Issue
Block a user