snapshots: measure SELECT / READ commands and show timing stats

Server measures every classified APDU (A4 select, B0 read binary, B2 read
record) from command to response: _collect_apdu_times() enables the tracer
(reattaching it if pySim nulled it) and /api/select + /api/read return
'apdu_times': [{type, ms}]. Collection is safe: handlers hold _CARD_LOCK.

Snapshots store per-file {select_ms, read_ms}, a ms value per record and
snapshot-level stats {select, read_binary, read_record}: {min, max, avg,
count} plus total_ms (wall time of the scan). The snapshot view gets a
summary under the title (files/records counts, scan time, min/avg/max per
command type) and shows select/read per file and read time per record.
Timings are display-only: checks, snapshots comparison and imports ignore
them (old snapshots simply show 'No timing data').

Tests: Python classifier/collection (tests/test_apdu_timing.py) and
frontend stats/accumulator/format/build-file/summary. SW cache v99 ->
v100; help, README and docs/api.md updated.
This commit is contained in:
2026-09-12 14:56:59 +03:00
parent a261675aad
commit 6d30e7095e
11 changed files with 391 additions and 44 deletions
+1 -1
View File
@@ -529,7 +529,7 @@ The scan dialog asks for a profile name and offers the FCP/FCI mode described ab
#### Card snapshots #### Card snapshots
The list view has two tabs — **Profiles** and **Card snapshots**. A snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The list view has two tabs — **Profiles** and **Card snapshots**. A snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures every card command (SELECT / READ BINARY / READ RECORD) from command to response and stores min/avg/max per type plus the total scan time; the snapshot view shows these in the summary and the select/read time per file (read time per record). Timings are display-only and ignored by checks/comparisons.
- **New snapshot** scans the card; **Import snapshot** loads JSON. - **New snapshot** scans the card; **Import snapshot** loads JSON.
- Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**. - Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**.
+1 -1
View File
@@ -503,7 +503,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
#### Снимки карт #### Снимки карт
Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. При сканировании также измеряется время каждой команды карты (SELECT / READ BINARY / READ RECORD) от отправки до ответа; сохраняются min/сред/max по типам и общее время сканирования — они показываются в сводке снимка и по файлам/записям. Время носит информационный характер и не используется при проверках и сравнении.
- **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON. - **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON.
- В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**. - В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**.
+11 -3
View File
@@ -277,15 +277,22 @@ Read file content. Auto-detects transparent vs record files.
Returns transparent data: Returns transparent data:
```json ```json
{"success": true, "sw": "9000", "file_type": "transparent", "data": "..."} {"success": true, "sw": "9000", "file_type": "transparent", "data": "...",
"apdu_times": [{"type": "select", "ms": 12}, {"type": "read_binary", "ms": 9}]}
``` ```
Returns records: Returns records:
```json ```json
{"success": true, "sw": "9000", "file_type": "linear_fixed", {"success": true, "sw": "9000", "file_type": "linear_fixed",
"records": [{"num": 1, "data": "..."}, {"num": 2, "data": "..."}]} "records": [{"num": 1, "data": "..."}, {"num": 2, "data": "..."}],
"apdu_times": [{"type": "select", "ms": 12},
{"type": "read_record", "ms": 11}, {"type": "read_record", "ms": 13}]}
``` ```
`apdu_times` reports each command's duration (command sent to response
received) classified as `select`, `read_binary` or `read_record`; the PWA uses
it for snapshot timing statistics. Other commands are not reported.
### `POST /api/write` ### `POST /api/write`
Write raw hex data to a file. Write raw hex data to a file.
@@ -316,7 +323,8 @@ Returns:
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent", {"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
"file_size": 10, "record_len": null, "num_of_rec": null, "file_size": 10, "record_len": null, "num_of_rec": null,
"fci_hex": "621082024021...", "exists": true} "fci_hex": "621082024021...",
"apdu_times": [{"type": "select", "ms": 12}], "exists": true}
``` ```
`fci_hex` is the raw FCP template (`'62'`) from the SELECT response, used by `fci_hex` is the raw FCP template (`'62'`) from the SELECT response, used by
+1 -1
View File
@@ -394,7 +394,7 @@
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p> <p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p>
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4> <h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка.</p> <p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li> <li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li>
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li> <li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
+1 -1
View File
@@ -394,7 +394,7 @@
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p> <p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4> <h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name.</p> <p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li> <li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li>
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li> <li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
+117 -7
View File
@@ -892,6 +892,7 @@
<span id="snapshot-iccid" class="text-xs font-mono text-gray-500 dark:text-slate-400"></span> <span id="snapshot-iccid" class="text-xs font-mono text-gray-500 dark:text-slate-400"></span>
<button onclick="snapshotSaveName()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button> <button onclick="snapshotSaveName()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
</div> </div>
<div id="snapshot-summary" class="mb-3"></div>
<div id="snapshot-files"></div> <div id="snapshot-files"></div>
</div> </div>
</div> </div>
@@ -7443,8 +7444,12 @@ async function profilerScanStart() {
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : ''); progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
}; };
if (_scanTarget === 'snapshot') { if (_scanTarget === 'snapshot') {
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot'); const timing = profilerTimingAccumulator();
const snapshot = { id: profilerNewId(), name: name, created: new Date().toISOString(), iccid: profilerSnapshotIccid(files), files: files }; const scanStart = performance.now();
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot', timing);
const stats = timing.stats();
stats.total_ms = Math.round(performance.now() - scanStart);
const snapshot = { id: profilerNewId(), name: name, created: new Date().toISOString(), iccid: profilerSnapshotIccid(files), files: files, timing: stats };
snapshots.push(snapshot); snapshots.push(snapshot);
snapshotsSave(); snapshotsSave();
profilerScanCancel(); profilerScanCancel();
@@ -7476,7 +7481,7 @@ async function profilerScanStart() {
// Phase 1 discovers every file (tree calls only) to know the total; phase 2 // Phase 1 discovers every file (tree calls only) to know the total; phase 2
// builds a rule per file, reporting progress via the optional onProgress // builds a rule per file, reporting progress via the optional onProgress
// callback as onProgress(done, total, path). // callback as onProgress(done, total, path).
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode) { async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode, timing) {
const rules = []; const rules = [];
const files = []; const files = [];
const seen = new Set(); const seen = new Set();
@@ -7531,7 +7536,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
const entry = files[i]; const entry = files[i];
if (onProgress) onProgress(i + 1, total, entry.path); if (onProgress) onProgress(i + 1, total, entry.path);
const rule = mode === 'snapshot' const rule = mode === 'snapshot'
? await profilerBuildSnapshotFile(entry.path, entry.child) ? await profilerBuildSnapshotFile(entry.path, entry.child, timing)
: await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids); : await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
if (rule) rules.push(rule); if (rule) rules.push(rule);
} }
@@ -7583,7 +7588,42 @@ async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode,
// Card snapshot entry: metadata + raw FCI + exact contents for every readable // Card snapshot entry: metadata + raw FCI + exact contents for every readable
// file (no ignore list, no masking, no FCP/FCI check mode). // file (no ignore list, no masking, no FCP/FCI check mode).
async function profilerBuildSnapshotFile(path, c) { function profilerTimingStats(values) {
const vals = (values || []).filter(v => typeof v === 'number' && isFinite(v));
if (!vals.length) return null;
let min = vals[0], max = vals[0], sum = 0;
for (const v of vals) {
if (v < min) min = v;
if (v > max) max = v;
sum += v;
}
return { min: Math.round(min), max: Math.round(max), avg: Math.round(sum / vals.length * 10) / 10, count: vals.length };
}
function profilerTimingAccumulator() {
const buckets = { select: [], read_binary: [], read_record: [] };
return {
add(type, ms) {
if (buckets[type]) buckets[type].push(ms);
},
stats() {
const out = {};
for (const type in buckets) {
const s = profilerTimingStats(buckets[type]);
if (s) out[type] = s;
}
return out;
},
};
}
function profilerFormatMs(ms) {
if (ms === null || ms === undefined) return 'n/a';
if (ms >= 1000) return (ms / 1000).toFixed(2) + ' s';
return Math.round(ms) + ' ms';
}
async function profilerBuildSnapshotFile(path, c, timing) {
let sel; let sel;
try { try {
sel = await pysimFetch('/api/select', { path: path }); sel = await pysimFetch('/api/select', { path: path });
@@ -7599,14 +7639,37 @@ async function profilerBuildSnapshotFile(path, c) {
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec, numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
fciHex: sel.fci_hex || null, fciHex: sel.fci_hex || null,
content: null, content: null,
timing: null,
}; };
const selectTimes = (sel.apdu_times || []).filter(t => t.type === 'select').map(t => t.ms);
if (selectTimes.length) {
if (timing) selectTimes.forEach(ms => timing.add('select', ms));
file.timing = { select_ms: selectTimes.reduce((a, b) => a + b, 0), read_ms: null };
}
try { try {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' }); const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
if (rd && rd.success) { if (rd && rd.success) {
const readTimes = rd.apdu_times || [];
const recTimes = readTimes.filter(t => t.type === 'read_record').map(t => t.ms);
const binTimes = readTimes.filter(t => t.type === 'read_binary').map(t => t.ms);
if (rd.records) { if (rd.records) {
file.content = { kind: 'record', records: rd.records.map(r => ({ num: r.num, data: r.data })) }; file.content = { kind: 'record', records: rd.records.map((r, i) => {
const rec = { num: r.num, data: r.data };
if (recTimes[i] !== undefined) rec.ms = recTimes[i];
return rec;
}) };
if (timing) recTimes.forEach(ms => timing.add('read_record', ms));
if (recTimes.length) {
file.timing = file.timing || { select_ms: null, read_ms: null };
file.timing.read_ms = recTimes.reduce((a, b) => a + b, 0);
}
} else if (rd.data) { } else if (rd.data) {
file.content = { kind: 'transparent', data: rd.data }; file.content = { kind: 'transparent', data: rd.data };
if (timing) binTimes.forEach(ms => timing.add('read_binary', ms));
if (binTimes.length) {
file.timing = file.timing || { select_ms: null, read_ms: null };
file.timing.read_ms = binTimes.reduce((a, b) => a + b, 0);
}
} }
} }
} catch (e) {} } catch (e) {}
@@ -7734,9 +7797,38 @@ function profilerRenderSnapshotData() {
const s = snapshots.find(x => x.id === snapshotViewId); const s = snapshots.find(x => x.id === snapshotViewId);
if (!s) return; if (!s) return;
document.getElementById('snapshot-iccid').textContent = s.iccid || ''; document.getElementById('snapshot-iccid').textContent = s.iccid || '';
document.getElementById('snapshot-summary').innerHTML = profilerRenderSnapshotSummary(s);
document.getElementById('snapshot-files').innerHTML = profilerRenderSnapshotFiles(s); document.getElementById('snapshot-files').innerHTML = profilerRenderSnapshotFiles(s);
} }
function profilerRenderSnapshotSummary(s) {
const files = (s.files || []).length;
let records = 0;
for (const f of (s.files || [])) {
if (f.content && f.content.kind === 'record' && f.content.records) records += f.content.records.length;
}
let html = '<div class="text-xs text-gray-500 dark:text-slate-400">' +
esc(t('Files')) + ': <b>' + files + '</b> · ' + esc(t('Records')) + ': <b>' + records + '</b>';
const timing = s.timing;
if (timing && timing.total_ms !== undefined) {
html += ' · ' + esc(t('Scan time')) + ': <b>' + esc(profilerFormatMs(timing.total_ms)) + '</b>';
}
html += '</div>';
const labels = [['select', 'Select'], ['read_binary', 'Read binary'], ['read_record', 'Read record']];
let rows = '';
if (timing) {
for (const [key, label] of labels) {
const st = timing[key];
if (!st) continue;
rows += '<div>' + esc(t(label)) + ': ' + esc(t('min')) + ' ' + st.min + ' ms · ' + esc(t('avg')) + ' ' + st.avg + ' ms · ' + esc(t('max')) + ' ' + st.max + ' ms (n=' + st.count + ')</div>';
}
}
html += rows
? '<div class="text-xs font-mono text-gray-600 dark:text-slate-400 mt-0.5">' + rows + '</div>'
: '<div class="text-xs text-gray-400 dark:text-slate-500 mt-0.5">' + esc(t('No timing data')) + '</div>';
return html;
}
function snapshotSaveName() { function snapshotSaveName() {
const s = snapshots.find(x => x.id === snapshotViewId); const s = snapshots.find(x => x.id === snapshotViewId);
if (!s) return; if (!s) return;
@@ -7763,6 +7855,15 @@ function profilerRenderSnapshotFiles(s) {
if (f.recordLen !== null && f.recordLen !== undefined) attrs.push(esc(t('Record length')) + ': ' + esc(String(f.recordLen))); if (f.recordLen !== null && f.recordLen !== undefined) attrs.push(esc(t('Record length')) + ': ' + esc(String(f.recordLen)));
if (f.numRecords !== null && f.numRecords !== undefined) attrs.push(esc(t('Record count')) + ': ' + esc(String(f.numRecords))); if (f.numRecords !== null && f.numRecords !== undefined) attrs.push(esc(t('Record count')) + ': ' + esc(String(f.numRecords)));
if (attrs.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + attrs.join(' · ') + '</div>'; if (attrs.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + attrs.join(' · ') + '</div>';
if (f.timing) {
const parts = [];
if (f.timing.select_ms !== null && f.timing.select_ms !== undefined) parts.push(esc(t('Select')) + ': ' + esc(profilerFormatMs(f.timing.select_ms)));
if (f.timing.read_ms !== null && f.timing.read_ms !== undefined) {
const readKey = profilerContentKindForFileType(f.fileType) === 'record' ? 'Read record' : 'Read binary';
parts.push(esc(t(readKey)) + ': ' + esc(profilerFormatMs(f.timing.read_ms)));
}
if (parts.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + parts.join(' · ') + '</div>';
}
if (f.fciHex) { if (f.fciHex) {
html += '<div class="mt-1 flex gap-3 items-start">'; html += '<div class="mt-1 flex gap-3 items-start">';
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</div>' + html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</div>' +
@@ -7778,7 +7879,9 @@ function profilerRenderSnapshotFiles(s) {
html += '<div class="space-y-0.5">'; html += '<div class="space-y-0.5">';
for (const rec of (f.content.records || [])) { for (const rec of (f.content.records || [])) {
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8 shrink-0">' + rec.num + '</span>' + html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8 shrink-0">' + rec.num + '</span>' +
'<input readonly value="' + escHtml(rec.data) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>'; '<input readonly value="' + escHtml(rec.data) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">' +
(rec.ms !== undefined ? '<span class="text-xs text-gray-400 w-16 text-right shrink-0">' + esc(profilerFormatMs(rec.ms)) + '</span>' : '') +
'</div>';
} }
html += '</div>'; html += '</div>';
} else { } else {
@@ -8845,6 +8948,13 @@ const LANG_RU = {
'Verify vs pySim': 'Проверить в pySim', 'Verify vs pySim': 'Проверить в pySim',
'Send to Card': 'Отправить на карту', 'Send to Card': 'Отправить на карту',
'Phone simulator': 'Симулятор телефона', 'Phone simulator': 'Симулятор телефона',
'Files': 'Файлов',
'Records': 'Записей',
'Scan time': 'Время сканирования',
'No timing data': 'Нет данных о времени',
'min': 'мин',
'avg': 'сред',
'max': 'макс',
'Phone': 'Телефон', 'Phone': 'Телефон',
'TR Config': 'Конфигурация TR', 'TR Config': 'Конфигурация TR',
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):', 'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v99'; const CACHE = 'otaman-v100';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+4
View File
@@ -37,3 +37,7 @@ test('phone simulator has Phone / TR Config pills', () => {
test('scan name input starts scanning on Enter', () => { test('scan name input starts scanning on Enter', () => {
assert.match(html, /id="profiler-scan-name"[^>]*onkeydown="profilerScanNameKeydown\(event\)"/); assert.match(html, /id="profiler-scan-name"[^>]*onkeydown="profilerScanNameKeydown\(event\)"/);
}); });
test('snapshot view has a timing summary block', () => {
assert.ok(html.includes('id="snapshot-summary"'));
});
+87 -1
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
} }
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown']; const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -1130,3 +1130,89 @@ test('profilerScanNameKeydown starts the scan on Enter only', () => {
delete global.document; delete global.document;
delete global.profilerScanStart; delete global.profilerScanStart;
}); });
// --- snapshot command timings ---
test('profilerTimingStats computes min, max and average', () => {
assert.deepStrictEqual(profilerTimingStats([10, 20, 30]), { min: 10, max: 30, avg: 20, count: 3 });
assert.deepStrictEqual(profilerTimingStats([7, 7.5, 8]), { min: 7, max: 8, avg: 7.5, count: 3 });
assert.strictEqual(profilerTimingStats([]), null);
assert.strictEqual(profilerTimingStats(null), null);
});
test('profilerFormatMs formats milliseconds and seconds', () => {
assert.strictEqual(profilerFormatMs(12), '12 ms');
assert.strictEqual(profilerFormatMs(999), '999 ms');
assert.strictEqual(profilerFormatMs(1500), '1.50 s');
assert.strictEqual(profilerFormatMs(null), 'n/a');
});
test('profilerTimingAccumulator aggregates per command type', () => {
const acc = profilerTimingAccumulator();
acc.add('select', 10);
acc.add('select', 20);
acc.add('read_record', 5);
acc.add('bogus', 1);
assert.deepStrictEqual(acc.stats(), {
select: { min: 10, max: 20, avg: 15, count: 2 },
read_record: { min: 5, max: 5, avg: 5, count: 1 },
});
});
test('profilerBuildSnapshotFile records per-command timings', async () => {
const acc = profilerTimingAccumulator();
mockFetch({
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 2,
apdu_times: [{ type: 'select', ms: 5 }, { type: 'select', ms: 7 }] }),
'/api/read': () => ({ success: true, file_type: 'linear_fixed', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }],
apdu_times: [{ type: 'select', ms: 4 }, { type: 'read_record', ms: 11 }, { type: 'read_record', ms: 13 }] }),
});
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, acc);
assert.deepStrictEqual(file.timing, { select_ms: 12, read_ms: 24 });
assert.strictEqual(file.content.records[0].ms, 11);
assert.strictEqual(file.content.records[1].ms, 13);
const stats = acc.stats();
assert.strictEqual(stats.select.count, 2);
assert.strictEqual(stats.read_record.count, 2);
});
test('profilerBuildSnapshotFile without apdu_times has empty timing', async () => {
mockFetch({
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'transparent', file_size: 2, record_len: null, num_of_rec: null }),
'/api/read': () => ({ success: true, file_type: 'transparent', data: 'AABB' }),
});
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, null);
assert.strictEqual(file.timing, null);
assert.deepStrictEqual(file.content, { kind: 'transparent', data: 'AABB' });
});
test('profilerRenderSnapshotSummary shows counts, scan time and stats', () => {
global.t = s => s;
const snap = {
files: [
{ content: { kind: 'record', records: [{ num: 1, data: 'AA', ms: 5 }, { num: 2, data: 'BB', ms: 7 }] } },
{ content: { kind: 'transparent', data: 'AA' } },
],
timing: {
select: { min: 3, max: 9, avg: 6, count: 3 },
read_record: { min: 5, max: 7, avg: 6, count: 2 },
total_ms: 1500,
},
};
const html = profilerRenderSnapshotSummary(snap);
assert.ok(html.includes('Files: <b>2</b>'), html);
assert.ok(html.includes('Records: <b>2</b>'), html);
assert.ok(html.includes('Scan time: <b>1.50 s</b>'), html);
assert.ok(html.includes('Select: min 3 ms'), html);
assert.ok(html.includes('Read record: min 5 ms'), html);
assert.ok(!html.includes('Read binary'), html);
delete global.t;
});
test('profilerRenderSnapshotSummary notes missing timing data', () => {
global.t = s => s;
const html = profilerRenderSnapshotSummary({ files: [], timing: undefined });
assert.ok(html.includes('Files: <b>0</b>'), html);
assert.ok(html.includes('No timing data'), html);
delete global.t;
});
+73 -28
View File
@@ -57,6 +57,38 @@ 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))
_APDU_TIMES = []
_APDU_TIME_COLLECT = False
def _classify_apdu(cmd):
"""Map a command APDU to a snapshot timing category by instruction byte."""
if not cmd or len(cmd) < 4:
return None
return {'A4': 'select', 'B0': 'read_binary', 'B2': 'read_record'}.get(cmd[2:4].upper())
def _collect_apdu_times():
"""Start collecting per-command times. (Re)attaches our tracer if pySim
nulled it (equip does). Callers hold _CARD_LOCK, so collection cannot be
interleaved by the background poll thread."""
global _APDU_TIME_COLLECT
scc = getattr(_server_ref, 'scc', None) if _server_ref else None
tp = getattr(scc, '_tp', None) if scc else None
if tp is not None and tp.apdu_tracer is None:
tp.apdu_tracer = _LoggingApduTracer()
_APDU_TIMES.clear()
_APDU_TIME_COLLECT = True
def _end_apdu_time_collection():
"""Stop collecting and return the collected [{type, ms}, ...] list."""
global _APDU_TIME_COLLECT
_APDU_TIME_COLLECT = False
times = list(_APDU_TIMES)
_APDU_TIMES.clear()
return times
class StderrApduTracer(ApduTracer): class StderrApduTracer(ApduTracer):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -75,6 +107,10 @@ class StderrApduTracer(ApduTracer):
global _APDU_N global _APDU_N
_APDU_N += 1 _APDU_N += 1
elapsed = int((time.time() - self._cmd_start) * 1000) elapsed = int((time.time() - self._cmd_start) * 1000)
if _APDU_TIME_COLLECT:
category = _classify_apdu(cmd)
if category:
_APDU_TIMES.append({'type': category, 'ms': elapsed})
if _TIMING: if _TIMING:
msg = 'APDU-TRACE(+%7.3fs #%d, %dms): %s → SW: %s' % (time.time() - _T0, _APDU_N, elapsed, cmd, sw) msg = 'APDU-TRACE(+%7.3fs #%d, %dms): %s → SW: %s' % (time.time() - _T0, _APDU_N, elapsed, cmd, sw)
else: else:
@@ -1916,10 +1952,14 @@ class PysimHandler(BaseHTTPRequestHandler):
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
try: try:
if path: _collect_apdu_times()
_select_path(lchan, path, app) try:
else: if path:
_select_with_parent(lchan, name, parent_sel, app) _select_path(lchan, path, app)
else:
_select_with_parent(lchan, name, parent_sel, app)
finally:
apdu_times = _end_apdu_time_collection()
cur = lchan.selected_file cur = lchan.selected_file
data = { data = {
'name': cur.name if cur else None, 'name': cur.name if cur else None,
@@ -1929,6 +1969,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'record_len': lchan.selected_file_record_len() if lchan else None, 'record_len': lchan.selected_file_record_len() if lchan else None,
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None, 'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None, 'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None,
'apdu_times': apdu_times,
'exists': True, 'exists': True,
} }
self._send_json(data) self._send_json(data)
@@ -1957,28 +1998,32 @@ class PysimHandler(BaseHTTPRequestHandler):
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
try: try:
sel = fid if fid else name _collect_apdu_times()
if path:
_select_path(lchan, path, app)
else:
_select_with_parent(lchan, sel, parent_sel, app)
ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic')
if mode == 'decoded':
cmd = 'read_records_decoded' if is_record else 'read_binary_decoded'
else:
cmd = 'read_records' if is_record else 'read_binary'
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try: try:
app.onecmd_plus_hooks(cmd) sel = fid if fid else name
output = _strip_ansi(out.getvalue()) if path:
_select_path(lchan, path, app)
else:
_select_with_parent(lchan, sel, parent_sel, app)
ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic')
if mode == 'decoded':
cmd = 'read_records_decoded' if is_record else 'read_binary_decoded'
else:
cmd = 'read_records' if is_record else 'read_binary'
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
app.onecmd_plus_hooks(cmd)
output = _strip_ansi(out.getvalue())
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
finally: finally:
app.stdout = old_stdout apdu_times = _end_apdu_time_collection()
sys.stderr = old_stderr
sw_match = re.search(r'SW:\s*(\w+)', output) sw_match = re.search(r'SW:\s*(\w+)', output)
err_match = re.search(r'got (\w+)', output) err_match = re.search(r'got (\w+)', output)
if err_match: if err_match:
@@ -1995,18 +2040,18 @@ class PysimHandler(BaseHTTPRequestHandler):
if mode == 'decoded': if mode == 'decoded':
try: try:
parsed = json.loads(clean) parsed = json.loads(clean)
resp = {'success': True, 'sw': sw, 'file_type': ft, 'decoded': parsed} resp = {'success': True, 'sw': sw, 'file_type': ft, 'decoded': parsed, 'apdu_times': apdu_times}
except json.JSONDecodeError: except json.JSONDecodeError:
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean} resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean, 'apdu_times': apdu_times}
elif is_record: elif is_record:
records = [] records = []
for line in clean.split('\n'): for line in clean.split('\n'):
m = re.match(r'^(\d+)\s(.+)', line) m = re.match(r'^(\d+)\s(.+)', line)
if m: if m:
records.append({'num': int(m.group(1)), 'data': m.group(2)}) records.append({'num': int(m.group(1)), 'data': m.group(2)})
resp = {'success': True, 'sw': sw, 'file_type': ft, 'records': records} resp = {'success': True, 'sw': sw, 'file_type': ft, 'records': records, 'apdu_times': apdu_times}
else: else:
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean} resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean, 'apdu_times': apdu_times}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
except Exception as e: except Exception as e:
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Tests for per-command APDU timing collection (card snapshot measurements)."""
import sys
import time
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 TestClassifyApdu(unittest.TestCase):
def test_select(self):
self.assertEqual(S._classify_apdu('00a40004023f0000'), 'select')
def test_read_binary(self):
self.assertEqual(S._classify_apdu('00b000000a'), 'read_binary')
def test_read_record(self):
self.assertEqual(S._classify_apdu('00b2010428'), 'read_record')
def test_other_not_classified(self):
self.assertIsNone(S._classify_apdu('80f2000c00'))
def test_short_input(self):
self.assertIsNone(S._classify_apdu(''))
self.assertIsNone(S._classify_apdu('00'))
class TestApduTimeCollection(unittest.TestCase):
def setUp(self):
self.saved = (S._APDU_TIME_COLLECT, list(S._APDU_TIMES), S._server_ref)
S._APDU_TIME_COLLECT = False
S._APDU_TIMES.clear()
S._server_ref = None
def tearDown(self):
S._APDU_TIME_COLLECT, times, S._server_ref = self.saved
S._APDU_TIMES[:] = times
def test_disabled_does_not_collect(self):
tracer = S.StderrApduTracer()
with mock.patch.object(S.os, 'write'):
tracer.trace_command('00a40004023f0000')
tracer.trace_response('00a40004023f0000', '9000', '')
self.assertEqual(S._APDU_TIMES, [])
def test_collects_only_classified_commands_with_ms(self):
S._collect_apdu_times()
tracer = S.StderrApduTracer()
with mock.patch.object(S.os, 'write'):
tracer._cmd_start = time.time() - 0.025
tracer.trace_response('00a40004023f0000', '9000', '')
tracer._cmd_start = time.time() - 0.010
tracer.trace_response('00b000000a', '9000', '')
tracer._cmd_start = time.time() - 0.005
tracer.trace_response('80f2000c00', '9000', '')
times = S._end_apdu_time_collection()
self.assertEqual([t['type'] for t in times], ['select', 'read_binary'])
self.assertGreaterEqual(times[0]['ms'], 20)
self.assertFalse(S._APDU_TIME_COLLECT)
self.assertEqual(S._APDU_TIMES, [])
def test_collect_reattaches_tracer_when_missing(self):
tp = types.SimpleNamespace(apdu_tracer=None)
scc = types.SimpleNamespace(_tp=tp)
S._server_ref = types.SimpleNamespace(scc=scc)
S._collect_apdu_times()
try:
self.assertIsInstance(tp.apdu_tracer, S._LoggingApduTracer)
finally:
S._end_apdu_time_collection()
def test_collect_keeps_existing_tracer(self):
tracer = S.StderrApduTracer()
tp = types.SimpleNamespace(apdu_tracer=tracer)
scc = types.SimpleNamespace(_tp=tp)
S._server_ref = types.SimpleNamespace(scc=scc)
S._collect_apdu_times()
try:
self.assertIs(tp.apdu_tracer, tracer)
finally:
S._end_apdu_time_collection()
if __name__ == '__main__':
unittest.main()