Files
otaman/frontend/index.html
T
catarrh 56c0e60462 ui: tooltip on the status dot shows the server state
- the dot carries its own title (Connecting... / No server connection),
  matching the wrapper tooltip; it is cleared when the sim icons replace
  the dot
- tests assert the dot title in both server states and its removal once
  the server is up; SW cache v110 -> v111.
2026-09-12 19:55:14 +03:00

9376 lines
446 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OTAMan</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" href="sim.png" sizes="462x462">
<link rel="manifest" href="manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="icon-192.png">
<script src="des-bundle.js"></script>
<script src="aes-bundle.js"></script>
</head>
<body class="bg-neutral-50 dark:bg-slate-900 text-gray-800 dark:text-slate-200">
<div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.0.0</span></h1>
<div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
<img id="state-indicator-img" class="hidden dark:invert" style="width:18px;height:18px" alt="" src="nosim.svg">
</span>
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
<a id="help-link" href="help.html" target="_blank" rel="noopener" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300" data-l10n="help">help</a>
<button onclick="toggleLang()" id="lang-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">EN</button>
<button onclick="toggleTheme()" id="theme-btn" class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">🌙</button>
</div>
</div>
<div class="flex flex-wrap gap-1 mb-4 border-b border-gray-300 dark:border-slate-600">
<button class="tab-btn active px-4 py-1.5 text-sm rounded-t bg-blue-600 dark:bg-blue-500 text-white dark:text-white" data-tab="c-apdu">Remote APDU</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="scp80">SCP80</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="pysim" data-l10n="Card reader">Card reader</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="profiler" data-l10n="Profiler">Profiler</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="phone" data-l10n="Phone simulator">Phone simulator</button>
</div>
<div id="tab-c-apdu" class="tab-content">
<div class="flex gap-4">
<div class="flex-1">
<div class="flex flex-wrap justify-center gap-2 mb-3">
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-sub="sim" onclick="cApduSwitchSubtab('sim')">SIM RFM</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="usim" onclick="cApduSwitchSubtab('usim')">USIM RFM</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="ber" onclick="cApduSwitchSubtab('ber')">Expanded Script</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="ram" onclick="cApduSwitchSubtab('ram')">RAM/GP</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="httpota" onclick="cApduSwitchSubtab('httpota')">HTTP OTA</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="parse" onclick="cApduSwitchSubtab('parse')" data-l10n="C-APDU Parser">C-APDU Parser</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="response" onclick="cApduSwitchSubtab('response')" data-l10n="Response parser">Response parser</button>
</div>
<div id="c-apdu-sub-sim">
<div id="chain-sim-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-sim','select')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SELECT</button>
<button onclick="chainAddRow('chain-sim','read-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ RECORD</button>
<button onclick="chainAddRow('chain-sim','read-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ BINARY</button>
<button onclick="chainAddRow('chain-sim','update-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-sim','update-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-sim','erase-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ERASE BINARY</button>
<button onclick="chainAddRow('chain-sim','activate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-sim','deactivate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-sim','verify')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-sim','change')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-sim','disable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-sim','enable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-sim','unblock')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-sim','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
</div>
<textarea id="chain-sim-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-sim-preview')" id="chain-sim-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
</div>
<div id="c-apdu-sub-usim" class="hidden">
<div id="chain-usim-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-usim','select')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SELECT</button>
<button onclick="chainAddRow('chain-usim','read-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ RECORD</button>
<button onclick="chainAddRow('chain-usim','read-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ BINARY</button>
<button onclick="chainAddRow('chain-usim','update-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-usim','update-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-usim','activate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-usim','deactivate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-usim','verify')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-usim','change')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-usim','disable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-usim','enable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-usim','unblock')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-usim','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
</div>
<textarea id="chain-usim-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-usim-preview')" id="chain-usim-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
</div>
<div id="c-apdu-sub-ber" class="hidden">
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Format</label>
<select id="ber-format" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 dark:text-slate-300 font-mono">
<option value="AA">Definite (AA)</option>
<option value="AE">Indefinite (AE)</option>
</select>
</div>
<div class="mb-3">
<div class="flex flex-wrap gap-1 mb-2 items-center">
<span class="text-sm font-medium text-gray-700 dark:text-slate-300 mr-1">Commands</span>
<button onclick="addBerRow('c-apdu')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ C-APDU</button>
<button onclick="addBerRow('immediate')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Immediate</button>
<button onclick="addBerRow('error')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Error</button>
<button onclick="addBerRow('chaining')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Chaining</button>
</div>
<div id="ber-rows" class="space-y-2"></div>
</div>
<button onclick="genBerTlv()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate Expanded Script APDU">Generate Expanded Script APDU</button>
<textarea id="ber-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800 dark:text-slate-300"></textarea>
<button onclick="packToSp('ber-result')" id="ber-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
</div>
<div id="c-apdu-sub-ram" class="hidden">
<div id="chain-ram-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-ram','install-load')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for load]</button>
<button onclick="chainAddRow('chain-ram','install-install')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for install]</button>
<button onclick="chainAddRow('chain-ram','install-make-sel')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for make selectable]</button>
<button onclick="chainAddRow('chain-ram','install-reg-update')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for registry update]</button>
<button onclick="chainAddRow('chain-ram','install-extradition')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for extradition]</button>
<button onclick="chainAddRow('chain-ram','load')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ LOAD</button>
<button onclick="chainAddRow('chain-ram','delete')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DELETE</button>
<button onclick="chainAddRow('chain-ram','get-status')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ GET STATUS</button>
<button onclick="chainAddRow('chain-ram','get-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ GET DATA</button>
<button onclick="chainAddRow('chain-ram','store-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ STORE DATA</button>
<button onclick="chainAddRow('chain-ram','set-status')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SET STATUS</button>
<button onclick="chainAddRow('chain-ram','ext-auth')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ EXTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','int-auth')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
</div>
<textarea id="chain-ram-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-ram-preview')" id="chain-ram-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
</div>
<div id="c-apdu-sub-parse" class="hidden">
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="APDU hex">APDU hex</label>
<textarea id="parse-input" rows="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="80 E6 0C 00 ..."></textarea>
</div>
<button onclick="parseHex()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Decode">Decode</button>
<div id="parse-output"></div>
</div>
<div id="c-apdu-sub-httpota" class="hidden">
<div class="flex gap-2 mb-3">
<button id="hota-mode-trigger" onclick="hotaSetMode('trigger')" class="px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-l10n="Trigger (Push SMS)">Trigger (Push SMS)</button>
<button id="hota-mode-store" onclick="hotaSetMode('store')" class="px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Store (SD admin params)">Store (SD admin params)</button>
</div>
<div id="hota-store-tag-row" class="mb-3 hidden">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="SD params tag">SD params tag</label>
<select id="hota-store-tag" onchange="hotaUpdatePreview()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
<option value="85">'85' — GP v2.2 Amendment B v1.1 (Table 4-4)</option>
<option value="A5">'A5' — GP v2.2 Amendment B v1.1.3</option>
</select>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-conn" checked onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Connection parameters ('84')">Connection parameters ('84')</span>
</label>
<div id="hota-conn-tlvs" class="hidden"></div>
<div class="flex flex-wrap gap-1 mt-2">
<button onclick="hotaAddTlv()" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ TLV">+ TLV</button>
<button onclick="hotaAddPreset('devid')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Device Identities">+ Device Identities</button>
<button onclick="hotaAddPreset('alpha')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Alpha (empty)">+ Alpha (empty)</button>
<button onclick="hotaAddPreset('bearer')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Bearer (GPRS)">+ Bearer (GPRS)</button>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-sec" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Security parameters ('85')">Security parameters ('85')</span>
</label>
<div id="hota-sec-fields" class="hidden space-y-2">
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="PSK Identity (text)">PSK Identity (text)</label>
<input id="hota-psk-text" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="89012345678901234567">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400">KVN</label>
<input id="hota-kvn" maxlength="2" value="01" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400">KID</label>
<input id="hota-kid" maxlength="2" value="01" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
</div>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-retry" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Retry policy ('86')">Retry policy ('86')</span>
</label>
<div id="hota-retry-fields" class="hidden space-y-2">
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Retry counter (B0,00=unlimited)">Retry counter (B0,00=unlimited)</label>
<input id="hota-retry-counter" maxlength="4" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="B000">
</div>
</div>
<div class="flex gap-2">
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Hours">Hours</label>
<input id="hota-delay-h" type="number" min="0" max="255" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Minutes">Minutes</label>
<input id="hota-delay-m" type="number" min="0" max="59" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
</div>
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Seconds">Seconds</label>
<input id="hota-delay-s" type="number" min="0" max="59" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
</div>
</div>
<div class="flex gap-2 items-end">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Report failure (vendor-specific TLV)">Report failure (vendor-specific TLV)</label>
<input id="hota-retry-report" maxlength="40" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0A08...">
</div>
</div>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
<input type="checkbox" id="hota-include-http" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="HTTP POST ('89', 8A=host,8B=agent,8C=URI)">HTTP POST ('89', 8A=host,8B=agent,8C=URI)</span>
</label>
<div id="hota-http-fields" class="hidden space-y-2">
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Host (text→hex)">Host (text→hex)</label>
<input id="hota-host" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="example.com">
</div>
</div>
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="HTTP agent ID (text, X-Admin-From)">HTTP agent ID (text, X-Admin-From)</label>
<input id="hota-agent" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="89012345678901234567">
</div>
</div>
<div class="flex gap-2">
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="URI (text→hex)">URI (text→hex)</label>
<input id="hota-uri" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="http://example.com/sdp">
</div>
</div>
</div>
</div>
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3 hidden" id="hota-expanded-row">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
<input type="checkbox" id="hota-expanded" onchange="hotaUpdatePreview()" class="rounded">
<span data-l10n="Wrapped in Command Scripting template ('AA')">Wrapped in Command Scripting template ('AA')</span>
</label>
</div>
<textarea id="hota-preview" rows="5" readonly oninput="hotaPreviewEdited()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800 mb-3" placeholder="RAM-over-HTTP payload will appear here..."></textarea>
<button onclick="packToSp('hota-preview')" id="hota-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
</div>
<div id="c-apdu-sub-response" class="hidden">
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Command">Command</label>
<select id="resp-cmd" onchange="updateRespCmd()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="" data-l10n="— Select command —">— Select command —</option>
<optgroup label="SIM / USIM">
<option value="sim-select">SELECT</option>
<option value="sim-status">STATUS</option>
<option value="sim-read-binary">READ BINARY</option>
<option value="sim-update-binary">UPDATE BINARY</option>
<option value="sim-read-record">READ RECORD</option>
<option value="sim-update-record">UPDATE RECORD</option>
<option value="sim-verify">VERIFY PIN</option>
<option value="sim-change">CHANGE PIN</option>
<option value="sim-disable">DISABLE PIN</option>
<option value="sim-enable">ENABLE PIN</option>
<option value="sim-unblock">UNBLOCK PIN</option>
<option value="sim-deactivate">DEACTIVATE FILE</option>
<option value="sim-activate">ACTIVATE FILE</option>
<option value="sim-challenge">GET CHALLENGE</option>
<option value="sim-auth">AUTHENTICATE</option>
<option value="sim-profile">TERMINAL PROFILE</option>
<option value="sim-envelope">ENVELOPE</option>
<option value="sim-fetch">FETCH</option>
<option value="sim-terminal-rsp">TERMINAL RESPONSE</option>
<option value="sim-manage-channel">MANAGE CHANNEL</option>
<option value="sim-msc">MANAGE SECURE CHANNEL</option>
<option value="sim-retrieve">RETRIEVE DATA</option>
<option value="sim-set-data">SET DATA</option>
<option value="sim-suspend">SUSPEND UICC</option>
</optgroup>
<optgroup label="RAM (GlobalPlatform)">
<option value="ram-install-load">INSTALL [for load]</option>
<option value="ram-install-install">INSTALL [for install]</option>
<option value="ram-install-make-sel">INSTALL [for make selectable]</option>
<option value="ram-install-reg-update">INSTALL [for registry update]</option>
<option value="ram-install-extradition">INSTALL [for extradition]</option>
<option value="ram-load">LOAD</option>
<option value="ram-delete">DELETE</option>
<option value="ram-get-status">GET STATUS</option>
<option value="ram-get-data">GET DATA</option>
<option value="ram-store-data">STORE DATA</option>
<option value="ram-set-status">SET STATUS</option>
<option value="ram-ext-auth">EXTERNAL AUTHENTICATE</option>
<option value="ram-int-auth">INTERNAL AUTHENTICATE</option>
<option value="ram-select">SELECT</option>
<option value="ram-init-update">INITIALIZE UPDATE</option>
<option value="ram-put-key">PUT KEY</option>
<option value="ram-manage-channel">MANAGE CHANNEL</option>
</optgroup>
</select>
</div>
<div class="flex gap-3 mb-3">
<div class="w-24">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SW</label>
<input id="resp-sw" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" maxlength="4" placeholder="9000">
</div>
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Context</label>
<input id="resp-ctx" readonly class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" value="generic">
</div>
</div>
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Response data (hex)">Response data (hex)</label>
<textarea id="resp-data" rows="3" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="E3 1A 4F 08 A0 00 00 00 03 00 00 00 9F 70 01 07 C5 03 80 00 00"></textarea>
</div>
<button onclick="decodeResponse()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Decode">Decode</button>
<div id="resp-output" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800 whitespace-pre-wrap" style="min-height:100px"></div>
</div>
</div>
<div class="w-1/4">
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800">
<select id="conv-type" onchange="updateConvFields()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-2">
<option value="nibswap">Nibble swap</option>
<option value="imsi">IMSI → EF.IMSI</option>
<option value="msisdn">MSISDN → BCD</option>
<option value="iccid">ICCID → hex</option>
<option value="spn">Provider Name → SPN</option>
<option value="plmn">MCC,MNC → PLMNsel</option>
<option value="plmnwact">MCC,MNC,AcT → PLMNwAcT</option>
</select>
<div class="flex flex-col gap-2 mb-2">
<input id="conv-input" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
<button onclick="genConvert()" class="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700" data-l10n="Convert">Convert</button>
</div>
<div id="conv-plmn-fields" class="" style="display:none">
<div class="flex gap-2 mb-2">
<div class="flex-1">
<input id="conv-mcc" maxlength="3" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="MCC">
</div>
<div class="flex-1">
<input id="conv-mnc" maxlength="3" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="MNC">
</div>
</div>
<div id="conv-act-row" class="" style="display:none">
<select id="conv-act" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
<option value="8000">GSM (8000)</option>
<option value="4000">UTRAN (4000)</option>
<option value="2000">E-UTRAN (2000)</option>
<option value="1000">NGRAN (1000)</option>
<option value="C000">GSM + UTRAN (C000)</option>
<option value="6000">UTRAN + E-UTRAN (6000)</option>
<option value="E000">GSM + UTRAN + E-UTRAN (E000)</option>
<option value="F000">All (F000)</option>
</select>
</div>
</div>
<textarea id="conv-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 mt-2 bg-white dark:bg-slate-800"></textarea>
</div>
</div>
</div>
</div>
<div id="tab-scp80" class="tab-content hidden">
<div class="flex flex-wrap justify-center gap-2 mb-3">
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-scp80-sub="sp" onclick="scp80SwitchSubtab('sp')" data-l10n="Secured packet">Secured packet</button>
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="cards" onclick="scp80SwitchSubtab('cards')" data-l10n="Cards">Cards</button>
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="ram" onclick="scp80SwitchSubtab('ram')" data-l10n="RAM">RAM</button>
</div>
<div id="scp80-sub-sp">
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card presets">Card presets</label>
<select id="sp-card-sel" onchange="cardsApply(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="" data-l10n="— Select card —">— Select card —</option>
</select>
</div>
<div class="flex gap-2 mb-3">
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SPI1 (Security Parameter Indicator)</label>
<div class="flex gap-2 items-center">
<select id="sp-spi1" onchange="updateSp()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="00">00 — No security</option>
<option value="01">01 — RC only</option>
<option value="02">02 — CC/MAC only</option>
<option value="06" selected>06 — CC/MAC + Ciphering</option>
<option value="0A">0A — CC/MAC + Counter (available)</option>
<option value="0E">0E — CC/MAC + Ciphering + Counter (available)</option>
<option value="12">12 — CC/MAC + Counter (higher)</option>
<option value="16">16 — CC/MAC + Ciphering + Counter (higher)</option>
<option value="1A">1A — CC/MAC + Counter (+1)</option>
<option value="1E">1E — CC/MAC + Ciphering + Counter (+1)</option>
</select>
<input id="sp-spi1-hex" readonly class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="06">
</div>
</div>
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SPI2 (PoR settings)</label>
<div class="flex gap-2 items-center">
<select id="sp-spi2" onchange="updateSp()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="00" selected>00 — No PoR, no security</option>
<option value="01">01 — PoR required, no security</option>
<option value="05">05 — PoR required, RC</option>
<option value="09">09 — PoR required, CC</option>
<option value="0D">0D — PoR required, DS</option>
<option value="11">11 — PoR required, no security, ciphered</option>
<option value="02">02 — PoR on error, no security</option>
<option value="06">06 — PoR on error, RC</option>
</select>
<select id="sp-spi2-sm" onchange="updateSp()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="0">DELIVER-REPORT</option>
<option value="1">SUBMIT SM</option>
</select>
<input id="sp-spi2-hex" readonly class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="00">
</div>
</div>
</div>
<div class="flex gap-2 mb-3">
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KIc (Ciphering Key Identifier)</label>
<div class="flex gap-2 items-center">
<select id="sp-kic-idx" onchange="updateSpKic()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="0">Implicit (0)</option>
<option value="1" selected>1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
<option value="D">D</option>
<option value="E">E</option>
<option value="F">F</option>
</select>
<select id="sp-kic-alg" onchange="updateSpKic()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="01">01 — DES-CBC (8B key)</option>
<option value="02">02 — AES-CBC (16/24/32B key)</option>
<option value="05" selected>05 — 3DES 2-key outer-CBC (16B key)</option>
<option value="09">09 — 3DES 3-key outer-CBC (24B key)</option>
<option value="0D">0D — DES-ECB (8B key)</option>
</select>
<input id="sp-kic-hex" readonly class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="15">
</div>
</div>
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KID (MAC Key Identifier)</label>
<div class="flex gap-2 items-center">
<select id="sp-kid-idx" onchange="updateSpKid()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="0">Implicit (0)</option>
<option value="1" selected>1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
<option value="D">D</option>
<option value="E">E</option>
<option value="F">F</option>
</select>
<select id="sp-kid-alg" onchange="updateSpKid()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="01">01 — DES-CBC (8B key)</option>
<option value="02">02 — AES-CMAC (16/24/32B key)</option>
<option value="05" selected>05 — 3DES 2-key outer-CBC (16B key)</option>
<option value="09">09 — 3DES 3-key outer-CBC (24B key)</option>
</select>
<input id="sp-kid-hex" readonly class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="15">
</div>
</div>
</div>
<div class="flex gap-2 mb-3">
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KIc Key (hex)</label>
<textarea id="sp-kic-key" rows="2" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="16 bytes key"></textarea>
</div>
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KID Key (hex)</label>
<textarea id="sp-kid-key" rows="2" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="16 bytes key"></textarea>
</div>
</div>
<div class="flex gap-2 mb-3">
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label>
<input id="sp-tar" oninput="spInvalidate()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001">
</div>
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Counter (5 bytes)</label>
<div class="flex gap-1 items-center">
<button type="button" onclick="spCntrAdjust(-1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600"></button>
<input id="sp-cntr" oninput="spInvalidate()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 flex-1 dark:bg-slate-800" placeholder="0000000001" maxlength="10" value="0000000001">
<button type="button" onclick="spCntrAdjust(1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">+</button>
</div>
</div>
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Padding byte</label>
<select id="sp-padding" onchange="spInvalidate()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="00" selected>00 (TS 102 225 default)</option>
<option value="FF">FF</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">APDU (hex)</label>
<textarea id="sp-apdu" rows="3" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></textarea>
</div>
<button onclick="genSp()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate Secure Packet">Generate Secure Packet</button>
<button data-needs="server" onclick="pysimVerifySp()" id="sp-verify-btn" class="hidden mb-3 px-5 py-2.5 bg-slate-600 text-white text-sm font-medium rounded hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Verify vs pySim">Verify vs pySim</button>
<button data-needs="card" onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send to Card">Send to Card</button>
<div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div>
<textarea id="sp-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
</div>
<div id="scp80-sub-cards" class="hidden">
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored locally in your browser. JSON export/import buttons are below the card list.">Card presets are stored locally in your browser. JSON export/import buttons are below the card list.</p>
<div class="flex gap-2 mb-3">
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)">
<input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)">
<button onclick="cardsAdd()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
</div>
<div class="flex gap-2 mb-3 flex-wrap">
<input id="cards-kic" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KIc" maxlength="2">
<input id="cards-kid" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KID" maxlength="2">
<input id="cards-spi1" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI1" maxlength="2">
<input id="cards-spi2" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI2" maxlength="2">
<input id="cards-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="ISD TAR" maxlength="6">
<input id="cards-cntr" class="w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="Counter" maxlength="10">
</div>
<div class="flex gap-2 mb-3">
<input id="cards-kic-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KIc key (32 hex chars)">
<input id="cards-kid-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KID key (32 hex chars)">
</div>
<div id="cards-list" class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Name">Name</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KIc</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KID</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI1</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI2</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">TAR</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Cntr">Cntr</th>
<th class="border-b border-gray-200 dark:border-slate-700"></th>
</tr></thead>
<tbody id="cards-tbody"></tbody>
</table>
</div>
<input type="file" id="cards-file" accept=".json,application/json" class="hidden" onchange="cardsImportFile(this)">
<div class="flex flex-wrap gap-2 mt-2">
<button onclick="cardsExport()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export as JSON">Export as JSON</button>
<button onclick="cardsExportFile()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export to file">Export to file</button>
<button onclick="document.getElementById('cards-file').click()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import from file">Import from file</button>
<button onclick="cardsImportField()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Paste & import">Paste &amp; import</button>
<button onclick="cardsImport()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import JSON from clipboard">Import JSON from clipboard</button>
</div>
<textarea id="cards-io" rows="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 hidden" placeholder="JSON data"></textarea>
</div>
<div id="scp80-sub-ram" class="hidden">
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.">RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.</p>
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card preset">Card preset</label>
<select id="ram-card-sel" onchange="ramApplyCard(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="" data-l10n="— Select card —">— Select card —</option>
</select>
</div>
<div class="mb-3 flex gap-2 items-end">
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label>
<select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
</select>
</div>
<button data-needs="card" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
</div>
<div id="ram-install-params" class="hidden mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
<input type="file" id="ram-cap-file" accept=".cap,.zip" class="w-full text-sm text-gray-600 dark:text-slate-300">
<div id="ram-cap-info" class="text-xs text-gray-500 mt-1 hidden"></div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
<input id="ram-sd-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
<div id="rc-priv-row" class="mb-3 mt-2">
<div class="flex items-center gap-3 mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Privileges">Privileges</label>
<input id="rc-priv" readonly class="font-mono w-20 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 bg-gray-100 dark:bg-slate-800" value="00">
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="font-medium text-gray-700 dark:text-slate-300 mb-1" data-l10n="Byte 1">Byte 1</div>
<div class="font-medium text-gray-700 dark:text-slate-300 mb-1" data-l10n="Byte 2">Byte 2</div>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="80" onchange="updateRcPriv()"> Security Domain</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="80" onchange="updateRcPriv()"> Trusted Path</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="40" onchange="updateRcPriv()"> DAP Verification</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="40" onchange="updateRcPriv()"> Authorized Management</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="20" onchange="updateRcPriv()"> Delegated Management</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="20" onchange="updateRcPriv()"> Token Verification</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="10" onchange="updateRcPriv()"> Card Lock</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="10" onchange="updateRcPriv()"> Global Delete</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="08" onchange="updateRcPriv()"> Card Terminate</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="08" onchange="updateRcPriv()"> Global Lock</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="04" onchange="updateRcPriv()"> Card Reset</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="04" onchange="updateRcPriv()"> Global Registry</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="02" onchange="updateRcPriv()"> CVM Management</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="01" onchange="updateRcPriv()"> Mandated DAP Verification</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="02" onchange="updateRcPriv()"> Final Application</label>
<div></div>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b3" value="80" onchange="updateRcPriv()"> Receipt Generation</label>
</div>
</div>
<div id="rc-toolkit-row" class="mb-3">
<div class="mb-2">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
<input id="rc-toolkit-enable" type="checkbox" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="updateRcToolkit()">
SIM/UICC Toolkit parameters
</label>
</div>
<div id="rc-toolkit-body" class="hidden">
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Toolkit mode">Toolkit mode</label>
<select id="rc-tk-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="ca">SIM Toolkit (Tag CA)</option>
<option value="ea">UICC Toolkit (Tag EA)</option>
</select>
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Priority">Priority</label>
<input id="rc-tk-priority" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Timers (max)">Timers (max)</label>
<input id="rc-tk-timers" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Menu text length">Menu text length</label>
<input id="rc-tk-textlen" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Menu items">Menu items</label>
<input id="rc-tk-menus" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="First menu position">First menu position</label>
<input id="rc-tk-firstpos" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="First menu ID (hex)">First menu ID (hex)</label>
<input id="rc-tk-firstid" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Last menu position">Last menu position</label>
<input id="rc-tk-lastpos" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Last menu ID (hex)">Last menu ID (hex)</label>
<input id="rc-tk-lastid" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Channels (max)">Channels (max)</label>
<input id="rc-tk-channels" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">MSL (SPI1, hex)</label>
<select id="rc-tk-msl" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
<option value="00">00 — no verification</option>
<option value="11">11 — RC/CC/DS</option>
<option value="12">12 — RC/DS/CC</option>
<option value="15">15 — RC/DS/CC + MAC</option>
<option value="16" selected>16 — RC/DS/CC + MAC + Cipher</option>
<option value="19">19 — RC/DS/CC + MAC + Cipher + DS</option>
</select>
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label>
<input id="rc-tk-tar" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001">
</div>
<div id="rc-tk-ad-row">
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Access domain (hex)">Access domain (hex)</label>
<input id="rc-tk-ad" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
</div>
<div id="rc-tk-services-row">
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Max services">Max services</label>
<input id="rc-tk-services" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div>
</div>
</div>
</div>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Install parameters (hex, optional)">Install parameters (hex, optional)</label>
<input id="ram-install-params-hex" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="C9 TLV">
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="STK parameters (hex, optional)">STK parameters (hex, optional)</label>
<input id="ram-stk-params" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="CA TLV">
<label class="flex items-center gap-2 mt-2">
<input type="checkbox" id="ram-make-sel" checked> <span class="text-sm" data-l10n="Make selectable">Make selectable</span>
</label>
</div>
<div id="ram-progress" class="hidden mb-3">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400">
<div class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<span id="ram-progress-text" data-l10n="Working...">Working...</span>
</div>
</div>
<div id="ram-result" class="mt-2 text-base font-semibold font-mono hidden"></div>
<div id="ram-explorer" class="mt-2 text-xs font-mono hidden"></div>
<div id="ram-steps" class="mt-2 text-xs font-mono whitespace-pre-wrap hidden"></div>
</div>
</div>
<div id="tab-pysim" class="tab-content hidden">
<div id="pysim-connect-row" class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Server URL">Server URL</label>
<div class="flex gap-2 items-center">
<input id="pysim-url" class="font-mono flex-1 max-w-md border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" value="http://127.0.0.1:8080">
<button onclick="pysimConnect()" id="pysim-connect-btn" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Connect">Connect</button>
</div>
</div>
<div id="pysim-connect-info" class="mb-3 text-xs text-gray-500 dark:text-slate-400 leading-relaxed">
<p data-l10n="To access local card reader, you need pysim-otaman-server running locally.">To access local card reader, you need pysim-otaman-server running locally.</p>
<p><span data-l10n="To connect, install">To connect, install</span> <a href="https://github.com/anttro/otaman" target="_blank" class="text-blue-600 dark:text-blue-400 underline">pysim-otaman-server</a>: <code class="font-mono bg-gray-100 dark:bg-slate-700 px-1 rounded">git clone https://github.com/anttro/otaman.git</code> <span data-l10n="and start it.">and start it.</span><br><span data-l10n="Refer to pysim-otaman-server README files for details.">Refer to pysim-otaman-server README files for details.</span> <span data-l10n="Click">Click</span> <b data-l10n="Connect">Connect</b> <span data-l10n="when pysim-otaman-server is ready">when pysim-otaman-server is ready</span></p>
<div id="pysim-connect-msg" class="text-xs"></div>
</div>
<div id="pysim-connected-row" class="mb-3 hidden">
<div class="flex gap-3 items-start">
<img id="pysim-status-img" src="nosim.svg" alt="pysim" class="w-16 h-16 shrink-0 dark:invert">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<button data-needs="server" onclick="pysimEquipCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Equip card">Equip card</button>
<button data-needs="card" onclick="pysimStatusCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Check status">Check status</button>
<button data-needs="card" onclick="pysimResetCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Reset card">Reset card</button>
<button onclick="pysimDisconnect()" id="pysim-disconnect-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Disconnect from server">Disconnect from server</button>
</div>
<div id="pysim-status" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
</div>
</div>
</div>
<div id="pysim-body" class="hidden">
<div class="flex flex-wrap justify-center gap-1 mb-2">
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-blue-600 text-white" data-pysim-sub="files" data-l10n="File manager">File manager</button>
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="cmd" data-l10n="pySim command line">pySim command line</button>
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="apdu" data-l10n="Raw APDU">Raw APDU</button>
</div>
<div id="pysim-sub-files">
<div class="flex gap-2">
<div class="w-1/3 border border-gray-300 dark:border-slate-600 rounded p-2 text-xs font-mono overflow-y-auto bg-gray-50 dark:bg-slate-800" style="max-height:420px">
<div class="flex items-center gap-1 mb-1 text-gray-500 dark:text-slate-400">
<span data-l10n="Sort:">Sort:</span>
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
</div>
<div id="pysim-fs-tree"></div>
</div>
<div class="flex-1">
<div id="pysim-fs-detail" class="hidden">
<div class="text-xs font-mono mb-1"><span data-l10n="File:">File:</span> <b id="pysim-fs-filename"></b></div>
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
<div class="flex gap-2 mt-1 items-center">
<button data-needs="card" onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Read">Read</button>
<button data-needs="card" onclick="pysimFsSave()" id="pysim-fs-save-btn" class="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 hidden disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
<button onclick="pysimFsCancel()" id="pysim-fs-cancel-btn" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 hidden" data-l10n="Cancel">Cancel</button>
<span class="text-xs text-gray-400 dark:text-slate-500">|</span>
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white" data-mode="raw" onclick="pysimFsSetMode('raw')" data-l10n="Raw">Raw</span>
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-mode="dec" onclick="pysimFsSetMode('dec')" data-l10n="Decoded">Decoded</span>
<button data-needs="card" onclick="pysimFsEdit()" id="pysim-fs-edit-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 ml-auto disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Edit">Edit</button>
</div>
<div id="pysim-fs-status" class="mt-1 text-xs font-mono text-gray-500 dark:text-slate-400" style="min-height:1.2em"></div>
</div>
</div>
</div>
</div>
<div id="pysim-sub-cmd" class="hidden">
<div class="flex gap-2 items-center mb-2">
<div class="relative flex-1">
<input id="pysim-cmd" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="select MF" autocomplete="off" onkeydown="pysimCmdKeydown(event)" oninput="pysimCmdInput()">
<div id="pysim-cmd-suggest" class="hidden absolute bottom-full left-0 right-0 mb-1 max-h-32 overflow-y-auto border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-800 text-xs font-mono shadow-lg z-10"></div>
</div>
<button data-needs="server" onclick="pysimExec()" class="px-4 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
</div>
<div id="pysim-cmd-hint" class="hidden mb-2 text-xs font-mono text-gray-500 dark:text-slate-400 border border-gray-200 dark:border-slate-700 rounded p-2 bg-gray-50 dark:bg-slate-800 whitespace-pre-wrap"></div>
<textarea id="pysim-cmd-output" rows="10" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
</div>
<div id="pysim-sub-apdu" class="hidden">
<div class="flex gap-2 items-center mb-2">
<input id="pysim-apdu" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="00A4040000" onkeydown="if(event.key==='Enter')pysimApdu()">
<button data-needs="card" onclick="pysimApdu()" class="px-4 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send">Send</button>
</div>
<textarea id="pysim-apdu-output" rows="5" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
</div>
</div>
</div>
<div id="tab-profiler" class="tab-content hidden">
<div id="profiler-list">
<div class="flex flex-wrap justify-center gap-2 mb-3">
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-full bg-blue-600 dark:bg-blue-500 text-white dark:text-white" data-list-tab="profiles" onclick="profilerListSwitch('profiles')" data-l10n="Profiles">Profiles</button>
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-list-tab="snapshots" onclick="profilerListSwitch('snapshots')" data-l10n="Card snapshots">Card snapshots</button>
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-list-tab="custom" onclick="profilerListSwitch('custom')" data-l10n="Custom files">Custom files</button>
</div>
<div id="profiler-list-profiles">
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
<button data-needs="card" onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Profile from card">Profile from card</button>
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button>
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
</div>
<div id="profiler-list-body"></div>
</div>
<div id="profiler-list-snapshots" class="hidden">
<div class="flex flex-wrap gap-2 mb-3">
<button data-needs="card" onclick="snapshotNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="New snapshot">New snapshot</button>
<button onclick="document.getElementById('snapshot-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import snapshot">Import snapshot</button>
<button onclick="snapshotCompareOpen()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Compare snapshots">Compare snapshots</button>
<input type="file" id="snapshot-import-file" accept=".json,application/json" class="hidden" onchange="snapshotImportFile(this)">
</div>
<div id="snapshot-list-body"></div>
</div>
<div id="profiler-list-custom" class="hidden">
<div class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400" data-l10n="Add known file paths and aliases to the file tree.">Add known file paths and aliases to the file tree.</div>
<div class="flex gap-2 items-center mb-2">
<input id="pysim-cf-path" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="3F00/6F46" onkeydown="if(event.key==='Enter')pysimCustomAdd()">
<input id="pysim-cf-name" class="font-mono w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomAdd()">
<button onclick="pysimCustomAdd()" class="px-3 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
</div>
<div id="pysim-cf-list" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
<input type="file" id="pysim-cf-file" accept=".json,application/json" class="hidden" onchange="pysimCustomImportFile(this)">
<div class="flex flex-wrap gap-2 mt-2">
<button onclick="pysimCustomExport()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export as JSON">Export as JSON</button>
<button onclick="pysimCustomExportFile()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export to file">Export to file</button>
<button onclick="document.getElementById('pysim-cf-file').click()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import from file">Import from file</button>
<button onclick="pysimCustomImportField()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Paste & import">Paste &amp; import</button>
<button onclick="pysimCustomImport()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import JSON from clipboard">Import JSON from clipboard</button>
</div>
<textarea id="pysim-cf-io" class="hidden mt-2 w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800" rows="6"></textarea>
</div>
</div>
<div id="profiler-editor" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
<input id="profiler-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Profile name">
<button onclick="profilerAddRule()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Add rule">Add rule</button>
<button onclick="profilerSaveProfile()" 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 id="profiler-rules"></div>
</div>
<div id="profiler-results" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
<span id="profiler-results-title" class="text-sm font-semibold"></span>
<label class="ml-auto flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400 cursor-pointer">
<input id="profiler-mismatch-only" type="checkbox" onchange="profilerToggleMismatchOnly(this.checked)">
<span data-l10n="Only mismatches">Only mismatches</span>
</label>
</div>
<div id="profiler-progress" class="mb-2 text-base font-mono text-gray-600 dark:text-slate-400"></div>
<div id="profiler-summary" class="mb-2 text-sm"></div>
<div id="profiler-report"></div>
</div>
<div id="profiler-snapshot" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
<input id="snapshot-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Snapshot name">
<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>
</div>
<div id="snapshot-summary" class="mb-3"></div>
<div id="snapshot-files"></div>
</div>
</div>
<div id="tab-phone" class="tab-content hidden">
<div class="flex flex-wrap justify-center gap-2 mb-3">
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-phone-sub="phone" onclick="phoneSwitchSubtab('phone')" data-l10n="Phone">Phone</button>
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-phone-sub="tr" onclick="phoneSwitchSubtab('tr')" data-l10n="TR Config">TR Config</button>
</div>
<div id="phone-sub-phone">
<div class="flex flex-wrap items-start gap-4 mb-4">
<div class="border border-gray-200 dark:border-slate-700 rounded p-3">
<div class="mb-1 text-sm text-gray-500 dark:text-slate-400" data-l10n="STK menu">STK menu</div>
<div class="flex items-center gap-2">
<button id="stk-menu-btn" data-needs="card" onclick="stkMenuOpen()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" style="display:none">STK: <span id="stk-menu-title"></span></button>
<span id="stk-menu-none" class="text-xs text-gray-500 dark:text-slate-400" style="display:none" data-l10n="No menu set by the card">No menu set by the card</span>
</div>
</div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3">
<div class="mb-1 text-sm text-gray-500 dark:text-slate-400" data-l10n="STATUS and Polling">STATUS and Polling</div>
<div class="flex items-center gap-2">
<button id="pli-status-btn" data-needs="card" onclick="pysimStatusPoll()" class="px-2.5 py-1 text-sm rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send STATUS">Send STATUS</button>
<span class="text-xs text-gray-500 dark:text-slate-400">|</span>
<span class="text-xs text-gray-500 dark:text-slate-400" data-l10n="Polling">Polling</span>
<button id="pli-pause-btn" data-needs="server" onclick="pysimPollToggle()" class="px-2.5 py-1 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed">OFF</button>
<span id="pli-poll-stat" class="text-xs text-gray-400"></span>
</div>
</div>
<div class="flex-1 min-w-0 border border-gray-200 dark:border-slate-700 rounded p-3">
<div class="mb-1 text-sm text-gray-500 dark:text-slate-400" data-l10n="Events that the card monitors (SET UP EVENT LIST):">Events that the card monitors (SET UP EVENT LIST):</div>
<div id="pysim-event-list" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
</div>
</div>
<div class="mb-2 text-sm text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</div>
<div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
</div>
<div id="phone-sub-tr" class="hidden">
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="PROVIDE LOCAL INFORMATION response data">PROVIDE LOCAL INFORMATION response data</span>
<button id="pli-save-btn" data-needs="server" onclick="pysimPliSave()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
</div>
<div id="pysim-pli-dict" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[60vh] overflow-auto">
<span class="text-gray-400" data-l10n="Loading...">Loading...</span>
</div>
</div>
</div>
<div id="event-send-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 id="event-send-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200"></h3>
<div id="event-send-body" class="space-y-3"></div>
<div id="event-send-error" class="text-xs text-red-500 hidden mt-2"></div>
<div id="event-send-result" class="text-xs text-emerald-600 dark:text-emerald-400 hidden mt-2"></div>
<div class="flex gap-2 mt-4 justify-end">
<button onclick="document.getElementById('event-send-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
<button id="event-send-btn" onclick="pysimEventSend()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Send">Send</button>
</div>
</div>
</div>
<div id="profiler-scan-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 id="profiler-scan-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
<label id="profiler-scan-name-label" class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
<input id="profiler-scan-name" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800 mb-3" placeholder="Profile name" onkeydown="profilerScanNameKeydown(event)">
<div id="profiler-scan-options">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Ignore contents of files:">Ignore contents of files:</span>
<label class="ml-auto flex items-center gap-1 text-xs text-gray-500 dark:text-slate-400 cursor-pointer">
<input type="checkbox" id="profiler-scan-ignore-all" onchange="profilerScanToggleAll(this.checked)" class="rounded">
<span data-l10n="All">All</span>
</label>
</div>
<div id="profiler-scan-ignore" class="grid grid-cols-2 gap-x-3 gap-y-1 mb-3 max-h-56 overflow-auto"></div>
<div id="profiler-scan-mask" class="space-y-1 mb-3"></div>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="FCP/FCI check">FCP/FCI check</label>
<select id="profiler-scan-fci-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-3">
<option value="type" data-l10n="Filetype only (FCP)">Filetype only (FCP)</option>
<option value="type_size" selected data-l10n="Filetype + size (FCP)">Filetype + size (FCP)</option>
<option value="exact" data-l10n="Exact FCI">Exact FCI</option>
</select>
</div>
<div id="profiler-scan-progress" class="text-xs text-gray-500 dark:text-slate-400 mb-2 hidden"></div>
<div id="profiler-scan-error" class="text-xs text-red-500 hidden mb-2"></div>
<div class="flex gap-2 justify-end">
<button id="profiler-scan-cancel-btn" onclick="profilerScanCancel()" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Cancel">Cancel</button>
<button id="profiler-scan-btn" onclick="profilerScanStart()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Scan">Scan</button>
</div>
</div>
</div>
<div id="snapshot-pick-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Select card snapshot">Select card snapshot</h3>
<div id="snapshot-pick-list" class="space-y-1 mb-4"></div>
<div class="flex gap-2 justify-end">
<button onclick="document.getElementById('snapshot-pick-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
</div>
</div>
</div>
<div id="snapshot-compare-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Compare snapshots">Compare snapshots</h3>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Master snapshot">Master snapshot</label>
<select id="snapshot-compare-master" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-3"></select>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Snapshot to check">Snapshot to check</label>
<select id="snapshot-compare-check" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-3"></select>
<div id="snapshot-compare-mask" class="space-y-1 mb-3"></div>
<div class="flex gap-2 justify-end">
<button onclick="document.getElementById('snapshot-compare-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
<button onclick="snapshotCompareRun()" class="px-4 py-1.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Compare">Compare</button>
</div>
</div>
</div>
<div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-xs w-full max-h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-3">
<button id="stk-back-btn" onclick="stkMenuRespond('back')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" style="display:none" data-l10n="← Back">← Back</button>
<span id="stk-menu-panel-title" class="font-bold text-sm flex-1 text-center"></span>
<button onclick="stkMenuExit()" class="px-3 py-1 text-xs rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50" data-l10n="Exit">Exit</button>
</div>
<div id="stk-menu-panel-content" class="flex-1 overflow-auto"></div>
<div id="stk-menu-buttons" class="flex justify-between mt-3 pt-3 border-t border-gray-200 dark:border-slate-700 hidden">
<button id="stk-timeout-btn" onclick="stkMenuRespond('timeout')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Timeout">Timeout</button>
<button id="stk-cancel-btn" onclick="stkMenuRespond('cancel')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Cancel">Cancel</button>
<button id="stk-ok-btn" onclick="stkMenuRespond('ok')" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
</div>
</div>
</div>
<script>
// ===== Tab switching =====
function switchTab(name) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.getElementById('tab-' + name).classList.remove('hidden');
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('bg-blue-600', 'text-white', 'dark:bg-blue-500', 'dark:text-white');
btn.classList.add('bg-gray-200', 'text-gray-700', 'dark:bg-slate-700', 'dark:text-slate-300');
});
const active = document.querySelector(`.tab-btn[data-tab="${name}"]`);
active.classList.remove('bg-gray-200', 'text-gray-700', 'dark:bg-slate-700', 'dark:text-slate-300');
active.classList.add('bg-blue-600', 'text-white', 'dark:bg-blue-500', 'dark:text-white');
if (name === 'c-apdu') cApduSwitchSubtab('sim');
else if (name === 'scp80') setHelpAnchor(scp80HelpAnchor);
else if (name === 'pysim') setHelpAnchor(pysimHelpAnchor);
else if (name === 'profiler') { profilerSetView('list'); setHelpAnchor('profiler'); }
else if (name === 'phone') phoneSwitchSubtab('phone');
}
let scp80HelpAnchor = 'secured-packet';
function scp80SwitchSubtab(name) {
document.querySelectorAll('.scp80-subtab').forEach(btn => {
const isActive = btn.dataset.scp80Sub === name;
btn.classList.toggle('bg-blue-600', isActive);
btn.classList.toggle('text-white', isActive);
btn.classList.toggle('bg-gray-200', !isActive);
btn.classList.toggle('dark:bg-slate-700', !isActive);
btn.classList.toggle('text-gray-700', !isActive);
btn.classList.toggle('dark:text-slate-300', !isActive);
});
document.getElementById('scp80-sub-sp').classList.toggle('hidden', name !== 'sp');
document.getElementById('scp80-sub-cards').classList.toggle('hidden', name !== 'cards');
document.getElementById('scp80-sub-ram').classList.toggle('hidden', name !== 'ram');
if (name === 'cards') cardsRender();
if (name === 'ram') ramRender();
scp80HelpAnchor = { sp: 'secured-packet', cards: 'cards', ram: 'ram' }[name] || 'secured-packet';
setHelpAnchor(scp80HelpAnchor);
}
function cApduSwitchSubtab(name) {
document.querySelectorAll('.c-apdu-subtab').forEach(btn => {
const active = btn.dataset.sub === name;
btn.classList.toggle('bg-blue-600', active);
btn.classList.toggle('text-white', active);
btn.classList.toggle('bg-gray-200', !active);
btn.classList.toggle('dark:bg-slate-700', !active);
btn.classList.toggle('text-gray-700', !active);
btn.classList.toggle('dark:text-slate-300', !active);
});
['sim', 'usim', 'ber', 'ram', 'parse', 'httpota', 'response'].forEach(s => {
const el = document.getElementById('c-apdu-sub-' + s);
if (el) el.classList.toggle('hidden', s !== name);
});
setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp', parse:'c-apdu-parser', httpota:'http-ota', response:'response-parser'}[name] || 'c-apdu');
}
function phoneSwitchSubtab(name) {
document.querySelectorAll('.phone-subtab').forEach(btn => {
const active = btn.dataset.phoneSub === name;
btn.classList.toggle('bg-blue-600', active);
btn.classList.toggle('text-white', active);
btn.classList.toggle('bg-gray-200', !active);
btn.classList.toggle('dark:bg-slate-700', !active);
btn.classList.toggle('text-gray-700', !active);
btn.classList.toggle('dark:text-slate-300', !active);
});
['phone', 'tr'].forEach(s => {
document.getElementById('phone-sub-' + s).classList.toggle('hidden', s !== name);
});
if (name === 'tr') {
pysimPliRender();
} else {
stkCheckMenu();
pysimEventsRender();
pysimProactiveLogRender();
pysimPollStatusInit();
}
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict' }[name] || 'stk-menu');
}
// ===== HTTP OTA constructor =====
let _hotaMode = 'trigger';
function _hotaHex(s) { return (s || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); }
function _hotaAsciiHex(s) { return (s || '').split('').map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('').toUpperCase(); }
function _hotaBerLen(n) {
if (n < 128) return n.toString(16).padStart(2, '0').toUpperCase();
if (n < 256) return '81' + n.toString(16).padStart(2, '0').toUpperCase();
return '82' + n.toString(16).padStart(4, '0').toUpperCase();
}
function _hotaTlv(tag8, val8) { const v = _hotaHex(val8); return tag8.toUpperCase() + _hotaBerLen(v.length / 2) + v; }
function hotaTlv(tag, value) { return _hotaTlv(tag, value); }
function hotaBuildConn(tlvs) {
let content = '';
for (const t of tlvs) {
const v = _hotaHex(t.value);
if (!t.tag) continue;
content += t.tag.toUpperCase() + _hotaBerLen(v.length / 2) + v;
}
return '84' + _hotaBerLen(content.length / 2) + content;
}
function hotaBuildSec(s) {
const psk = _hotaAsciiHex(s.pskIdentity);
const kvn = _hotaHex(s.kvn).padStart(2, '0');
const kid = _hotaHex(s.kid).padStart(2, '0');
const data = (psk.length / 2).toString(16).padStart(2, '0').toUpperCase() + psk + '02' + kvn + kid;
return '85' + _hotaBerLen(data.length / 2) + data;
}
function hotaBuildRetry(r) {
const counter = _hotaHex(r.counter);
const hh = (parseInt(r.delayH) || 0).toString(16).padStart(2, '0').toUpperCase();
const mm = (parseInt(r.delayM) || 0).toString(16).padStart(2, '0').toUpperCase();
const ss = (parseInt(r.delayS) || 0).toString(16).padStart(2, '0').toUpperCase();
const timerTlv = '2503' + hh + mm + ss;
const report = _hotaHex(r.reportFailure);
const content = counter + timerTlv + (report && report.length % 2 === 0 ? report : '');
return '86' + _hotaBerLen(content.length / 2) + content;
}
function hotaBuildHttpPost(h) {
const host = _hotaAsciiHex(h.host);
const agent = _hotaAsciiHex(h.agent);
const uri = _hotaAsciiHex(h.uri);
const content = _hotaTlv('8A', host) + _hotaTlv('8B', agent) + _hotaTlv('8C', uri);
return '89' + _hotaBerLen(content.length / 2) + content;
}
function hotaBuildTrigger(conn, sec, retry, httpPost, expanded) {
let content = conn;
if (sec) content += sec;
if (retry) content += retry;
if (httpPost) content += httpPost;
const sd = '83' + _hotaBerLen(content.length / 2) + content;
const trig = '81' + _hotaBerLen(sd.length / 2) + sd;
if (expanded) return 'AA' + _hotaBerLen(trig.length / 2) + trig;
return trig;
}
function hotaBuildStore(conn, sec, retry, httpPost, storeTag) {
const inner = _hotaTlv(storeTag, conn + (sec || '') + (retry || '') + (httpPost || ''));
return '80E29000' + _hotaBerLen(inner.length / 2) + inner;
}
function hotaBuild(mode, conn, sec, retry, httpPost, storeTag, expanded) {
return mode === 'store'
? hotaBuildStore(conn, sec, retry, httpPost, storeTag)
: hotaBuildTrigger(conn, sec, retry, httpPost, expanded);
}
function hotaSetMode(mode) {
_hotaMode = mode;
document.getElementById('hota-mode-trigger').className = 'px-4 py-1.5 text-sm rounded-full ' + (mode === 'trigger' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600');
document.getElementById('hota-mode-store').className = 'px-4 py-1.5 text-sm rounded-full ' + (mode === 'store' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600');
document.getElementById('hota-store-tag-row').classList.toggle('hidden', mode !== 'store');
document.getElementById('hota-expanded-row').classList.toggle('hidden', mode !== 'trigger');
hotaUpdatePreview();
}
function hotaToggleConn() {
const on = document.getElementById('hota-include-conn').checked;
document.getElementById('hota-conn-tlvs').classList.toggle('hidden', !on);
hotaUpdatePreview();
}
document.addEventListener('change', function(e) {
if (e.target && e.target.id === 'hota-include-conn') hotaToggleConn();
else if (e.target && e.target.id === 'hota-include-sec') document.getElementById('hota-sec-fields').classList.toggle('hidden', !e.target.checked);
else if (e.target && e.target.id === 'hota-include-retry') document.getElementById('hota-retry-fields').classList.toggle('hidden', !e.target.checked);
else if (e.target && e.target.id === 'hota-include-http') document.getElementById('hota-http-fields').classList.toggle('hidden', !e.target.checked);
});
hotaToggleConn();
function hotaAddTlv() {
const container = document.getElementById('hota-conn-tlvs');
const row = document.createElement('div');
row.className = 'flex gap-1 items-center mb-1';
row.innerHTML =
'<input maxlength="2" oninput="hotaUpdatePreview()" class="font-mono w-14 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Tag">' +
'<input oninput="hotaUpdatePreview()" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Value">' +
'<button onclick="this.parentElement.remove();hotaUpdatePreview()" class="text-gray-400 hover:text-red-500 px-1" title="Delete">&times;</button>';
container.appendChild(row);
hotaUpdatePreview();
}
function hotaAddPreset(name) {
const container = document.getElementById('hota-conn-tlvs');
const row = document.createElement('div');
row.className = 'flex gap-1 items-center mb-1';
const presets = {
devid: ['02', '8182'],
alpha: ['80', ''],
bearer: ['01', '014001'],
};
const [tag, value] = presets[name] || ['', ''];
row.innerHTML =
'<input maxlength="2" value="' + tag + '" oninput="hotaUpdatePreview()" class="font-mono w-14 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Tag">' +
'<input value="' + value + '" oninput="hotaUpdatePreview()" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Value">' +
'<button onclick="this.parentElement.remove();hotaUpdatePreview()" class="text-gray-400 hover:text-red-500 px-1" title="Delete">&times;</button>';
container.appendChild(row);
hotaUpdatePreview();
}
function hotaReadConnTlvs() {
const tlvs = [];
document.querySelectorAll('#hota-conn-tlvs .flex').forEach(row => {
const inputs = row.querySelectorAll('input');
if (inputs.length >= 2) tlvs.push({ tag: _hotaHex(inputs[0].value), value: inputs[1].value });
});
return tlvs;
}
function hotaUpdatePreview() {
const connOn = document.getElementById('hota-include-conn').checked;
const secOn = document.getElementById('hota-include-sec').checked;
const retryOn = document.getElementById('hota-include-retry').checked;
const httpOn = document.getElementById('hota-include-http').checked;
const expanded = document.getElementById('hota-expanded').checked;
const storeTag = document.getElementById('hota-store-tag').value;
const conn = connOn ? hotaBuildConn(hotaReadConnTlvs()) : '';
const sec = secOn ? hotaBuildSec({
pskIdentity: document.getElementById('hota-psk-text').value,
kvn: document.getElementById('hota-kvn').value,
kid: document.getElementById('hota-kid').value,
}) : '';
const retry = retryOn ? hotaBuildRetry({
counter: document.getElementById('hota-retry-counter').value,
delayH: document.getElementById('hota-delay-h').value,
delayM: document.getElementById('hota-delay-m').value,
delayS: document.getElementById('hota-delay-s').value,
reportFailure: document.getElementById('hota-retry-report').value,
}) : '';
const http = httpOn ? hotaBuildHttpPost({
host: document.getElementById('hota-host').value,
agent: document.getElementById('hota-agent').value,
uri: document.getElementById('hota-uri').value,
}) : '';
const hex = hotaBuild(_hotaMode, conn, sec, retry, http, storeTag, expanded);
const preview = document.getElementById('hota-preview');
preview.value = hex;
document.getElementById('hota-pack-btn').disabled = !hex;
}
function hotaPreviewEdited() {
const hex = document.getElementById('hota-preview').value;
document.getElementById('hota-pack-btn').disabled = !hex;
}
document.querySelectorAll('#c-apdu-sub-httpota input, #c-apdu-sub-httpota select').forEach(el => {
if (el.type === 'checkbox') return;
el.addEventListener('input', hotaUpdatePreview);
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
function updateRcPriv() {
let b1 = 0, b2 = 0, b3 = 0;
document.querySelectorAll('.rc-priv-b1:checked').forEach(cb => b1 |= parseInt(cb.value, 16));
document.querySelectorAll('.rc-priv-b2:checked').forEach(cb => b2 |= parseInt(cb.value, 16));
document.querySelectorAll('.rc-priv-b3:checked').forEach(cb => b3 |= parseInt(cb.value, 16));
let hex = b1.toString(16).padStart(2, '0').toUpperCase();
hex += b2.toString(16).padStart(2, '0').toUpperCase();
if (b3) hex += b3.toString(16).padStart(2, '0').toUpperCase();
document.getElementById('rc-priv').value = hex;
}
function updateRcToolkit() {
const checked = document.getElementById('rc-toolkit-enable').checked;
document.getElementById('rc-toolkit-body').classList.toggle('hidden', !checked);
updateRcTkMode();
updateStkParamsHex();
}
function updateRcTkMode() {
const isSim = document.getElementById('rc-tk-mode').value === 'ca';
document.getElementById('rc-tk-ad-row').style.display = isSim ? '' : 'none';
document.getElementById('rc-tk-services-row').style.display = isSim ? 'none' : '';
}
document.getElementById('rc-tk-mode').addEventListener('change', () => { updateRcTkMode(); updateStkParamsHex(); });
function buildRcToolkitParams() {
if (!document.getElementById('rc-toolkit-enable').checked) return '';
const tkMode = document.getElementById('rc-tk-mode').value;
const priority = parseInt(document.getElementById('rc-tk-priority').value) || 0;
const timers = parseInt(document.getElementById('rc-tk-timers').value) || 0;
const textLen = parseInt(document.getElementById('rc-tk-textlen').value) || 0;
const menus = parseInt(document.getElementById('rc-tk-menus').value) || 0;
const firstPos = parseInt(document.getElementById('rc-tk-firstpos').value) || 0;
const firstId = (document.getElementById('rc-tk-firstid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
const lastPos = parseInt(document.getElementById('rc-tk-lastpos').value) || 0;
const lastId = (document.getElementById('rc-tk-lastid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
const channels = parseInt(document.getElementById('rc-tk-channels').value) || 0;
const msl = document.getElementById('rc-tk-msl').value;
const tar = (document.getElementById('rc-tk-tar').value || '').replace(/[^0-9a-fA-F]/g, '');
const adRaw = (document.getElementById('rc-tk-ad').value || '').replace(/[^0-9a-fA-F]/g, '');
const ad = adRaw.padStart(2, '0').slice(0, 2);
const services = parseInt(document.getElementById('rc-tk-services').value) || 0;
if (tar && tar.length % 6 !== 0) return null;
if (parseInt(firstId, 16) > 0x7F || parseInt(lastId, 16) > 0x7F) return null;
let menuPairs = '';
for (let i = 1; i <= menus; i++) {
if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId;
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId;
else menuPairs += '0000';
}
const mslField = msl === '00' ? '00' : '0201' + msl;
let tkPayload = '';
if (tkMode === 'ca') {
tkPayload = (adRaw ? '01' + ad : '00') +
priority.toString(16).padStart(2, '0') +
timers.toString(16).padStart(2, '0') +
textLen.toString(16).padStart(2, '0') +
menus.toString(16).padStart(2, '0') +
menuPairs +
channels.toString(16).padStart(2, '0') +
mslField +
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00');
const caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
} else {
tkPayload = priority.toString(16).padStart(2, '0') +
timers.toString(16).padStart(2, '0') +
textLen.toString(16).padStart(2, '0') +
menus.toString(16).padStart(2, '0') +
menuPairs +
channels.toString(16).padStart(2, '0') +
mslField +
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
services.toString(16).padStart(2, '0');
const innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
}
}
function updateStkParamsHex() {
const tk = buildRcToolkitParams();
document.getElementById('ram-stk-params').value = tk || '';
}
function updateInstallParamsHex() {
document.getElementById('ram-install-params-hex').value = 'C900';
}
// ===== GSM 7-bit alphabet =====
const GSM7_ALPHABET = [
'\u0040','\u00a3','\u0024','\u00a5','\u00e8','\u00e9','\u00f9','\u00ec',
'\u00f2','\u00c7','\u000a','\u00d8','\u00f8','\u000d','\u00c5','\u00e5',
'\u0394','\u005f','\u03a6','\u0393','\u039b','\u03a9','\u03a0','\u03a8',
'\u03a3','\u0398','\u039e','\u001b','\u00c6','\u00e6','\u00df','\u00c9',
'\u0020','\u0021','\u0022','\u0023','\u00a4','\u0025','\u0026','\u0027',
'\u0028','\u0029','\u002a','\u002b','\u002c','\u002d','\u002e','\u002f',
'\u0030','\u0031','\u0032','\u0033','\u0034','\u0035','\u0036','\u0037',
'\u0038','\u0039','\u003a','\u003b','\u003c','\u003d','\u003e','\u003f',
'\u00a1','\u0041','\u0042','\u0043','\u0044','\u0045','\u0046','\u0047',
'\u0048','\u0049','\u004a','\u004b','\u004c','\u004d','\u004e','\u004f',
'\u0050','\u0051','\u0052','\u0053','\u0054','\u0055','\u0056','\u0057',
'\u0058','\u0059','\u005a','\u00c4','\u00d6','\u00d1','\u00dc','\u00a7',
'\u00bf','\u0061','\u0062','\u0063','\u0064','\u0065','\u0066','\u0067',
'\u0068','\u0069','\u006a','\u006b','\u006c','\u006d','\u006e','\u006f',
'\u0070','\u0071','\u0072','\u0073','\u0074','\u0075','\u0076','\u0077',
'\u0078','\u0079','\u007a','\u00e4','\u00f6','\u00f1','\u00fc','\u00e0',
];
const GSM7_EXT_MAP = {
'\n': 0x0a, '^': 0x14, '{': 0x28, '}': 0x29, '\\': 0x2f,
'[': 0x3c, '~': 0x3d, ']': 0x3e, '|': 0x40, '\u20ac': 0x65,
};
function gsm7TextToSeptets(text) {
const septets = [];
for (const c of text) {
if (c in GSM7_EXT_MAP) {
septets.push(0x1B);
septets.push(GSM7_EXT_MAP[c]);
} else {
const idx = GSM7_ALPHABET.indexOf(c);
if (idx === -1) throw new Error('Character not in GSM7 alphabet');
septets.push(idx);
}
}
return septets;
}
function gsm7Encode(septets) {
const numOctets = Math.ceil(septets.length * 7 / 8);
const packed = new Uint8Array(numOctets);
for (let i = 0; i < septets.length; i++) {
const bitPos = i * 7;
const byteIdx = bitPos >>> 3;
const bitShift = bitPos & 7;
packed[byteIdx] |= (septets[i] << bitShift) & 0xFF;
if (byteIdx + 1 < numOctets) {
packed[byteIdx + 1] |= septets[i] >>> (8 - bitShift);
}
}
return packed;
}
function encodeSpn(text) {
try {
const septets = gsm7TextToSeptets(text);
const packed = gsm7Encode(septets);
const nSeptets = septets.length;
const nOctets = packed.length;
const lastOctetDataBits = nSeptets * 7 - (nOctets - 1) * 8;
const spareBits = 8 - lastOctetDataBits;
let dcs = 0x80 | (spareBits & 0x07);
if (dcs === 0x81) dcs = 0x80;
const inner = new Uint8Array(16);
inner[0] = dcs;
inner.set(packed, 1);
for (let i = 1 + nOctets; i < 16; i++) inner[i] = 0xFF;
return '01' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
} catch (e) {
let hasNonBmp = false;
for (const c of text) {
const code = c.charCodeAt(0);
if (code >= 0xD800 && code <= 0xDBFF) { hasNonBmp = true; break; }
}
if (hasNonBmp) {
const buf = [];
for (const c of text) {
const code = c.charCodeAt(0);
buf.push(code >>> 8, code & 0xFF);
}
const inner = new Uint8Array(16);
inner[0] = 0x80;
for (let i = 0; i < buf.length && i + 1 < 16; i++) inner[i + 1] = buf[i];
for (let i = 1 + buf.length; i < 16; i++) inner[i] = 0xFF;
return '00' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
} else {
const nonGsm7 = [];
for (const c of text) {
if (GSM7_ALPHABET.indexOf(c) === -1 && !(c in GSM7_EXT_MAP)) {
nonGsm7.push(c.charCodeAt(0));
}
}
const base = nonGsm7.length ? (Math.min(...nonGsm7) & 0xFF00) : 0;
const baseHigh = base >>> 8;
const inner = new Uint8Array(16);
inner[0] = 0x81;
inner[1] = text.length;
inner[2] = baseHigh;
let pos = 3;
for (const c of text) {
if (GSM7_EXT_MAP.hasOwnProperty(c)) {
inner[pos++] = GSM7_EXT_MAP[c];
} else {
const idx = GSM7_ALPHABET.indexOf(c);
if (idx !== -1) {
inner[pos++] = idx;
} else {
const code = c.charCodeAt(0);
inner[pos++] = 0x80 | (code - base);
}
}
}
while (pos < 16) inner[pos++] = 0xFF;
return '00' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
}
}
}
function swapNibbles(s) {
let out = '';
for (let i = 0; i < s.length; i += 2) {
out += s[i + 1] || 'f';
out += s[i];
}
return out.toUpperCase();
}
function encImsi(imsi) {
const l = Math.ceil((imsi.length + 1) / 2);
const oe = imsi.length & 1;
const indicator = ((oe << 3) | 1).toString(16);
const padded = imsi.padEnd(15, 'f');
const second = swapNibbles(indicator + padded);
return l.toString(16).padStart(2, '0') + second;
}
function encMsisdn(msisdn) {
let s = msisdn.replace(/^\+/, '');
if (s.length % 2) s += 'f';
return swapNibbles(s);
}
function encIccid(iccid) {
return swapNibbles(iccid);
}
// EF.ICCID (2FE2): nibble-swapped E.118 digits, 19-20 digits, 'F' pad (TS 102 221 13.2).
function decIccid(hex) {
return swapNibbles((hex || '').replace(/[^0-9a-fA-F]/g, '')).replace(/F+$/, '');
}
function buildApdu(cla, ins, p1, p2, p3, data) {
let s = cla.toString(16).padStart(2, '0').toUpperCase() +
ins.toString(16).padStart(2, '0').toUpperCase() +
p1.toString(16).padStart(2, '0').toUpperCase() +
p2.toString(16).padStart(2, '0').toUpperCase();
if (p3 !== null && p3 !== undefined) s += p3.toString(16).padStart(2, '0').toUpperCase();
if (data) s += data.toUpperCase();
return s;
}
function buildSelect(cla, p1, p2, fid) {
const data = fid.length === 4 ? fid : '0000';
return buildApdu(cla, 0xA4, p1, p2, 0x02, data);
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ===== Chain Builder =====
// Row-based APDU chain builder for SIM RFM, USIM RFM, and RAM/GP sub-tabs.
// Each chain has a list of rows; each row = one APDU command with its own fields.
// Chain preview = concatenation of all row hexes, with Le stripping when
// a Case 4 command is followed by GET RESPONSE (ETSI TS 102 226 compact).
const _chains = {};
const CHAIN_CMDS_SIM = [
{value:'select',label:'SELECT'},{value:'read-record',label:'READ RECORD'},
{value:'read-binary',label:'READ BINARY'},{value:'update-record',label:'UPDATE RECORD'},
{value:'update-binary',label:'UPDATE BINARY'},{value:'erase-binary',label:'ERASE BINARY'},
{value:'activate-file',label:'ACTIVATE FILE'},{value:'deactivate-file',label:'DEACTIVATE FILE'},
{value:'verify',label:'VERIFY PIN'},{value:'change',label:'CHANGE PIN'},
{value:'disable',label:'DISABLE PIN'},{value:'enable',label:'ENABLE PIN'},
{value:'unblock',label:'UNBLOCK PIN'},{value:'get-response',label:'GET RESPONSE'},
];
const CHAIN_CMDS_USIM = [
{value:'select',label:'SELECT'},{value:'read-record',label:'READ RECORD'},
{value:'read-binary',label:'READ BINARY'},{value:'update-record',label:'UPDATE RECORD'},
{value:'update-binary',label:'UPDATE BINARY'},
{value:'activate-file',label:'ACTIVATE FILE'},{value:'deactivate-file',label:'DEACTIVATE FILE'},
{value:'verify',label:'VERIFY PIN'},{value:'change',label:'CHANGE PIN'},
{value:'disable',label:'DISABLE PIN'},{value:'enable',label:'ENABLE PIN'},
{value:'unblock',label:'UNBLOCK PIN'},{value:'get-response',label:'GET RESPONSE'},
];
const CHAIN_CMDS_RAM = [
{value:'install-load',label:'INSTALL [for load]'},
{value:'install-install',label:'INSTALL [for install]'},
{value:'install-make-sel',label:'INSTALL [for make selectable]'},
{value:'install-reg-update',label:'INSTALL [for registry update]'},
{value:'install-extradition',label:'INSTALL [for extradition]'},
{value:'load',label:'LOAD'},{value:'delete',label:'DELETE'},
{value:'get-status',label:'GET STATUS'},{value:'get-data',label:'GET DATA'},
{value:'store-data',label:'STORE DATA'},{value:'set-status',label:'SET STATUS'},
{value:'ext-auth',label:'EXTERNAL AUTHENTICATE'},
{value:'int-auth',label:'INTERNAL AUTHENTICATE'},
{value:'get-response',label:'GET RESPONSE'},
];
function chainInit(id) { if (!_chains[id]) _chains[id] = {rows: []}; }
function chainAddRow(id, cmd) { chainInit(id); _chains[id].rows.push({cmd: cmd, fields: {}}); chainRender(id); }
function chainDeleteRow(id, idx) { chainInit(id); _chains[id].rows.splice(idx, 1); chainRender(id); }
function chainUpdateCmd(id, idx, cmd) { chainInit(id); _chains[id].rows[idx].cmd = cmd; _chains[id].rows[idx].fields = {}; chainRender(id); }
function chainUpdateField(id, idx, field, value) {
chainInit(id);
_chains[id].rows[idx].fields[field] = value;
const h = document.getElementById(id + '-row-' + idx + '-hex');
if (h) h.value = chainBuildRowHex(id, idx);
const p = document.getElementById(id + '-preview');
if (p) p.value = chainBuildHex(id);
const b = document.getElementById(id + '-pack-btn');
if (b) b.disabled = !chainBuildHex(id);
}
function chainRender(chainId) {
chainInit(chainId);
const container = document.getElementById(chainId + '-rows');
const preview = document.getElementById(chainId + '-preview');
const packBtn = document.getElementById(chainId + '-pack-btn');
if (!container) return;
const rows = _chains[chainId].rows;
container.innerHTML = '';
rows.forEach(function(row, idx) {
const div = document.createElement('div');
div.className = 'mb-3 p-3 border border-gray-200 dark:border-slate-600 rounded';
div.innerHTML = chainRenderRow(chainId, idx, row);
container.appendChild(div);
});
const hex = chainBuildHex(chainId);
if (preview) preview.value = hex;
if (packBtn) packBtn.disabled = !hex;
}
function chainRenderRow(chainId, idx, row) {
const isRam = chainId === 'chain-ram';
const cmds = isRam ? CHAIN_CMDS_RAM : (chainId === 'chain-usim' ? CHAIN_CMDS_USIM : CHAIN_CMDS_SIM);
const cmdOpts = cmds.map(function(c) {
return '<option value="' + c.value + '"' + (c.value === row.cmd ? ' selected' : '') + '>' + c.label + '</option>';
}).join('');
const fieldsHtml = chainRowFieldsHtml(chainId, idx, row);
const hex = chainBuildRowHex(chainId, idx);
return '<div class="flex items-center gap-2 mb-2">' +
'<select onchange="chainUpdateCmd(\'' + chainId + '\',' + idx + ',this.value)" class="flex-shrink-0 w-52 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' + cmdOpts + '</select>' +
'<input id="' + chainId + '-row-' + idx + '-hex" readonly value="' + escHtml(hex) + '" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-50 dark:bg-slate-900">' +
'<button onclick="chainDeleteRow(\'' + chainId + '\',' + idx + ')" class="text-red-500 hover:text-red-700 text-lg font-bold px-1" title="Delete">✕</button>' +
'</div>' +
'<div class="pl-4">' + fieldsHtml + '</div>';
}
function chainRowFieldsHtml(chainId, idx, row) {
var f = row.fields;
function uf(field) { return "chainUpdateField('" + chainId + "'," + idx + ",'" + field + "',this.value)"; }
if (chainId === 'chain-sim' || chainId === 'chain-usim') return chainSimFieldsHtml(chainId, idx, f, uf);
return chainRamFieldsHtml(idx, row, f, uf);
}
// ===== SIM/USIM field rendering =====
function chainSimFieldsHtml(chainId, idx, f, uf) {
var cmd = (_chains[chainId] && _chains[chainId].rows[idx]) ? _chains[chainId].rows[idx].cmd : '';
if (cmd === 'select') {
var method = f.method || 'fid';
var silent = chainId === 'chain-usim' && f.silent;
var html = '<div class="grid grid-cols-2 gap-2 mb-2"><div>' +
'<label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Method</label>' +
'<select onchange="' + uf('method') + ';chainRender(\'' + chainId + '\')" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="fid"' + (method === 'fid' ? ' selected' : '') + '>By FID</option>' +
'<option value="path"' + (method === 'path' ? ' selected' : '') + '>By path</option>' +
'<option value="dfname"' + (method === 'dfname' ? ' selected' : '') + '>By AID</option>' +
'<option value="chain"' + (method === 'chain' ? ' selected' : '') + '>Chain</option>' +
'</select></div>';
if (chainId === 'chain-usim') {
html += '<div class="flex items-end pb-0.5"><label class="flex items-center gap-1.5 text-xs">' +
'<input type="checkbox"' + (silent ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'silent\',this.checked);chainRender(\'chain-usim\')" class="rounded">' +
'Silent (P2=0C)</label></div>';
}
html += '</div>';
if (method === 'fid') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">FID (2 bytes)</label>' +
'<input value="' + escHtml(f.fid || '') + '" oninput="' + uf('fid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="6FC5" maxlength="4"></div>';
}
if (method === 'path') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Path (hex)</label>' +
'<input value="' + escHtml(f.path || '') + '" oninput="' + uf('path') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="7FFF6FC5">';
if (chainId === 'chain-usim') {
html += '<div class="flex gap-3 mt-1 text-xs"><label class="flex items-center gap-1"><input type="radio" name="ch-usim-' + idx + '-base" value="mf"' + ((f.base || 'mf') === 'mf' ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'base\',\'mf\')"> from MF</label>' +
'<label class="flex items-center gap-1"><input type="radio" name="ch-usim-' + idx + '-base" value="df"' + (f.base === 'df' ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'base\',\'df\')"> from current DF</label></div>';
}
html += '</div>';
}
if (method === 'dfname') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">AID (hex)</label>' +
'<input value="' + escHtml(f.dfname || '') + '" oninput="' + uf('dfname') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>';
}
if (method === 'chain') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">FID chain (comma-separated)</label>' +
'<input value="' + escHtml(f.chain || '') + '" oninput="' + uf('chain') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="3F00,2FE2,C0"></div>';
}
return html;
}
if (cmd === 'read-record') {
var rm = f.recMode || '04';
return '<div class="grid grid-cols-3 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Record #</label>' +
'<input type="number" min="1" max="255" value="' + escHtml(f.record || '1') + '" oninput="' + uf('record') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">P2 (mode)</label>' +
'<select onchange="' + uf('recMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="04"' + (rm === '04' ? ' selected' : '') + '>Absolute (04)</option>' +
'<option value="02"' + (rm === '02' ? ' selected' : '') + '>Next (02)</option>' +
'<option value="03"' + (rm === '03' ? ' selected' : '') + '>Previous (03)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le</label>' +
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div></div>';
}
if (cmd === 'read-binary') {
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le</label>' +
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div></div>';
}
if (cmd === 'update-record') {
var rm2 = f.recMode || '04';
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Record #</label>' +
'<input type="number" min="1" max="255" value="' + escHtml(f.record || '1') + '" oninput="' + uf('record') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">P2 (mode)</label>' +
'<select onchange="' + uf('recMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="04"' + (rm2 === '04' ? ' selected' : '') + '>Absolute (04)</option>' +
'<option value="02"' + (rm2 === '02' ? ' selected' : '') + '>Next (02)</option>' +
'<option value="03"' + (rm2 === '03' ? ' selected' : '') + '>Previous (03)</option></select></div></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>';
}
if (cmd === 'update-binary') {
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>';
}
if (cmd === 'erase-binary') {
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>';
}
if (cmd === 'activate-file' || cmd === 'deactivate-file') {
var target = f.target || 'current';
var html2 = '<div class="grid grid-cols-2 gap-2"><div>' +
'<label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Target</label>' +
'<select onchange="' + uf('target') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="current"' + (target === 'current' ? ' selected' : '') + '>Current EF (case 1)</option>' +
'<option value="fid"' + (target === 'fid' ? ' selected' : '') + '>By FID</option>' +
'<option value="mfpath"' + (target === 'mfpath' ? ' selected' : '') + '>Path from MF</option>' +
'<option value="dfpath"' + (target === 'dfpath' ? ' selected' : '') + '>Path from current DF</option></select></div>';
if (target !== 'current') {
html2 += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">File (hex)</label>' +
'<input value="' + escHtml(f.file || '') + '" oninput="' + uf('file') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="6FC5 / 7FFF6FC5"></div>';
}
html2 += '</div>';
return html2;
}
if (cmd === 'verify' || cmd === 'disable' || cmd === 'enable') {
var pin = f.pin || '01';
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN</label>' +
'<select onchange="' + uf('pin') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="01"' + (pin === '01' ? ' selected' : '') + '>PIN1 (01)</option>' +
'<option value="02"' + (pin === '02' ? ' selected' : '') + '>PIN2 (02)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN value</label>' +
'<input value="' + escHtml(f.pinVal || '') + '" oninput="' + uf('pinVal') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="1234" maxlength="8"></div></div>';
}
if (cmd === 'change' || cmd === 'unblock') {
var pin2 = f.pin || '01';
return '<div class="grid grid-cols-3 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN</label>' +
'<select onchange="' + uf('pin') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="01"' + (pin2 === '01' ? ' selected' : '') + '>PIN1 (01)</option>' +
'<option value="02"' + (pin2 === '02' ? ' selected' : '') + '>PIN2 (02)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + (cmd === 'unblock' ? 'Unblock PIN' : 'Old PIN') + '</label>' +
'<input value="' + escHtml(f.pinOld || '') + '" oninput="' + uf('pinOld') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="1234" maxlength="8"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">New PIN</label>' +
'<input value="' + escHtml(f.pinNew || '') + '" oninput="' + uf('pinNew') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="5678" maxlength="8"></div></div>';
}
if (cmd === 'get-response') {
return '<div class="text-xs text-gray-500 dark:text-slate-400 italic">No parameters &mdash; CLA=C0 INS=C0 P1=00 P2=00</div>';
}
return '';
}
// ===== RAM/GP field rendering =====
function chainRamFieldsHtml(idx, row, f, uf) {
var cmd = row.cmd;
if (cmd === 'install-load') {
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load File AID</label>' +
'<input value="' + escHtml(f.loadFileAid || '') + '" oninput="' + uf('loadFileAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load data (hex)</label>' +
'<textarea oninput="' + uf('loadData') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.loadData || '') + '</textarea></div>';
}
if (cmd === 'install-install') {
var html = '<div class="grid grid-cols-3 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load File AID / Package AID</label>' +
'<input value="' + escHtml(f.elfAid || '') + '" oninput="' + uf('elfAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Executable Module AID / Applet Class AID</label>' +
'<input value="' + escHtml(f.modAid || '') + '" oninput="' + uf('modAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>';
// Privileges
html += chainRamPrivHtml(idx, f, uf);
// Toolkit
html += chainRamToolkitHtml(idx, f, uf);
return html;
}
if (cmd === 'install-make-sel') {
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
chainRamPrivHtml(idx, f, uf);
}
if (cmd === 'install-reg-update') {
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>' +
chainRamPrivHtml(idx, f, uf);
}
if (cmd === 'install-extradition') {
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>';
}
if (cmd === 'load') {
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Block number</label>' +
'<input type="number" min="0" value="' + escHtml(f.block || '0') + '" oninput="' + uf('block') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>';
}
if (cmd === 'delete') {
var dm = f.delMode || '00';
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">DELETE mode</label>' +
'<select onchange="' + uf('delMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (dm === '00' ? ' selected' : '') + '>AID only (00)</option>' +
'<option value="80"' + (dm === '80' ? ' selected' : '') + '>AID + related objects (80)</option></select></div>';
}
if (cmd === 'get-status') {
var gm = f.gsMode || '80';
var gp = f.gsP2 || '00';
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET STATUS mode</label>' +
'<select onchange="' + uf('gsMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="80"' + (gm === '80' ? ' selected' : '') + '>ISD (80)</option>' +
'<option value="40"' + (gm === '40' ? ' selected' : '') + '>Applications and SSD (40)</option>' +
'<option value="20"' + (gm === '20' ? ' selected' : '') + '>Executable Load Files (20)</option>' +
'<option value="10"' + (gm === '10' ? ' selected' : '') + '>ELF and modules (10)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET STATUS format</label>' +
'<select onchange="' + uf('gsP2') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="02"' + (gp === '02' ? ' selected' : '') + '>02 &mdash; First/all, TLV format</option>' +
'<option value="03"' + (gp === '03' ? ' selected' : '') + '>03 &mdash; Next, TLV format</option>' +
'<option value="00"' + (gp === '00' ? ' selected' : '') + '>00 &mdash; First/all, raw format</option></select></div></div>';
}
if (cmd === 'get-data') {
var tag = f.tag || '5F50';
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET DATA tag</label>' +
'<select onchange="chainUpdateField(\'chain-ram\',' + idx + ',\'tag\',this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800 mb-1">' +
'<option value=""' + (!tag ? ' selected' : '') + '>&mdash; select preset &mdash;</option>' +
'<option value="42"' + (tag === '42' ? ' selected' : '') + '>42 &mdash; IIN</option>' +
'<option value="45"' + (tag === '45' ? ' selected' : '') + '>45 &mdash; CIN</option>' +
'<option value="66"' + (tag === '66' ? ' selected' : '') + '>66 &mdash; Card Data</option>' +
'<option value="67"' + (tag === '67' ? ' selected' : '') + '>67 &mdash; Card Capability</option>' +
'<option value="E0"' + (tag === 'E0' ? ' selected' : '') + '>E0 &mdash; Key Info Template</option>' +
'<option value="D3"' + (tag === 'D3' ? ' selected' : '') + '>D3 &mdash; Current Security Level</option>' +
'<option value="2F00"' + (tag === '2F00' ? ' selected' : '') + '>2F00 &mdash; List of Applications</option>' +
'<option value="FF21"' + (tag === 'FF21' ? ' selected' : '') + '>FF21 &mdash; Extended Card Resources</option>' +
'<option value="5F50"' + (tag === '5F50' ? ' selected' : '') + '>5F50 &mdash; SD Manager URL</option>' +
'<option value="C1"' + (tag === 'C1' ? ' selected' : '') + '>C1 &mdash; Sequence Counter</option>' +
'<option value="C2"' + (tag === 'C2' ? ' selected' : '') + '>C2 &mdash; Confirmation Counter</option>' +
'<option value="7F21"' + (tag === '7F21' ? ' selected' : '') + '>7F21 &mdash; Certificate</option>' +
'<option value="5031"' + (tag === '5031' ? ' selected' : '') + '>5031 &mdash; Certificate info</option>' +
'</select>' +
'<input value="' + escHtml(tag) + '" oninput="' + uf('tag') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="5F50" maxlength="4"></div>';
}
if (cmd === 'store-data') {
var enc = f.enc || '00';
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>' +
'<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Encryption</label>' +
'<select onchange="' + uf('enc') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (enc === '00' ? ' selected' : '') + '>No encryption, more blocks</option>' +
'<option value="40"' + (enc === '40' ? ' selected' : '') + '>App-dependent, more blocks</option>' +
'<option value="80"' + (enc === '80' ? ' selected' : '') + '>No encryption, last block</option>' +
'<option value="C0"' + (enc === 'C0' ? ' selected' : '') + '>App-dependent, last block</option>' +
'<option value="E0"' + (enc === 'E0' ? ' selected' : '') + '>With encryption, last block</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Block number</label>' +
'<input type="number" min="0" value="' + escHtml(f.block || '0') + '" oninput="' + uf('block') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div></div>';
}
if (cmd === 'set-status') {
var sm = f.ssMode || '80';
var ss = f.ssState || '00';
var html3 = '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SET STATUS type</label>' +
'<select onchange="' + uf('ssMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="80"' + (sm === '80' ? ' selected' : '') + '>ISD (80)</option>' +
'<option value="40"' + (sm === '40' ? ' selected' : '') + '>Application or SSD (40)</option>' +
'<option value="60"' + (sm === '60' ? ' selected' : '') + '>SD + associated apps (60)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">State</label>' +
'<select onchange="' + uf('ssState') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (ss === '00' ? ' selected' : '') + '>00 &mdash; Unlock</option>' +
'<option value="80"' + (ss === '80' ? ' selected' : '') + '>80 &mdash; Lock (LOCKED)</option>' +
'<option value="01"' + (ss === '01' ? ' selected' : '') + '>01 &mdash; OP_READY</option>' +
'<option value="07"' + (ss === '07' ? ' selected' : '') + '>07 &mdash; INITIALIZED</option>' +
'<option value="0F"' + (ss === '0F' ? ' selected' : '') + '>0F &mdash; SECURED</option>' +
'<option value="7F"' + (ss === '7F' ? ' selected' : '') + '>7F &mdash; CARD_LOCKED</option>' +
'<option value="FF"' + (ss === 'FF' ? ' selected' : '') + '>FF &mdash; TERMINATED</option></select></div></div>';
if (sm !== '80') {
html3 += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>';
}
return html3;
}
if (cmd === 'ext-auth') {
var ap = f.authP1 || '00';
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex) &mdash; 16 bytes</label>' +
'<input value="' + escHtml(f.data || '') + '" oninput="' + uf('data') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="32 hex chars"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Security level (P1)</label>' +
'<select onchange="' + uf('authP1') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (ap === '00' ? ' selected' : '') + '>00 &mdash; No secure messaging</option>' +
'<option value="01"' + (ap === '01' ? ' selected' : '') + '>01 &mdash; C-MAC</option>' +
'<option value="03"' + (ap === '03' ? ' selected' : '') + '>03 &mdash; C-DEC + C-MAC</option></select></div>';
}
if (cmd === 'int-auth') {
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex) &mdash; challenge</label>' +
'<input value="' + escHtml(f.data || '') + '" oninput="' + uf('data') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="hex"></div>';
}
if (cmd === 'get-response') {
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le (max bytes)</label>' +
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div>';
}
return '';
}
// ===== RAM/GP privileges sub-form (per-row) =====
function chainRamPrivHtml(idx, f, uf) {
var priv = f.priv || '00';
var b1 = parseInt(priv.substring(0, 2), 16) || 0;
var b2 = priv.length >= 4 ? parseInt(priv.substring(2, 4), 16) : 0;
var b3 = priv.length >= 6 ? parseInt(priv.substring(4, 6), 16) : 0;
var html = '<div class="mb-2"><div class="flex items-center gap-3 mb-1">' +
'<label class="text-xs font-medium text-gray-700 dark:text-slate-300">Privileges</label>' +
'<input readonly value="' + escHtml(priv) + '" class="font-mono w-16 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-0.5 bg-gray-100 dark:bg-slate-800">' +
'</div><div class="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs">';
var CHECKS = [
['Security Domain','80','b1'],['Trusted Path','80','b2'],
['DAP Verification','40','b1'],['Authorized Management','40','b2'],
['Delegated Management','20','b1'],['Token Verification','20','b2'],
['Card Lock','10','b1'],['Global Delete','10','b2'],
['Card Terminate','08','b1'],['Global Lock','08','b2'],
['Card Reset','04','b1'],['Global Registry','04','b2'],
['CVM Management','02','b1'],['Final Application','02','b2'],
['Mandated DAP Verification','01','b1'],['',0,''],
['Receipt Generation','80','b3'],['','','']
];
CHECKS.forEach(function(c) {
if (!c[0]) return '';
var byteIdx = c[2];
var val = parseInt(c[1], 16);
var checked = (byteIdx === 'b1' && (b1 & val)) || (byteIdx === 'b2' && (b2 & val)) || (byteIdx === 'b3' && (b3 & val));
html += '<label class="flex items-center gap-1"><input type="checkbox" class="chain-priv-' + byteIdx + '-' + idx + '"' +
(checked ? ' checked' : '') + ' onchange="chainRamUpdatePriv(' + idx + ')"> ' + c[0] + '</label>';
});
html += '</div></div>';
return html;
}
function chainRamUpdatePriv(idx) {
var b1 = 0, b2 = 0, b3 = 0;
document.querySelectorAll('.chain-priv-b1-' + idx + ':checked').forEach(function(cb) { b1 |= parseInt(cb.value, 16); });
document.querySelectorAll('.chain-priv-b2-' + idx + ':checked').forEach(function(cb) { b2 |= parseInt(cb.value, 16); });
document.querySelectorAll('.chain-priv-b3-' + idx + ':checked').forEach(function(cb) { b3 |= parseInt(cb.value, 16); });
var hex = b1.toString(16).padStart(2, '0').toUpperCase();
hex += b2.toString(16).padStart(2, '0').toUpperCase();
if (b3) hex += b3.toString(16).padStart(2, '0').toUpperCase();
chainUpdateField('chain-ram', idx, 'priv', hex);
}
// ===== RAM/GP toolkit sub-form (per-row) =====
function chainRamToolkitHtml(idx, f, uf) {
var enabled = f.tkEnabled;
var html = '<div class="mb-2"><label class="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-slate-300">' +
'<input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-ram\',' + idx + ',\'tkEnabled\',this.checked);chainRender(\'chain-ram\')" class="rounded">' +
'SIM/UICC Toolkit parameters</label></div>';
if (!enabled) return html;
var tkMode = f.tkMode || 'ea';
var priority = f.tkPriority || '0';
var timers = f.tkTimers || '0';
var textLen = f.tkTextlen || '0';
var menus = f.tkMenus || '0';
var firstPos = f.tkFirstpos || '0';
var firstId = f.tkFirstid || '00';
var lastPos = f.tkLastpos || '0';
var lastId = f.tkLastid || '00';
var channels = f.tkChannels || '0';
var msl = f.tkMsl || '16';
var tar = f.tkTar || 'B00001';
var ad = f.tkAd || '';
var services = f.tkServices || '0';
html += '<div class="p-2 bg-gray-50 dark:bg-slate-900 rounded border border-gray-200 dark:border-slate-700">' +
'<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Toolkit mode</label>' +
'<select onchange="' + uf('tkMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="ca"' + (tkMode === 'ca' ? ' selected' : '') + '>SIM Toolkit (Tag CA)</option>' +
'<option value="ea"' + (tkMode === 'ea' ? ' selected' : '') + '>UICC Toolkit (Tag EA)</option></select></div>' +
'<div class="grid grid-cols-4 gap-x-2 gap-y-1.5 text-xs">' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Priority</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(priority) + '" oninput="' + uf('tkPriority') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Timers</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(timers) + '" oninput="' + uf('tkTimers') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Text length</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(textLen) + '" oninput="' + uf('tkTextlen') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Menus</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(menus) + '" oninput="' + uf('tkMenus') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">1st pos</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(firstPos) + '" oninput="' + uf('tkFirstpos') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">1st ID (hex)</label>' +
'<input value="' + escHtml(firstId) + '" oninput="' + uf('tkFirstid') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Last pos</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(lastPos) + '" oninput="' + uf('tkLastpos') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Last ID (hex)</label>' +
'<input value="' + escHtml(lastId) + '" oninput="' + uf('tkLastid') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Channels</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(channels) + '" oninput="' + uf('tkChannels') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">MSL (SPI1)</label>' +
'<select onchange="' + uf('tkMsl') + '" class="w-full border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800">' +
'<option value="00"' + (msl === '00' ? ' selected' : '') + '>00</option>' +
'<option value="11"' + (msl === '11' ? ' selected' : '') + '>11 RC/CC/DS</option>' +
'<option value="12"' + (msl === '12' ? ' selected' : '') + '>12 RC/DS/CC</option>' +
'<option value="15"' + (msl === '15' ? ' selected' : '') + '>15 RC/DS/CC+MAC</option>' +
'<option value="16"' + (msl === '16' ? ' selected' : '') + '>16 RC/DS/CC+MAC+Cipher</option>' +
'<option value="19"' + (msl === '19' ? ' selected' : '') + '>19 RC/DS/CC+MAC+Cipher+DS</option></select></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">TAR (3 bytes)</label>' +
'<input value="' + escHtml(tar) + '" oninput="' + uf('tkTar') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="6"></div>';
if (tkMode === 'ca') {
html += '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Access domain (hex)</label>' +
'<input value="' + escHtml(ad) + '" oninput="' + uf('tkAd') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>';
} else {
html += '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Max services</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(services) + '" oninput="' + uf('tkServices') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>';
}
html += '</div></div>';
return html;
}
// ===== SIM/USIM hex builder =====
function chainSimBuildRowHex(chainId, idx, row) {
var f = row.fields;
var isSim = chainId === 'chain-sim';
var cla = isSim ? 0xA0 : 0x00;
if (row.cmd === 'select') {
var method = f.method || 'fid';
var silent = !isSim && f.silent;
var p2 = silent ? 0x0C : (isSim ? 0x00 : 0x04);
if (method === 'fid') {
var fid = (f.fid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (fid.length !== 4) return '';
var apdu = buildApdu(cla, 0xA4, 0x00, p2, 0x02, fid);
if (!isSim && !silent) apdu += '00';
return apdu;
}
if (method === 'path') {
var path = (f.path || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (path.length < 2 || path.length % 2 !== 0) return '';
var p1 = (!isSim && f.base === 'df') ? 0x09 : 0x08;
var apdu2 = buildApdu(cla, 0xA4, p1, p2, path.length / 2, path);
if (!isSim && !silent) apdu2 += '00';
return apdu2;
}
if (method === 'dfname') {
var aid = (f.dfname || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (aid.length < 2) return '';
var apdu3 = buildApdu(cla, 0xA4, 0x04, p2, aid.length / 2, aid);
if (!isSim && !silent) apdu3 += '00';
return apdu3;
}
if (method === 'chain') {
var tokens = (f.chain || '').split(',').map(function(t) { return t.trim(); }).filter(function(t) { return t.length > 0; });
var out = '';
for (var ti = 0; ti < tokens.length; ti++) {
var tok = tokens[ti];
var gr = /^c0(?::([0-9a-fA-F]{1,2}))?$/i.exec(tok);
if (gr) {
var leByte = parseInt(gr[1] || '00', 16);
out += buildApdu(cla, 0xC0, 0x00, 0x00, leByte, '');
continue;
}
var fid2 = tok.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (fid2.length !== 4) continue;
var s = buildSelect(cla, 0x00, p2, fid2);
if (!isSim && !silent) s += '00';
out += s;
}
return out;
}
return '';
}
if (row.cmd === 'get-response') {
return '00C0000000';
}
var OPS2 = {
'read-record': {ins: 0xB2}, 'read-binary': {ins: 0xB0},
'update-record': {ins: 0xDC}, 'update-binary': {ins: 0xD6},
'erase-binary': {ins: 0x0E}, 'activate-file': {ins: 0x44},
'deactivate-file': {ins: 0x04}, 'verify': {ins: 0x20},
'change': {ins: 0x24}, 'disable': {ins: 0x26},
'enable': {ins: 0x28}, 'unblock': {ins: 0x2C},
};
var op = OPS2[row.cmd];
if (!op) return '';
var p1, p2v, p3, data = null;
if (row.cmd === 'read-record') {
var rm = parseInt(f.recMode || '04', 16);
var rec = parseInt(f.record) || 1;
var le = parseInt(f.le || '00', 16);
p1 = (rm === 0x02 || rm === 0x03) ? 0x00 : rec;
p2v = rm; p3 = le;
} else if (row.cmd === 'read-binary') {
var off = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
var le2 = parseInt(f.le || '00', 16);
p1 = (off >> 8) & 0xFF; p2v = off & 0xFF; p3 = le2;
} else if (row.cmd === 'update-record') {
var rm2 = parseInt(f.recMode || '04', 16);
var rec2 = parseInt(f.record) || 1;
data = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!data) return '';
p1 = (rm2 === 0x02 || rm2 === 0x03) ? 0x00 : rec2;
p2v = rm2; p3 = data.length / 2;
} else if (row.cmd === 'update-binary') {
var off2 = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
data = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!data) return '';
p1 = (off2 >> 8) & 0xFF; p2v = off2 & 0xFF; p3 = data.length / 2;
} else if (row.cmd === 'erase-binary') {
var off3 = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
p1 = (off3 >> 8) & 0xFF; p2v = off3 & 0xFF; p3 = 0x00;
} else if (row.cmd === 'activate-file' || row.cmd === 'deactivate-file') {
var target = f.target || 'current';
if (target === 'current') { p1 = 0x00; p2v = 0x00; p3 = null; }
else {
var fileHex = (f.file || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!fileHex || fileHex.length % 2 !== 0) return '';
p1 = target === 'fid' ? 0x00 : (target === 'mfpath' ? 0x08 : 0x09);
p2v = 0x00; p3 = fileHex.length / 2; data = fileHex;
}
} else if (row.cmd === 'verify' || row.cmd === 'disable' || row.cmd === 'enable') {
var pin = f.pin || '01';
var pinVal = f.pinVal || '';
if (!pinVal) return '';
var pinHex = Array.from(pinVal).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
p1 = 0x00; p2v = parseInt(pin, 16); p3 = 0x08; data = pinHex;
} else if (row.cmd === 'change' || row.cmd === 'unblock') {
var pin2 = f.pin || '01';
var oldPin = f.pinOld || '';
var newPin = f.pinNew || '';
if (!oldPin || !newPin) return '';
var oldHex = Array.from(oldPin).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
var newHex = Array.from(newPin).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
p1 = 0x00; p2v = parseInt(pin2, 16); p3 = 0x10; data = oldHex + newHex;
} else {
return '';
}
return buildApdu(cla, op.ins, p1, p2v, p3, data);
}
// ===== RAM/GP hex builder =====
function chainRamBuildRowHex(idx, row) {
var f = row.fields;
var cmd = row.cmd;
function lv(data) { if (!data) return '00'; return berLenStr(data.length / 2) + data; }
function computePriv() {
var ph = f.priv || '00';
var b1 = parseInt(ph.substring(0, 2), 16) || 0;
var b2 = ph.length >= 4 ? parseInt(ph.substring(2, 4), 16) : 0;
var b3 = ph.length >= 6 ? parseInt(ph.substring(4, 6), 16) : 0;
if (b2 === 0 && b3 === 0) return b1.toString(16).padStart(2, '0').toUpperCase();
var r = b1.toString(16).padStart(2, '0').toUpperCase() + b2.toString(16).padStart(2, '0').toUpperCase();
if (b3) r += b3.toString(16).padStart(2, '0').toUpperCase();
return r;
}
function buildTkParams() {
if (!f.tkEnabled) return '';
var tkMode = f.tkMode || 'ea';
var priority = parseInt(f.tkPriority) || 0;
var timers = parseInt(f.tkTimers) || 0;
var textLen = parseInt(f.tkTextlen) || 0;
var menus = parseInt(f.tkMenus) || 0;
var firstPos = parseInt(f.tkFirstpos) || 0;
var firstId = (f.tkFirstid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
var lastPos = parseInt(f.tkLastpos) || 0;
var lastId = (f.tkLastid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
var channels = parseInt(f.tkChannels) || 0;
var msl = f.tkMsl || '00';
var tar = (f.tkTar || '').replace(/[^0-9a-fA-F]/g, '');
var ad = (f.tkAd || '').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
var services = parseInt(f.tkServices) || 0;
if (tar && tar.length % 6 !== 0) return null;
var menuPairs = '';
for (var i = 1; i <= menus; i++) {
if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId;
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId;
else menuPairs += '0000';
}
var mslField = msl === '00' ? '00' : '0201' + msl;
var tkPayload = '';
if (tkMode === 'ca') {
tkPayload = (f.tkAd ? '01' + ad : '00') +
priority.toString(16).padStart(2, '0') +
timers.toString(16).padStart(2, '0') +
textLen.toString(16).padStart(2, '0') +
menus.toString(16).padStart(2, '0') +
menuPairs +
channels.toString(16).padStart(2, '0') +
mslField +
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00');
var caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
} else {
tkPayload = priority.toString(16).padStart(2, '0') +
timers.toString(16).padStart(2, '0') +
textLen.toString(16).padStart(2, '0') +
menus.toString(16).padStart(2, '0') +
menuPairs +
channels.toString(16).padStart(2, '0') +
mslField +
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
services.toString(16).padStart(2, '0');
var innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
}
}
var INSTALL_P1 = {
'install-load': 0x02, 'install-install': 0x0C,
'install-make-sel': 0x08, 'install-reg-update': 0x40,
'install-extradition': 0x10,
};
var aid = (f.aid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var sdAid = (f.sdAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (cmd === 'install-load') {
var loadFileAid = (f.loadFileAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var loadData = (f.loadData || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!loadFileAid) return '';
var dataField = lv(loadFileAid) + lv(sdAid) + '00' + (loadData ? lv(loadData) : '00') + '00';
var dataLen = berLenStr(dataField.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
}
if (cmd === 'install-install') {
var elfAid = (f.elfAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var modAid = (f.modAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!aid) return '';
var privBytes = computePriv();
var installParams = 'C900';
var tkParams = buildTkParams();
if (tkParams === null) return '';
if (tkParams) installParams += tkParams;
var df2 = lv(elfAid) + lv(modAid) + lv(aid) + lv(privBytes) + lv(installParams) + '00';
var dl2 = berLenStr(df2.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl2, 16), df2) + '00';
}
if (cmd === 'install-make-sel') {
if (!aid) return '';
var privBytes2 = computePriv();
var df3 = '00' + '00' + lv(aid) + lv(privBytes2) + '00' + '00';
var dl3 = berLenStr(df3.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl3, 16), df3) + '00';
}
if (cmd === 'install-reg-update') {
if (!aid) return '';
var privBytes3 = computePriv();
var df4 = lv(sdAid) + '00' + lv(aid) + lv(privBytes3) + '00' + '00';
var dl4 = berLenStr(df4.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl4, 16), df4) + '00';
}
if (cmd === 'install-extradition') {
if (!sdAid || sdAid.length < 10 || sdAid.length > 20) return '';
if (!aid) return '';
var df5 = lv(sdAid) + '00' + lv(aid) + '00' + '00' + '00';
var dl5 = berLenStr(df5.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl5, 16), df5) + '00';
}
if (cmd === 'load') {
var ramData = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var blockNum = parseInt(f.block) || 0;
if (!ramData) return '';
var dl6 = berLenStr(ramData.length / 2);
return buildApdu(0x80, 0xE8, 0x80, blockNum, parseInt(dl6, 16), ramData) + '00';
}
if (cmd === 'delete') {
if (!aid) return '';
var delMode = f.delMode || '00';
var df7 = '4F' + berLenStr(aid.length / 2) + aid;
var dl7 = berLenStr(df7.length / 2);
return buildApdu(0x80, 0xE4, 0x00, parseInt(delMode, 16), parseInt(dl7, 16), df7) + '00';
}
if (cmd === 'get-status') {
var gsMode = f.gsMode || '80';
var gsP2 = f.gsP2 || '00';
return buildApdu(0x80, 0xF2, parseInt(gsMode, 16), parseInt(gsP2, 16), 0x02, '4F00') + '00';
}
if (cmd === 'get-data') {
var tagHex = (f.tag || '5F50').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0').slice(0, 4);
var tag = parseInt(tagHex, 16);
if (tagHex === '2F00' || tagHex === '5031') {
return buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x02, '5C00') + '00';
}
return buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x00, null) + '00';
}
if (cmd === 'store-data') {
var ramData2 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var enc = f.enc || '00';
var blockNum2 = parseInt(f.block) || 0;
if (!ramData2) return '';
var dl8 = berLenStr(ramData2.length / 2);
return buildApdu(0x80, 0xE2, parseInt(enc, 16), blockNum2, parseInt(dl8, 16), ramData2);
}
if (cmd === 'set-status') {
var ssMode = f.ssMode || '80';
var ssState = f.ssState || '00';
if (ssMode === '80') {
return buildApdu(0x80, 0xF0, 0x80, parseInt(ssState, 16), 0x00, null);
}
if (!aid) return '';
return buildApdu(0x80, 0xF0, parseInt(ssMode, 16), parseInt(ssState, 16), aid.length / 2, aid);
}
if (cmd === 'ext-auth') {
var ramData3 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var authP1 = f.authP1 || '00';
if (!ramData3 || ramData3.length !== 32) return '';
return buildApdu(0x84, 0x82, parseInt(authP1, 16), 0x00, 0x10, ramData3);
}
if (cmd === 'int-auth') {
var ramData4 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!ramData4) return '';
var dl9 = berLenStr(ramData4.length / 2);
return buildApdu(0x00, 0x88, 0x00, 0x00, parseInt(dl9, 16), ramData4) + '00';
}
if (cmd === 'get-response') {
var le = parseInt(f.le, 16) || 0;
return buildApdu(0x80, 0xC0, 0x00, 0x00, le, null);
}
return '';
}
// ===== Chain hex builder (with Le stripping) =====
function chainBuildRowHex(chainId, idx) {
chainInit(chainId);
var row = _chains[chainId].rows[idx];
if (!row) return '';
if (chainId === 'chain-sim' || chainId === 'chain-usim') {
return chainSimBuildRowHex(chainId, idx, row);
}
return chainRamBuildRowHex(idx, row);
}
function chainIsCase4(cmd, chainId) {
if (cmd === 'select') return chainId === 'chain-usim';
if (cmd === 'get-status' || cmd === 'get-data' || cmd === 'store-data' || cmd === 'int-auth') return true;
return false;
}
function chainBuildHex(chainId) {
chainInit(chainId);
var rows = _chains[chainId].rows;
if (!rows.length) return '';
var parts = [];
for (var i = 0; i < rows.length; i++) {
var rowHex = chainBuildRowHex(chainId, i);
if (i < rows.length - 1 && rows[i + 1].cmd === 'get-response') {
if (chainIsCase4(rows[i].cmd, chainId) && rowHex.length >= 2) {
rowHex = rowHex.slice(0, -2);
}
}
parts.push(rowHex);
}
return parts.join('');
}
function updateConvFields() {
const type = document.getElementById('conv-type').value;
const isPlmn = type === 'plmn' || type === 'plmnwact';
document.getElementById('conv-input').style.display = isPlmn ? 'none' : '';
document.getElementById('conv-plmn-fields').style.display = isPlmn ? '' : 'none';
document.getElementById('conv-act-row').style.display = type === 'plmnwact' ? '' : 'none';
}
function encPlmn(mcc, mnc) {
mcc = mcc.padEnd(3, '0').slice(0, 3);
mnc = mnc.padEnd(3, 'F').slice(0, 3);
const d = mcc + mnc;
const b1 = d[1] + d[0];
const b2 = d[5] + d[2];
const b3 = d[4] + d[3];
return (b1 + b2 + b3).toUpperCase();
}
function genConvert() {
const type = document.getElementById('conv-type').value;
const input = document.getElementById('conv-input').value.trim();
const mcc = document.getElementById('conv-mcc').value.trim();
const mnc = document.getElementById('conv-mnc').value.trim();
const act = document.getElementById('conv-act').value;
let result = '';
try {
if (type === 'imsi') {
if (!input) { result = 'Error: enter a value'; }
else if (!/^\d{15}$/.test(input)) { result = 'Error: IMSI must be 15 digits'; }
else result = encImsi(input);
} else if (type === 'msisdn') {
if (!input) { result = 'Error: enter a value'; }
else result = encMsisdn(input);
} else if (type === 'iccid') {
if (!input) { result = 'Error: enter a value'; }
else result = encIccid(input);
} else if (type === 'spn') {
if (!input) { result = 'Error: enter a value'; }
else result = encodeSpn(input);
} else if (type === 'plmn') {
if (!mcc || !mnc) { result = 'Error: specify MCC and MNC'; }
else if (!/^\d{3}$/.test(mcc)) { result = 'Error: MCC must be 3 digits'; }
else if (!/^\d{2,3}$/.test(mnc)) { result = 'Error: MNC must be 2-3 digits'; }
else result = encPlmn(mcc, mnc);
} else if (type === 'plmnwact') {
if (!mcc || !mnc) { result = 'Error: specify MCC and MNC'; }
else if (!/^\d{3}$/.test(mcc)) { result = 'Error: MCC must be 3 digits'; }
else if (!/^\d{2,3}$/.test(mnc)) { result = 'Error: MNC must be 2-3 digits'; }
else result = encPlmn(mcc, mnc) + act.toUpperCase();
} else if (type === 'nibswap') {
if (!input) { result = 'Error: enter a value'; }
else if (input.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(input)) { result = 'Error: enter an even number of hex characters'; }
else result = swapNibbles(input);
}
} catch (e) {
result = 'Error: ' + e.message;
}
document.getElementById('conv-result').value = result;
}
// ===== Secured Packet UI =====
function packToSp(sourceId) {
const val = document.getElementById(sourceId).value;
if (!val) return;
switchTab('scp80');
scp80SwitchSubtab('sp');
document.getElementById('sp-apdu').value = val;
}
function spCntrAdjust(delta) {
const el = document.getElementById('sp-cntr');
let val = parseInt(el.value.replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
val = Math.max(0, val + delta);
el.value = val.toString(16).padStart(10, '0').toUpperCase();
}
function spInvalidate() {
document.getElementById('sp-result').value = '';
}
function updateSp() {
document.getElementById('sp-spi1-hex').value = document.getElementById('sp-spi1').value;
const spi2Base = parseInt(document.getElementById('sp-spi2').value, 16);
const sm = parseInt(document.getElementById('sp-spi2-sm').value, 16);
const spi2Byte = spi2Base | (sm << 5);
document.getElementById('sp-spi2-hex').value = spi2Byte.toString(16).padStart(2, '0').toUpperCase();
spInvalidate();
}
function updateSpKic() {
const idx = parseInt(document.getElementById('sp-kic-idx').value, 16);
const alg = parseInt(document.getElementById('sp-kic-alg').value, 16);
const byte = (idx << 4) | alg;
document.getElementById('sp-kic-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
updateKeyPlaceholder('kic', alg & 0x0F);
spInvalidate();
}
function updateSpKid() {
const idx = parseInt(document.getElementById('sp-kid-idx').value, 16);
const alg = parseInt(document.getElementById('sp-kid-alg').value, 16);
const byte = (idx << 4) | alg;
document.getElementById('sp-kid-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
updateKeyPlaceholder('kid', alg & 0x0F);
spInvalidate();
}
function updateKeyPlaceholder(prefix, algNib) {
const keyLen = {1: 8, 2: 16, 5: 16, 9: 24, 0xD: 8}[algNib] || 8;
const ph = algNib === 2 ? '16/24/32 bytes key (AES)' : keyLen + ' bytes key';
document.getElementById('sp-' + prefix + '-key').placeholder = ph;
}
// ===== DES / 3DES using des.js library (bundled as des-bundle.js) =====
function hexToBytes(s) {
const len = s.length / 2;
const b = new Uint8Array(len);
for (let i = 0; i < len; i++) b[i] = parseInt(s.substr(i * 2, 2), 16);
return b;
}
function bytesToHex(b) {
return Array.from(b).map(x => x.toString(16).padStart(2, '0').toUpperCase()).join('');
}
function des3Keys(keyBytes) {
const kLen = keyBytes.length;
if (kLen === 8) return [keyBytes, keyBytes, keyBytes];
if (kLen === 16) return [keyBytes.subarray(0,8), keyBytes.subarray(8,16), keyBytes.subarray(0,8)];
if (kLen === 24) return [keyBytes.subarray(0,8), keyBytes.subarray(8,16), keyBytes.subarray(16,24)];
throw new Error('Invalid key length: ' + kLen + ' bytes (expected 8, 16, or 24)');
}
function des3EncryptBlock(block, keyBytes) {
const [k1, k2, k3] = des3Keys(keyBytes);
const c1 = des.DES.create({type: 'encrypt', key: k1});
const c2 = des.DES.create({type: 'decrypt', key: k2});
const c3 = des.DES.create({type: 'encrypt', key: k3});
const out = new Uint8Array(8);
c1._update(block, 0, out, 0);
c2._update(out, 0, out, 0);
c3._update(out, 0, out, 0);
return out;
}
function des3DecryptBlock(block, keyBytes) {
const [k1, k2, k3] = des3Keys(keyBytes);
const c1 = des.DES.create({type: 'decrypt', key: k3});
const c2 = des.DES.create({type: 'encrypt', key: k2});
const c3 = des.DES.create({type: 'decrypt', key: k1});
const out = new Uint8Array(8);
c1._update(block, 0, out, 0);
c2._update(out, 0, out, 0);
c3._update(out, 0, out, 0);
return out;
}
function desEncryptBlock(block, key) {
const c = des.DES.create({type: 'encrypt', key: key});
const out = new Uint8Array(8);
c._update(block, 0, out, 0);
return out;
}
function desDecryptBlock(block, key) {
const c = des.DES.create({type: 'decrypt', key: key});
const out = new Uint8Array(8);
c._update(block, 0, out, 0);
return out;
}
function des3CbcEncrypt(data, keyBytes, iv) {
const bs = 8;
const padded = zeroPad(data, bs);
const out = new Uint8Array(padded.length);
let prev = iv;
for (let i = 0; i < padded.length; i += bs) {
const block = padded.subarray(i, i + bs);
const xored = new Uint8Array(bs);
for (let j = 0; j < bs; j++) xored[j] = block[j] ^ prev[j];
const enc = des3EncryptBlock(xored, keyBytes);
out.set(enc, i);
prev = enc;
}
return out;
}
function xorBytes(a, b) {
const len = Math.min(a.length, b.length);
const out = new Uint8Array(len);
for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i];
return out;
}
function zeroPad(data, blockSize) {
const padLen = blockSize - (data.length % blockSize);
if (padLen === blockSize) return data;
const out = new Uint8Array(data.length + padLen);
out.set(data);
for (let i = data.length; i < out.length; i++) out[i] = 0x00;
return out;
}
// TS 03.48 / TS 102 225 KID CC: DES/3DES in CBC mode, zero IV, '00' padding.
// The MAC is the last 8 octets of the CBC encryption of the input.
function cbcMac(data, keyBytes) {
const bs = 8;
const padded = zeroPad(data, bs);
let prev = new Uint8Array(bs);
for (let i = 0; i < padded.length; i += bs) {
const block = padded.subarray(i, i + bs);
const xored = xorBytes(block, prev);
prev = des3EncryptBlock(xored, keyBytes);
}
return prev;
}
// ===== AES using aes-js library (bundled as aes-bundle.js) =====
function aesCbcEncrypt(data, keyBytes, iv) {
const bs = 16;
const padded = zeroPad(data, bs);
const aesCbc = new aesjs.ModeOfOperation.cbc(keyBytes, iv);
return new Uint8Array(aesCbc.encrypt(padded));
}
function aesShiftLeft1(bytes) {
const out = new Uint8Array(bytes.length);
let carry = 0;
for (let i = bytes.length - 1; i >= 0; i--) {
out[i] = (bytes[i] << 1) | carry;
carry = (bytes[i] & 0x80) ? 1 : 0;
}
return out;
}
function aesCmacSubkeys(keyBytes) {
const ecb = new aesjs.ModeOfOperation.ecb(keyBytes);
const L = new Uint8Array(ecb.encrypt(new Uint8Array(16)));
const K1 = aesShiftLeft1(L);
if (L[0] & 0x80) K1[15] ^= 0x87;
const K2 = aesShiftLeft1(K1);
if (K1[0] & 0x80) K2[15] ^= 0x87;
return [K1, K2];
}
// AES-CMAC (NIST SP 800-38B) truncated to 8 octets, matching pySim's CMAC(mac_len=8).
// Unlike the 3DES CBC-MAC above, CMAC pads internally - do NOT pre-pad the input.
function aesCmac(data, keyBytes) {
const bs = 16;
const [K1, K2] = aesCmacSubkeys(keyBytes);
const ecb = new aesjs.ModeOfOperation.ecb(keyBytes);
const n = Math.max(1, Math.ceil(data.length / bs));
const lastLen = data.length % bs;
const complete = data.length > 0 && lastLen === 0;
let prev = new Uint8Array(bs);
for (let i = 0; i < n - 1; i++) {
const block = data.subarray(i * bs, (i + 1) * bs);
const xored = xorBytes(block, prev);
prev = new Uint8Array(ecb.encrypt(xored));
}
let lastBlock;
if (complete) {
lastBlock = xorBytes(data.subarray((n - 1) * bs, n * bs), K1);
} else {
const padded = new Uint8Array(bs);
const start = (n - 1) * bs;
if (data.length > start) padded.set(data.subarray(start));
padded[data.length - start] = 0x80;
lastBlock = xorBytes(padded, K2);
}
const xored = xorBytes(lastBlock, prev);
const mac = new Uint8Array(ecb.encrypt(xored));
return mac.subarray(0, 8);
}
// ===== Secure Packet assembly =====
// TS 102 225 Table 1/2 + TS 31.115 Table 1 (SMS-PP):
// CPL(2) CHL(1) SPI(2) KIc(1) KID(1) TAR(3) CNTR(5) PCNTR(1) [RC/CC/DS] secured-data(+padding)
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() {
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
const spi1 = parseInt(document.getElementById('sp-spi1').value, 16);
const spi2 = parseInt(document.getElementById('sp-spi2-hex').value, 16);
const kicHex = document.getElementById('sp-kic-hex').value;
const kidHex = document.getElementById('sp-kid-hex').value;
const tarHex = (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').padEnd(6, '0').slice(0, 6);
const cntrHex = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '').padStart(10, '0').slice(0, 10);
const kicKeyHex = (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, '');
const kidKeyHex = (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, '');
const padByte = parseInt(document.getElementById('sp-padding').value, 16);
const kicAlg = parseInt(kicHex, 16) & 0x0F;
const kidAlg = parseInt(kidHex, 16) & 0x0F;
const kicIsAes = kicAlg === 0x02;
const kidIsAes = kidAlg === 0x02;
const ciphering = (spi1 & 0x04) !== 0;
const hasMac = (spi1 & 0x03) === 0x02;
const resultEl = document.getElementById('sp-result');
// Rel-18: AES requires a replay-protected counter (SPI1 b5b4 = 10 or 11)
if ((kicIsAes && ciphering) || (kidIsAes && hasMac)) {
const counterBits = (spi1 >> 3) & 0x03;
if (counterBits !== 2 && counterBits !== 3) {
resultEl.value = 'Error: AES requires a replay-protected counter (SPI1 counter: "higher" or "+1")';
return;
}
}
let kicKey, kidKey;
try {
if (kicKeyHex) {
kicKey = hexToBytes(kicKeyHex);
const ok = kicIsAes
? (kicKey.length === 16 || kicKey.length === 24 || kicKey.length === 32)
: (kicKey.length === 8 || kicKey.length === 16 || kicKey.length === 24);
if (!ok) {
resultEl.value = kicIsAes
? 'Error: AES KIc key must be 16, 24, or 32 bytes'
: 'Error: KIc key must be 8, 16, or 24 bytes';
return;
}
}
} catch (e) { resultEl.value = 'KIc key error: ' + e.message; return; }
try {
if (kidKeyHex) {
kidKey = hexToBytes(kidKeyHex);
const ok = kidIsAes
? (kidKey.length === 16 || kidKey.length === 24 || kidKey.length === 32)
: (kidKey.length === 8 || kidKey.length === 16 || kidKey.length === 24);
if (!ok) {
resultEl.value = kidIsAes
? 'Error: AES KID key must be 16, 24, or 32 bytes'
: 'Error: KID key must be 8, 16, or 24 bytes';
return;
}
}
} catch (e) { resultEl.value = 'KID key error: ' + e.message; return; }
if (ciphering && !kicKey) { resultEl.value = 'Error: ciphering requires KIc key'; return; }
if (hasMac && !kidKey) { resultEl.value = 'Error: MAC requires KID key'; return; }
const apdu = hexToBytes(apduHex);
const macLen = hasMac ? 8 : 0;
// padding aligns CNTR+PCNTR+RC/CC/DS+data to the cipher block size (ciphering only)
const cipherBlock = kicIsAes ? 16 : 8;
const padCnt = ciphering ? (cipherBlock - ((6 + macLen + apdu.length) % cipherBlock)) % cipherBlock : 0;
const chl = 13 + macLen;
const cpl = 14 + macLen + apdu.length + padCnt;
const packetLen = 3 + chl + apdu.length + padCnt;
const packet = new Uint8Array(packetLen);
packet[0] = (cpl >> 8) & 0xFF;
packet[1] = cpl & 0xFF;
packet[2] = chl;
packet[3] = spi1;
packet[4] = spi2;
packet[5] = parseInt(kicHex, 16);
packet[6] = parseInt(kidHex, 16);
packet[7] = parseInt(tarHex.substr(0,2), 16);
packet[8] = parseInt(tarHex.substr(2,2), 16);
packet[9] = parseInt(tarHex.substr(4,2), 16);
packet[10] = parseInt(cntrHex.substr(0,2), 16);
packet[11] = parseInt(cntrHex.substr(2,2), 16);
packet[12] = parseInt(cntrHex.substr(4,2), 16);
packet[13] = parseInt(cntrHex.substr(6,2), 16);
packet[14] = parseInt(cntrHex.substr(8,2), 16);
packet[15] = padCnt;
const macOff = 16;
const dataOff = macOff + macLen;
packet.set(apdu, dataOff);
for (let i = 0; i < padCnt; i++) packet[dataOff + apdu.length + i] = padByte;
if (hasMac && kidKey) {
// CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding),
// i.e. everything up to (but excluding) the RC/CC/DS field (TS 31.115 4.2).
const macInput = new Uint8Array(packet.length - macLen);
macInput.set(packet.subarray(0, macOff));
macInput.set(packet.subarray(dataOff), macOff);
const mac = kidIsAes ? aesCmac(macInput, kidKey) : cbcMac(macInput, kidKey);
packet.set(mac, macOff);
}
if (ciphering && kicKey) {
// ciphering covers CNTR+PCNTR+RC/CC/DS+secured data (TS 102 225 Table 2 note 1)
const toEncrypt = packet.subarray(10, packetLen);
const encrypted = kicIsAes
? aesCbcEncrypt(toEncrypt, kicKey, new Uint8Array(16))
: des3CbcEncrypt(toEncrypt, kicKey, new Uint8Array(8));
packet.set(encrypted, 10);
}
// pySim encode_cmd parity: the 2-byte CPL prefix is transmitted only when
// ciphering is applied; unciphered packets start at CHL (v1.9.5).
resultEl.value = bytesToHex(ciphering ? packet : packet.subarray(2));
}
// ===== BER-TLV =====
const BER_TAGS = {'c-apdu':'22','immediate':'81','error':'82','chaining':'83'};
const BER_QUAL = {
refresh: [
['00','NAA Initialization and Full File Change Notification'],
['01','File Change Notification'],
['02','NAA Initialization and File Change Notification'],
['03','NAA Initialization'],
['04','UICC Reset'],
['05','NAA Application Reset (not for 2G SIM)'],
['06','NAA Session Reset (not for 2G SIM)'],
['07','Steering of Roaming'],
['08','Steering of Roaming for I-WLAN'],
['09','eUICC Profile State Change (eUICC only)'],
['0A','Application Update (eUICC only)']
],
display: [
['00','Normal priority'],
['01','High priority'],
['80','Wait for user to clear message']
],
tone: [
['00','Use of vibrate alert up to the terminal'],
['01','Vibrate alert, if available, with the tone']
]
};
const BER_TONES = [
['01','Dial tone'],['02','Called subscriber busy'],['03','Congestion'],
['04','Radio path acknowledge'],['05','Radio path not available'],
['06','Error/Special info'],['07','Call waiting'],['08','Ringing tone'],
['10','General beep'],['11','Positive acknowledgement'],['12','Negative acknowledgement']
];
const BER_DEVICES = [['81','UICC'],['82','Terminal'],['83','Network'],['02','Display']];
function addBerRow(type) {
const c = document.getElementById('ber-rows');
const idx = c.children.length;
const row = document.createElement('div');
row.className = 'ber-row border border-gray-300 dark:border-slate-600 rounded p-2 bg-gray-50 dark:bg-slate-800';
row.dataset.berIdx = idx;
row.innerHTML = berRowHtml(idx, type);
c.appendChild(row);
onBerTypeChange(row.querySelector('.ber-type'));
}
function berRowHtml(idx, type) {
const types = ['c-apdu','immediate','error','chaining'].map(t =>
`<option value="${t}"${t===type?' selected':''}>${t==='c-apdu'?'C-APDU':t==='immediate'?'Immediate':t==='error'?'Error':'Chaining'}</option>`
).join('');
return `<div class="flex gap-2 items-start mb-1">
<span class="text-xs text-gray-500 dark:text-slate-400 w-6 pt-2">${idx+1}</span>
<select class="ber-type text-xs w-24 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerTypeChange(this)">
${types}
</select>
<div class="ber-body flex-1"></div>
<button onclick="removeBerRow(this)" class="text-red-500 hover:text-red-700 text-sm px-1 pt-2">✕</button>
</div>
<div class="ber-preview text-xs text-gray-400 dark:text-slate-500 font-mono ml-8" data-ber-preview="${idx}"></div>`;
}
function removeBerRow(btn) {
btn.closest('.ber-row').remove();
document.querySelectorAll('.ber-row').forEach((r,i)=>r.dataset.berIdx=i);
document.querySelectorAll('.ber-row .ber-preview').forEach((p,i)=>p.dataset.berPreview=i);
}
function onBerTypeChange(sel) {
const row = sel.closest('.ber-row');
const body = row.querySelector('.ber-body');
const type = sel.value;
if (type === 'c-apdu') {
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
} else if (type === 'chaining') {
body.innerHTML = buildScriptChainingRow(row.dataset.berIdx);
} else if (type === 'error') {
body.innerHTML = buildErrorActionRow();
const actionType = body.querySelector('.error-action-type');
onErrorActionTypeChange(actionType);
updateBerRow(sel);
} else {
body.innerHTML = `<div class="flex gap-1 mb-1 flex-wrap">
<select class="ber-subtype text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerSubTypeChange(this)">
<option value="action">Action indicator</option>
<option value="proactive">Proactive command</option>
<option value="custom">Custom hex</option>
</select>
<span class="ber-sub-body flex-1"></span>
</div>`;
onBerSubTypeChange(body.querySelector('.ber-subtype'));
}
updateBerRow(sel);
}
function buildErrorActionRow() {
return `<div class="flex gap-2 items-center">
<span class="text-xs text-gray-600 dark:text-slate-400">Error Action:</span>
<select class="error-action-type text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onErrorActionTypeChange(this)">
<option value="proactive" selected>Proactive command (DISPLAY TEXT / PLAY TONE)</option>
<option value="noaction">No action</option>
<option value="reference">Reference to EFRMA record</option>
<option value="custom">Custom hex</option>
</select>
</div>
<div class="error-action-body mt-2"></div>`;
}
function onErrorActionTypeChange(sel) {
const body = sel.parentElement.nextElementSibling;
if (sel.value === 'proactive') {
body.innerHTML = buildBerPcHtml(['display','tone']);
const pc = body.querySelector('.ber-pc');
berPopulateDevices(pc);
onBerPcTypeChange(pc.querySelector('.ber-pc-type'));
} else if (sel.value === 'noaction') {
body.innerHTML = '<span class="text-xs text-gray-400 dark:text-slate-500">Emits <code class="font-mono">82 00</code> (no action)</span>';
} else if (sel.value === 'reference') {
body.innerHTML = `<div class="flex gap-2 items-center">
<span class="text-xs text-gray-600 dark:text-slate-400">EFRMA record ref:</span>
<input class="error-ref font-mono text-xs w-10 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" maxlength="2" placeholder="01" oninput="updateBerRow(this)">
<span class="text-xs text-gray-400">(01-7F)</span>
</div>`;
} else {
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
}
const row = sel.closest('.ber-row');
if (row) updateBerRow(sel);
}
function buildBerPcHtml(allowed = ['refresh','display','tone']) {
const typeOpts = {refresh:'REFRESH', display:'DISPLAY TEXT', tone:'PLAY TONE'};
return `<div class="ber-pc">
<div class="flex gap-1 mb-1 flex-wrap">
<select class="ber-pc-type text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerPcTypeChange(this)">
${allowed.map(t => `<option value="${t}">${typeOpts[t]}</option>`).join('')}
</select>
<span class="text-xs pt-1">#</span>
<input class="ber-pc-num text-xs w-10 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" type="number" min="1" max="255" value="1" onchange="updateBerRow(this)">
<span class="text-xs pt-1">Qual:</span>
<select class="ber-pc-qual text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
</div>
<div class="flex gap-1 mb-1 flex-wrap items-center">
<span class="text-xs">IDs:</span>
<select class="ber-pc-src text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
<span class="text-xs">→</span>
<select class="ber-pc-dst text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
</div>
<div class="ber-pc-fields text-xs"></div>
</div>`;
}
function onBerSubTypeChange(sel) {
const body = sel.closest('.ber-sub-body') || sel.parentElement.querySelector('.ber-sub-body');
const st = sel.value;
if (st === 'action') {
body.innerHTML = `<select class="ber-action text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)">
<option value="81">81 (Session indication)</option>
<option value="82">82 (Early response)</option>
</select>`;
} else if (st === 'custom') {
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
} else {
body.innerHTML = buildBerPcHtml();
const pc = body.querySelector('.ber-pc');
berPopulateDevices(pc);
onBerPcTypeChange(pc.querySelector('.ber-pc-type'));
}
updateBerRow(sel);
}
function berPopulateDevices(pc) {
const src = pc.querySelector('.ber-pc-src');
const dst = pc.querySelector('.ber-pc-dst');
BER_DEVICES.forEach(([v,l])=>{
const o1 = document.createElement('option'); o1.value=v; o1.textContent=v+' ('+l+')';
if(v==='81') o1.selected=true; src.appendChild(o1);
const o2 = document.createElement('option'); o2.value=v; o2.textContent=v+' ('+l+')';
if(v==='82') o2.selected=true; dst.appendChild(o2);
});
}
function onBerPcTypeChange(sel) {
const pc = sel.closest('.ber-pc');
const qual = pc.querySelector('.ber-pc-qual');
const fields = pc.querySelector('.ber-pc-fields');
const type = sel.value;
qual.innerHTML = (BER_QUAL[type]||[['00','']]).map(([v,l])=>`<option value="${v}">${v} ${l}</option>`).join('');
if (type === 'display') {
fields.innerHTML = `<div class="flex gap-1 items-center">
<input class="ber-pc-text flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="Text" oninput="updateBerRow(this)">
<select class="ber-pc-enc text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="updateBerRow(this)">
<option value="gsm7">GSM 7-bit</option>
<option value="ucs2">UCS2</option>
</select>
</div>
<div class="flex gap-1 items-center mt-1">
<span class="text-xs">Alpha ID:</span>
<input class="ber-pc-alpha flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" placeholder="hex (tag 85)" oninput="updateBerRow(this)">
</div>
<div class="flex gap-1 items-center mt-1">
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><input type="checkbox" class="ber-pc-dur-enable" checked onchange="updateBerRow(this)"> Duration:</label>
<select class="ber-pc-dur-unit text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)">
<option value="00">minutes</option>
<option value="01" selected>seconds</option>
<option value="02">tenths of seconds</option>
</select>
<input class="ber-pc-dur-val text-xs w-12 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" type="number" min="1" max="255" value="30" onchange="updateBerRow(this)">
</div>`;
} else if (type === 'tone') {
fields.innerHTML = `<div class="flex gap-1 items-center">
<span class="text-xs">Tone:</span>
<select class="ber-pc-tone text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
</div>`;
const tsel = fields.querySelector('.ber-pc-tone');
BER_TONES.forEach(([v,l])=>{const o=document.createElement('option');o.value=v;o.textContent=v+' '+l;tsel.appendChild(o);});
} else if (type === 'refresh') {
fields.innerHTML = `<div class="flex gap-1 items-center">
<span class="text-xs">File list (tag 92):</span>
<input class="ber-pc-flist flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" placeholder="hex (optional)" oninput="updateBerRow(this)">
</div>`;
}
updateBerRow(sel);
}
function updateBerRow(el) {
const row = el.closest('.ber-row');
const preview = row.querySelector('.ber-preview');
const val = genBerRowValue(row);
preview.textContent = val || '(empty)';
}
function genBerRowValue(row) {
const type = row.querySelector('.ber-type').value;
const tag = BER_TAGS[type];
if (type === 'error') {
return genErrorActionValue(row, tag);
}
if (type === 'chaining') {
return genScriptChainingValue(row, tag);
}
if (type === 'c-apdu') {
const hex = (row.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,'');
if (!hex) return '';
return tag + berLenStr(hex.length/2) + hex;
}
const st = row.querySelector('.ber-subtype').value;
if (st === 'action') {
const a = row.querySelector('.ber-action').value;
return tag + berLenStr(1) + a;
}
if (st === 'custom') {
const hex = (row.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,'');
if (!hex) return '';
return tag + berLenStr(hex.length/2) + hex;
}
return genBerPcValue(row, tag);
}
function genErrorActionValue(row, tag) {
const actionType = row.querySelector('.error-action-type');
if (!actionType) return '';
const body = row.querySelector('.error-action-body');
if (!body) return '';
switch (actionType.value) {
case 'proactive':
return genBerPcValue(body, tag);
case 'noaction':
return tag + '00';
case 'reference': {
const ref = (body.querySelector('.error-ref').value || '').replace(/[^0-9a-fA-F]/g, '');
const refNum = parseInt(ref, 16);
if (!ref || isNaN(refNum) || refNum < 0x01 || refNum > 0x7F) return '';
return tag + '01' + refNum.toString(16).padStart(2, '0').toUpperCase();
}
default: {
const hex = (body.querySelector('.ber-hex').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!hex) return '';
return tag + berLenStr(hex.length / 2) + hex;
}
}
}
function buildScriptChainingRow(rowIdx, hint = '') {
return `
<div class="space-y-2">
<div class="flex gap-4 items-center flex-wrap">
<span class="text-xs font-medium text-gray-700 dark:text-slate-300">Position:</span>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
<input type="radio" name="chaining-${rowIdx}" value="first" ${hint === 'intermediary' ? '' : 'checked'} onchange="updateChainingFlags(${rowIdx})">
First Script
</label>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
<input type="radio" name="chaining-${rowIdx}" value="intermediary" ${hint === 'intermediary' ? 'checked' : ''} onchange="updateChainingFlags(${rowIdx})">
Intermediary Script
</label>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
<input type="checkbox" name="chaining-${rowIdx}-last" value="last" ${hint === 'last' ? 'checked' : ''} onchange="updateChainingFlags(${rowIdx})">
Also Last Script
</label>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
<input type="checkbox" name="chaining-${rowIdx}-keep" value="keep" onchange="updateChainingFlags(${rowIdx})">
Keep chaining info across reset (RFM)
</label>
</div>
</div>`;
}
function updateChainingFlags(rowIdx) {
const first = document.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
const intermediate = document.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
const lastCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-last"]`);
const keepCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
if (first && intermediate) {
if (first.checked) intermediate.checked = false;
if (intermediate.checked) first.checked = false;
}
if (lastCheckbox) lastCheckbox.disabled = !!(first && first.checked);
if (keepCheckbox) keepCheckbox.disabled = !!(first && !first.checked);
updateBerRow(document.querySelector(`[data-ber-idx="${rowIdx}"] .ber-type`));
}
function genScriptChainingValue(row, tag) {
const rowIdx = row.dataset.berIdx;
const firstRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
const intermediateRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
const lastCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-last"]`);
const keepCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
let flags = '';
if (firstRadio && firstRadio.checked) {
flags = (keepCheckbox && keepCheckbox.checked) ? '11' : '01';
} else if (intermediateRadio && intermediateRadio.checked) {
flags = (lastCheckbox && lastCheckbox.checked) ? '03' : '02';
}
if (!flags) return '';
return tag + '01' + flags;
}
function genBerPcValue(row, tag) {
const pc = row.querySelector('.ber-pc');
const pct = pc.querySelector('.ber-pc-type').value;
const cmdNum = parseInt(pc.querySelector('.ber-pc-num').value)||1;
const qual = pc.querySelector('.ber-pc-qual').value;
const src = pc.querySelector('.ber-pc-src').value;
const dst = pc.querySelector('.ber-pc-dst').value;
let value = '8103' + cmdNum.toString(16).padStart(2,'0').toUpperCase();
const typeBytes = {refresh:'01',display:'21',tone:'20'};
value += (typeBytes[pct]||'00') + qual;
value += '8202' + src + dst;
if (pct === 'display') {
const text = pc.querySelector('.ber-pc-text').value;
const enc = pc.querySelector('.ber-pc-enc').value;
if (text) {
if (enc === 'ucs2') {
const buf = [0x08];
for (const c of text) { const cd=c.charCodeAt(0); buf.push((cd>>8)&0xFF,cd&0xFF); }
value += '8D' + berLenStr(buf.length) + buf.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join('');
} else {
try {
const septets = gsm7TextToSeptets(text);
const packed = gsm7Encode(septets);
const inner = Array.from(packed).map(b=>b.toString(16).padStart(2,'0').toUpperCase());
value += '8D' + berLenStr(inner.length + 1) + '00' + inner.join('');
} catch(e) {
value += '8D' + berLenStr(text.length + 1) + '04' + text.split('').map(c=>c.charCodeAt(0).toString(16).padStart(2,'0').toUpperCase()).join('');
}
}
}
const durEnable = pc.querySelector('.ber-pc-dur-enable');
if (durEnable && durEnable.checked) {
const durVal = parseInt(pc.querySelector('.ber-pc-dur-val').value);
if (!isNaN(durVal) && durVal >= 1 && durVal <= 255) {
const durUnit = pc.querySelector('.ber-pc-dur-unit').value;
value += '84' + berLenStr(2) + durUnit + durVal.toString(16).padStart(2,'0').toUpperCase();
}
}
const alpha = (pc.querySelector('.ber-pc-alpha').value||'').replace(/[^0-9a-fA-F]/g,'');
if (alpha) value += '85' + berLenStr(alpha.length/2) + alpha;
} else if (pct === 'tone') {
const tone = pc.querySelector('.ber-pc-tone').value;
value += '8E01' + tone;
} else if (pct === 'refresh') {
const flist = (pc.querySelector('.ber-pc-flist').value||'').replace(/[^0-9a-fA-F]/g,'');
if (flist) value += '92' + berLenStr(flist.length/2) + flist;
}
return tag + berLenStr(value.length/2) + value;
}
function berLenStr(n) {
if (n < 128) return n.toString(16).padStart(2,'0').toUpperCase();
return '81' + n.toString(16).padStart(2,'0').toUpperCase();
}
function genBerTlv() {
const fmt = document.getElementById('ber-format').value;
let content = '';
document.querySelectorAll('.ber-row').forEach(row => {
const v = genBerRowValue(row);
if (v) content += v;
});
if (!content) { document.getElementById('ber-result').value = ''; return; }
let result;
if (fmt === 'AA') {
result = 'AA' + berLenStr(content.length/2) + content;
} else {
result = 'AE80' + content + '0000';
}
document.getElementById('ber-result').value = result;
document.getElementById('ber-pack-btn').disabled = false;
}
// ===== C-APDU Parser =====
const PARSE_INS = {
'A4': {name:'SELECT', data:'lc'},
'B0': {name:'READ BINARY', data:'le'},
'B2': {name:'READ RECORD(S)', data:'le'},
'DC': {name:'UPDATE RECORD', data:'lc'},
'D6': {name:'UPDATE BINARY', data:'lc'},
'0E': {name:'ERASE BINARY', data:'none'},
'44': {name:'ACTIVATE FILE', data:'none'},
'04': {name:'DEACTIVATE FILE', data:'none'},
'20': {name:'VERIFY', data:'lc'},
'24': {name:'CHANGE', data:'lc'},
'26': {name:'DISABLE PIN', data:'lc'},
'28': {name:'ENABLE PIN', data:'lc'},
'2C': {name:'UNBLOCK PIN', data:'lc'},
'E6': {name:'INSTALL', data:'lc'},
'E8': {name:'LOAD', data:'lc'},
'D4': {name:'RESIZE FILE', data:'lc'},
'E0': {name:'CREATE FILE', data:'lc'},
'E4': {name:'DELETE', data:'lc'},
'F2': {name:'GET STATUS', data:'lc'},
'CA': {name:'GET DATA', data:'none'},
'DA': {name:'PUT DATA', data:'lc'},
'CB': {name:'RETRIEVE DATA', data:'le'},
'DB': {name:'SET DATA', data:'lc'},
'A2': {name:'SEARCH RECORD', data:'lc'},
'32': {name:'INCREASE', data:'lc'},
'84': {name:'GET CHALLENGE', data:'le'},
'88': {name:'AUTHENTICATE', data:'lc'},
'89': {name:'AUTHENTICATE (odd INS)', data:'lc'},
'E2': {name:'STORE DATA', data:'lc'},
'F0': {name:'SET STATUS', data:'lc'},
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
'C0': {name:'GET RESPONSE', data:'le'},
'10': {name:'TERMINAL PROFILE', data:'lc'},
'C2': {name:'ENVELOPE', data:'lc'},
'12': {name:'FETCH', data:'le'},
'14': {name:'TERMINAL RESPONSE', data:'lc'},
'76': {name:'SUSPEND UICC', data:'lc'},
'AA': {name:'TERMINAL CAPABILITY', data:'lc'},
'73': {name:'MANAGE SECURE CHANNEL', data:'lc'},
'75': {name:'TRANSACT DATA', data:'lc'},
'78': {name:'GET IDENTITY', data:'le'},
'7A': {name:'EXCHANGE CAPABILITIES', data:'lc'},
'7C': {name:'MANAGE LSI', data:'lc'},
'50': {name:'INITIALIZE UPDATE', data:'lc'},
'D8': {name:'PUT KEY', data:'lc'},
'70': {name:'MANAGE CHANNEL', data:'none'},
};
function parseBerLen(hex, i) {
const b = parseInt(hex.substr(i, 2), 16);
if (b < 128) return {len: b, consumed: 2};
const n = b & 0x7F;
return {len: parseInt(hex.substr(i + 2, n * 2), 16), consumed: 2 + n * 2};
}
function parseTlvList(hex) {
const out = [];
let i = 0;
while (i + 4 <= hex.length) {
const tag = hex.substr(i, 2);
let tagBytes = 2;
if ((parseInt(tag, 16) & 0x1F) === 0x1F) {
tagBytes = 4;
if (i + 4 > hex.length) break;
}
const r = parseBerLen(hex, i + tagBytes);
const len = r.len;
const consumed = tagBytes + r.consumed;
if (i + consumed + len * 2 > hex.length) break;
const value = hex.substr(i + consumed, len * 2);
const raw = hex.substr(i, consumed + len * 2);
out.push({tag, length: len, value, raw});
i += consumed + len * 2;
}
return out;
}
function baseCompTag(tag) {
const b = parseInt(tag, 16);
return (b >= 0x80 && b <= 0x9F) ? (b & 0x7F).toString(16).padStart(2, '0').toUpperCase() : tag;
}
function decodePrivileges(hex) {
if (hex.length < 2) return '';
const names = [
['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'],
['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'],
['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'],
];
const parts = [];
for (let i = 0; i < Math.min(names.length, hex.length / 2); i++) {
const b = parseInt(hex.substr(i * 2, 2), 16);
for (let bit = 7; bit >= 0; bit--) {
const idx = 7 - bit;
if (b & (1 << bit) && names[i][idx]) parts.push(names[i][idx]);
}
}
return parts.join(', ') || 'None';
}
function gsm7Decode(octets) {
const bits = [];
for (const b of octets) {
for (let j = 0; j < 8; j++) bits.push((b >> j) & 1);
}
let out = '';
let escape = false;
for (let i = 0; i + 7 <= bits.length; i += 7) {
let septet = 0;
for (let j = 0; j < 7; j++) septet |= bits[i + j] << j;
if (escape) {
const ext = Object.entries(GSM7_EXT_MAP).find(([,v]) => v === septet);
out += ext ? ext[0] : '?';
escape = false;
} else if (septet === 0x1B) {
escape = true;
} else {
out += septet < GSM7_ALPHABET.length ? GSM7_ALPHABET[septet] : '?';
}
}
return out;
}
function tryUcs2(hex) {
if (hex.length < 4 || hex.length % 4 !== 0) return null;
let out = '';
for (let i = 0; i < hex.length; i += 4) {
const cp = parseInt(hex.substr(i, 4), 16);
if (cp < 0x20 || cp > 0x10FFFF) return null;
out += String.fromCodePoint(cp);
}
return out;
}
function uniformHighBytes(hex) {
const first = hex.substr(0, 2);
for (let i = 4; i < hex.length; i += 4) {
if (hex.substr(i, 2) !== first) return false;
}
return true;
}
function decodeTextData(hex) {
if (!hex || hex.length < 2) return '?';
const dcs = parseInt(hex.substr(0, 2), 16);
const rest = hex.substr(2);
if (dcs === 0x08) {
const ucs2 = tryUcs2(rest);
return ucs2 !== null ? ucs2 : '?';
}
if (dcs === 0x00 || dcs === 0x04) {
const full = tryUcs2(hex);
if (full !== null && hex.length >= 8 && uniformHighBytes(hex)) return full;
try {
const bytes = new Uint8Array(rest.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(rest.substr(i * 2, 2), 16);
if (dcs === 0x00) {
const gsm = gsm7Decode(bytes);
if (gsm.length > 0) return gsm;
} else {
let s = '';
for (const b of bytes) { if (b < 0x20 || b > 0x7E) return '?'; s += String.fromCharCode(b); }
if (s.length > 0) return s;
}
} catch (e) {}
}
if (hex.length >= 4 && hex.length % 4 === 0) {
let allPrintable = true;
let ucs2 = '';
for (let i = 0; i < hex.length; i += 4) {
const cp = parseInt(hex.substr(i, 4), 16);
if (cp < 0x20 || cp > 0x10FFFF) { allPrintable = false; break; }
ucs2 += String.fromCodePoint(cp);
}
if (allPrintable && ucs2.length > 0) return ucs2;
}
try {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
const gsm = gsm7Decode(bytes);
if (gsm.length > 0 && !gsm.includes('\uFFFD')) return gsm;
} catch (e) {}
return '?';
}
function describeToolkit(hex) {
const children = [];
if (!hex || hex.length < 2) return children;
let isUicc = true;
if (hex.length >= 2) {
const first = parseInt(hex.substr(0, 2), 16);
if (first <= 0x0F) isUicc = false;
}
let i = 0;
if (isUicc) {
children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
const menus = parseInt(hex.substr(i,2), 16); i += 2;
children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
i += 4;
}
children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
if (i + 2 <= hex.length) {
const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
i += mslLen * 2;
} else {
children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
}
}
if (i + 2 <= hex.length) {
const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
i += tarLen * 2;
} else {
children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
}
}
if (i + 2 <= hex.length) {
children.push({label:'Max services', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()});
}
} else {
const adLen = parseInt(hex.substr(i,2), 16); i += 2;
if (adLen > 0 && i + adLen * 2 <= hex.length) {
children.push({label:'Access Domain', hex:hex.substr(i-2, 2+adLen*2), desc:hex.substr(i, adLen*2)});
i += adLen * 2;
} else {
children.push({label:'Access Domain', hex:hex.substr(i-2,2), desc:'Empty'});
}
children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
const menus = parseInt(hex.substr(i,2), 16); i += 2;
children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
i += 4;
}
children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
if (i + 2 <= hex.length) {
const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
i += mslLen * 2;
} else {
children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
}
}
if (i + 2 <= hex.length) {
const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
i += tarLen * 2;
} else {
children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
}
}
}
return children;
}
function describeProactiveCommand(tlvs) {
const children = [];
for (const tlv of tlvs) {
const base = baseCompTag(tlv.tag);
let desc = '';
if (base === '01') {
const cmdNum = parseInt(tlv.value.substr(0, 2), 16);
const typeByte = tlv.value.substr(2, 2);
const qual = tlv.value.substr(4, 2);
const typeKey = { '01': 'refresh', '21': 'display', '20': 'tone' }[typeByte];
const cmdName = { refresh: 'REFRESH', display: 'DISPLAY TEXT', tone: 'PLAY TONE' }[typeKey];
let typeName = 'Command #' + cmdNum;
if (typeKey && cmdName) {
const arr = BER_QUAL[typeKey];
const found = arr ? arr.find(([v]) => v === qual) : null;
if (found) typeName += ', ' + cmdName + ' qual ' + qual + ' ' + found[1];
else typeName += ', ' + cmdName + ' qual ' + qual;
} else {
typeName += ', type ' + typeByte + ' qual ' + qual;
}
desc = typeName;
} else if (base === '02') {
const src = tlv.value.substr(0, 2);
const dst = tlv.value.substr(2, 2);
const srcName = (BER_DEVICES.find(([v]) => v === src) || [])[1] || src;
const dstName = (BER_DEVICES.find(([v]) => v === dst) || [])[1] || dst;
desc = 'from ' + srcName + ' to ' + dstName;
} else if (base === '0D' || base === '8D') {
desc = decodeTextData(tlv.value);
} else if (base === '04' || base === '84') {
if (tlv.value.length >= 4) {
const unit = ['minutes','seconds','tenths'][parseInt(tlv.value.substr(0,2), 16)] || '?';
const val = parseInt(tlv.value.substr(2,2), 16);
desc = val + ' ' + unit;
}
} else if (base === '05' || base === '85') {
desc = 'Alpha ID: ' + tlv.value;
} else if (base === '0E' || base === '8E') {
const tone = tlv.value.substr(0, 2);
const toneName = (BER_TONES.find(([v]) => v === tone) || [])[1] || tone;
desc = 'Tone: ' + toneName;
} else if (base === '12' || base === '92') {
desc = 'File list: ' + tlv.value;
} else {
desc = tlv.value || '(empty)';
}
children.push({label:'TLV ' + tlv.tag + ' (' + baseCompTag(tlv.tag) + ')', hex:tlv.raw, desc});
}
return children;
}
function parseBerScript(hex) {
const rows = [];
let i = 0;
if (hex.startsWith('AA')) {
const r = parseBerLen(hex, 2);
i = 2 + r.consumed;
} else if (hex.startsWith('AE80')) {
i = 4;
} else {
return rows;
}
while (i < hex.length) {
if (hex.startsWith('0000', i)) break;
if (i + 4 > hex.length) break;
const tag = hex.substr(i, 2);
const r = parseBerLen(hex, i + 2);
const len = r.len;
const consumed = 2 + r.consumed;
if (i + consumed + len * 2 > hex.length) break;
const value = hex.substr(i + consumed, len * 2);
const raw = hex.substr(i, consumed + len * 2);
let label = 'Row ' + (rows.length + 1);
let children = [];
if (tag === '22') {
label = 'C-APDU';
children = parseCompactApdus(value);
} else if (tag === '81') {
label = 'Immediate Action';
children = parseActionRow(value, '81');
} else if (tag === '82') {
label = 'Error Action';
children = parseActionRow(value, '82');
} else if (tag === '83') {
label = 'Script Chaining';
children = [{label:'Flags', hex:value.substr(0,2), desc:parseChainingFlags(value.substr(0,2))}];
if (value.length > 2) children.push({label:'Script ID', hex:value.substr(2), desc:value.substr(2)});
} else {
label = 'Row ' + (rows.length + 1) + ' (tag ' + tag + ')';
}
rows.push({label, hex:raw, desc:'', children});
i += consumed + len * 2;
}
if (i < hex.length && !hex.startsWith('0000', i)) {
rows.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
}
return rows;
}
function parseChainingFlags(hex) {
if (hex === '01') return 'First';
if (hex === '11') return 'First, keep across reset';
if (hex === '02') return 'Intermediary (more to follow)';
if (hex === '03') return 'Intermediary (last)';
return hex;
}
function parseActionRow(hex, tag) {
if (!hex || hex === '00') return [{label:'No action', hex:hex||'', desc:tag === '82' ? 'No action (82 00)' : ''}];
if (hex.length === 2) {
const val = parseInt(hex, 16);
if (val >= 0x01 && val <= 0x7F) {
return [{label:'Reference to EFRMA record', hex, desc:'Record 0x' + hex}];
}
const names = {'81':'Proactive session indication','82':'Early response'};
return [{label:'Action indicator', hex, desc:names[hex] || 'Value ' + hex}];
}
const tlvs = parseTlvList(hex);
if (tlvs.length > 0) {
const children = describeProactiveCommand(tlvs);
return [{label:'Proactive command', hex, desc:'', children}];
}
return [{label:'Raw', hex, desc:hex}];
}
function describeP1P2(ins, p1, p2) {
const v1 = parseInt(p1, 16);
const v2 = parseInt(p2, 16);
let d1 = '', d2 = '';
if (ins === 'A4') {
d1 = {'00':'by FID','01':'child DF','03':'parent DF','04':'by DF name (AID)','08':'path from MF','09':'path from current DF'}[p1] || '';
const resp = (v2 >> 2) & 0x07;
if (resp === 0x01) d2 = 'return FCP template';
else if (resp === 0x03) d2 = 'no response data';
if (v1 === 0x04) {
const occName = {'00':'first or only occurrence','01':'last occurrence','10':'next occurrence','11':'previous occurrence'}[(v2 & 0x03).toString(2).padStart(2,'0')];
if (occName) d2 += (d2 ? ', ' : '') + occName;
}
} else if (ins === 'B0' || ins === 'D6') {
if (v1 & 0x80) d1 = 'SFI mode, SFI ' + (v1 & 0x1F);
else d1 = 'offset high';
d2 = 'offset low';
} else if (ins === 'B2' || ins === 'DC') {
const modeKey = (v2 & 0x07).toString(16).padStart(2, '0');
d2 = {'02':'next record','03':'previous record','04':'absolute/current'}[modeKey] || '';
const sfi = (v2 >> 3) & 0x1F;
if (sfi) d2 += (d2 ? ', ' : '') + 'SFI ' + sfi;
if ((v2 & 0x07) === 0x02 || (v2 & 0x07) === 0x03) d1 = v1 === 0 ? 'ignored' : 'record # (ignored)';
else if (v1 > 0) d1 = 'record #' + v1;
} else if (ins === 'F2') {
d1 = {'80':'ISD','40':'Applications incl. SSDs','20':'Executable Load Files','10':'ELFs and Modules'}[p1] || '';
d2 = {'02':'first/all, TLV format','03':'next, TLV format','00':'first/all, deprecated raw','01':'next, deprecated raw'}[p2] || '';
} else if (ins === 'E6') {
const roles = [];
if (v1 & 0x02) roles.push('for load');
if (v1 & 0x04) roles.push('for install');
if (v1 & 0x08) roles.push('for make selectable');
if (v1 & 0x10) roles.push('for extradition');
if (v1 & 0x20) roles.push('for personalization');
if (v1 & 0x40) roles.push('for registry update');
if (roles.length) d1 = roles.join(' + ');
if (v1 & 0x80) d1 = 'more INSTALL commands follow; ' + d1;
} else if (ins === 'F0') {
d1 = {'80':'ISD','40':'Application or SSD','60':'SD + associated apps'}[p1] || '';
if (p1 === '80') {
d2 = {'01':'OP_READY','07':'INITIALIZED','0F':'SECURED','7F':'CARD_LOCKED','FF':'TERMINATED'}[p2] || '';
} else {
d2 = {'00':'unlock (previous state)','80':'LOCKED'}[p2] || '';
}
} else if (ins === '20' || ins === '24') {
if (v2 >= 1 && v2 <= 8) d2 = 'PIN ref ' + v2;
}
return {d1, d2};
}
const RAM_PARAM_TAGS = {
'C9': 'Application Specific Parameters',
'C6': 'Volatile memory quotas',
'C7': 'Non-volatile memory quotas',
'C8': 'Non-volatile memory quotas (persistent)',
'CB': 'Global Services parameters',
'CF': 'Implicit selection parameters',
'B6': 'Token CRT',
'EF': 'System Specific Parameters (SIM)',
'EA': 'System Specific Parameters (UICC)',
};
function readLvField(hex, i) {
const r = parseBerLen(hex, i);
const total = r.consumed + r.len * 2;
if (i + total > hex.length) return null;
return {value: hex.substr(i + r.consumed, r.len * 2), consumed: total, lenHex: hex.substr(i + 1, r.consumed - 2)};
}
function walkInstallParams(value) {
const tlvs = parseTlvList(value);
if (tlvs.length === 0) return null;
return tlvs.map(tlv => {
const name = RAM_PARAM_TAGS[tlv.tag];
let children = null;
if ((tlv.tag === 'EA' || tlv.tag === 'EF') && tlv.value.length >= 4) {
children = parseTlvList(tlv.value).map(inner => ({
label: 'TLV ' + inner.tag + (inner.tag === '80' ? ' (Toolkit parameters)' : inner.tag === 'CA' ? ' (SIM Toolkit parameters)' : ''),
hex: inner.raw, desc: inner.value || '(empty)',
}));
if (!children.length) children = [{label:'Value', hex:tlv.value, desc:tlv.value}];
}
const node = {label: name ? 'Tag ' + tlv.tag + ' — ' + name : 'TLV ' + tlv.tag, hex: tlv.raw, desc: tlv.value || '(empty)'};
if (children) node.children = children;
return node;
});
}
function describeInstallDataLv(p1val, data) {
let fields;
if (p1val & 0x40) {
fields = ['SD AID', '(reserved)', 'Application AID', 'Privileges', 'RU parameters', 'RU token'];
} else if (p1val & 0x20) {
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'Personalization parameters', 'Personalization token'];
} else if (p1val & 0x10) {
fields = ['Target SD AID', '(reserved)', 'Application/ELF AID', '(reserved)', 'Extradition parameters', 'Extradition token'];
} else if ((p1val & 0x08) && !(p1val & 0x04)) {
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'MS parameters', 'MS token'];
} else if (p1val & 0x04) {
fields = ['ELF AID', 'Module AID', 'Application AID', 'Privileges', 'Install parameters', 'Install token'];
} else if (p1val & 0x02) {
fields = ['Load File AID', 'SD AID', 'LFDB hash', 'Load parameters', 'Load token'];
} else {
return null;
}
const nodes = [];
let i = 0;
for (const name of fields) {
if (i >= data.length) return null;
const f = readLvField(data, i);
if (!f) return null;
const node = {label: name, hex: f.lenHex + f.value, desc:''};
if (name === 'Privileges') node.desc = decodePrivileges(f.value);
else if (name.endsWith('parameters')) {
const walked = walkInstallParams(f.value);
node.children = walked || [{label:'Value', hex:f.value, desc:f.value}];
node.desc = f.value ? '' : '(empty)';
} else if (f.value) {
node.desc = f.value;
} else {
node.desc = '(empty)';
}
nodes.push(node);
i += f.consumed;
}
if (i !== data.length) return null;
return nodes;
}
function parseOneApdu(hex) {
const isActivateDeactivate = hex.length >= 4 && (hex.substr(2, 2) === '44' || hex.substr(2, 2) === '04');
if (hex.length < 10 && !(isActivateDeactivate && hex.length === 8)) return null;
const cla = hex.substr(0, 2);
const ins = hex.substr(2, 2);
const p1 = hex.substr(4, 2);
const p2 = hex.substr(6, 2);
const p3 = hex.substr(8, 2);
const insInfo = PARSE_INS[ins];
const labels = describeP1P2(ins, p1, p2);
const children = [
{label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : parseInt(cla,16) >= 0x84 && parseInt(cla,16) <= 0x87 ? 'GlobalPlatform (secure messaging)' : 'UICC'},
{label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'},
{label:'P1', hex:p1, desc:labels.d1},
{label:'P2', hex:p2, desc:labels.d2},
];
if (isActivateDeactivate && hex.length === 8) {
return {label:'APDU (case 1)', hex:hex, desc:'no data, no Le', children};
}
if (ins === 'CA') {
if (hex.length === 10) {
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
return {label:'APDU', hex:hex, desc:'', children};
}
const lc = parseInt(p3, 16);
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
const data = hex.substr(10, lc * 2);
children.push({label:'Data', hex:data, desc:data.startsWith('5C') && data.length >= 4 ? 'Tag list: ' + data : data});
const restLen = hex.length - (10 + lc * 2);
if (restLen >= 2) children.push({label:'Le', hex:hex.substr(10 + lc * 2, 2), desc:'Expected length: ' + parseInt(hex.substr(10 + lc * 2, 2), 16)});
return {label:'APDU', hex:hex, desc:'', children};
}
if (insInfo && insInfo.data === 'le') {
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
} else if (isActivateDeactivate && hex.length >= 10) {
if (parseInt(p3, 16) === 0 && hex.length === 10) {
children.push({label:'P3', hex:p3, desc:'empty Lc (legacy form)'});
} else {
const alc = parseInt(p3, 16);
children.push({label:'Lc', hex:p3, desc:'Data length: ' + alc});
if (alc > 0 && hex.length >= 10 + alc * 2) {
const adata = hex.substr(10, alc * 2);
const adesc = adata.length === 4 ? 'FID ' + adata : (adata.length > 4 && adata.length % 2 === 0 ? 'Path ' + adata : adata);
children.push({label:'Data', hex:adata, desc:adesc});
}
}
} else if (insInfo && insInfo.data === 'none') {
children.push({label:'P3', hex:p3, desc:'No data'});
} else {
const lc = parseInt(p3, 16);
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
if (lc > 0 && hex.length >= 10 + lc * 2) {
const data = hex.substr(10, lc * 2);
let dataDesc = data;
let dataChildren = null;
if (ins === 'E6') {
const lv = describeInstallDataLv(parseInt(p1, 16), data);
if (lv) { dataDesc = ''; dataChildren = lv; }
else dataDesc = data;
} else if (ins === 'F0') {
if (p1 === '80') dataDesc = 'ignored for ISD';
else if (data.startsWith('4F') && data.length >= 4 && 2 + 2 + parseInt(data.substr(2, 2), 16) * 2 === data.length) dataDesc = 'Legacy 4F-TLV AID: ' + data.substr(4);
else dataDesc = 'AID (raw): ' + data;
}
const node = {label:'Data', hex:data, desc:dataDesc};
if (dataChildren) node.children = dataChildren;
children.push(node);
if (hex.length > 10 + lc * 2) {
const le = hex.substr(10 + lc * 2, 2);
children.push({label:'Le', hex:le, desc:'Expected response length: ' + parseInt(le, 16)});
}
}
}
const totalLen = hex.length;
return {label:'APDU', hex:hex.substr(0, totalLen), desc:'', children};
}
function parseCompactApdus(hex) {
const apdus = [];
let i = 0;
let prevCla = '';
while (i + 4 <= hex.length) {
const first = hex.substr(i, 2);
const second = hex.substr(i + 2, 2);
let fullCla = '';
let fullIns = '';
let consumed = 0;
const fb = parseInt(first, 16);
const knownCla = first === '80' || first === 'A0' || first === '00' || (fb >= 0x84 && fb <= 0x87);
if (knownCla && PARSE_INS[second]) {
fullCla = first;
fullIns = second;
consumed = 2;
} else if (PARSE_INS[first] && prevCla && i > 0) {
fullCla = prevCla;
fullIns = first;
consumed = 0;
}
if (fullCla) {
const p3Byte = parseInt(hex.substr(i + 6 + consumed, 2), 16);
const dataType = PARSE_INS[fullIns].data;
let apduLen = 8;
if (fullIns === 'CA') {
const remAvail = hex.length - (i + consumed);
if (p3Byte > 0 && remAvail >= p3Byte * 2 + 2) apduLen += p3Byte * 2 + 2;
} else if (fullIns === '44' || fullIns === '04') {
const avail = hex.length - i;
const p3Hex = avail >= consumed + 8 ? hex.substr(i + 6 + consumed, 2) : null;
if (p3Hex !== null) {
if (p3Byte > 0) apduLen += p3Byte * 2;
} else if (avail >= consumed + 6) {
apduLen -= 2;
}
} else if (dataType === 'lc') {
apduLen += p3Byte * 2;
}
if (i + consumed + apduLen <= hex.length) {
const apduHex = fullCla + hex.substr(i + consumed, apduLen);
const parsed = parseOneApdu(apduHex);
if (parsed) {
prevCla = fullCla;
parsed.hex = hex.substr(i, consumed + apduLen);
parsed.label = consumed === 0 ? 'APDU (implied CLA)' : 'APDU';
apdus.push(parsed);
i += consumed + apduLen;
continue;
}
}
}
apdus.push({label:'Unknown', hex:hex.substr(i, 2), desc:'Unrecognized'});
i += 2;
}
if (i < hex.length) {
if (apdus.length > 0 && hex.length - i === 2) {
const leHex = hex.substr(i);
apdus.push({label:'Le', hex:leHex, desc:'Expected response length: ' + parseInt(leHex, 16)});
} else {
apdus.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
}
}
return apdus;
}
function parseHexTree(hex) {
const s = hex.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (s.length % 2 !== 0) return {label:'Error', hex:'', desc:'Odd hex length'};
if (s.startsWith('AA') && s.length >= 4) {
const rows = parseBerScript(s);
return {label:'Expanded Script (AA)', hex:s, desc:'', children:rows};
}
if (s.startsWith('AE80')) {
const rows = parseBerScript(s);
return {label:'Expanded Script (AE80)', hex:s, desc:'', children:rows};
}
const apdus = parseCompactApdus(s);
return {label:'Compact C-APDU chain', hex:s, desc:'', children:apdus};
}
function renderTree(node, container) {
const div = document.createElement('div');
div.className = 'ml-3 mb-1';
const header = document.createElement('div');
header.className = 'flex gap-2 items-start py-0.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 rounded';
if (node.children && node.children.length > 0) {
const toggle = document.createElement('span');
toggle.textContent = '▾';
toggle.className = 'text-xs text-gray-400 w-4 flex-shrink-0';
header.appendChild(toggle);
header.onclick = () => {
const body = div.querySelector('.tree-body');
if (body) {
body.classList.toggle('hidden');
toggle.textContent = body.classList.contains('hidden') ? '▸' : '▾';
}
};
} else {
const dot = document.createElement('span');
dot.textContent = '·';
dot.className = 'text-xs text-gray-300 w-4 flex-shrink-0';
header.appendChild(dot);
}
const label = document.createElement('span');
label.className = 'text-sm font-medium text-gray-700 dark:text-slate-300';
label.textContent = node.label;
header.appendChild(label);
if (node.hex) {
const hex = document.createElement('span');
hex.className = 'text-sm font-mono text-indigo-600 dark:text-indigo-400 ml-2';
hex.textContent = node.hex;
header.appendChild(hex);
}
if (node.desc) {
const desc = document.createElement('span');
desc.className = 'text-sm text-gray-600 dark:text-slate-400 ml-2';
desc.textContent = node.desc;
header.appendChild(desc);
}
div.appendChild(header);
if (node.children && node.children.length > 0) {
const body = document.createElement('div');
body.className = 'tree-body';
div.appendChild(body);
for (const child of node.children) {
renderTree(child, body);
}
}
container.appendChild(div);
}
function parseHex() {
const input = document.getElementById('parse-input');
const output = document.getElementById('parse-output');
const hex = input.value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!hex || hex.length % 2 !== 0) {
output.innerHTML = '<div class="text-xs text-red-500">Invalid hex input</div>';
return;
}
const tree = parseHexTree(hex);
output.innerHTML = '';
renderTree(tree, output);
}
// ===== Response Parser =====
const SW_MAP = {
generic: {
'9000': 'Normal ending of command',
'61XX': 'Send GET RESPONSE (Le = SW2)',
'6CXX': 'Repeat command with Le = SW2',
// TS 51.011 §9.4 SIM-specific families
'9200': 'Command successful after internal update retry routine',
'9240': 'Memory problem',
'9400': 'No EF selected',
'9402': 'Out of range (invalid address)',
'9404': 'File ID not found / pattern not found',
'9408': 'File inconsistent with the command',
'9802': 'No CHV initialized',
'9804': 'Access condition not fulfilled / CHV verification failed, attempts left / authentication failed',
'9808': 'In contradiction with CHV status',
'9810': 'In contradiction with invalidation status',
'9840': 'CHV verification failed - no attempt left; CHV blocked; UNBLOCK blocked',
'9850': 'Increase cannot be performed, max value reached',
'9EXX': 'SIM data download error (SW2 = length)',
'9FXX': 'Response data available (SW2 = N bytes) - send GET RESPONSE',
'6200': 'Warning: No information given, NV memory unchanged',
'6281': 'Warning: Part of returned data may be corrupted',
'6282': 'Warning: End of file/record reached before reading Le bytes',
'6283': 'Warning: Selected file invalidated',
'6285': 'Warning: Selected file in termination state',
'62F1': 'Warning: More data available',
'62F2': 'Warning: More data available and proactive command pending',
'62F3': 'Warning: Response data available',
'63F1': 'Warning: More data expected',
'63F2': 'Warning: More data expected and proactive command pending',
'63CX': 'Verification failed, X retries remaining (SW2 & 0x0F = tries; X=0: no counter provided / blocked for VERIFY)',
'6400': 'No information given, state of NV memory unchanged',
'6500': 'No information given, state of NV memory changed',
'6581': 'Memory problem',
'6700': 'Wrong length in Lc',
'6800': 'Checking error: No information given (CLA functions)',
'6881': 'Logical channel not supported',
'6882': 'Secure messaging not supported',
'6900': 'No information given',
'6981': 'Command incompatible with file structure',
'6982': 'Security status not satisfied',
'6983': 'Authentication/PIN method blocked',
'6984': 'Referenced data invalidated',
'6985': 'Conditions of use not satisfied',
'6986': 'Command not allowed (no EF selected)',
'6989': 'Command not allowed — secure channel — security not satisfied',
'6A80': 'Incorrect parameters in the data field',
'6A81': 'Function not supported',
'6A82': 'File not found',
'6A83': 'Record not found',
'6A84': 'Not enough memory space',
'6A86': 'Incorrect parameters P1-P2',
'6A87': 'Lc inconsistent with P1-P2',
'6A85': 'Nc inconsistent with TLV data object',
'6A88': 'Referenced data not found',
'6A89': 'File already exists',
'6A8A': 'DF name already exists',
'6B00': 'Wrong parameter(s) P1-P2',
'6D00': 'Instruction code not supported or invalid',
'6E00': 'Class not supported',
'6F00': 'Technical problem, no precise diagnosis',
'9850': 'INCREASE cannot be performed, max value reached',
'9862': 'Authentication error, application specific',
'9863': 'Security session or association expired',
'9864': 'Minimum UICC suspension time is too long',
},
gp: {
'6310': 'More data available — issue GET STATUS [get next occurrence]',
'6581': 'Memory failure',
'6A80': 'Incorrect values in command data',
'6A81': 'Function not supported (card Life Cycle State is CARD_LOCKED)',
'6A82': 'Selected Application / file not found',
'6A84': 'Not enough memory space',
'6A88': 'Referenced data not found',
'6881': 'Logical channel not supported or is not active',
'6882': 'Secure messaging not supported',
'6883': 'The last command of the chain was expected',
'9484': 'Algorithm not supported (DEPRECATED)',
'9485': 'Invalid key check value (DEPRECATED)',
'6300': 'Authentication of host cryptogram failed',
'6200': 'Warning: Logical Channel already closed',
'6283': 'Warning: Card Life Cycle State is CARD_LOCKED',
},
uicc: {
'91XX': 'Normal ending with proactive UICC command pending (SW2 = length of response data)',
'9100': 'Normal ending with proactive UICC command pending (no response data)',
'92XX': 'Normal ending with ongoing data transfer session info',
'9300': 'SIM Application Toolkit is busy',
'62F1': 'More data available (ENVELOPE, RETRIEVE DATA, SET DATA)',
'62F2': 'More data available and proactive command pending',
'62F3': 'Response data available (ENVELOPE)',
'63F1': 'More data expected (ENVELOPE, RETRIEVE DATA, SET DATA)',
'63F2': 'More data expected and proactive command pending (RETRIEVE DATA)',
'63CX': 'Verification failed, X retries remaining (VERIFY PIN, CHANGE PIN, etc.)',
'6A80': 'Incorrect parameters in the data field',
'6A82': 'File not found',
'6A83': 'Record not found (READ RECORD, SEARCH RECORD, UPDATE RECORD)',
'6A84': 'Not enough memory space (RETRIEVE DATA, SET DATA)',
'6A87': 'Lc inconsistent with P1 to P2 (SELECT only)',
'6A88': 'Referenced data not found',
'6981': 'Command incompatible with file structure',
'6983': 'Authentication/PIN method blocked',
'6984': 'Referenced data invalidated',
'6986': 'Command not allowed (no EF selected)',
'6989': 'Command not allowed — secure channel — security not satisfied',
},
};
const SW_CMD_NAMES = {
'sim-select': 'SIM / USIM — SELECT',
'sim-status': 'SIM / USIM — STATUS',
'sim-read-binary': 'SIM / USIM — READ BINARY',
'sim-update-binary': 'SIM / USIM — UPDATE BINARY',
'sim-read-record': 'SIM / USIM — READ RECORD',
'sim-update-record': 'SIM / USIM — UPDATE RECORD',
'sim-verify': 'SIM / USIM — VERIFY PIN',
'sim-change': 'SIM / USIM — CHANGE PIN',
'sim-disable': 'SIM / USIM — DISABLE PIN',
'sim-enable': 'SIM / USIM — ENABLE PIN',
'sim-unblock': 'SIM / USIM — UNBLOCK PIN',
'sim-deactivate': 'SIM / USIM — DEACTIVATE FILE',
'sim-activate': 'SIM / USIM — ACTIVATE FILE',
'sim-challenge': 'SIM / USIM — GET CHALLENGE',
'sim-auth': 'SIM / USIM — AUTHENTICATE',
'sim-profile': 'SIM / USIM — TERMINAL PROFILE',
'sim-envelope': 'SIM / USIM — ENVELOPE',
'sim-fetch': 'SIM / USIM — FETCH',
'sim-terminal-rsp': 'SIM / USIM — TERMINAL RESPONSE',
'sim-manage-channel': 'SIM / USIM — MANAGE CHANNEL',
'sim-msc': 'SIM / USIM — MANAGE SECURE CHANNEL',
'sim-retrieve': 'SIM / USIM — RETRIEVE DATA',
'sim-set-data': 'SIM / USIM — SET DATA',
'sim-suspend': 'SIM / USIM — SUSPEND UICC',
'ram-install-load': 'RAM — INSTALL [for load]',
'ram-install-install': 'RAM — INSTALL [for install]',
'ram-install-make-sel': 'RAM — INSTALL [for make selectable]',
'ram-install-reg-update': 'RAM — INSTALL [for registry update]',
'ram-install-extradition': 'RAM — INSTALL [for extradition]',
'ram-load': 'RAM — LOAD',
'ram-delete': 'RAM — DELETE',
'ram-get-status': 'RAM — GET STATUS',
'ram-get-data': 'RAM — GET DATA',
'ram-store-data': 'RAM — STORE DATA',
'ram-set-status': 'RAM — SET STATUS',
'ram-ext-auth': 'RAM — EXTERNAL AUTHENTICATE',
'ram-int-auth': 'RAM — INTERNAL AUTHENTICATE',
'ram-select': 'RAM — SELECT',
'ram-init-update': 'RAM — INITIALIZE UPDATE',
'ram-put-key': 'RAM — PUT KEY',
'ram-manage-channel': 'RAM — MANAGE CHANNEL',
};
const LIFECYCLE_MAP = {
'01': 'OP_READY (card) / LOADED (ELF)',
'03': 'INSTALLED',
'07': 'SELECTABLE',
'0F': 'SECURED (card)',
'1F': 'PERSONALIZED (SD)',
'7F': 'CARD_LOCKED',
'FF': 'TERMINATED',
'83': 'LOCKED (SD)',
'87': 'LOCKED (SD)',
'8F': 'LOCKED (SD)',
};
const PRIVILEGE_NAMES = [
['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'],
['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'],
['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'],
];
function getSwCategory(cmd) {
if (!cmd) return 'generic';
return cmd.startsWith('ram-') ? 'gp' : 'uicc';
}
function lookupSw(sw1, sw2, cat) {
const key = sw1 + sw2;
const wild = sw1 + 'XX';
const cx = sw1 + 'CX';
const maps = [SW_MAP.generic];
if (cat === 'gp' || cat === 'uicc') maps.push(SW_MAP[cat]);
if (cat === 'uicc') maps.push(SW_MAP.gp);
for (const m of maps) {
if (m[key]) return m[key];
if (m[wild]) return m[wild];
if (m[cx] && (parseInt(sw2,16) & 0xF0) === 0xC0) {
const tries = parseInt(sw2,16) & 0x0F;
return m[cx].replace('X', tries);
}
}
return '';
}
function updateRespCmd() {
const cmd = document.getElementById('resp-cmd').value;
const ctx = document.getElementById('resp-ctx');
if (!cmd) { ctx.value = 'generic'; return; }
ctx.value = getSwCategory(cmd) === 'gp' ? 'GlobalPlatform' : 'SIM / UICC';
}
function parseTLV(hex) {
const tags = [];
let i = 0;
while (i < hex.length) {
let tag = hex.substr(i, 2); i += 2;
const firstByte = parseInt(tag, 16);
// Multi-byte tags: bits 4-0 of first byte are 0x1F (ISO 7816)
if ((firstByte & 0x1F) === 0x1F) { tag += hex.substr(i, 2); i += 2; }
let lenByte = parseInt(hex.substr(i, 2), 16); i += 2;
let len;
if (lenByte < 0x80) { len = lenByte; }
else if (lenByte === 0x81) { len = parseInt(hex.substr(i, 2), 16); i += 2; }
else if (lenByte === 0x82) { len = parseInt(hex.substr(i, 4), 16); i += 4; }
else { len = lenByte; }
const value = hex.substr(i, len * 2); i += len * 2;
// Constructed if bit 5 of first tag byte is set (ISO 7816)
const isConstructed = (firstByte & 0x20) !== 0;
const children = isConstructed ? parseTLV(value) : null;
tags.push({ tag, len, value, children, isConstructed });
}
return tags;
}
function formatTlvTree(tags, indent) {
if (!indent) indent = 0;
const pad = ' '.repeat(indent);
let html = '';
for (const t of tags) {
const tagName = TLV_TAG_NAMES[t.tag] || '';
html += pad + '<span class="text-blue-600 dark:text-blue-400">' + t.tag + '</span>';
html += ' <span class="text-gray-500">len=' + t.len + '</span>';
if (tagName) html += ' <span class="text-green-600 dark:text-green-400">// ' + tagName + '</span>';
if (t.children && t.children.length) {
html += '\n' + formatTlvTree(t.children, indent + 1);
} else {
const decoded = decodeTlvValue(t);
if (decoded) html += ' <span class="text-purple-600 dark:text-purple-400">→ ' + decoded + '</span>';
html += '\n';
}
}
return html;
}
const TLV_TAG_NAMES = {
'4F': 'Application AID',
'5F50': 'SD Manager URL',
'9F70': 'Life Cycle State',
'C5': 'Privileges',
'C4': 'Application ELF AID',
'CC': 'Associated SD AID',
'CE': 'ELF Version Number',
'84': 'Executable Module AID',
'CF': 'Implicit Selection Parameter',
'E3': 'GP Registry Data',
'42': 'Issuer Identification Number',
'45': 'Card Image Number',
'66': 'Card Data',
'67': 'Card Capability Information',
'E0': 'Key Information Template',
'C0': 'Key Information Data',
'D3': 'Current Security Level',
'C1': 'Sequence Counter',
'C2': 'Confirmation Counter',
'5C': 'Tag List',
'7F21': 'Certificate',
'5031': 'Certificate Info (EF.OD)',
'6F': 'FCI Template',
'62': 'FCP Template',
'64': 'FMD Template',
'A0': 'Application Template',
'A5': 'Proprietary FCI Data',
'81': 'Immediate Action',
'82': 'Error Action',
'22': 'C-APDU',
'83': 'Script Chaining',
};
function decodeTlvValue(t) {
if (t.tag === '9F70' && t.value.length === 2) {
return LIFECYCLE_MAP[t.value] || 'Unknown state (' + t.value + ')';
}
if (t.tag === 'C5' && t.value.length >= 2) {
return decodePrivileges(t.value);
}
if (t.tag === '4F') {
return t.value.replace(/(..)/g, '$1 ').trim();
}
if (t.tag === 'C4' || t.tag === 'CC' || t.tag === '84') {
return t.value.replace(/(..)/g, '$1 ').trim();
}
if (t.tag === 'CE' && t.value.length === 4) {
return 'v' + parseInt(t.value.substr(0,2),16) + '.' + parseInt(t.value.substr(2,2),16);
}
if (t.tag === 'D3' && t.value.length === 2) {
const v = parseInt(t.value,16);
const parts = [];
if (v & 0x80) parts.push('C-MAC');
if (v & 0x40) parts.push('MAC');
if (v & 0x20) parts.push('R-MAC');
if (v & 0x10) parts.push('R-CC');
if (v & 0x08) parts.push('C-DEC');
if (v & 0x04) parts.push('D-DEC');
if (v & 0x02) parts.push('C-ENC');
if (v & 0x01) parts.push('D-ENC');
return parts.length ? parts.join(' + ') : 'No security';
}
if (t.tag === 'C1' && t.value.length >= 2) {
return 'Seq: 0x' + t.value;
}
if (t.tag === 'C2' && t.value.length >= 2) {
return 'Counter: 0x' + t.value;
}
if (t.tag === '5F50') {
try {
const bytes = [];
for (let i = 0; i < t.value.length; i += 2) bytes.push(parseInt(t.value.substr(i,2),16));
return new TextDecoder().decode(new Uint8Array(bytes));
} catch(e) { return t.value; }
}
return '';
}
function decodePrivileges(hex) {
const bytes = [];
for (let i = 0; i < hex.length; i += 2) bytes.push(parseInt(hex.substr(i,2),16));
const names = [];
for (let b = 0; b < Math.min(bytes.length, PRIVILEGE_NAMES.length); b++) {
const byteNames = PRIVILEGE_NAMES[b] || [];
for (let bit = 0; bit < byteNames.length; bit++) {
if (bytes[b] & (0x80 >> bit)) {
if (byteNames[bit]) names.push(byteNames[bit]);
}
}
}
return names.length ? names.join(', ') : 'None';
}
function decodeResponse() {
const cmd = document.getElementById('resp-cmd').value;
const swHex = (document.getElementById('resp-sw').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(4, '0').slice(0, 4);
const sw1 = swHex.slice(0, 2);
const sw2 = swHex.slice(2, 4);
const rawData = (document.getElementById('resp-data').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const out = document.getElementById('resp-output');
let html = '';
if (!sw1 || !sw2) {
html += '<span class="text-red-500">Please enter SW1 and SW2</span>';
out.innerHTML = html;
return;
}
const cat = getSwCategory(cmd);
const swDesc = lookupSw(sw1, sw2, cat);
const isOk = sw1 === '90' && sw2 === '00';
html += '<div class="mb-2">';
if (isOk) {
html += '<span class="text-green-600 dark:text-green-400 font-bold">✓</span> ';
} else {
html += '<span class="text-red-500 dark:text-red-400 font-bold">✗</span> ';
}
html += 'SW1=' + sw1 + ' SW2=' + sw2;
if (cmd && SW_CMD_NAMES[cmd]) html += ' <span class="text-gray-500">(' + SW_CMD_NAMES[cmd] + ')</span>';
html += '<br>';
if (swDesc) {
html += '<span class="text-gray-700 dark:text-slate-300">' + swDesc + '</span>';
} else {
html += '<span class="text-yellow-600 dark:text-yellow-400">Unknown status word</span>';
}
html += '</div>';
if (rawData) {
html += '<div class="mt-2 pt-2 border-t border-gray-300 dark:border-slate-600">';
html += '<div class="text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Response Data</div>';
if (cmd === 'ram-get-status') {
const tlv = parseTLV(rawData);
if (tlv.length) {
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
} else {
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
}
} else if (cmd === 'ram-get-data') {
const tlv = parseTLV(rawData);
if (tlv.length) {
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
} else {
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
}
} else if (cmd === 'sim-select' || cmd === 'ram-select') {
const tlv = parseTLV(rawData);
if (tlv.length) {
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
} else {
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
}
} else {
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
}
html += '</div>';
}
out.innerHTML = html;
}
// ===== pysim integration =====
// Default to a local server on 127.0.0.1:8080. If this PWA is served by
// pysim-otaman-server itself (same origin), detect the co-located API once
// and switch to a relative base; otherwise keep the default and wait for the
// user to point at a server.
let pysimBase = 'http://127.0.0.1:8080';
async function pysimDetectSameOrigin() {
try {
const res = await fetch('/api/version', { method: 'GET', cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (!data || typeof data.version !== 'string') return;
pysimBase = '';
const urlEl = document.getElementById('pysim-url');
if (urlEl) { urlEl.value = ''; urlEl.placeholder = '(same origin)'; }
_pysimAutoConnectAttempted = true;
pysimConnect();
} catch (e) {
// no co-located API (file://, plain static server, SPA fallback) — keep default
}
}
pysimDetectSameOrigin();
function esc(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function pysimFetch(path, body) {
const url = pysimBase + path;
const opts = { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'Accept-Language': currentLang } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
return await res.json();
}
// ===== UI availability (server / card) =====
let _pysimServerAvailable = null; // null until probed
let _pysimCardEquipped = false;
let _pysimEquipping = false;
let _pysimAvailabilityTimer = null;
function pysimAvailabilityState() {
if (!_pysimServerAvailable) return 'server-down';
return _pysimCardEquipped ? 'card' : 'no-card';
}
function pysimControlDisabled(needs, state) {
if (needs === 'server') return state === 'server-down';
if (needs === 'card') return state !== 'card';
return false;
}
function pysimNeedsHint(needs, state) {
return state === 'server-down' ? t('Connect to server') : t('Insert and equip a card');
}
function pysimUpdateStateIndicator() {
const el = document.getElementById('state-indicator');
const dot = document.getElementById('state-indicator-dot');
const img = document.getElementById('state-indicator-img');
if (!el || !dot || !img) return;
if (_pysimServerAvailable === true) {
const state = pysimAvailabilityState();
dot.classList.add('hidden');
dot.removeAttribute('title');
img.classList.remove('hidden');
img.src = _pysimEquipping ? 'sim_anim.svg' : (state === 'card' ? 'sim.svg' : 'nosim.svg');
el.setAttribute('title', _pysimEquipping ? t('Card inserted — initializing...')
: (state === 'card' ? t('Card equipped') : t('Server connected, no card equipped')));
} else {
const serverState = _pysimServerAvailable === false ? t('No server connection') : t('Connecting...');
img.classList.add('hidden');
dot.classList.remove('hidden');
dot.classList.remove('text-red-500', 'text-gray-400');
dot.classList.add(_pysimServerAvailable === false ? 'text-red-500' : 'text-gray-400');
dot.setAttribute('title', serverState);
el.setAttribute('title', serverState);
}
}
function pysimApplyAvailability() {
const state = pysimAvailabilityState();
document.querySelectorAll('[data-needs]').forEach(el => {
const needs = el.getAttribute('data-needs');
const disabled = pysimControlDisabled(needs, state);
el.disabled = disabled;
if (disabled) el.setAttribute('title', pysimNeedsHint(needs, state));
else el.removeAttribute('title');
});
pysimUpdateStateIndicator();
}
function pysimSetServerAvailable(available) {
if (_pysimServerAvailable === available) return;
_pysimServerAvailable = available;
pysimApplyAvailability();
}
function pysimStartAvailabilityProbe() {
if (_pysimAvailabilityTimer) return;
_pysimAvailabilityTimer = setInterval(async () => {
if (_pysimServerAvailable === true && _pysimCardEquipped) return; // 2s status poll covers this
try {
const ver = await pysimFetch('/api/version');
if (ver && typeof ver.version === 'string') {
_pysimServerAvailable = true;
try { pysimCardStateUpdate(await pysimFetch('/api/status')); } catch (e) { /* status still optional here */ }
pysimApplyAvailability();
} else {
pysimSetServerAvailable(false);
}
} catch (e) {
pysimSetServerAvailable(false);
}
}, 5000);
}
function pysimTargetSpace() {
let h;
try { h = new URL(pysimBase).hostname.toLowerCase(); } catch (e) { return 'public'; }
if (h === 'localhost' || h === '[::1]' || h === '::1' || /^127\./.test(h)) return 'loopback';
if (/^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 'private';
return 'public';
}
function pysimIsLikelyBlocked() {
const pagePublic = location.protocol === 'https:'
|| !(location.hostname === 'localhost' || /^127\./.test(location.hostname));
const s = pysimTargetSpace();
return pagePublic && (s === 'loopback' || s === 'private');
}
function pysimSetConnected(state) {
const img = document.getElementById('pysim-status-img');
if (state === 'spin') {
img.src = 'sim_anim.svg';
} else {
img.src = state ? 'sim.svg' : 'nosim.svg';
}
}
function pysimShowConnected(connected) {
document.getElementById('pysim-connect-row').classList.toggle('hidden', connected);
document.getElementById('pysim-connect-info').classList.toggle('hidden', connected);
document.getElementById('pysim-connected-row').classList.toggle('hidden', !connected);
document.getElementById('pysim-body').classList.toggle('hidden', !connected);
document.getElementById('sp-verify-btn').classList.toggle('hidden', !connected);
document.getElementById('sp-send-btn').classList.toggle('hidden', !connected);
}
async function pysimRefresh() {
const statusEl = document.getElementById('pysim-status');
try {
const data = await pysimFetch('/api/status');
pysimSetConnected(true);
let html = 'Reader: <b>' + esc(data.reader || 'none') + '</b>' +
' | Card: <b>' + esc(data.card || 'none') + '</b>' +
' | Profile: <b>' + esc(data.profile || 'none') + '</b><br>' +
'ATR: <b>' + esc(data.atr || 'n/a') + '</b>' +
(data.cla_byte ? ' | CLA: <b>' + esc(data.cla_byte) + '</b>' : '') +
(data.sel_ctrl ? ' | SEL: <b>' + esc(data.sel_ctrl) + '</b>' : '') +
' | Channels: <b>' + (data.channels && data.channels.length ? data.channels.join(', ') : 'none') + '</b><br>' +
'ADM: <b>' + (data.adm_verified ? 'verified' : 'not verified') + '</b>' +
' | Selection: <b>' + (data.current_selection
? (data.current_selection.name || data.current_selection.fid || '?')
: 'none') + '</b>' +
(data.current_selection
? ' [' + (data.current_selection.type || '?') + ']' +
' FID=' + (data.current_selection.fid || '?') +
(data.current_selection.path ? ' path=' + data.current_selection.path : '') +
(data.current_selection.file_size ? ' size=' + data.current_selection.file_size : '') +
(data.current_selection.record_len ? ' rec_len=' + data.current_selection.record_len : '') +
(data.current_selection.num_of_rec ? ' rec_cnt=' + data.current_selection.num_of_rec : '')
: '');
statusEl.innerHTML = html;
return true;
} catch (e) {
pysimSetConnected(false);
statusEl.textContent = t('Connection failed:') + ' ' + e.message;
return false;
}
}
async function pysimConnect() {
const urlInput = document.getElementById('pysim-url');
pysimBase = urlInput.value.replace(/\/+$/, '');
const wasAuto = _pysimAutoConnectAttempted;
_pysimAutoConnectAttempted = false;
const btn = document.getElementById('pysim-connect-btn');
btn.disabled = true;
btn.textContent = t('Connecting...');
const statusEl = document.getElementById('pysim-status');
const msgEl = document.getElementById('pysim-connect-msg');
msgEl.innerHTML = '<span class="text-gray-500">' + t('Checking card...') + '</span>';
pysimSetConnected('spin');
try {
// Version check
const ver = await pysimFetch('/api/version');
if (!ver.version) throw new Error('No version');
_pysimServerAvailable = true;
const sv = (ver.version || '').split('.').map(Number);
if (sv[0] < 1 || (sv[0] === 1 && sv[1] < 0)) {
msgEl.innerHTML = '<span class="text-red-500">Server version ' + esc(ver.version) + ' is outdated. Please update pysim-otaman-server.</span>';
btn.textContent = t('Connect');
btn.disabled = false;
return;
}
if (sv[0] > 1) {
msgEl.innerHTML = '<span class="text-yellow-500">Warning: server version ' + esc(ver.version) + ' is newer than expected. Update OTAMan for full compatibility.</span>';
}
const status = await pysimFetch('/api/status');
if (!status.app_ready) {
throw new Error('Server not ready');
}
_pysimCardEquipped = !!status.connected;
const cmd = await pysimFetch('/api/command', { cmd: 'status' });
if (cmd.output && (cmd.output.includes('Exception') || cmd.output.includes('ProtocolError'))) {
pysimSetConnected(false);
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
pysimShowConnected(true);
btn.textContent = t('Connected');
btn.disabled = false;
return;
}
pysimSetConnected(true);
pysimShowConnected(true);
pysimApplyAvailability();
await pysimRefresh();
pysimLoadCommands();
pysimFsRefresh();
pysimPollStatusInit();
pysimStartBackendPoll();
btn.textContent = t('Connected');
stkCheckMenu();
} catch (e) {
if (wasAuto) _pysimAutoConnectAttempted = true;
pysimSetConnected(false);
_pysimServerAvailable = false;
_pysimCardEquipped = false;
pysimApplyAvailability();
const hint = t(pysimIsLikelyBlocked() ? 'pysim-blocked-hint' : 'pysim-unreachable-hint')
.replace('{url}', esc(pysimBase));
msgEl.innerHTML =
'<span class="text-red-500">' + t('Connection failed:') + ' ' + esc(e.message) + '</span>' +
'<br><span class="text-sm text-gray-600 dark:text-slate-400">' + hint + '</span>';
btn.textContent = t('Connect');
}
btn.disabled = false;
}
function pysimDisconnect() {
if (_pysimPollTimer) { clearInterval(_pysimPollTimer); _pysimPollTimer = null; }
if (_pysimStatusTimer) { clearInterval(_pysimStatusTimer); _pysimStatusTimer = null; }
pysimShowConnected(false);
document.getElementById('pysim-status').textContent = '';
pysimSetConnected(false);
_pysimServerAvailable = null;
_pysimCardEquipped = false;
pysimApplyAvailability();
}
async function pysimRunCmd(cmd) {
const statusEl = document.getElementById('pysim-status');
try {
const result = await pysimFetch('/api/command', { cmd });
if (result.output && (result.output.includes('Exception') || result.output.includes('ProtocolError') || result.output.includes('SwMatchError'))) {
pysimSetConnected(false);
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
return null;
}
return result;
} catch (e) {
pysimSetConnected(false);
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
return null;
}
}
async function pysimStatusCmd() {
const statusEl = document.getElementById('pysim-status');
statusEl.innerHTML = '<span class="text-gray-500">' + t('Checking...') + '</span>';
pysimSetConnected('spin');
const result = await pysimRunCmd('status');
if (result) await pysimRefresh();
}
async function pysimResetCmd() {
const statusEl = document.getElementById('pysim-status');
statusEl.innerHTML = '<span class="text-gray-500">' + t('Resetting...') + '</span>';
pysimSetConnected('spin');
const result = await pysimRunCmd('reset');
if (result) {
await pysimRefresh();
pysimFsRefresh();
}
}
async function pysimEquipCmd() {
const statusEl = document.getElementById('pysim-status');
statusEl.innerHTML = '<span class="text-gray-500">' + t('Equipping...') + '</span>';
pysimSetConnected('spin');
const result = await pysimRunCmd('equip');
if (result) {
await pysimRefresh();
pysimFsRefresh();
stkCheckMenu();
}
}
async function pysimExec() {
const cmd = document.getElementById('pysim-cmd').value.trim();
if (!cmd) return;
const out = document.getElementById('pysim-cmd-output');
out.value = '> ' + cmd + '\n';
try {
const data = await pysimFetch('/api/command', { cmd });
out.value += data.output || '(no output)';
} catch (e) {
out.value += 'Error: ' + e.message;
}
pysimRefresh();
}
async function pysimApdu() {
const apdu = document.getElementById('pysim-apdu').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!apdu) return;
const out = document.getElementById('pysim-apdu-output');
out.value = '> ' + apdu + '\n';
try {
const data = await pysimFetch('/api/apdu', { apdu });
if (data.error) {
out.value += 'Error: ' + data.error;
} else {
out.value += 'Response: ' + data.response + '\nSW: ' + data.sw;
}
} catch (e) {
out.value += 'Error: ' + e.message;
}
pysimRefresh();
}
function spParams() {
return {
spi1: document.getElementById('sp-spi1').value,
spi2: document.getElementById('sp-spi2-hex').value,
kic: document.getElementById('sp-kic-hex').value,
kid: document.getElementById('sp-kid-hex').value,
tar: document.getElementById('sp-tar').value,
cntr: document.getElementById('sp-cntr').value,
kicKey: document.getElementById('sp-kic-key').value,
kidKey: document.getElementById('sp-kid-key').value,
apdu: document.getElementById('sp-apdu').value,
};
}
async function pysimVerifySp() {
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const resultEl = document.getElementById('sp-verify-result');
if (!sp) { alert('No secured packet to verify. Generate a secure packet first.'); return; }
resultEl.classList.remove('hidden');
resultEl.textContent = t('Verifying against pySim reference...');
try {
const data = await pysimFetch('/api/sp-verify', { sp, ...spParams() });
if (data.match) {
resultEl.textContent = 'MATCH: JS packet byte-identical to pySim reference (' + sp.length / 2 + ' bytes)';
resultEl.classList.remove('text-red-600');
resultEl.classList.add('text-green-600');
} else {
let msg = 'MISMATCH vs pySim.';
if (data.diffs) msg += ' First diff at byte ' + data.diffs[0].offset + ' (JS=' + data.diffs[0].js + ' pySim=' + data.diffs[0].ref + ')';
if (data.py_sp) msg += ' pySim reference: ' + data.py_sp;
resultEl.textContent = msg;
resultEl.classList.remove('text-green-600');
resultEl.classList.add('text-red-600');
}
} catch (e) {
resultEl.textContent = 'Error: ' + e.message;
resultEl.classList.remove('text-green-600');
resultEl.classList.add('text-red-600');
}
}
async function pysimSendOta() {
const connectedRow = document.getElementById('pysim-connected-row');
if (!connectedRow || connectedRow.classList.contains('hidden')) {
alert('Card reader server is not connected');
return;
}
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!sp) { alert('No secured packet to send. Generate a secure packet first.'); return; }
const sendResultEl = document.getElementById('sp-send-result');
const porStatusEl = document.getElementById('sp-por-status');
sendResultEl.classList.remove('hidden', 'text-red-600', 'text-green-600');
sendResultEl.classList.add('text-gray-500');
sendResultEl.textContent = t('Sending OTA...');
porStatusEl.classList.add('hidden');
try {
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
if (data.success) {
if (data.por && data.por.decoded) {
document.getElementById('resp-sw').value = data.por.decoded.last_status_word || '';
document.getElementById('resp-data').value = data.por.decoded.last_response_data || '';
switchTab('c-apdu');
cApduSwitchSubtab('response');
}
let msg = 'OTA sent. SW: ' + data.sw;
const por = data.por;
if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
if (por.cntr) msg += ' | PoR CNTR: ' + por.cntr;
if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || '');
const okPor = por.response_status === 'por_ok';
porStatusEl.textContent = 'PoR: ' + por.response_status;
porStatusEl.classList.remove('hidden', okPor ? 'text-red-600' : 'text-green-600');
porStatusEl.classList.add(okPor ? 'text-green-600' : 'text-red-600');
if (okPor) {
// packet was accepted and executed - advance the counter so the
// same secured packet can never be sent twice (v1.9.6)
const cntrEl = document.getElementById('sp-cntr');
cntrEl.value = ((parseInt(cntrEl.value, 16) || 0) + 1)
.toString(16).toUpperCase().padStart(10, '0').slice(-10);
document.getElementById('sp-result').value = '';
// keep the selected card preset in sync with the new counter (v1.9.8)
const selIdx = parseInt(document.getElementById('sp-card-sel').value, 10);
if (!isNaN(selIdx) && cards[selIdx]) {
cards[selIdx].cntr = cntrEl.value;
cardsSave();
cardsRender();
}
}
}
sendResultEl.textContent = msg;
if (por && por.raw) {
const rawLine = document.createElement('div');
rawLine.className = 'break-all';
rawLine.textContent = 'raw PoR: ' + por.raw;
sendResultEl.appendChild(rawLine);
}
sendResultEl.classList.remove('hidden', 'text-red-600', 'text-gray-500');
sendResultEl.classList.add('text-green-600');
} else {
let msg = 'OTA failed';
if (data.error) msg += ': ' + data.error;
if (data.sw) msg += (data.error ? ' | ' : ': ') + 'SW ' + data.sw;
sendResultEl.textContent = msg;
sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500');
sendResultEl.classList.add('text-red-600');
}
} catch (e) {
sendResultEl.textContent = 'Error: ' + e.message;
sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500');
sendResultEl.classList.add('text-red-600');
}
}
// ===== SCP80/RAM pill =====
// Atomic GlobalPlatform Remote Application Management over SCP80.
// Simple ops (GET DATA / GET STATUS / DELETE) build the GP APDU locally and
// reuse /api/send-ota. Install Package sends the .cap hex to /api/ram-install
// which orchestrates INSTALL[for load] -> LOAD x N -> INSTALL[for install].
function ramRender() {
// populate the card preset selector from the in-memory cards[] array
const sel = document.getElementById('ram-card-sel');
if (!sel) return;
sel.innerHTML = '<option value="" data-l10n="— Select card —">— Select card —</option>';
cards.forEach((c, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = c.name || ('Card ' + i);
sel.appendChild(opt);
});
ramOpChanged();
}
function ramApplyCard(idx) {
// copy the selected card preset into the SP form fields so that
// getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys
cardsApply(idx);
}
function ramOpChanged() {
const op = document.getElementById('ram-op').value;
document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap');
if (op !== 'explore') {
document.getElementById('ram-explorer').classList.add('hidden');
document.getElementById('ram-explorer').innerHTML = '';
}
}
function ramShowProgress(text) {
const el = document.getElementById('ram-progress');
el.classList.remove('hidden');
document.getElementById('ram-progress-text').textContent = text;
}
function ramHideProgress() {
document.getElementById('ram-progress').classList.add('hidden');
}
function ramClearResults() {
document.getElementById('ram-result').classList.add('hidden');
document.getElementById('ram-explorer').classList.add('hidden');
document.getElementById('ram-explorer').innerHTML = '';
document.getElementById('ram-steps').classList.add('hidden');
document.getElementById('ram-steps').textContent = '';
}
// Read a File as a hex string via FileReader (client-side, no upload)
function ramReadFileHex(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const bytes = new Uint8Array(reader.result);
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
resolve(hex.toUpperCase());
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsArrayBuffer(file);
});
}
// Collect SP params from the SP form (populated by ramApplyCard -> cardsApply)
function getRamSpParams() {
return {
spi1: document.getElementById('sp-spi1').value,
spi2: document.getElementById('sp-spi2').value,
kic: document.getElementById('sp-kic-hex').value,
kid: document.getElementById('sp-kid-hex').value,
tar: (document.getElementById('sp-tar').value || '000000').replace(/[^0-9a-fA-F]/g, ''),
cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''),
kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
};
}
function ramIncrementCntr(cntr) {
let v = (parseInt(cntr, 16) || 0) + 1;
return v.toString(16).toUpperCase().padStart(10, '0').slice(-10);
}
function ramSaveCntr(cntr) {
const el = document.getElementById('sp-cntr');
el.value = cntr;
const selIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
if (!isNaN(selIdx) && cards[selIdx]) {
cards[selIdx].cntr = cntr;
cardsSave();
cardsRender();
ramRender();
}
}
// Send a single GP APDU wrapped in SCP80 via /api/send-ota
async function ramSendOta(apduHex, sp) {
// RAM operations: send raw GP command + SCP80 params; server handles SCP80 wrapping
// Caller controls SPI2: 0x01 = PoR via ENVELOPE response, 0x21 = PoR via SMS-SUBMIT
const body = Object.assign({}, sp, { apdu: apduHex, sp: '', includeCpi: true });
return await pysimFetch('/api/send-ota', body);
}
// ===== TLV parsers for GET STATUS responses (P2=02 format) =====
// P2=02 wraps each entry in an 'E3' GP Registry Data template containing TLV tags.
// Fallback: if the card only supports P2=00 (raw format), raw parsers are used.
function _parseE3Entry(hex) {
const children = parseTLV(hex || '');
const r = {};
for (const c of children) {
if (c.tag === '4F') r.aid = c.value.toUpperCase();
else if (c.tag === '9F70') r.lifecycle = c.value.toUpperCase();
else if (c.tag === 'C5') r.privileges = c.value.toUpperCase();
else if (c.tag === 'CE') r.version = c.value.toUpperCase();
else if (c.tag === 'CC') r.sdAid = c.value.toUpperCase();
else if (c.tag === 'C4') r.elfAid = c.value.toUpperCase();
else if (c.tag === 'CF') r.implicitSel = c.value.toUpperCase();
else if (c.tag === '84') r.moduleAids = ramParseModuleAids(c.value);
}
return r;
}
// Raw format (P2=00): ISD uses pure AID length, Apps use combined length (AID+lifecycle).
// Heuristic: length > 8 means combined (AID = length-1 bytes, lifecycle inside length).
function _parseRawAppEntry(hex, i) {
const rawLen = parseInt(hex.substr(i, 2), 16);
if (rawLen < 1 || i + 2 + rawLen * 2 + 2 > hex.length) return null;
const aidLen = rawLen > 8 ? rawLen - 1 : rawLen;
const aid = hex.substr(i + 2, aidLen * 2).toUpperCase();
let j = i + 2 + rawLen * 2;
const lifecycle = hex.substr(j, 2).toUpperCase(); j += 2;
const privileges = hex.substr(j, 2).toUpperCase(); j += 2;
return { aid, lifecycle, privileges, next: j };
}
// Raw format (P2=00): ELF header then trailing bytes with module entries.
// P1=20: <ELF_header> <00> <module_entries separated by 00>
// P1=10: <ELF_header> <version(2B)> <SD_AID> <module_entries...>
// In both, a module entry starts with 0x10 (len=16) followed by 15-byte AID + lifecycle.
// Scan for 0x10 markers, advance past each full entry to avoid AID-internal false positives.
function _parseRawElfEntry(hex, i) {
const aidLen = parseInt(hex.substr(i, 2), 16);
if (aidLen < 1 || i + 2 + aidLen * 2 + 2 > hex.length) return null;
const aid = hex.substr(i + 2, aidLen * 2).toUpperCase();
let j = i + 2 + aidLen * 2;
const lifecycle = hex.substr(j, 2).toUpperCase(); j += 2;
// Scan: when 0x10 found, validate 15-byte AID follows, then advance past full entry
const moduleAids = [];
const seen = new Set();
while (j + 32 <= hex.length) {
const tag = parseInt(hex.substr(j, 2), 16);
if (tag === 0x10) {
const modAid = hex.substr(j + 2, 30).toUpperCase();
if (!seen.has(modAid)) { seen.add(modAid); moduleAids.push(modAid); }
j += 32; // skip marker(1) + AID(15), continue past lifecycle+appCount
} else {
j += 2;
}
}
return { aid, lifecycle, moduleAids: moduleAids.length ? moduleAids : undefined, next: j };
}
function ramParseAppStatus(hex) {
const s = (hex || '').toUpperCase();
if (!s) return [];
// TLV format (P2=02): E3 templates with structured tags
if (s.length >= 4 && s.substr(0, 2) === 'E3') {
const tlvs = parseTLV(s);
return tlvs.filter(t => t.tag === 'E3').map(t => {
const r = _parseE3Entry(t.value);
r.type = 'app';
return r;
}).filter(r => r.aid);
}
// Raw format (P2=00 fallback): consecutive <aid_len><AID><lifecycle><privileges>
const out = [];
let i = 0;
while (i + 6 <= s.length) {
const r = _parseRawAppEntry(s, i);
if (!r) break;
out.push({ type: 'app', ...r });
i = r.next;
}
return out;
}
function ramParseElfStatus(hex) {
const s = (hex || '').toUpperCase();
if (!s) return [];
// TLV format (P2=02): E3 templates with structured tags
if (s.length >= 4 && s.substr(0, 2) === 'E3') {
const tlvs = parseTLV(s);
return tlvs.filter(t => t.tag === 'E3').map(t => {
const r = _parseE3Entry(t.value);
r.type = 'elf';
return r;
}).filter(r => r.aid);
}
// Raw format (P2=00 fallback): consecutive <aid_len><AID><lifecycle>
const out = [];
let i = 0;
while (i + 6 <= s.length) {
const r = _parseRawElfEntry(s, i);
if (!r) break;
out.push({ type: 'elf', ...r });
i = r.next;
}
return out;
}
// Parse the '84' module-AID list (concatenated 4F<len><aid> TLVs) into an array
function ramParseModuleAids(hex) {
const out = [];
let j = 0;
const s = (hex || '').toUpperCase();
while (j + 4 <= s.length) {
const t = s.substr(j, 2);
const l = parseInt(s.substr(j + 2, 2), 16);
const v = s.substr(j + 4, l * 2);
j += 4 + l * 2;
if (t === '4F') out.push(v);
}
return out;
}
// GET DATA FF21 response: 81 app count, 82 free NV (3B), 83 free volatile (2B)
function ramParseGetMemory(hex) {
const s = (hex || '').toUpperCase();
if (!s.startsWith('FF21')) return null;
let i = 6; // Skip FF21 (4 bytes) + length byte (1 byte) = start at first tag
const out = {};
while (i + 4 <= s.length) {
const t = s.substr(i, 2);
const l = parseInt(s.substr(i + 2, 2), 16);
const v = s.substr(i + 4, l * 2);
i += 4 + l * 2;
if (t === '81') out.appCount = parseInt(v, 16);
else if (t === '82') out.freeNV = parseInt(v, 16);
else if (t === '83') out.freeV = parseInt(v, 16);
}
return out;
}
// ===== Lifecycle / privilege formatting =====
const RAM_LIFECYCLE = {
'01': 'OP_READY', '07': 'SELECTABLE', '0F': 'PERSONALIZED',
'03': 'INSTALLED', '1F': 'SD_PERSONALIZED', '7F': 'LOCKED',
'FF': 'TERMINATED',
};
function ramFmtLifecycle(hex) {
return RAM_LIFECYCLE[hex] || ('0x' + hex);
}
// Decode GP Card Spec privileges TLV (tag C5) into human-readable strings
function ramFmtPrivileges(hex) {
const p = hex || '';
if (!p) return '(none)';
const bytes = p.match(/.{2}/g) || [];
const privs = [];
const b1 = parseInt(bytes[0] || '00', 16);
if (b1 & 0x01) privs.push('Card Lock');
if (b1 & 0x02) privs.push('Card Terminate');
if (b1 & 0x04) privs.push('Card Reset');
if (b1 & 0x08) privs.push('Cum Deletion Ctr');
if (b1 & 0x10) privs.push('GSM Card Binding');
if (b1 & 0x20) privs.push('Default Selected');
if (b1 & 0x40) privs.push('Global PIN');
const b2 = parseInt(bytes[1] || '00', 16);
if (b2 & 0x01) privs.push('Mandated DAP');
if (b2 & 0x02) privs.push('Security Domain');
if (b2 & 0x04) privs.push('DAP Verification');
if (b2 & 0x08) privs.push('Delegated Mgmt');
if (b2 & 0x10) privs.push('RFM');
if (b2 & 0x20) privs.push('CFM');
const b3 = parseInt(bytes[2] || '00', 16);
if (b3 & 0x01) privs.push('Receipt Gen');
if (b3 & 0x02) privs.push('Ciphered Load');
if (b3 & 0x04) privs.push('Delegated Perso');
if (b3 & 0x08) privs.push('Trusted Path');
if (b3 & 0x10) privs.push('Authorized Mgmt');
return privs.length ? privs.join(', ') : '(none)';
}
// Merge ELF-module entries (P1=10, carry module AIDs) into ELF entries (P1=20).
// With TLV format (P2=02), module AIDs come from tag '84' inside E3 templates.
// With raw format (P2=00), module AIDs are not available in the response.
function ramMergeElfData(elfs, modules) {
const byAid = {};
elfs.forEach(e => { if (e.aid) byAid[e.aid] = e; });
modules.forEach(m => {
if (m.aid && byAid[m.aid]) {
if (m.moduleAids && m.moduleAids.length) byAid[m.aid].moduleAids = m.moduleAids;
} else if (m.aid) {
elfs.push(m);
}
});
return elfs;
}
// Render the full explorer result as structured HTML
function ramRenderExploreHtml(mem, isd, apps, elfs) {
let html = '';
if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) {
html += '<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-800 rounded">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Memory (GET DATA FF21)">Memory (GET DATA FF21)</div>';
html += '<div>Applications: ' + (mem.appCount != null ? mem.appCount : '?') + '</div>';
html += '<div>Free NV: ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>';
html += '<div>Free Volatile: ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>';
html += '</div>';
}
if (isd && isd.length) {
html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="ISD (Issuer Security Domain)">ISD (Issuer Security Domain)</div>';
isd.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-blue-400">';
html += '<div>AID: ' + (o.aid || '?') + '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
html += '</div>';
});
html += '</div>';
}
if (apps && apps.length) {
html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Applications / Applet Instances">Applications / Applet Instances</div>';
apps.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-green-400">';
html += '<div>Application / Instance AID: ' + (o.aid || '?');
if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete">Delete</button>';
}
html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.implicitSel) html += '<div>Implicit sel: ' + o.implicitSel + '</div>';
if (o.elfAid) html += '<div>Load File AID / Package AID: ' + o.elfAid + '</div>';
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
html += '</div>';
});
html += '</div>';
}
if (elfs && elfs.length) {
html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Executable Load Files (ELFs) / Packages">Executable Load Files (ELFs) / Packages</div>';
elfs.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-purple-400">';
html += '<div>Load File AID / Package AID: ' + (o.aid || '?');
if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete">Delete</button>';
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', true)" data-needs="card" class="ml-1 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete All">Delete All</button>';
}
html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.version) html += '<div>Version: ' + o.version + '</div>';
if (o.moduleAids && o.moduleAids.length) {
html += '<div>Executable Module AIDs / Applet Class AIDs:</div>';
o.moduleAids.forEach(m => { html += '<div class="pl-4">- ' + m + '</div>'; });
}
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
html += '</div>';
});
html += '</div>';
}
return html;
}
// ===== Operation handlers =====
async function ramExplore(sp) {
const resultEl = document.getElementById('ram-result');
const explorerEl = document.getElementById('ram-explorer');
const stepsEl = document.getElementById('ram-steps');
let cntr = sp.cntr;
const errors = [];
const mem = { appCount: null, freeNV: null, freeV: null };
const isd = [], apps = [], elfs = [], modules = [];
async function paginate(p1, collector, parser, label) {
// Chain GET STATUS + GET RESPONSE into a single SCP80 payload.
// The card's SCP80 layer executes both: GET STATUS returns 61XX,
// then GET RESPONSE fetches the data — the PoR captures the final
// result (9000 + response data) without the frontend handling 61XX.
// ELF queries (P1=20/10) use SPI2=0x21 (PoR via SMS-SUBMIT) because
// ELF data won't fit in the ENVELOPE response.
const isElf = (p1 === '20' || p1 === '10');
const spi2 = isElf ? '21' : '01';
// Try TLV format (P2=02) first for structured data; fall back to raw
// (P2=00) if the card doesn't support TLV GET STATUS.
for (const p2Init of ['02', '00']) {
let p2 = p2Init;
let guard = 0;
let tlvFailed = false;
while (guard++ < 32) {
// P2=02: no data field (card returns all tags). P2=00: Lc=02 TagList=4F00 (ignored by card).
const dataField = p2 === '02' ? '' : '024F0000';
const apdu = '80F2' + p1 + p2 + dataField + 'C0000000';
ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...');
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
cntr = ramIncrementCntr(cntr);
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
const errorMsg = res.por ? res.por.response_status : (res.error || 'no data');
errors.push(label + ': ' + errorMsg);
tlvFailed = true;
break;
}
const data = res.por.decoded ? res.por.decoded.last_response_data : '';
const sw = (res.por.decoded ? res.por.decoded.last_status_word : '').toUpperCase();
// If first attempt with P2=02 gets an unsupported error, retry with P2=00.
if (guard === 1 && p2Init === '02' && (sw === '6A86' || sw === '6A88')) {
tlvFailed = true;
break;
}
// Defensive: 61XX means more data available (shouldn't happen with
// chained GET RESPONSE, but handle it if the card responds this way).
if (sw && sw.startsWith('61')) {
if (data) {
const parsed61 = parser(data);
if (parsed61.length) collector.push(...parsed61);
}
p2 = '01';
continue;
}
if (sw === '6F00') { tlvFailed = true; break; }
if (!data) {
if (sw !== '9000') {
errors.push(label + ': (no data) — SW ' + sw);
tlvFailed = true;
}
break;
}
const parsed = parser(data);
if (!parsed.length) break;
collector.push(...parsed);
if (sw === '9000') break;
p2 = '01';
}
if (!tlvFailed) break;
}
}
ramShowProgress('GET DATA FF21 (memory)...');
try {
const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
cntr = ramIncrementCntr(cntr);
if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') {
const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : '';
if (!data) {
errors.push('Memory: (no data)');
} else {
const m = ramParseGetMemory(data);
if (m) Object.assign(mem, m);
}
} else {
const errorMsg = memRes.por ? memRes.por.response_status : (memRes.error || 'no data');
errors.push('Memory: ' + errorMsg);
}
} catch (e) {
errors.push('Memory: ' + e.message);
}
// 2-5. GET STATUS for ISD / Apps / ELFs / ELF modules
// P1 per GP Card Spec v2.3.1 table 11-33:
// 80=ISD, 40=Applications, 20=Executable Load Files (ELFs), 10=ELF+modules
await paginate('80', isd, ramParseAppStatus, 'ISD');
await paginate('40', apps, ramParseAppStatus, 'Apps');
await paginate('20', elfs, ramParseElfStatus, 'ELFs');
await paginate('10', modules, ramParseElfStatus, 'ELF Modules');
ramMergeElfData(elfs, modules);
ramSaveCntr(cntr);
ramHideProgress();
if (errors.length) {
resultEl.textContent = 'Partial — ' + errors.join('; ');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
stepsEl.classList.remove('hidden');
stepsEl.textContent = errors.join('\n');
} else {
resultEl.textContent = 'OK — ' + isd.length + ' ISD, ' + apps.length + ' apps, ' + elfs.length + ' ELFs' + (mem.freeNV ? ', ' + mem.freeNV + ' free NV' : '');
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
}
const html = ramRenderExploreHtml(mem, isd, apps, elfs);
explorerEl.innerHTML = html || '(no data)';
explorerEl.classList.remove('hidden');
pysimApplyAvailability();
}
async function ramDeleteFromExplorer(aid, withCascade) {
const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) {
alert('Select a card preset with keys first (RAM subtab → Card preset)');
return;
}
const label = withCascade ? 'DELETE (cascade)' : 'DELETE';
if (!confirm(label + ' — AID: ' + aid + '?')) return;
const p2 = withCascade ? '80' : '00';
const aidLen = (aid.length / 2).toString(16).padStart(2, '0');
const apdu = '80E400' + p2 + _ber_len(2 + aid.length / 2) + '4F' + aidLen + aid;
ramShowProgress(label + ' ' + aid + '...');
const res = await ramSendOta(apdu, sp);
ramHideProgress();
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
resultEl.textContent = 'Failed: ' + (res.por ? res.por.response_status : res.error || res.sw);
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
return;
}
ramSaveCntr(ramIncrementCntr(sp.cntr));
const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
stepsEl.classList.remove('hidden');
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
resultEl.textContent = 'OK';
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
await ramExplore(sp);
}
async function ramInstallCap(sp) {
const fileInput = document.getElementById('ram-cap-file');
const file = fileInput.files[0];
if (!file) { alert('Select a .cap file'); return; }
if (file.size > 48 * 1024) { alert('CAP file exceeds 48 kB limit'); return; }
ramShowProgress('Reading CAP file...');
const capHex = await ramReadFileHex(file);
ramShowProgress('Sending to server for install...');
const body = {
cap_hex: capHex,
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''),
privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
install_params: (document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, ''),
stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''),
make_selectable: document.getElementById('ram-make-sel').checked,
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
};
const data = await pysimFetch('/api/ram-install', body);
ramHideProgress();
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
stepsEl.classList.remove('hidden');
let txt = '';
(data.steps || []).forEach((s, idx) => {
const mark = s.por_status === 'por_ok' ? '✅' : '❌';
txt += mark + ' Step ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
});
stepsEl.textContent = txt;
if (data.success) {
ramSaveCntr(data.final_cntr);
resultEl.textContent = 'Install OK — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid;
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
} else {
resultEl.textContent = 'Install FAILED at step: ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
}
}
async function ramExecute() {
ramClearResults();
const op = document.getElementById('ram-op').value;
const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) {
alert('Select a card preset with keys first (RAM subtab → Card preset)');
return;
}
try {
if (op === 'explore') await ramExplore(sp);
else if (op === 'install-cap') await ramInstallCap(sp);
} catch (e) {
const resultEl = document.getElementById('ram-result');
resultEl.textContent = 'Error: ' + e.message;
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
ramHideProgress();
}
}
// ===== STK Menu Browser =====
let stkMenuStack = [];
async function stkCheckMenu() {
try {
const data = await pysimFetch('/api/menu');
const btn = document.getElementById('stk-menu-btn');
const none = document.getElementById('stk-menu-none');
if (data && data.title && data.items && data.items.length) {
document.getElementById('stk-menu-title').textContent = data.title;
btn.style.display = '';
none.style.display = 'none';
} else {
btn.style.display = 'none';
none.style.display = '';
}
} catch (e) {
console.error('stkCheckMenu error:', e);
}
}
function stkSetPillsDisabled(disabled) {
document.querySelectorAll('.tab-btn, .pysim-subtab').forEach(b => {
b.disabled = disabled;
b.classList.toggle('opacity-50', disabled);
});
}
function stkMenuOpen() {
document.getElementById('stk-menu-panel-title').textContent = document.getElementById('stk-menu-title').textContent;
stkSetPillsDisabled(true);
stkMenuStack = [];
document.getElementById('stk-back-btn').style.display = 'none';
document.getElementById('stk-menu-buttons').classList.add('hidden');
document.getElementById('stk-menu-panel').classList.remove('hidden');
stkMenuRenderItems();
}
function stkMenuClose() {
document.getElementById('stk-menu-panel').classList.add('hidden');
stkSetPillsDisabled(false);
}
async function stkMenuExit() {
try {
await pysimFetch('/api/menu-respond', { result: 'cancel' });
} catch (e) {}
stkMenuClose();
}
function stkMenuRenderItems() {
const content = document.getElementById('stk-menu-panel-content');
content.innerHTML = '<span class="text-gray-500 text-sm">Loading...</span>';
pysimFetch('/api/menu').then(data => {
if (!data || !data.items) { content.innerHTML = '<span class="text-red-500 text-sm">No menu data</span>'; return; }
let html = '<div class="space-y-1">';
data.items.forEach(item => {
html += '<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 cursor-pointer" onclick="stkMenuItemClick(' + item.id + ')"><span class="w-5 text-xs text-gray-300 dark:text-gray-600 text-right">' + item.id + '</span><span class="text-sm">' + esc(item.text) + '</span></div>';
});
html += '</div>';
content.innerHTML = html;
}).catch(() => { content.innerHTML = '<span class="text-red-500 text-sm">Error loading menu</span>'; });
}
async function stkMenuItemClick(itemId) {
stkSetPillsDisabled(true);
const content = document.getElementById('stk-menu-panel-content');
content.innerHTML = '<span class="text-gray-500 text-sm">Sending...</span>';
try {
const data = await pysimFetch('/api/menu-select', { item_id: itemId });
stkMenuHandleResponse(data);
} catch (e) {
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
stkSetPillsDisabled(false);
}
}
function stkMenuHandleResponse(data) {
const content = document.getElementById('stk-menu-panel-content');
const backBtn = document.getElementById('stk-back-btn');
const btns = document.getElementById('stk-menu-buttons');
btns.classList.add('hidden');
if (data.type === 'display_text') {
content.innerHTML = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded">' + esc(data.text) + '</div>';
backBtn.style.display = '';
document.getElementById('stk-ok-btn').style.display = '';
btns.classList.remove('hidden');
} else if (data.type === 'select_item') {
stkMenuStack.push(data.items);
content.innerHTML = '';
const items = data.items;
let html = '<div class="space-y-1">';
items.forEach(item => {
html += '<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 cursor-pointer" onclick="stkSubItemClick(' + item.id + ')"><span class="w-5 text-xs text-gray-300 dark:text-gray-600 text-right">' + item.id + '</span><span class="text-sm">' + esc(item.text) + '</span></div>';
});
html += '</div>';
content.innerHTML = html;
backBtn.style.display = '';
document.getElementById('stk-ok-btn').style.display = 'none';
btns.classList.remove('hidden');
} else {
content.innerHTML = '<span class="text-gray-500 text-sm">SW: ' + esc(data.sw || '?') + '</span>';
stkSetPillsDisabled(false);
backBtn.style.display = 'none';
}
}
async function stkSubItemClick(itemId) {
const content = document.getElementById('stk-menu-panel-content');
content.innerHTML = '<span class="text-gray-500 text-sm">Sending...</span>';
try {
const data = await pysimFetch('/api/menu-respond', { result: 'ok', item_id: itemId });
stkMenuHandleResponse(data);
} catch (e) {
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
}
}
async function stkMenuRespond(result) {
try {
const data = await pysimFetch('/api/menu-respond', { result: result });
if (result === 'cancel' || result === 'back' || result === 'timeout') {
if (data && (data.type === 'select_item' || data.type === 'display_text')) {
stkMenuHandleResponse(data);
} else {
stkMenuRenderItems();
document.getElementById('stk-menu-buttons').classList.add('hidden');
document.getElementById('stk-back-btn').style.display = 'none';
}
return;
}
stkMenuHandleResponse(data);
} catch (e) {
document.getElementById('stk-menu-panel-content').innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
}
}
// ===== Cards storage =====
let cards = [];
function cardsLoad() {
try {
const d = localStorage.getItem('otaman_cards');
cards = d ? JSON.parse(d) : [];
} catch (e) { cards = []; }
cardsRebuildSelect();
cardsRender();
}
function cardsSave() {
localStorage.setItem('otaman_cards', JSON.stringify(cards));
cardsRebuildSelect();
cardsRender();
}
function cardsAdd() {
const name = document.getElementById('cards-name').value.trim();
const iccid = document.getElementById('cards-iccid').value.trim();
const kic = document.getElementById('cards-kic').value.trim();
const kid = document.getElementById('cards-kid').value.trim();
const spi1 = document.getElementById('cards-spi1').value.trim();
const spi2 = document.getElementById('cards-spi2').value.trim();
const tar = document.getElementById('cards-tar').value.trim();
const cntr = document.getElementById('cards-cntr').value.trim();
const kicKey = document.getElementById('cards-kic-key').value.trim();
const kidKey = document.getElementById('cards-kid-key').value.trim();
if (!name) { alert('Name is required'); return; }
if (!kic || !kid) { alert('KIc and KID are required'); return; }
if (!kicKey || !kidKey) { alert('KIc key and KID key are required'); return; }
if (iccid && cards.some(c => c.iccid === iccid)) { alert('Card with this ICCID already exists'); return; }
cards.push({
name: name,
iccid: iccid,
kic: kic,
kid: kid,
spi1: spi1 || '16',
spi2: spi2 || '01',
tar: tar,
cntr: cntr || '0000000001',
kicKey: kicKey,
kidKey: kidKey,
});
cardsSave();
['name','iccid','kic','kid','spi1','spi2','tar','cntr','kic-key','kid-key'].forEach(id => {
document.getElementById('cards-' + id).value = '';
});
}
function cardsRemove(i) {
cards.splice(i, 1);
cardsSave();
cardsRender();
}
function cardsRender() {
const tbody = document.getElementById('cards-tbody');
if (!cards.length) { tbody.innerHTML = '<tr><td colspan="9" class="py-2 text-gray-400">' + t('No cards defined.') + '</td></tr>'; return; }
let html = '';
for (let i = 0; i < cards.length; i++) {
const c = cards[i];
html += '<tr class="border-b border-gray-100 dark:border-slate-700">';
html += '<td class="py-1 px-2 font-medium">' + esc(c.name) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kic) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kid) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi1) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi2) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.tar || '') + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.cntr) + '</td>';
html += '<td class="py-1 px-2"><button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-100 text-red-700 hover:bg-red-200">' + t('Remove') + '</button></td>';
html += '</tr>';
}
tbody.innerHTML = html;
}
function cardsRebuildSelect() {
const sel = document.getElementById('sp-card-sel');
if (!sel) return;
let html = '<option value="">' + t('— Select card —') + '</option>';
for (let i = 0; i < cards.length; i++) {
html += '<option value="' + i + '">' + esc(cards[i].name) + ' (' + esc(cards[i].iccid) + ')</option>';
}
sel.innerHTML = html;
}
function cardsApply(idx) {
const c = cards[parseInt(idx)];
if (!c) return;
document.getElementById('sp-spi1').value = c.spi1;
document.getElementById('sp-spi2-hex').value = c.spi2;
const kicByte = parseInt(c.kic, 16);
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase();
document.getElementById('sp-kic-alg').value = (kicByte & 0x0F).toString(16).padStart(2, '0');
updateSpKic();
const kidByte = parseInt(c.kid, 16);
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase();
document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0');
updateSpKid();
document.getElementById('sp-tar').value = c.tar || 'B00001';
document.getElementById('sp-cntr').value = c.cntr;
document.getElementById('sp-kic-key').value = c.kicKey;
document.getElementById('sp-kid-key').value = c.kidKey;
spInvalidate();
updateSp();
genSp();
}
function cardsExport() {
const el = document.getElementById('cards-io');
el.value = JSON.stringify(cards, null, 2);
el.classList.remove('hidden');
el.select();
}
function downloadJson(name, obj) {
const blob = new Blob([JSON.stringify(obj, null, 2)], {type: 'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
function cardsExportFile() {
downloadJson('otaman-cards.json', cards);
}
async function cardsImport(text) {
try {
if (text == null) text = await navigator.clipboard.readText();
const imported = JSON.parse(text);
if (!Array.isArray(imported)) throw new Error('Expected an array');
let added = 0;
for (const entry of imported) {
if (!entry.name) continue;
if (entry.iccid && cards.some(c => c.iccid === entry.iccid)) continue;
cards.push({
name: entry.name,
iccid: entry.iccid,
kic: entry.kic || '15',
kid: entry.kid || '15',
spi1: entry.spi1 || '16',
spi2: entry.spi2 || '01',
tar: entry.tar || '',
cntr: entry.cntr || '0000000001',
kicKey: entry.kicKey || '',
kidKey: entry.kidKey || '',
});
added++;
}
cardsSave();
cardsRender();
const el = document.getElementById('cards-io');
el.value = 'Imported ' + added + ' cards.';
setTimeout(() => { el.classList.add('hidden'); }, 2000);
} catch (e) {
alert('Import error: ' + e.message);
}
}
function cardsImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => cardsImport(reader.result);
reader.readAsText(file);
}
function cardsImportField() {
const el = document.getElementById('cards-io');
const val = el.value.trim();
if (!el.classList.contains('hidden') && val.startsWith('[')) { cardsImport(val); return; }
el.value = '';
el.placeholder = 'Paste JSON array here, then press Paste & import again';
el.classList.remove('hidden');
el.focus();
}
// ===== pysim sub-tabs =====
let pysimCmdHistory = [];
let pysimCmdHistIdx = -1;
let _pysimAutoConnectAttempted = false;
function pysimSwitchSubtab(name) {
document.querySelectorAll('.pysim-subtab').forEach(btn => {
const isActive = btn.dataset.pysimSub === name;
btn.classList.toggle('bg-blue-600', isActive);
btn.classList.toggle('text-white', isActive);
btn.classList.toggle('bg-gray-200', !isActive);
btn.classList.toggle('dark:bg-slate-700', !isActive);
btn.classList.toggle('text-gray-700', !isActive);
btn.classList.toggle('dark:text-slate-300', !isActive);
});
['cmd', 'apdu', 'files'].forEach(s => {
document.getElementById('pysim-sub-' + s).classList.toggle('hidden', s !== name);
});
pysimHelpAnchor = {cmd:'pysim-cmdline', apdu:'raw-apdu', files:'file-manager'}[name] || 'card-reader';
setHelpAnchor(pysimHelpAnchor);
}
function pysimCmdKeydown(e) {
if (e.key === 'Enter') {
const cmd = e.target.value.trim();
if (cmd) {
pysimCmdHistory.push(cmd);
pysimCmdHistIdx = pysimCmdHistory.length;
}
pysimExec();
document.getElementById('pysim-cmd-hint').classList.add('hidden');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (pysimCmdHistIdx > 0) {
pysimCmdHistIdx--;
e.target.value = pysimCmdHistory[pysimCmdHistIdx];
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (pysimCmdHistIdx < pysimCmdHistory.length - 1) {
pysimCmdHistIdx++;
e.target.value = pysimCmdHistory[pysimCmdHistIdx];
} else {
pysimCmdHistIdx = pysimCmdHistory.length;
e.target.value = '';
}
}
}
let pysimCmdHintTimeout = null;
async function pysimCmdHint() {
clearTimeout(pysimCmdHintTimeout);
pysimCmdHintTimeout = setTimeout(async () => {
const input = document.getElementById('pysim-cmd');
const text = input.value.trim();
const cmd = text.split(/\s+/)[0];
if (!cmd || text.includes(' ')) {
document.getElementById('pysim-cmd-hint').classList.add('hidden');
return;
}
const hintEl = document.getElementById('pysim-cmd-hint');
try {
const data = await pysimFetch('/api/help', { cmd });
if (data.usage) {
hintEl.textContent = data.usage;
hintEl.classList.remove('hidden');
} else {
hintEl.classList.add('hidden');
}
} catch (e) {
hintEl.classList.add('hidden');
}
}, 300);
}
let pysimCmdList = [];
async function pysimLoadCommands() {
try {
const data = await pysimFetch('/api/commands');
if (Array.isArray(data)) pysimCmdList = data;
} catch (e) {}
}
function pysimCmdInput() {
const input = document.getElementById('pysim-cmd');
const text = input.value;
const suggestEl = document.getElementById('pysim-cmd-suggest');
const hintEl = document.getElementById('pysim-cmd-hint');
if (text.includes(' ')) {
suggestEl.classList.add('hidden');
const cmd = text.split(/\s+/)[0];
if (cmd) {
clearTimeout(pysimCmdHintTimeout);
pysimCmdHintTimeout = setTimeout(async () => {
try {
const data = await pysimFetch('/api/help', { cmd });
if (data.usage) {
hintEl.textContent = data.usage;
hintEl.classList.remove('hidden');
} else {
hintEl.classList.add('hidden');
}
} catch (e) {
hintEl.classList.add('hidden');
}
}, 300);
return;
}
hintEl.classList.add('hidden');
return;
}
hintEl.classList.add('hidden');
if (!text) { suggestEl.classList.add('hidden'); return; }
const matches = pysimCmdList.filter(c => c.startsWith(text)).slice(0, 20);
if (matches.length === 0) { suggestEl.classList.add('hidden'); return; }
suggestEl.innerHTML = matches.map(c =>
'<div class="px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700" onclick="pysimCmdSelect(\'' + esc(c) + '\')">' + esc(c) + '</div>'
).join('');
suggestEl.classList.remove('hidden');
}
function pysimCmdSelect(cmd) {
document.getElementById('pysim-cmd').value = cmd;
document.getElementById('pysim-cmd-suggest').classList.add('hidden');
document.getElementById('pysim-cmd').focus();
}
// ===== pysim file browser =====
let pysimFsTreeRoot = null;
function getParentSel(node) {
if (!node || !node.parent) return null;
const p = node.parent;
if (p.name === 'MF') return 'MF';
if (p.name.startsWith('ADF.')) return p.name;
if (p.fid) return p.fid;
return p.name;
}
function pysimCustomInject(node) {
if (!node || !node.fid) return;
const pfid = node.fid.toLowerCase();
for (const c of pysimCustomFiles) {
if (c.parentFid.toLowerCase() !== pfid) continue;
const existing = (node.children || []).find(ch => ch.fid?.toLowerCase() === c.fid.toLowerCase());
if (existing) {
existing.name = c.name;
existing.custom = true;
} else {
node.children = node.children || [];
node.children.push({
name: c.name,
fid: c.fid,
isDir: c.name.startsWith('DF.') || c.name.startsWith('ADF.'),
children: null,
expanded: false,
exists: null,
custom: true,
parent: node,
});
}
}
}
let pysimFsSort = localStorage.getItem('otaman_fs_sort') === 'name' ? 'name' : 'fid';
function pysimFsSortChildren(children, mode) {
return (children || []).slice().sort((a, b) => {
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
const af = (a.fid || '').toUpperCase();
const bf = (b.fid || '').toUpperCase();
const an = (a.name || '').toUpperCase();
const bn = (b.name || '').toUpperCase();
let r;
if (mode === 'name') {
const ak = an || af;
const bk = bn || bf;
r = ak < bk ? -1 : ak > bk ? 1 : 0;
if (r === 0) r = af < bf ? -1 : af > bf ? 1 : 0;
} else {
r = af < bf ? -1 : af > bf ? 1 : 0;
if (r === 0) r = an < bn ? -1 : an > bn ? 1 : 0;
}
return r;
});
}
function pysimFsSetSort(mode) {
pysimFsSort = mode === 'name' ? 'name' : 'fid';
localStorage.setItem('otaman_fs_sort', pysimFsSort);
document.querySelectorAll('.pysim-fs-sort-pill').forEach(p => {
const active = p.dataset.fsSort === pysimFsSort;
p.classList.toggle('bg-blue-600', active);
p.classList.toggle('text-white', active);
p.classList.toggle('bg-gray-200', !active);
p.classList.toggle('dark:bg-slate-700', !active);
p.classList.toggle('text-gray-700', !active);
p.classList.toggle('dark:text-slate-300', !active);
});
pysimFsRenderTree();
}
async function pysimFsRefresh() {
const detailEl = document.getElementById('pysim-fs-detail');
detailEl.classList.add('hidden');
const data = await pysimFetch('/api/tree', { name: 'MF' });
pysimFsTreeRoot = {
name: data.name || 'MF',
path: data.name || 'MF',
fid: data.fid || '3F00',
isDir: true,
children: null,
expanded: true,
parent: null,
};
pysimFsTreeRoot.children = (data.children || []).map(c => ({
name: c.name,
fid: c.fid,
isDir: c.isDir,
aid: c.aid,
children: null,
expanded: false,
exists: true,
parent: pysimFsTreeRoot,
}));
pysimFsTreeRoot.exists = true;
pysimCustomInject(pysimFsTreeRoot);
pysimFsRenderTree();
}
async function pysimFsLoadChildren(node) {
if (node.children) return;
try {
let body = { name: node.name, fid: node.fid };
const parentSel = getParentSel(node);
if (parentSel) body.parent_sel = parentSel;
let data = await pysimFetch('/api/tree', body);
if (!data || data.exists === false) {
body = { name: node.name, fid: node.fid };
data = await pysimFetch('/api/tree', body);
}
if (!data || data.exists === false) {
throw new Error('File not found');
}
node.children = (data.children || []).map(c => ({
name: c.name,
fid: c.fid,
isDir: c.isDir,
aid: c.aid,
children: null,
expanded: false,
exists: true,
parent: node,
}));
node.exists = true;
pysimCustomInject(node);
} catch (e) {
node.exists = false;
node.children = [];
pysimFsRenderTree();
}
}
function pysimFsRenderTree() {
const el = document.getElementById('pysim-fs-tree');
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
el.innerHTML = pysimFsRenderNode(pysimFsTreeRoot, 0);
}
function pysimFsRenderNode(node, depth) {
if (!node) return '';
const pad = '&nbsp;'.repeat(depth * 4);
const isDir = node.isDir;
const exists = node.exists !== false;
const verified = node.exists !== null;
const color = verified ? (exists ? (isDir ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-slate-300') : 'text-red-400 dark:text-red-400') : 'text-gray-500 dark:text-slate-400';
const italic = node.custom ? ' italic' : '';
const fidStr = node.fid ? ' <span class="text-gray-400 dark:text-slate-500">(' + node.fid.toUpperCase() + ')</span>' : '';
const aidStr = node.aid ? ' <span class="text-gray-400 dark:text-slate-500">[' + node.aid + ']</span>' : '';
let html = '<div class="' + color + italic + '">';
html += pad;
if (verified && !exists) {
html += '✗ ';
}
if (isDir) {
if ((verified && exists) || !verified) {
const toggle = node.children && node.expanded ? '▼' : '▶';
html += '<span class="cursor-pointer select-none' + italic + '" onclick="pysimFsToggleDir(\'' + esc(node.name) + '\')">' + toggle + '</span> ';
html += '<span class="cursor-pointer hover:underline' + italic + '" onclick="pysimFsToggleDir(\'' + esc(node.name) + '\')">' + esc(node.name) + fidStr + aidStr + '</span>';
} else {
html += esc(node.name) + fidStr + aidStr + '</div>';
return html;
}
} else {
html += '<span class="cursor-pointer hover:underline ml-4' + italic + '"' +
((verified && exists) ? ' onclick="pysimFsClickFile(\'' + esc(node.name) + '\')"' : (!verified ? ' onclick="pysimFsClickFile(\'' + esc(node.name) + '\')"' : '')) + '>' + esc(node.name) + fidStr + aidStr + '</span>';
}
html += '</div>';
if (isDir && node.children && node.expanded) {
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
html += pysimFsRenderNode(child, depth + 1);
}
}
return html;
}
function pysimFsFindNode(name, root) {
if (!root) return null;
if (root.name === name) return root;
if (root.children) {
for (const c of root.children) {
const found = pysimFsFindNode(name, c);
if (found) return found;
}
}
return null;
}
async function pysimFsToggleDir(name) {
const node = pysimFsFindNode(name, pysimFsTreeRoot);
if (!node || !node.isDir) return;
if (!node.children) {
await pysimFsLoadChildren(node);
}
node.expanded = !node.expanded;
pysimFsRenderTree();
if (node.expanded) pysimRefresh();
}
async function pysimFsClickFile(name) {
pysimFsResetEdit();
pysimFsSetMode('raw');
const node = pysimFsFindNode(name, pysimFsTreeRoot);
let body = { name: name, fid: node ? node.fid : null };
const parentSel = getParentSel(node);
if (parentSel) body.parent_sel = parentSel;
let sel = await pysimFetch('/api/select', body);
if (!sel || sel.exists === false) {
body = { name: name, fid: node ? node.fid : null };
sel = await pysimFetch('/api/select', body);
}
const exists = sel && sel.exists !== false;
if (node) {
node.exists = exists;
if (!exists) node.children = [];
pysimFsRenderTree();
}
const ft = exists ? sel.file_type : null;
const label = ({ transparent: 'TR', linear_fixed: 'LF', cyclic: 'CY', ber_tlv: 'BT', df: 'DF' })[ft] || '';
const prefix = exists ? '' : '✗ ';
document.getElementById('pysim-fs-filename').textContent = prefix + name + (label ? ' [' + label + ']' : '');
document.getElementById('pysim-fs-content').innerHTML = '';
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
if (exists) {
document.getElementById('pysim-fs-detail').classList.remove('hidden');
} else {
document.getElementById('pysim-fs-detail').classList.add('hidden');
}
pysimRefresh();
}
async function pysimFsRead() {
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
const out = document.getElementById('pysim-fs-content');
const statusEl = document.getElementById('pysim-fs-status');
out.innerHTML = '';
statusEl.textContent = 'Loading...';
try {
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
let body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
const parentSel = getParentSel(node);
if (parentSel) body.parent_sel = parentSel;
let data = await pysimFetch('/api/read', body);
if (!data.success) {
body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
data = await pysimFetch('/api/read', body);
}
if (!data.success) {
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
return;
}
statusEl.textContent = 'SW: ' + data.sw + ' OK';
if (data.decoded) {
out.innerHTML = '<pre class="text-xs leading-relaxed">' + esc(JSON.stringify(data.decoded, null, 2)) + '</pre>';
} else if (data.records) {
let html = '';
for (const r of data.records) {
html += '<div class="flex items-center gap-1 mb-1">' +
'<input type="checkbox" class="pysim-fs-record-cb shrink-0 hidden">' +
'<span class="text-xs w-8 text-gray-500 dark:text-slate-400 shrink-0">' + r.num + '</span>' +
'<input class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200" style="overflow-x:auto;min-width:100px" value="' + esc(r.data) + '" readonly>' +
'</div>';
}
out.innerHTML = html || '(no records)';
} else {
out.innerHTML = '<textarea rows="8" readonly class="w-full font-mono text-xs border-0 bg-transparent resize-none">' + esc(data.data || '(no output)') + '</textarea>';
}
} catch (e) {
statusEl.textContent = 'Error: ' + e.message;
}
}
async function pysimFsSelect() {
const name = document.getElementById('pysim-fs-filename').textContent;
const node = pysimFsFindNode(name, pysimFsTreeRoot);
if (node && node.fid) {
await pysimFetch('/api/command', { cmd: 'select ' + node.fid });
} else {
await pysimFetch('/api/command', { cmd: 'select ' + name });
}
pysimFsRefresh();
pysimRefresh();
}
let pysimFsEditMode = false;
let pysimFsEditData = null;
function pysimFsEdit() {
pysimFsEditMode = true;
document.getElementById('pysim-fs-read-btn').classList.add('hidden');
document.getElementById('pysim-fs-edit-btn').classList.add('hidden');
document.getElementById('pysim-fs-save-btn').classList.remove('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.remove('hidden');
const out = document.getElementById('pysim-fs-content');
out.querySelectorAll('textarea, input:not([type=checkbox])').forEach(el => el.removeAttribute('readonly'));
out.querySelectorAll('.pysim-fs-record-cb').forEach(cb => cb.classList.remove('hidden'));
out.querySelectorAll('input:not([type=checkbox])').forEach(el => {
el.addEventListener('input', function() {
const cb = this.closest('div')?.querySelector('.pysim-fs-record-cb');
if (cb) cb.checked = true;
});
});
pysimFsEditData = out.innerHTML;
}
function pysimFsCancel() {
pysimFsEditMode = false;
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
const out = document.getElementById('pysim-fs-content');
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
pysimFsEditData = null;
}
async function pysimFsSave() {
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
const statusEl = document.getElementById('pysim-fs-status');
const out = document.getElementById('pysim-fs-content');
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
statusEl.textContent = 'Saving...';
try {
const textareas = out.querySelectorAll('textarea');
if (textareas.length === 1) {
// Transparent file
const body = { name: rawName, fid: node ? node.fid : null, data: textareas[0].value.replace(/[^0-9a-fA-F]/g, '') };
const parentSel = getParentSel(node);
if (parentSel) body.parent_sel = parentSel;
const data = await pysimFetch('/api/write', body);
if (data.success) {
statusEl.textContent = 'SW: ' + data.sw + ' OK';
pysimFsCancel();
pysimFsRead();
} else {
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
}
} else {
// Record file
const inputs = out.querySelectorAll('input:not([type=checkbox])');
let wroteAny = false;
for (const inp of inputs) {
const row = inp.closest('div');
const cb = row?.querySelector('.pysim-fs-record-cb');
if (cb && !cb.checked) continue;
wroteAny = true;
const num = row?.querySelector('span')?.textContent;
if (num) {
const body = { name: rawName, fid: node ? node.fid : null, data: inp.value.replace(/[^0-9a-fA-F]/g, ''), record_nr: parseInt(num) };
const parentSel = getParentSel(node);
if (parentSel) body.parent_sel = parentSel;
const data = await pysimFetch('/api/write', body);
if (!data.success) {
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')';
return;
}
}
}
if (!wroteAny) {
statusEl.textContent = 'No records selected — check the ones you want to update';
return;
}
statusEl.textContent = 'SW: 9000 OK';
pysimFsCancel();
pysimFsRead();
}
} catch (e) {
statusEl.textContent = 'Error: ' + e.message;
}
}
function pysimFsSetMode(mode) {
pysimFsDecodedMode = mode === 'dec';
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
const active = el.dataset.mode === mode;
el.classList.toggle('bg-blue-600', active);
el.classList.toggle('text-white', active);
el.classList.toggle('bg-gray-200', !active);
el.classList.toggle('dark:bg-slate-700', !active);
el.classList.toggle('text-gray-700', !active);
el.classList.toggle('dark:text-slate-300', !active);
});
}
function pysimFsResetEdit() {
pysimFsEditMode = false;
pysimFsEditData = null;
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
}
// ===== proactive UICC events =====
const EVENT_NAMES = {
0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected',
0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available',
0x06: 'Card reader status', 0x07: 'Language selection',
0x08: 'Browser termination', 0x09: 'Data available',
0x0A: 'Channel status', 0x0B: 'Access Technology Change',
0x0C: 'Display parameters changed', 0x0D: 'Local connection',
0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status',
0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status',
0x12: 'Network Rejection', 0x13: 'HCI Connectivity',
0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change',
0x16: 'Contactless state request', 0x17: 'Profile Container',
0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring',
0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation',
0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery',
0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS',
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
};
const REJECTION_CAUSES = [
{v:2,l:'02 \u2014 IMSI unknown in HLR/HSS'},{v:3,l:'03 \u2014 Illegal UE / MS'},
{v:5,l:'05 \u2014 IMEI / PEI not accepted'},{v:6,l:'06 \u2014 Illegal ME'},
{v:7,l:'07 \u2014 Services not allowed'},{v:8,l:'08 \u2014 Services + non-services not allowed'},
{v:9,l:'09 \u2014 UE identity cannot be derived'},{v:10,l:'0A \u2014 Implicitly detached / de-registered'},
{v:11,l:'0B \u2014 PLMN not allowed'},{v:12,l:'0C \u2014 Location / Tracking Area not allowed'},
{v:13,l:'0D \u2014 Roaming not allowed in this area'},{v:14,l:'0E \u2014 Services not allowed in this PLMN'},
{v:15,l:'0F \u2014 No Suitable Cells'},{v:16,l:'10 \u2014 MSC temporarily not reachable'},
{v:17,l:'11 \u2014 Network failure'},
{v:18,l:'12 \u2014 CS domain not avail. (EMM) / MAC fail (GMM)'},
{v:19,l:'13 \u2014 ESM failure (EMM) / Synch failure (GMM)'},
{v:20,l:'14 \u2014 MAC failure (EMM) / Congestion (GMM)'},
{v:21,l:'15 \u2014 Synch failure (EMM)'},{v:22,l:'16 \u2014 Congestion / PLMN rejected perm. (LU)'},
{v:23,l:'17 \u2014 UE security capabilities mismatch'},{v:24,l:'18 \u2014 Security mode rejected, unspecified'},
{v:25,l:'19 \u2014 Not authorized for this CSG'},{v:26,l:'1A \u2014 Non-3GPP/5G auth unacceptable'},
{v:27,l:'1B \u2014 N1 mode not allowed (5GMM)'},{v:28,l:'1C \u2014 Restricted service area (5GMM)'},
{v:31,l:'1F \u2014 Redirection to EPC required (5GMM)'},
{v:32,l:'20 \u2014 Service option not supported (LU)'},
{v:33,l:'21 \u2014 Requested service option not subscribed (LU)'},
{v:34,l:'22 \u2014 Service option temporarily out of order (LU)'},
{v:35,l:'23 \u2014 Requested service option not authorized (EMM)'},
{v:38,l:'26 \u2014 Call cannot be identified (LU)'},
{v:39,l:'27 \u2014 CS service temporarily not available (EMM)'},
{v:40,l:'28 \u2014 No EPS bearer context activated (EMM)'},
{v:42,l:'2A \u2014 Severe network failure (EMM)'},{v:43,l:'2B \u2014 LADN not available (5GMM)'},
{v:48,l:'30 \u2014 Retry upon entry into new cell (LU)'},
{v:62,l:'3E \u2014 No network slices available (5GMM)'},
{v:65,l:'41 \u2014 Maximum PDU sessions reached (5GMM)'},
{v:67,l:'43 \u2014 Insufficient resources for slice/DNN (5GMM)'},
{v:69,l:'45 \u2014 Insufficient resources for slice (5GMM)'},
{v:71,l:'47 \u2014 ngKSI already in use (5GMM)'},
{v:72,l:'48 \u2014 Non-3GPP access to 5GCN not allowed (5GMM)'},
{v:73,l:'49 \u2014 Serving network not authorized (5GMM)'},
{v:74,l:'4A \u2014 Temporarily not authorized for SNPN (5GMM)'},
{v:75,l:'4B \u2014 Permanently not authorized for SNPN (5GMM)'},
{v:76,l:'4C \u2014 Not authorized for this CAG (5GMM)'},
{v:90,l:'5A \u2014 Payload was not forwarded (5GMM)'},
{v:91,l:'5B \u2014 DNN not supported/subscribed in slice (5GMM)'},
{v:92,l:'5C \u2014 Insufficient UP resources for PDU session (5GMM)'},
{v:95,l:'5F \u2014 Semantically incorrect message'},{v:96,l:'60 \u2014 Invalid mandatory information'},
{v:97,l:'61 \u2014 Message type non-existent/not implemented'},
{v:98,l:'62 \u2014 Message type not compatible with protocol state'},
{v:99,l:'63 \u2014 IE non-existent/not implemented'},
{v:100,l:'64 \u2014 Conditional IE error'},{v:101,l:'65 \u2014 Message not compatible with protocol state'},
{v:111,l:'6F \u2014 Protocol error, unspecified'},
];
const EVENT_FORMS = {
0x00: { title: 'MT call', note: 'not_yet' },
0x01: { title: 'Call connected', note: 'not_yet' },
0x02: { title: 'Call disconnected', note: 'not_yet' },
0x03: { title: 'Location status',
fields: [
{ id: 'status', label: 'Service Status', type: 'select',
opts: [{v:0,l:'Normal service'},{v:1,l:'Limited service'},{v:2,l:'No service'}] }
],
build(v) { return '9B01' + (v.status|0).toString(16).padStart(2,'0'); }
},
0x04: null,
0x05: null,
0x06: { title: 'Card reader status',
fields: [
{ id: 'status', label: 'Status', type: 'select',
opts: [{v:0,l:'Card reader removed'},{v:1,l:'Card reader inserted'}] }
],
build(v) { return '' + (v.status|0).toString(16).padStart(2,'0'); }
},
0x07: { title: 'Language selection',
fields: [
{ id: 'lang', label: 'Language code', type: 'text', placeholder: 'ISO 639, e.g. en, ru', pattern: '[a-zA-Z]{2,3}' }
],
build(v) { const b = new TextEncoder().encode(v.lang||'en'); return b.length.toString(16).padStart(2,'0') + bytesToHex(Array.from(b)); }
},
0x08: { title: 'Browser termination', note: 'not_yet' },
0x09: null,
0x0A: { title: 'Channel status', note: 'not_yet' },
0x0B: { title: 'Access Technology Change',
fields: [
{ id: 'tech', label: 'Access Technology', type: 'select',
opts: [{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:3,l:'UTRAN'},{v:4,l:'GSM w/EGPRS'},{v:5,l:'UTRAN w/HSDPA'},{v:6,l:'UTRAN w/HSUPA'},{v:7,l:'UTRAN w/HSPA+'},{v:8,l:'E-UTRAN'},{v:9,l:'E-UTRAN (4G)'},{v:10,l:'5G NR'},{v:11,l:'5G NR (NSA)'},{v:12,l:'Satellite'}] }
],
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
},
0x0C: null,
0x0D: null,
0x0E: null,
0x0F: null,
0x10: null,
0x11: { title: 'I-WLAN Access Status', note: 'not_yet' },
0x12: { title: 'Network Rejection',
fields: [
{ id: 'reg_type', label: 'Registration type', type: 'select', onchange: 'updateNetRejectionFields()',
opts: [
{v:0x00,l:'Normal Location Updating',g:'lu'},{v:0x01,l:'Periodic Location Updating',g:'lu'},
{v:0x02,l:'IMSI Attach',g:'lu'},{v:0x03,l:'GPRS Attach',g:'gprs'},
{v:0x04,l:'Combined GPRS/IMSI Attach',g:'gprs'},{v:0x05,l:'RA Updating',g:'gprs'},
{v:0x06,l:'Combined RA/LA Updating',g:'gprs'},{v:0x07,l:'Combined RA/LA Updating w/ IMSI Attach',g:'gprs'},
{v:0x08,l:'Periodic RA Updating',g:'gprs'},{v:0x09,l:'EPS Attach (EMM)',g:'eps'},
{v:0x0A,l:'Combined EPS/IMSI Attach (EMM)',g:'eps'},{v:0x0B,l:'TA Updating (TAU)',g:'eps'},
{v:0x0C,l:'Combined TA/LA Updating (TAU)',g:'eps'},{v:0x0D,l:'Combined TA/LA Updating w/ IMSI Attach (TAU)',g:'eps'},
{v:0x0E,l:'Periodic TA Updating (TAU)',g:'eps'},{v:0x0F,l:'Initial Registration (5GS)',g:'eps'},
{v:0x10,l:'Mobility Registration Updating (5GS)',g:'eps'},{v:0x11,l:'Periodic Registration Updating (5GS)',g:'eps'},
]},
{ id: 'mcc', label: 'MCC', type: 'text', pattern: '\\d{3}', placeholder: 'e.g. 250', g: 'lu,gprs,eps' },
{ id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01', g: 'lu,gprs,eps' },
{ id: 'lac', label: 'LAC', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 00FF', g: 'lu,gprs' },
{ id: 'rac', label: 'RAC', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex, e.g. 01', g: 'gprs' },
{ id: 'tac', label: 'TAC', type: 'text', pattern: '[0-9A-Fa-f]{4,6}', placeholder: '4 (E-UTRAN) or 6 (NR) hex chars', g: 'eps' },
{ id: 'access_tech', label: 'Access Technology', type: 'select',
opts: [{v:0x00,l:'GSM'},{v:0x03,l:'UTRAN'},{v:0x08,l:'E-UTRAN'},{v:0x0A,l:'NG-RAN'},{v:0x0B,l:'Satellite NG-RAN'},{v:0x0C,l:'Satellite E-UTRAN'}], g: 'lu,gprs,eps' },
{ id: 'cause', label: 'Rejection cause code', type: 'select', opts: REJECTION_CAUSES, g: 'lu,gprs,eps' },
{ id: 'ext_cause', label: 'Extended EMM cause (optional)', type: 'number', min: '0', max: '255', placeholder: '0255', g: 'eps' },
],
build(v) {
const rt = parseInt(v.reg_type) || 0;
const g = rt <= 0x02 ? 'lu' : rt <= 0x08 ? 'gprs' : 'eps';
let d = 'F401' + rt.toString(16).padStart(2,'0');
d += 'BF01' + (parseInt(v.access_tech)|0).toString(16).padStart(2,'0');
d += 'F501' + (parseInt(v.cause) || 2).toString(16).padStart(2,'0');
const pm = encPlmn(v.mcc||'250',v.mnc||'01');
if (g === 'lu') {
d += '9305' + pm + ((v.lac||'0000').toUpperCase());
} else if (g === 'gprs') {
d += 'F306' + pm + ((v.lac||'0000').toUpperCase()) + ((v.rac||'01').toUpperCase());
} else {
const tc = (v.tac||'0001').toUpperCase();
d += '7D0' + (3 + tc.length/2).toString(16) + pm + tc;
if (v.ext_cause !== '' && v.ext_cause !== undefined) {
d += '5701' + (parseInt(v.ext_cause)|0).toString(16).padStart(2,'0');
}
}
return d.toUpperCase();
}
},
0x13: null,
0x14: { title: 'Change of UICC Access',
fields: [{ id: 'tech', label: 'Access Technology', type: 'select',
opts: [{v:0,l:'GSM'},{v:3,l:'UTRAN'},{v:8,l:'E-UTRAN'},{v:10,l:'NG-RAN'}] }],
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
},
0x15: { title: 'CSG Cell Change', note: 'not_yet' },
0x16: null,
0x17: null,
};
const PLI_QUALIFIERS = [
{code:'00',name:'Location Info (MCC, MNC, LAC/TAC, Cell ID)'},{code:'01',name:'IMEI'},
{code:'02',name:'Network Measurement results'},{code:'03',name:'Date, time and time zone'},
{code:'04',name:'Language setting'},{code:'05',name:'Timing Advance'},
{code:'06',name:'Access Technology (single)'},{code:'08',name:'IMEISV'},
{code:'09',name:'Search Mode'},{code:'0A',name:'Battery charge state'},
{code:'0C',name:'Current WSID'},{code:'0D',name:'Broadcast Network info'},
{code:'0E',name:'Multiple Access Technologies'},{code:'0F',name:'Location Info (multi-RAT)'},
{code:'10',name:'NMR (multi-RAT)'},{code:'11',name:'CSG ID list + HNB name'},
{code:'12',name:'H(e)NB IP address'},{code:'13',name:'H(e)NB surrounding macrocells'},
{code:'14',name:'Current WLAN identifier'},{code:'15',name:'Slices information'},
{code:'16',name:'CAG information list'},{code:'17',name:'Rejected slices information'},
];
async function pysimEventsRender() {
const el = document.getElementById('pysim-event-list');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
try {
const events = await pysimFetch('/api/events');
if (!events || !events.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No events configured.') + '</span>';
return;
}
let html = '';
events.forEach(e => {
const name = EVENT_NAMES[e] || ('Event 0x' + e.toString(16).padStart(2, '0').toUpperCase());
html += '<div class="flex items-center gap-2 py-0.5"><span class="w-6 text-right text-gray-400">'
+ e.toString(16).padStart(2, '0').toUpperCase()
+ '</span> ' + esc(name)
+ '<button data-needs="card" onclick="pysimEventSendForm(' + e + ')" class="ml-auto px-2 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed">' + t('Send') + '</button></div>';
});
el.innerHTML = html;
pysimApplyAvailability();
} catch (e) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
}
}
let _eventFormType = null;
function pysimEventSendForm(eventType) {
const modal = document.getElementById('event-send-modal');
const title = document.getElementById('event-send-title');
const body = document.getElementById('event-send-body');
const err = document.getElementById('event-send-error');
const result = document.getElementById('event-send-result');
const btn = document.getElementById('event-send-btn');
err.classList.add('hidden');
result.classList.add('hidden');
err.textContent = '';
result.textContent = '';
const name = EVENT_NAMES[eventType] || ('0x' + eventType.toString(16).padStart(2,'0'));
const cfg = EVENT_FORMS[eventType];
_eventFormType = eventType;
if (!cfg) {
title.textContent = name + ' (' + t('Send') + ')';
body.innerHTML = '<p class="text-sm text-gray-600 dark:text-slate-400">' + t('Send') + ' <span class="font-mono">' + esc(name) + '</span> ' + t('event notification to the card?') + '</p>';
btn.textContent = t('Send');
modal.classList.remove('hidden');
return;
}
if (cfg.note === 'not_yet') {
title.textContent = name;
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-slate-400">' + t('This event type is not yet supported.') + '</p>';
btn.classList.add('hidden');
} else {
title.textContent = name + ' (' + t('Send') + ')';
let html = '';
cfg.fields.forEach(f => {
const rg = f.g ? f.g.split(',') : null;
html += '<div class="event-field" data-groups="' + (f.g||'') + '" data-id="' + f.id + '">';
html += '<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' + t(f.label) + '</label>';
if (f.type === 'select') {
html += '<select id="ev-f-' + f.id + '"' + (f.onchange ? ' onchange="' + f.onchange + '"' : '') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">';
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (o.g ? ' data-gr="' + o.g + '"' : '') + '>' + esc(o.l) + '</option>');
html += '</select>';
} else {
const attrs = [];
if (f.pattern) attrs.push('pattern="' + f.pattern + '"');
if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"');
if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"');
html += '<input id="ev-f-' + f.id + '" type="' + f.type + '" ' + attrs.join(' ') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono">';
}
html += '</div>';
});
body.innerHTML = html;
btn.classList.remove('hidden');
if (eventType === 0x12) updateNetRejectionFields();
}
modal.classList.remove('hidden');
}
function updateNetRejectionFields() {
const sel = document.getElementById('ev-f-reg_type');
if (!sel) return;
const opt = sel.options[sel.selectedIndex];
const g = opt ? opt.getAttribute('data-gr') || 'lu' : 'lu';
document.querySelectorAll('#event-send-body .event-field').forEach(div => {
const groups = div.getAttribute('data-groups') || '';
const show = groups === '' || groups.indexOf(g) >= 0;
div.style.display = show ? '' : 'none';
});
}
async function pysimEventSend() {
const err = document.getElementById('event-send-error');
const result = document.getElementById('event-send-result');
const btn = document.getElementById('event-send-btn');
err.classList.add('hidden');
result.classList.add('hidden');
const cfg = EVENT_FORMS[_eventFormType];
let eventData = null;
if (cfg && cfg.build) {
const values = {};
let valid = true;
cfg.fields.forEach(f => {
const el = document.getElementById('ev-f-' + f.id);
if (!el || el.closest('.event-field').style.display === 'none') return;
const v = el.value.trim();
values[f.id] = v;
if (f.pattern && v && !new RegExp('^' + f.pattern + '$').test(v)) {
err.textContent = t('Invalid') + ': ' + t(f.label);
err.classList.remove('hidden');
valid = false;
}
});
if (!valid) return;
try {
eventData = cfg.build(values);
} catch (e) {
err.textContent = 'Error: ' + e.message;
err.classList.remove('hidden');
return;
}
}
btn.disabled = true;
btn.textContent = t('Sending...');
try {
const resp = await pysimFetch('/api/event-send', { event_type: _eventFormType, event_data: eventData });
if (resp.sw === '9000') {
result.textContent = (eventData ? t('Event data sent.') : t('Event sent.')) + ' SW: ' + resp.sw;
result.classList.remove('hidden');
} else {
result.textContent = 'SW: ' + resp.sw;
result.classList.remove('hidden');
result.classList.add('text-amber-600');
}
} catch (e2) {
err.textContent = 'Error: ' + e2.message;
err.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = t('Send');
}
}
async function pysimStatusPoll() {
const btn = document.getElementById('pli-status-btn');
if (!btn) return;
btn.disabled = true;
btn.textContent = t('STATUS...');
try {
const resp = await pysimFetch('/api/status-poll', {});
if (resp.fetch) {
btn.textContent = 'SW: ' + resp.sw + ' + ' + resp.fetch_sw;
} else {
btn.textContent = 'SW: ' + resp.sw;
}
pysimEventsRender();
pysimProactiveLogRender();
} catch (e) {
btn.textContent = t('Error');
} finally {
setTimeout(() => { btn.textContent = t('Send STATUS'); btn.disabled = false; }, 2000);
}
}
let _pysimPollTimer = null;
let _pysimStatusTimer = null;
let _pysimCardStateKey = null;
let _pysimCardSession = null;
async function pysimPollToggle() {
const btn = document.getElementById('pli-pause-btn');
if (!btn) return;
try {
const status = await pysimFetch('/api/poll-status');
const newEnabled = !status.enabled;
const resp = await pysimFetch('/api/poll-toggle', { enabled: newEnabled });
pysimUpdatePollUI(resp.enabled, resp.interval);
} catch (e) { /* ignore */ }
}
function pysimUpdatePollUI(enabled, interval) {
const btn = document.getElementById('pli-pause-btn');
const stat = document.getElementById('pli-poll-stat');
if (!btn) return;
if (enabled) {
btn.textContent = 'ON';
btn.classList.remove('bg-gray-200', 'dark:bg-slate-700', 'text-gray-700', 'dark:text-slate-300');
btn.classList.add('bg-emerald-600', 'text-white');
} else {
btn.textContent = 'OFF';
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-slate-700', 'text-gray-700', 'dark:text-slate-300');
}
if (stat) stat.textContent = enabled ? '(' + interval + 's)' : '';
}
async function pysimPollStatusInit() {
try {
const status = await pysimFetch('/api/poll-status');
pysimUpdatePollUI(status.enabled, status.interval);
} catch (e) { /* ignore */ }
}
async function pysimResetCardData(refreshStatus) {
stkMenuClose();
stkMenuStack = [];
pysimFsRefresh();
stkCheckMenu();
pysimEventsRender();
pysimProactiveLogRender();
pysimPliRender();
if (refreshStatus) await pysimRefresh();
}
function pysimCardStateUpdate(status) {
if (!status || typeof status.connected !== 'boolean') return;
_pysimServerAvailable = true;
_pysimCardEquipped = !!status.connected;
_pysimEquipping = !!status.equipping;
pysimApplyAvailability();
const key = [status.connected, !!status.card_present, !!status.equipping, !!status.auto_equip, status.card_session].join('|');
if (key === _pysimCardStateKey) return;
_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) {
pysimSetConnected(true);
pysimResetCardData(true);
return;
}
pysimSetConnected(false);
const statusEl = document.getElementById('pysim-status');
if (statusEl && statusEl.textContent.trim() !== '') {
if (status.equipping || (status.card_present && 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>';
} else {
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() {
if (_pysimPollTimer) clearInterval(_pysimPollTimer);
if (_pysimStatusTimer) clearInterval(_pysimStatusTimer);
_pysimCardStateKey = null;
_pysimCardSession = null;
_pysimStatusTimer = setInterval(async () => {
try {
pysimCardStateUpdate(await pysimFetch('/api/status'));
} catch (e) {
pysimSetServerAvailable(false);
}
}, 2000);
_pysimPollTimer = setInterval(async () => {
try {
const [log, stk, ps] = await Promise.all([
pysimFetch('/api/proactive-log'),
pysimFetch('/api/stk-status'),
pysimFetch('/api/poll-status'),
]);
pysimUpdatePollUI(ps.enabled, ps.interval);
} catch (e) { /* ignore */ }
}, 5000);
}
// ===== proactive command log =====
const CMD_NAMES = {
'03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST',
'13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE',
'21': 'DISPLAY TEXT', '22': 'GET INKEY', '23': 'GET INPUT',
'24': 'SELECT ITEM', '25': 'SET UP MENU',
'26': 'PROVIDE LOCAL INFORMATION',
'15': 'LAUNCH BROWSER', '70': 'ACTIVATE',
};
const CMD_QUALIFIER_SHORT = {
'01': {0x00:'NAA Init+FC', 0x01:'File Chg', 0x02:'NAA Init+FC', 0x03:'NAA Init',
0x04:'UICC Reset', 0x05:'App Reset', 0x06:'Sess Reset', 0x07:'SoR', 0x08:'SoR I-WLAN'},
'10': {0x00:'IfNotBusy', 0x01:'IfNotBusy+Redial', 0x02:'HoldOthers', 0x03:'HoldOthers+Redial',
0x04:'DiscOthers', 0x05:'DiscOthers+Redial'},
'15': {0x00:'New', 0x01:'Existing', 0x02:'Close+New'},
'21': {0x00:'Normal', 0x01:'HighPri', 0x80:'WaitUser'},
'22': {0x00:'Digits', 0x01:'Alpha', 0x04:'YesNo', 0x08:'ImmDigit'},
'23': {0x00:'Digits+Echo', 0x01:'Alpha+Echo', 0x04:'NoEcho'},
'24': {0x00:'Normal', 0x02:'Help', 0x04:'DefaultItem', 0x80:'Nav'},
'25': {0x00:'Normal', 0x04:'Help', 0x80:'Nav'},
'26': {0x00:'Loc', 0x01:'IMEI', 0x02:'NMR', 0x03:'Time', 0x04:'Lang', 0x05:'TA',
0x06:'AccTech', 0x08:'IMEISV', 0x09:'Search', 0x0A:'Batt', 0x0C:'WSID',
0x0D:'BCInfo', 0x0E:'MultiAT', 0x0F:'MultiLoc', 0x10:'MultiNMR',
0x11:'CSG', 0x12:'HNB-IP', 0x13:'HNB-Macro', 0x14:'WLAN', 0x15:'Slices',
0x16:'CAG', 0x17:'RejSlice'},
};
let _pysimLogExpanded = new Set();
function pysimLogToggle(id) {
const panel = document.querySelector('[data-log-panel="' + id + '"]');
const tog = document.querySelector('[data-log-tog="' + id + '"]');
if (_pysimLogExpanded.has(id)) {
_pysimLogExpanded.delete(id);
if (panel) panel.classList.add('hidden');
if (tog) tog.textContent = '▶';
} else {
_pysimLogExpanded.add(id);
if (panel) panel.classList.remove('hidden');
if (tog) tog.textContent = '▼';
}
}
function pysimLogFields(decoded) {
if (!decoded || !decoded.length) return '';
let html = '';
decoded.forEach(f => {
html += '<div class="flex gap-1"><span class="text-gray-500 dark:text-slate-400 shrink-0">'
+ esc(f.label) + ':</span><span class="break-all">' + esc(f.value) + '</span></div>';
});
return html;
}
async function pysimProactiveLogRender() {
const el = document.getElementById('pysim-proactive-log');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
try {
const log = await pysimFetch('/api/proactive-log');
if (!log || !log.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No commands logged yet.') + '</span>';
return;
}
let html = '';
log.forEach((e, i) => {
const name = CMD_NAMES[e.type_hex] || ('Cmd 0x' + e.type_hex.toUpperCase());
let qual = '';
if (e.qualifier) {
const qs = CMD_QUALIFIER_SHORT[e.type_hex];
if (qs) {
const qv = qs[parseInt(e.qualifier,16)];
if (qv) qual = ' [' + qv + ']';
}
}
const expanded = _pysimLogExpanded.has(e.id);
html += '<div data-log-id="' + e.id + '" class="border-b border-gray-100 dark:border-slate-700/50">'
+ '<div class="flex items-center gap-2 py-0.5 cursor-pointer select-none" onclick="pysimLogToggle(' + e.id + ')">'
+ '<span class="w-12 text-right text-gray-400">' + e.elapsed + 's</span>'
+ '<span class="font-mono text-gray-400">0x' + e.type_hex.toUpperCase() + '</span>'
+ '<span class="truncate">' + esc(name) + '</span>'
+ (qual ? '<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(qual) + '</span>' : '')
+ '<span class="ml-auto text-gray-400" data-log-tog="' + e.id + '">' + (expanded ? '▼' : '▶') + '</span>'
+ '</div>'
+ '<div data-log-panel="' + e.id + '" class="' + (expanded ? '' : 'hidden') + ' ml-8 pl-2 border-l border-gray-300 dark:border-slate-600 mb-1 text-xs space-y-0.5">'
+ '<div class="text-gray-500 dark:text-slate-400">' + t('Command') + ':</div>'
+ pysimLogFields(e.cmd_decoded);
if (e.raw) html += '<input type="text" readonly value="' + esc(e.raw) + '" class="w-full max-w-md font-mono text-xs rounded border border-gray-300 dark:border-slate-600 px-2 py-1 bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400">';
html += '<div class="text-gray-500 dark:text-slate-400 mt-1">' + t('Response') + ':</div>';
if (e.tr_hex !== undefined) {
if (e.tr_result !== undefined) {
html += '<div class="flex gap-1"><span class="text-gray-500 dark:text-slate-400 shrink-0">' + t('Result') + ':</span>'
+ '<span class="break-all">' + (e.tr_result_name ? esc(e.tr_result_name) + ' (' + esc(e.tr_result) + ')' : esc(e.tr_result)) + '</span></div>';
}
if (e.tr_hex) {
html += pysimLogFields(e.tr_decoded);
html += '<input type="text" readonly value="' + esc(e.tr_hex) + '" class="w-full max-w-md font-mono text-xs rounded border border-gray-300 dark:border-slate-600 px-2 py-1 bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400">';
}
} else {
html += '<div class="text-gray-400">' + t('No response yet') + '</div>';
}
html += '</div></div>';
});
el.innerHTML = html;
} catch (err) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>';
}
}
// ===== PLI data dictionary =====
function decPlmn(hex3) {
const h = hex3.replace(/\s/g, '').slice(0, 6);
if (h.length < 6) return ['250', '01'];
const b1 = parseInt(h[0]+h[1], 16), b2 = parseInt(h[2]+h[3], 16), b3 = parseInt(h[4]+h[5], 16);
const mcc = '' + (b1&0xF) + ((b1>>4)&0xF) + (b2&0xF);
let mnc = '' + (b3&0xF);
if ((b2>>4) !== 0xF) mnc += ((b2>>4)&0xF);
return [mcc, mnc];
}
function decImei(hex8) {
const h = hex8.replace(/\s/g, '').slice(0, 16);
if (h.length < 16) return '';
let s = '';
for (let i = 0; i < 15; i += 2) {
const b = parseInt(h[i]+h[i+1], 16);
s += '' + (b&0xF) + ((b>>4)&0xF);
}
return s.slice(0, 15);
}
function encImeiPli(digits) {
const d = digits.replace(/\D/g, '').slice(0, 15);
if (d.length < 15) return '';
let h = '';
for (let i = 0; i < 15; i += 2)
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
return h;
}
function encImeisvPli(digits) {
const d = digits.replace(/\D/g, '').slice(0, 16);
if (d.length < 16) return '';
let h = '';
for (let i = 0; i < 16; i += 2)
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
return h;
}
const ACCESSTECH = [
{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:2,l:'TIA/EIA-533'},{v:3,l:'UTRAN'},
{v:4,l:'TETRA'},{v:5,l:'TIA/EIA-95-B'},{v:6,l:'CDMA2000 1x'},{v:7,l:'CDMA2000 HRPD'},
{v:8,l:'E-UTRAN'},{v:9,l:'eHRPD'},{v:10,l:'NG-RAN'},{v:11,l:'Satellite NG-RAN'},{v:12,l:'Satellite E-UTRAN'},
];
const PLI_CODECS = {
'00': { tlv: '9305',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 10) return null;
const pl = decPlmn(v); return { mcc: pl[0], mnc: pl[1], lac: v.slice(6,10).toUpperCase() };
},
encode(v) { return '9305' + encPlmn(v.mcc||'250',v.mnc||'01') + ((v.lac||'0000').toUpperCase()); },
fields: [
{id:'mcc',label:'MCC',type:'text',pattern:'\\d{3}',placeholder:'e.g. 250'},
{id:'mnc',label:'MNC',type:'text',pattern:'\\d{2,3}',placeholder:'e.g. 01'},
{id:'lac',label:'LAC/TAC',type:'text',pattern:'[0-9A-Fa-f]{4}',placeholder:'hex'},
],
},
'01': { tlv: '9408',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
return { imei: decImei(v) };
},
encode(v) { const imei = encImeiPli(v.imei||''); return imei ? '9408'+imei : ''; },
fields: [
{id:'imei',label:'IMEI',type:'text',pattern:'\\d{15}',placeholder:'15 digits'},
],
},
'03': { tlv: '2607',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 14) return null;
const yr = parseInt(v[0]+v[1])<70 ? 2000+parseInt(v[0]+v[1]) : 1900+parseInt(v[0]+v[1]);
const mo = parseInt(v[2]+v[3]), dy = parseInt(v[4]+v[5]);
const hh = parseInt(v[6]+v[7]), mm = parseInt(v[8]+v[9]), ss = parseInt(v[10]+v[11]);
const tz = parseInt(v[12]+v[13], 16);
const tzSign = (tz & 0x80) ? '-' : '+';
const tzH = Math.floor(((tz & 0x3F) || 0) / 4);
const tzM = (((tz & 0x3F) || 0) % 4) * 15;
return {
dt: yr+'-'+(''+mo).padStart(2,'0')+'-'+(''+dy).padStart(2,'0'),
tm: (''+hh).padStart(2,'0')+':'+(''+mm).padStart(2,'0')+':'+(''+ss).padStart(2,'0'),
tz: tzSign+(''+tzH).padStart(2,'0')+':'+(''+tzM).padStart(2,'0'),
};
},
encode(v) {
const dt = (v.dt||'2000-01-01').split('-'), tm = (v.tm||'00:00:00').split(':');
let tzQ = 0;
const tzM = v.tz ? v.tz.match(/^([-+])(\d{2}):?(\d{2})$/) : null;
if (tzM) { const h = parseInt(tzM[2])||0, m = parseInt(tzM[3])||0; tzQ = h*4 + Math.floor(m/15); if (tzM[1]==='-') tzQ |= 0x80; }
return '2607'
+ (parseInt(dt[0])%100).toString(16).padStart(2,'0')
+ (parseInt(dt[1])||1).toString(16).padStart(2,'0')
+ (parseInt(dt[2])||1).toString(16).padStart(2,'0')
+ (parseInt(tm[0])||0).toString(16).padStart(2,'0')
+ (parseInt(tm[1])||0).toString(16).padStart(2,'0')
+ (parseInt(tm[2])||0).toString(16).padStart(2,'0')
+ tzQ.toString(16).padStart(2,'0');
},
fields: [
{id:'dt',label:'Date',type:'date'},{id:'tm',label:'Time',type:'time'},
{id:'tz',label:'TZ offset',type:'text',pattern:'[-+]\\d{2}:?\\d{2}',placeholder:'+00:00'},
],
},
'04': { tlv: 'AD02',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
return { lang: String.fromCharCode(parseInt(v[0]+v[1],16), parseInt(v[2]+v[3],16)) };
},
encode(v) {
const lang = (v.lang||'en').slice(0,2);
return 'AD02' + lang.charCodeAt(0).toString(16).padStart(2,'0') + lang.charCodeAt(1).toString(16).padStart(2,'0');
},
fields: [
{id:'lang',label:'Language',type:'text',pattern:'[a-zA-Z]{2,3}',placeholder:'en'},
],
},
'05': { tlv: 'C602',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
return { status: parseInt(v[0]+v[1],16).toString(), ta: parseInt(v[2]+v[3],16).toString() };
},
encode(v) { return 'C602' + (parseInt(v.status)||0).toString(16).padStart(2,'0') + (parseInt(v.ta)||0).toString(16).padStart(2,'0'); },
fields: [
{id:'status',label:'ME Status',type:'number',min:'0',max:'255',placeholder:'0'},
{id:'ta',label:'Timing Advance',type:'number',min:'0',max:'255',placeholder:'0'},
],
},
'06': { tlv: 'BF01',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
return { tech: parseInt(v[0]+v[1],16).toString() };
},
encode(v) { return 'BF01' + (parseInt(v.tech)||0).toString(16).padStart(2,'0'); },
fields: [{id:'tech',label:'Access Technology',type:'select',opts:ACCESSTECH}],
},
'08': { tlv: '9408',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
return { imeisv: decImei(v) + v.slice(14,16).toUpperCase() };
},
encode(v) {
const raw = (v.imeisv||'').replace(/\D/g, '').slice(0, 16);
if (raw.length < 16) return '';
let h = '';
for (let i = 0; i < 16; i += 2)
h += (parseInt(raw[i+1])<<4 | parseInt(raw[i])).toString(16).padStart(2,'0');
return '9408' + h;
},
fields: [{id:'imeisv',label:'IMEISV',type:'text',pattern:'\\d{16}',placeholder:'16 digits'}],
},
'09': { tlv: 'AD01',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
return { mode: parseInt(v[0]+v[1],16).toString() };
},
encode(v) {
return 'AD01' + (parseInt(v.mode)||0).toString(16).padStart(2,'0');
},
fields: [{id:'mode',label:'Search Mode',type:'select',
opts:[{v:0,l:'Automatic'},{v:1,l:'Manual'}]}],
},
'0A': { tlv: '6301',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
return { charge: parseInt(v[0]+v[1],16).toString() };
},
encode(v) { return '6301' + (parseInt(v.charge)||50).toString(16).padStart(2,'0'); },
fields: [{id:'charge',label:'Charge state (%)',type:'number',min:'0',max:'100',placeholder:'50'}],
},
'0E': { tlv: 'BF',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
const techs = []; for (let i = 0; i < v.length; i += 2) techs.push(parseInt(v[i]+v[i+1],16));
return { techs: techs.join(',') };
},
encode(v) {
const techs = (v.techs||'').split(',').filter(t => t !== '').map(t => parseInt(t)&0xFF);
if (!techs.length) return '';
const len = techs.length.toString(16).padStart(2,'0');
return 'BF' + len + techs.map(t => t.toString(16).padStart(2,'0')).join('');
},
fields: [
{id:'techs',label:'Access Technologies (comma-separated)',type:'text',
placeholder:'e.g. 3,8,10 (= UTRAN + E-UTRAN + NG-RAN)'},
],
},
};
async function pysimPliRender() {
const el = document.getElementById('pysim-pli-dict');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
try {
const data = await pysimFetch('/api/pli-dict');
let html = '<div class="space-y-1">';
PLI_QUALIFIERS.forEach(q => {
const val = data[q.code] || '';
const hasCodec = !!PLI_CODECS[q.code];
html += '<div data-pli-row="' + q.code + '" class="py-0.5 border-b border-gray-200 dark:border-slate-700">'
+ '<div class="flex items-baseline gap-1.5">'
+ '<span class="w-6 shrink-0 text-right text-gray-400 text-xs">' + q.code + '</span>'
+ '<span class="flex-1 min-w-0 text-[13px] leading-snug">' + esc(q.name) + '</span>'
+ '</div>'
+ '<div class="flex items-center gap-1 mt-0.5 ml-8">'
+ '<input type="text" id="pli-v-' + q.code + '" value="' + esc(val) + '" placeholder="hex"'
+ ' class="flex-1 min-w-0 border border-gray-300 dark:border-slate-600 text-xs rounded px-1.5 py-1 dark:bg-slate-800 font-mono">';
if (hasCodec) {
html += '<span onclick="pysimPliDecodeRow(\'' + q.code + '\')" id="pli-tog-' + q.code + '"'
+ ' class="cursor-pointer select-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">▶</span>';
}
html += '</div><div class="pli-decode-panel hidden mt-1 ml-8 pl-2 border-l border-gray-300 dark:border-slate-600" data-pli-panel="' + q.code + '"></div></div>';
});
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
}
}
function pysimPliDecodeRow(code) {
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
const codec = PLI_CODECS[code];
if (!codec) return;
if (!panel.classList.contains('hidden')) {
panel.innerHTML = '';
panel.classList.add('hidden');
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▶';
return;
}
const input = document.getElementById('pli-v-' + code);
const hex = input.value.trim().replace(/\s/g, '');
let values = {};
if (hex) {
values = codec.decode(hex);
if (!values) {
panel.innerHTML = '<span class="text-xs text-red-500">Invalid hex</span>';
panel.classList.remove('hidden');
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▼';
return;
}
}
let html = '<div class="space-y-1 mb-1">';
codec.fields.forEach(f => {
html += '<div class="flex items-center gap-1">'
+ '<span class="text-xs w-12 text-gray-500 dark:text-slate-400 truncate" title="' + esc(f.label) + '">' + esc(f.label) + '</span>';
if (f.type === 'select') {
html += '<select id="pli-d-' + code + '-' + f.id + '" class="text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-1 dark:bg-slate-800 w-40">';
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (String(values[f.id]) === String(o.v) ? ' selected' : '') + '>' + esc(o.l) + '</option>');
html += '</select>';
} else {
html += '<input id="pli-d-' + code + '-' + f.id + '" type="' + f.type + '"'
+ ' value="' + esc(String(values[f.id] || '')) + '"'
+ (f.pattern ? ' pattern="' + f.pattern + '"' : '')
+ (f.placeholder ? ' placeholder="' + f.placeholder + '"' : '')
+ (f.min !== undefined ? ' min="' + f.min + '" max="' + f.max + '"' : '')
+ ' class="text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-1 dark:bg-slate-800 w-40 font-mono">';
}
html += '</div>';
});
html += '</div>';
html += '<div class="flex gap-1">'
+ '<button onclick="pysimPliApplyRow(\'' + code + '\')" class="text-xs px-2 py-1 rounded bg-emerald-600 text-white hover:bg-emerald-700">Apply</button>'
+ '<button onclick="var p=document.querySelector(\'[data-pli-panel=\\\'' + code + '\\\']\');p.classList.add(\'hidden\');p.innerHTML=\'\';var t=document.getElementById(\'pli-tog-' + code + '\');if(t)t.textContent=\'▶\'" class="text-xs px-2 py-1 rounded border border-gray-300 dark:border-slate-600">Cancel</button>'
+ '</div>';
panel.innerHTML = html;
panel.classList.remove('hidden');
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▼';
}
function pysimPliApplyRow(code) {
const codec = PLI_CODECS[code];
if (!codec) return;
const values = {};
codec.fields.forEach(f => {
const el = document.getElementById('pli-d-' + code + '-' + f.id);
if (el) values[f.id] = el.value;
});
const hex = codec.encode(values);
if (!hex) return;
const input = document.getElementById('pli-v-' + code);
if (input) input.value = hex;
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
if (panel) { panel.innerHTML = ''; panel.classList.add('hidden'); }
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▶';
}
async function pysimPliSave() {
const btn = document.getElementById('pli-save-btn');
btn.disabled = true;
btn.textContent = t('Saving...');
const data = {};
PLI_QUALIFIERS.forEach(q => {
const el = document.getElementById('pli-v-' + q.code);
if (el) {
const v = el.value.trim().replace(/\s/g, '');
if (v && !/^[0-9A-Fa-f]*$/.test(v)) return;
data[q.code] = v;
}
});
try {
await pysimFetch('/api/pli-dict', data);
btn.textContent = t('Saved');
btn.classList.add('bg-emerald-700');
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-emerald-700'); btn.disabled = false; }, 1500);
} catch (e) {
btn.textContent = 'Error';
btn.classList.add('bg-red-600');
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-red-600'); btn.disabled = false; }, 2000);
}
}
// ===== custom files =====
let pysimCustomFiles = [];
function pysimCustomLoad() {
try {
const d = localStorage.getItem('pysim_custom_files');
pysimCustomFiles = d ? JSON.parse(d) : [];
} catch (e) { pysimCustomFiles = []; }
}
function pysimCustomSave() {
localStorage.setItem('pysim_custom_files', JSON.stringify(pysimCustomFiles));
}
function pysimCustomAdd() {
const pathEl = document.getElementById('pysim-cf-path');
const nameEl = document.getElementById('pysim-cf-name');
const path = pathEl.value.trim().toUpperCase();
const name = nameEl.value.trim();
if (!path || !name) return;
if (!name.startsWith('EF.') && !name.startsWith('DF.')) {
alert('Alias must start with "EF." or "DF."');
return;
}
const parts = path.split('/').filter(Boolean);
if (parts.length < 2) { alert('Path must have at least 2 parts, e.g. 3F00/6F46'); return; }
const fid = parts[parts.length - 1];
const parentFid = parts[parts.length - 2];
if (pysimCustomFiles.some(c => c.path.toUpperCase() === path)) { alert('Path already exists'); return; }
pysimCustomFiles.push({ path, name, fid, parentFid });
pysimCustomSave();
pysimCustomRender();
pathEl.value = '';
nameEl.value = '';
}
function pysimCustomRemove(i) {
pysimCustomFiles.splice(i, 1);
pysimCustomSave();
pysimCustomRender();
}
function pysimCustomRender() {
const el = document.getElementById('pysim-cf-list');
if (!pysimCustomFiles.length) { el.innerHTML = '<span class="text-gray-400">No custom files defined.</span>'; return; }
let html = '';
for (let i = 0; i < pysimCustomFiles.length; i++) {
const c = pysimCustomFiles[i];
html += '<div class="flex items-center gap-2 py-1 border-b border-gray-200 dark:border-slate-700">';
html += '<span class="text-gray-500">' + esc(c.path) + '</span> <span class="text-blue-600 dark:text-blue-400">→</span> <b>' + esc(c.name) + '</b>';
html += '<span onclick="pysimCustomRemove(' + i + ')" class="ml-auto cursor-pointer text-red-400 hover:text-red-600">✕</span>';
html += '</div>';
}
el.innerHTML = html;
}
function pysimCustomExport() {
const el = document.getElementById('pysim-cf-io');
el.value = JSON.stringify(pysimCustomFiles, null, 2);
el.classList.remove('hidden');
el.select();
}
function pysimCustomExportFile() {
downloadJson('otaman-custom-files.json', pysimCustomFiles);
}
async function pysimCustomImport(text) {
try {
if (text == null) text = await navigator.clipboard.readText();
const imported = JSON.parse(text);
if (!Array.isArray(imported)) throw new Error('Expected an array');
let added = 0;
for (const entry of imported) {
if (!entry.path || !entry.name) continue;
const path = entry.path.toUpperCase();
if (!pysimCustomFiles.some(c => c.path.toUpperCase() === path)) {
const parts = path.split('/').filter(Boolean);
pysimCustomFiles.push({
path: path,
name: entry.name.toUpperCase(),
fid: (entry.fid || parts[parts.length - 1]).toUpperCase(),
parentFid: (entry.parentFid || parts[parts.length - 2]).toUpperCase(),
});
added++;
}
}
pysimCustomSave();
pysimCustomRender();
const el = document.getElementById('pysim-cf-io');
el.value = 'Imported ' + added + ' files.';
setTimeout(() => { el.classList.add('hidden'); }, 2000);
} catch (e) {
alert('Import error: ' + e.message);
}
}
function pysimCustomImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => pysimCustomImport(reader.result);
reader.readAsText(file);
}
function pysimCustomImportField() {
const el = document.getElementById('pysim-cf-io');
const val = el.value.trim();
if (!el.classList.contains('hidden') && val.startsWith('[')) { pysimCustomImport(val); return; }
el.value = '';
el.placeholder = 'Paste JSON array here, then press Paste & import again';
el.classList.remove('hidden');
el.focus();
}
// Init custom files
pysimCustomLoad();
// ===== Profiler =====
let profiles = [];
let profilerView = 'list';
let profilerEditId = null;
let profilerDraft = null;
let profilerResults = null;
let profilerResultsLabels = null;
let profilerMismatchOnly = false;
let snapshots = [];
let snapshotViewId = null;
let _scanTarget = 'profile';
let profilerListTab = 'profiles';
// Files whose contents are ignored (contents check omitted) when scanning.
// Keyed by FID; display name is the pySim name.
const PROFILER_IGNORE_FILES = [
{ fid: '6F7E', name: 'EF.LOCI' },
{ fid: '6F73', name: 'EF.PSLOCI' },
{ fid: '6FE3', name: 'EF.EPSLOCI' },
{ fid: '4F01', name: 'EF.5GS3GPPLOCI' },
{ fid: '6F08', name: 'EF.Keys' },
{ fid: '6F09', name: 'EF.KeysPS' },
{ fid: '6F3C', name: 'EF.SMS' },
{ fid: '6F20', name: 'EF.Kc' },
{ fid: '6F52', name: 'EF.KcGPRS' },
{ fid: '6F53', name: 'EF.LOCIGPRS' },
{ fid: '6F48', name: 'EF.CBMID' },
{ fid: '6F43', name: 'EF.SMSS' },
{ fid: '6F78', name: 'EF.ACC' },
{ fid: '6FE4', name: 'EF.EPSNSC' },
{ fid: '6F5B', name: 'EF.START-HFN' },
{ fid: '2F06', name: 'EF.ARR', checked: false },
];
// Files where contents are captured as a mask matching only the first 4 bytes
// (the rest are '?' wildcards). Keyed by FID; display name is the pySim name.
const PROFILER_MASK_PREFIX4_FIDS = {
'6F07': 'EF.IMSI',
'2FE2': 'EF.ICCID',
};
function profilerLoad() {
try {
const d = localStorage.getItem('otaman_profiles');
profiles = d ? JSON.parse(d) : [];
} catch (e) { profiles = []; }
}
function profilerSave() {
localStorage.setItem('otaman_profiles', JSON.stringify(profiles));
}
function snapshotsLoad() {
try {
const d = localStorage.getItem('otaman_snapshots');
snapshots = d ? JSON.parse(d) : [];
} catch (e) { snapshots = []; }
}
function snapshotsSave() {
try {
localStorage.setItem('otaman_snapshots', JSON.stringify(snapshots));
} catch (e) {
alert('Snapshot save failed: ' + e.message);
}
}
function profilerNewId() {
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
function profilerNormHex(s) {
return (s || '').toUpperCase().replace(/[^0-9A-F?]/g, '');
}
// Strict hex normalization for byte-exact comparisons (no '?' wildcards).
function profilerNormHexStrict(s) {
return (s || '').toUpperCase().replace(/[^0-9A-F]/g, '');
}
// Match an expected hex (may contain '?' nibble wildcards) against actual hex.
function profilerMatch(mode, expected, actual) {
const e = profilerNormHex(expected);
const a = profilerNormHex(actual);
if (!e) return false;
if (mode === 'mask') {
if (e.indexOf('?') < 0) {
return a.indexOf(e) === 0;
}
if (e.length !== a.length) return false;
for (let i = 0; i < e.length; i++) {
if (e[i] !== '?' && e[i] !== a[i]) return false;
}
return true;
}
return e === a;
}
// Like profilerMatch, but compares only the overlapping (shorter) portion.
function profilerMatchMin(mode, expected, actual) {
const e = profilerNormHex(expected);
const a = profilerNormHex(actual);
const n = Math.min(e.length, a.length);
return profilerMatch(mode, e.slice(0, n), a.slice(0, n));
}
// Keep the first 4 bytes (8 hex chars) exact and mask the rest with '?'.
function profilerMaskPrefix4(hex) {
const h = profilerNormHex(hex);
if (h.length <= 8) return h;
return h.slice(0, 8) + '?'.repeat(h.length - 8);
}
// Which size/record fields apply to a given file type.
function profilerFileFields(ft) {
if (ft === 'linear_fixed' || ft === 'cyclic') return { size: false, records: true };
if (ft === 'df') return { size: false, records: false };
if (ft === 'transparent' || ft === 'ber_tlv') return { size: true, records: false };
return { size: true, records: true };
}
// Content representation ('record' vs 'transparent') for a given file type.
function profilerContentKindForFileType(ft) {
return (ft === 'linear_fixed' || ft === 'cyclic') ? 'record' : 'transparent';
}
// A fresh record content object seeded with one empty record row.
function profilerEmptyRecordContent(mode) {
return { mode: mode, kind: 'record', records: [{ num: 1, data: '' }] };
}
function profilerValidateProfile(obj) {
if (!obj || typeof obj !== 'object') return 'Not an object';
if (!obj.name) return 'Missing name';
if (!Array.isArray(obj.rules)) return 'Missing rules array';
for (const r of obj.rules) {
if (!r || typeof r !== 'object') return 'Invalid rule';
if (r.type !== 'file') return 'Unsupported rule type: ' + r.type;
if (!r.path) return 'Rule missing path';
if (r.fciMode && !['type', 'type_size', 'exact'].includes(r.fciMode)) return 'Invalid FCP/FCI mode: ' + r.fciMode;
}
return null;
}
function profilerRenderList() {
const el = document.getElementById('profiler-list-body');
if (!profiles.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No profiles defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < profiles.length; i++) {
const p = profiles[i];
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
html += '<b>' + esc(p.name) + '</b>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(p.created).toLocaleString()) + '</span>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + p.rules.length + ' ' + esc(t('rules')) + ')</span>';
html += '<span class="ml-auto flex gap-1">';
html += '<button data-needs="card" onclick="profilerCheck(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Check card')) + ' ▶</button>';
html += '<button onclick="profilerCheckSnapshotPick(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700">' + esc(t('Check card snapshot')) + '</button>';
html += '<button onclick="profilerEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Edit')) + '</button>';
html += '<button onclick="profilerExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
html += '<button onclick="profilerDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</span></div>';
}
el.innerHTML = html;
pysimApplyAvailability();
}
function profilerListSwitch(name) {
profilerListTab = name;
document.querySelectorAll('.profiler-list-tab').forEach(btn => {
const active = btn.dataset.listTab === name;
btn.classList.toggle('bg-blue-600', active);
btn.classList.toggle('dark:bg-blue-500', active);
btn.classList.toggle('text-white', active);
btn.classList.toggle('dark:text-white', active);
btn.classList.toggle('bg-gray-200', !active);
btn.classList.toggle('dark:bg-slate-700', !active);
btn.classList.toggle('text-gray-700', !active);
btn.classList.toggle('dark:text-slate-300', !active);
});
document.getElementById('profiler-list-profiles').classList.toggle('hidden', name !== 'profiles');
document.getElementById('profiler-list-snapshots').classList.toggle('hidden', name !== 'snapshots');
document.getElementById('profiler-list-custom').classList.toggle('hidden', name !== 'custom');
if (name === 'custom') pysimCustomRender();
setHelpAnchor({ profiles: 'profiler', snapshots: 'card-snapshots', custom: 'custom-files' }[name] || 'profiler');
}
function profilerSetView(view) {
profilerView = view;
document.getElementById('profiler-list').classList.toggle('hidden', view !== 'list');
document.getElementById('profiler-editor').classList.toggle('hidden', view !== 'editor');
document.getElementById('profiler-results').classList.toggle('hidden', view !== 'results');
document.getElementById('profiler-snapshot').classList.toggle('hidden', view !== 'snapshot');
if (view === 'list') {
profilerRenderList();
profilerRenderSnapshotList();
profilerListSwitch(profilerListTab);
}
}
function profilerRenderSnapshotList() {
const el = document.getElementById('snapshot-list-body');
if (!snapshots.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No snapshots defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < snapshots.length; i++) {
const s = snapshots[i];
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
html += '<b>' + esc(s.name) + '</b>';
if (s.iccid) html += '<span class="text-xs font-mono text-gray-500 dark:text-slate-400">' + esc(s.iccid) + '</span>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(s.created).toLocaleString()) + '</span>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + esc(profilerSnapshotCountLabel(s)) + ')</span>';
html += '<span class="ml-auto flex gap-1">';
html += '<button onclick="snapshotOpen(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Open')) + '</button>';
html += '<button onclick="snapshotExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
html += '<button onclick="snapshotDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</span></div>';
}
el.innerHTML = html;
}
function profilerNew() {
const name = prompt(t('Profile name') + ':', '');
if (!name) return;
profiles.push({ id: profilerNewId(), name: name, created: new Date().toISOString(), rules: [] });
profilerSave();
profilerEdit(profiles.length - 1);
}
function profilerScanToggleAll(checked) {
document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]').forEach(cb => { cb.checked = checked; });
}
function profilerScanIgnoreAllState() {
const boxes = document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]');
const all = document.getElementById('profiler-scan-ignore-all');
if (!all) return;
let checked = 0;
boxes.forEach(cb => { if (cb.checked) checked++; });
all.checked = boxes.length > 0 && checked === boxes.length;
all.indeterminate = checked > 0 && checked < boxes.length;
}
function profilerScanSetTarget(target) {
_scanTarget = target;
const title = document.getElementById('profiler-scan-title');
const label = document.getElementById('profiler-scan-name-label');
const titleKey = target === 'snapshot' ? 'New snapshot' : 'Profile from card';
const labelKey = target === 'snapshot' ? 'Snapshot name' : 'Profile name';
title.setAttribute('data-l10n', titleKey);
title.textContent = t(titleKey);
label.setAttribute('data-l10n', labelKey);
label.textContent = t(labelKey);
document.getElementById('profiler-scan-options').classList.toggle('hidden', target === 'snapshot');
}
// Re-translate the dynamically built mask labels in the open scan modal,
// preserving the checkbox state (ignore-list labels are plain file names).
function profilerScanRefreshOptions() {
if (_scanTarget === 'snapshot') return;
document.querySelectorAll('#profiler-scan-mask input[data-mask-fid]').forEach(cb => {
const span = cb.parentElement ? cb.parentElement.querySelector('span') : null;
if (span) span.textContent = t('Match first 4 bytes for') + ' ' + PROFILER_MASK_PREFIX4_FIDS[cb.getAttribute('data-mask-fid')];
});
}
function profilerFromCard() {
profilerScanSetTarget('profile');
document.getElementById('profiler-scan-name').value = '';
document.getElementById('profiler-scan-name').readOnly = false;
document.getElementById('profiler-scan-name').classList.remove('opacity-50');
const ignore = document.getElementById('profiler-scan-ignore');
let html = '';
for (const f of PROFILER_IGNORE_FILES) {
html += '<label class="flex items-center gap-2 text-sm">' +
'<input type="checkbox" data-ignore-fid="' + f.fid + '" data-ignore-name="' + f.name + '"' + (f.checked === false ? '' : ' checked') + ' onchange="profilerScanIgnoreAllState()">' +
'<span>' + esc(f.name) + '</span></label>';
}
ignore.innerHTML = html;
profilerScanIgnoreAllState();
const mask = document.getElementById('profiler-scan-mask');
let mhtml = '';
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
mhtml += '<label class="flex items-center gap-2 text-sm">' +
'<input type="checkbox" data-mask-fid="' + fid + '" checked>' +
'<span>' + esc(t('Match first 4 bytes for')) + ' ' + esc(PROFILER_MASK_PREFIX4_FIDS[fid]) + '</span></label>';
}
mask.innerHTML = mhtml;
document.getElementById('profiler-scan-options').classList.remove('hidden');
document.getElementById('profiler-scan-progress').classList.add('hidden');
document.getElementById('profiler-scan-error').classList.add('hidden');
document.getElementById('profiler-scan-cancel-btn').disabled = false;
document.getElementById('profiler-scan-btn').disabled = false;
document.getElementById('profiler-scan-btn').textContent = t('Scan');
document.getElementById('profiler-scan-modal').classList.remove('hidden');
document.getElementById('profiler-scan-name').focus();
}
function profilerScanCancel() {
document.getElementById('profiler-scan-modal').classList.add('hidden');
}
function snapshotNew() {
profilerScanSetTarget('snapshot');
const nameEl = document.getElementById('profiler-scan-name');
nameEl.value = '';
nameEl.readOnly = false;
nameEl.classList.remove('opacity-50');
document.getElementById('profiler-scan-progress').classList.add('hidden');
document.getElementById('profiler-scan-error').classList.add('hidden');
document.getElementById('profiler-scan-cancel-btn').disabled = false;
document.getElementById('profiler-scan-btn').disabled = false;
document.getElementById('profiler-scan-btn').textContent = t('Scan');
document.getElementById('profiler-scan-modal').classList.remove('hidden');
nameEl.focus();
}
function profilerScanNameKeydown(e) {
if (e.key !== 'Enter') return;
if (document.getElementById('profiler-scan-btn').disabled) return;
e.preventDefault();
profilerScanStart();
}
async function profilerScanStart() {
const name = document.getElementById('profiler-scan-name').value.trim();
const errEl = document.getElementById('profiler-scan-error');
if (!name) {
errEl.textContent = t(_scanTarget === 'snapshot' ? 'Please enter a snapshot name' : 'Please enter a profile name');
errEl.classList.remove('hidden');
return;
}
const ignoreFids = new Set();
const ignoreNames = new Set();
document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]').forEach(cb => {
if (cb.checked) {
ignoreFids.add(cb.getAttribute('data-ignore-fid').toUpperCase());
ignoreNames.add((cb.getAttribute('data-ignore-name') || '').toUpperCase());
}
});
const maskFids = new Set();
document.querySelectorAll('#profiler-scan-mask input[data-mask-fid]').forEach(cb => {
if (cb.checked) maskFids.add(cb.getAttribute('data-mask-fid').toUpperCase());
});
const fciMode = document.getElementById('profiler-scan-fci-mode').value;
const btn = document.getElementById('profiler-scan-btn');
const cancelBtn = document.getElementById('profiler-scan-cancel-btn');
const nameEl = document.getElementById('profiler-scan-name');
const progEl = document.getElementById('profiler-scan-progress');
document.getElementById('profiler-scan-options').classList.add('hidden');
cancelBtn.disabled = true;
nameEl.readOnly = true;
nameEl.classList.add('opacity-50');
progEl.classList.remove('hidden');
progEl.textContent = t('Discovering...');
btn.disabled = true;
btn.textContent = t('Scanning...');
try {
const onProgress = (done, total, path) => {
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
};
if (_scanTarget === 'snapshot') {
const timing = profilerTimingAccumulator();
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);
snapshotsSave();
profilerScanCancel();
profilerSetView('list');
profilerListSwitch('snapshots');
} else {
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
profilerScanCancel();
profilerEdit(profiles.length - 1);
}
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
profilerScanSetTarget(_scanTarget);
cancelBtn.disabled = false;
nameEl.readOnly = false;
nameEl.classList.remove('opacity-50');
progEl.classList.add('hidden');
btn.disabled = false;
btn.textContent = t('Scan');
}
}
// Recursively walk the card filesystem and build rules for files that exist.
// 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
// callback as onProgress(done, total, path).
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode, timing) {
const rules = [];
const files = [];
const seen = new Set();
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
async function walkDir(dir) {
let body = { name: dir.name, fid: dir.fid };
if (dir.parentSel) body.parent_sel = dir.parentSel;
let data;
try {
data = await pysimFetch('/api/tree', body);
} catch (e) { return; }
if (!data || data.exists === false) return;
for (const c of (data.children || [])) {
// parent selector for c, mirroring getParentSel()
const parentSel = (dir.name === 'MF') ? 'MF'
: (dir.name && dir.name.startsWith('ADF.')) ? dir.name
: (dir.fid ? dir.fid : dir.name);
if (c.isDir) {
// ADF roots use the AID, not the generic ADF fid
const childPrefix = c.aid ? [c.aid.toUpperCase()]
: dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name);
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, pathPrefix: childPrefix });
} else {
const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/');
if (seen.has(childPath)) continue;
seen.add(childPath);
files.push({ path: childPath, child: c });
}
}
}
// MF-rooted walk
await walkDir({ name: 'MF', fid: null, parentSel: null, pathPrefix: ['MF'] });
// Sweep custom files not already covered (map leading MF fid 3F00 -> MF)
for (const cf of pysimCustomFiles) {
const parts = cf.path.split('/').filter(Boolean);
if (!parts.length) continue;
let segs;
if (parts[0].toUpperCase() === '3F00') segs = ['MF'].concat(parts.slice(1));
else segs = parts;
const path = segs.join('/');
if (seen.has(path)) continue;
seen.add(path);
files.push({ path: path, child: { fid: cf.fid, name: cf.name } });
}
const total = files.length;
if (onProgress) onProgress(0, total, '');
for (let i = 0; i < files.length; i++) {
const entry = files[i];
if (onProgress) onProgress(i + 1, total, entry.path);
const rule = mode === 'snapshot'
? await profilerBuildSnapshotFile(entry.path, entry.child, timing)
: await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
if (rule) rules.push(rule);
}
return rules;
}
async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode, maskFids) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
} catch (e) { return null; }
if (!sel || sel.exists !== true) return null;
const isRecord = profilerContentKindForFileType(sel.file_type) === 'record';
const rule = {
type: 'file',
path: path,
name: c.name || sel.name || null,
fileType: sel.file_type || null,
fileSize: isRecord ? null : ((sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size),
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
fciMode: fciMode || 'type_size',
fciHex: sel.fci_hex || null,
content: null,
};
const fid = (c.fid || sel.fid || '').toUpperCase();
const name = (c.name || sel.name || '').toUpperCase();
if (ignoreFids.has(fid) || (ignoreNames && ignoreNames.has(name))) return rule;
try {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
if (rd && rd.success) {
if (rd.records) {
rule.content = {
mode: 'exact', kind: 'record',
records: rd.records.map(r => ({ num: r.num, data: r.data })),
};
} else if (rd.data) {
const useMask = maskFids ? maskFids.has(fid) : !!PROFILER_MASK_PREFIX4_FIDS[fid];
if (useMask) {
rule.content = { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(rd.data) };
} else {
rule.content = { mode: 'exact', kind: 'transparent', expected: rd.data };
}
}
}
} catch (e) {}
return rule;
}
// Card snapshot entry: metadata + raw FCI + exact contents for every readable
// file (no ignore list, no masking, no FCP/FCI check mode).
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';
}
function profilerSnapshotCountLabel(s) {
let label = (s.files ? s.files.length : 0) + ' ' + t('files');
if (s.timing && s.timing.total_ms !== undefined) {
label += ', ' + t('scanned in') + ' ' + (s.timing.total_ms / 1000).toFixed(2) + ' ' + t('sec');
}
return label;
}
async function profilerBuildSnapshotFile(path, c, timing) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
} catch (e) { return null; }
if (!sel || sel.exists !== true) return null;
const isRecord = profilerContentKindForFileType(sel.file_type) === 'record';
const file = {
path: path,
name: c.name || sel.name || null,
fileType: sel.file_type || null,
fileSize: isRecord ? null : ((sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size),
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
fciHex: sel.fci_hex || 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 {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
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) {
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) {
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) {}
return file;
}
// Decode the ICCID from the captured EF.ICCID (2FE2) entry, if readable.
function profilerSnapshotIccid(files) {
const f = (files || []).find(x => x.name === 'EF.ICCID' || (x.path || '').toUpperCase().endsWith('/2FE2'));
if (!f || !f.content || f.content.kind !== 'transparent' || !f.content.data) return null;
return decIccid(f.content.data) || null;
}
function profilerImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
const err = profilerValidateProfile(obj);
if (err) throw new Error(err);
obj.id = profilerNewId();
obj.created = obj.created || new Date().toISOString();
profiles.push(obj);
profilerSave();
profilerRenderList();
profilerListSwitch('profiles');
} catch (e) {
alert('Import error: ' + e.message);
}
};
reader.readAsText(file);
}
function profilerExport(i) {
const p = profiles[i];
downloadJson((p.name || 'profile').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', p);
}
function profilerDelete(i) {
const p = profiles[i];
if (!confirm(t('Delete profile') + ' "' + p.name + '"?') ) return;
profiles.splice(i, 1);
profilerSave();
profilerRenderList();
}
function profilerEdit(i) {
const p = profiles[i];
profilerEditId = p.id;
profilerDraft = JSON.parse(JSON.stringify(p));
profilerSetView('editor');
document.getElementById('profiler-name').value = profilerDraft.name;
profilerRenderEditor();
}
function profilerBackToList() {
profilerDraft = null;
profilerEditId = null;
snapshotViewId = null;
profilerSetView('list');
}
function profilerValidateSnapshot(obj) {
if (!obj || typeof obj !== 'object') return 'Not an object';
if (!obj.name) return 'Missing name';
if (!Array.isArray(obj.files)) return 'Missing files array';
for (const f of obj.files) {
if (!f || typeof f !== 'object') return 'Invalid file entry';
if (!f.path) return 'File entry missing path';
}
return null;
}
function snapshotImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
const err = profilerValidateSnapshot(obj);
if (err) throw new Error(err);
obj.id = profilerNewId();
obj.created = obj.created || new Date().toISOString();
obj.iccid = obj.iccid || profilerSnapshotIccid(obj.files);
snapshots.push(obj);
snapshotsSave();
profilerRenderSnapshotList();
profilerListSwitch('snapshots');
} catch (e) {
alert('Import error: ' + e.message);
}
};
reader.readAsText(file);
}
function snapshotExport(i) {
const s = snapshots[i];
downloadJson((s.name || 'snapshot').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', s);
}
function snapshotDelete(i) {
const s = snapshots[i];
if (!confirm(t('Delete snapshot') + ' "' + s.name + '"?') ) return;
snapshots.splice(i, 1);
snapshotsSave();
profilerRenderSnapshotList();
}
function snapshotOpen(i) {
const s = snapshots[i];
snapshotViewId = s.id;
profilerSetView('snapshot');
document.getElementById('snapshot-name').value = s.name;
profilerRenderSnapshotData();
}
// Re-render the immutable part of the snapshot view (ICCID + file cards)
// without touching the (editable) name input.
function profilerRenderSnapshotData() {
const s = snapshots.find(x => x.id === snapshotViewId);
if (!s) return;
document.getElementById('snapshot-iccid').textContent = s.iccid || '';
document.getElementById('snapshot-summary').innerHTML = profilerRenderSnapshotSummary(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() {
const s = snapshots.find(x => x.id === snapshotViewId);
if (!s) return;
const name = document.getElementById('snapshot-name').value.trim();
if (!name) return;
s.name = name;
snapshotsSave();
document.getElementById('snapshot-name').value = s.name;
}
function profilerRenderSnapshotFiles(s) {
if (!s.files.length) return '<span class="text-gray-400">' + t('No files captured.') + '</span>';
let html = '';
for (const f of s.files) {
const name = profilerCustomNameForPath(f.path) || f.name;
const title = name
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(f.path) + ')</span>'
: '<span>' + esc(f.path) + '</span>';
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="font-mono text-sm">' + title + '</div>';
const attrs = [];
if (f.fileType) attrs.push(esc(t('File type')) + ': ' + esc(f.fileType));
if (f.fileSize !== null && f.fileSize !== undefined) attrs.push(esc(t('Size')) + ': ' + esc(String(f.fileSize)));
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 (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) {
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>' +
'<textarea readonly rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800 break-all">' + escHtml(f.fciHex) + '</textarea></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('Decoded FCI')) + '</div>' +
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(f.fciHex) + '</div></div>';
html += '</div>';
}
html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-1 mb-0.5">' + esc(t('Contents')) + '</div>';
if (!f.content) {
html += '<div class="text-xs text-gray-400 dark:text-slate-500">' + esc(t('Not captured')) + '</div>';
} else if (f.content.kind === 'record') {
html += '<div class="space-y-0.5">';
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>' +
'<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>';
} else {
html += '<input readonly value="' + escHtml(f.content.data || '') + '" class="w-full 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">';
}
html += '</div>';
}
return html;
}
function profilerAddRule() {
if (!profilerDraft) return;
profilerDraft.rules.push({ type: 'file', path: '', name: null, fileType: null, fileSize: null, recordLen: null, numRecords: null, fciMode: 'type_size', fciHex: null, content: null });
profilerRenderEditor();
}
function profilerDeleteRule(i) {
if (!profilerDraft) return;
profilerDraft.rules.splice(i, 1);
profilerRenderEditor();
}
function profilerRenderEditor() {
if (!profilerDraft) return;
const el = document.getElementById('profiler-rules');
if (!profilerDraft.rules.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No rules defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < profilerDraft.rules.length; i++) {
const r = profilerDraft.rules[i];
html += profilerRenderRule(i, r);
}
el.innerHTML = html;
}
function profilerRenderRule(i, r) {
const fts = { transparent: 'transparent', linear_fixed: 'linear_fixed', cyclic: 'cyclic', ber_tlv: 'ber_tlv', df: 'df' };
let ftOpts = '<option value=""' + (!r.fileType ? ' selected' : '') + '>—</option>';
for (const k in fts) {
ftOpts += '<option value="' + k + '"' + (r.fileType === k ? ' selected' : '') + '>' + fts[k] + '</option>';
}
const cm = r.content ? r.content.mode : 'exact';
const fciMode = r.fciMode || 'type_size';
let html = '<div class="mb-2 p-3 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="flex items-center gap-2 mb-2">';
html += '<span class="text-xs font-semibold text-gray-500 dark:text-slate-400">#' + (i + 1) + '</span>';
html += '<button onclick="profilerDeleteRule(' + i + ')" class="ml-auto px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</div>';
const sname = profilerCustomNameForPath(r.path) || r.name || '';
const vis = profilerFileFields(r.fileType);
const showSize = vis.size && fciMode !== 'type';
const showRecords = vis.records && fciMode !== 'type';
html += '<div class="flex flex-wrap gap-2 mb-2">';
html += '<div class="w-48 shrink-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Path')) +
' <span class="profiler-rule-name font-normal text-gray-400 dark:text-slate-500">' + esc(sname) + '</span></label>' +
'<input value="' + escHtml(r.path || '') + '" oninput="profilerUpdateRulePath(' + i + ',this.value,this)" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="MF/7F10/6F3A"></div>';
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCP/FCI check')) + '</label>' +
'<select onchange="profilerUpdateRuleFciMode(' + i + ',this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="type"' + (fciMode === 'type' ? ' selected' : '') + '>' + esc(t('Filetype only (FCP)')) + '</option>' +
'<option value="type_size"' + (fciMode === 'type_size' ? ' selected' : '') + '>' + esc(t('Filetype + size (FCP)')) + '</option>' +
'<option value="exact"' + (fciMode === 'exact' ? ' selected' : '') + '>' + esc(t('Exact FCI')) + '</option>' +
'</select></div>';
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('File type')) + '</label>' +
'<select onchange="profilerUpdateRuleFileType(' + i + ',this.value||null)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' + ftOpts + '</select></div>';
if (showSize) {
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Size')) + '</label>' +
'<input type="number" value="' + (r.fileSize === null ? '' : r.fileSize) + '" oninput="profilerUpdateRule(' + i + ',\'fileSize\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
}
if (showRecords) {
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record length')) + '</label>' +
'<input type="number" value="' + (r.recordLen === null ? '' : r.recordLen) + '" oninput="profilerUpdateRule(' + i + ',\'recordLen\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record count')) + '</label>' +
'<input type="number" value="' + (r.numRecords === null ? '' : r.numRecords) + '" oninput="profilerUpdateRule(' + i + ',\'numRecords\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
}
html += '</div>';
if (fciMode === 'exact') {
html += '<div class="mb-2 flex gap-3 items-start">';
html += '<div class="flex-1 min-w-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</label>' +
'<textarea oninput="profilerFciInput(' + i + ',this.value)" rows="4" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="62...">' + escHtml(r.fciHex || '') + '</textarea></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('Decoded FCI')) + '</div>' +
'<div id="profiler-fci-preview-' + i + '" class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(r.fciHex || '') + '</div></div>';
html += '</div>';
}
if (r.fileType !== 'df') {
html += profilerRenderContentEditor(i, r);
}
html += '</div>';
return html;
}
function profilerRenderContentEditor(i, r) {
const c = r.content;
const mode = c ? c.mode : 'exact';
let html = '<div class="flex items-center justify-between mb-1">';
html += '<span class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Check contents')) + '</span>';
html += '<div class="flex items-center gap-3">';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="none"' + (!c ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',null)"> ' + esc(t('None')) + '</label>';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="exact"' + (c && mode === 'exact' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'exact\')"> ' + esc(t('Exact')) + '</label>';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="mask"' + (c && mode === 'mask' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'mask\')"> ' + esc(t('Mask')) + '</label>';
html += '</div></div>';
if (c) {
if (c.kind === 'record') {
html += '<div class="space-y-1">';
for (let ri = 0; ri < c.records.length; ri++) {
const rec = c.records[ri];
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8">' + rec.num + '</span>' +
'<input value="' + escHtml(rec.data) + '" oninput="profilerUpdateRecord(' + i + ',' + ri + ',this.value)" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">';
if (c.records.length > 1) {
html += '<button onclick="profilerDeleteRecord(' + i + ',' + ri + ')" class="text-red-400 hover:text-red-600 text-xs px-1">✕</button>';
}
html += '</div>';
}
html += '</div>';
html += '<button onclick="profilerAddRecord(' + i + ')" class="mt-1 text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300">+ Record</button>';
} else {
html += '<textarea oninput="profilerUpdateRuleContent(' + i + ',this.value)" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(c.expected || '') + '</textarea>';
}
}
return html;
}
function profilerUpdateRule(i, field, value) {
if (!profilerDraft) return;
profilerDraft.rules[i][field] = value;
}
// Path edits drop the scan-time symbolic name (it no longer corresponds);
// a custom-file lookup still supplies a display name for known paths.
function profilerUpdateRulePath(i, value, input) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
r.path = value;
r.name = null;
const span = input && input.parentElement ? input.parentElement.querySelector('.profiler-rule-name') : null;
if (span) span.textContent = profilerCustomNameForPath(value) || '';
}
function profilerUpdateRuleFileType(i, value) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
r.fileType = value;
const vis = profilerFileFields(value);
if (!vis.size) r.fileSize = null;
if (!vis.records) { r.recordLen = null; r.numRecords = null; }
if (r.content) {
const kind = profilerContentKindForFileType(value);
if (r.content.kind !== kind) {
r.content = kind === 'record'
? profilerEmptyRecordContent(r.content.mode)
: { mode: r.content.mode, kind: 'transparent', expected: '' };
}
}
profilerRenderEditor();
}
function profilerUpdateRuleFciMode(i, value) {
if (!profilerDraft) return;
profilerDraft.rules[i].fciMode = value;
profilerRenderEditor();
}
function profilerSetContent(i, mode) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
if (!mode) {
r.content = null;
} else if (r.content) {
r.content.mode = mode;
} else {
const kind = profilerContentKindForFileType(r.fileType);
r.content = kind === 'record'
? profilerEmptyRecordContent(mode)
: { mode: mode, kind: 'transparent', expected: '' };
}
profilerRenderEditor();
}
function profilerUpdateRuleContent(i, value) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (c) c.expected = value;
}
function profilerUpdateRecord(i, ri, value) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (c && c.records) c.records[ri].data = value;
}
function profilerAddRecord(i) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (!c || c.kind !== 'record') return;
const lastNum = c.records.length ? c.records[c.records.length - 1].num : 0;
c.records.push({ num: lastNum + 1, data: '' });
profilerRenderEditor();
}
function profilerDeleteRecord(i, ri) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (!c || c.kind !== 'record') return;
if (c.records.length <= 1) return;
c.records.splice(ri, 1);
profilerRenderEditor();
}
function profilerSaveProfile() {
if (!profilerDraft) return;
profilerDraft.name = document.getElementById('profiler-name').value.trim();
const idx = profiles.findIndex(p => p.id === profilerEditId);
if (idx >= 0) profiles[idx] = profilerDraft;
profilerSave();
profilerSetView('list');
}
async function profilerCheck(i) {
const p = profiles[i];
await profilerRunProfile(p, null, p.name);
}
async function profilerRunProfile(p, source, title, extraResults, labels) {
profilerSetView('results');
document.getElementById('profiler-results-title').textContent = title;
const prog = document.getElementById('profiler-progress');
document.getElementById('profiler-summary').innerHTML = '';
document.getElementById('profiler-report').innerHTML = '';
const results = [];
for (let ri = 0; ri < p.rules.length; ri++) {
const r = p.rules[ri];
prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path;
results.push(await profilerRunRule(r, source));
}
if (extraResults && extraResults.length) results.push(...extraResults);
prog.textContent = '';
profilerResults = results;
profilerResultsLabels = labels || null;
profilerRenderResultsView();
}
function profilerCheckSnapshotPick(i) {
if (!snapshots.length) {
alert(t('No snapshots defined.'));
return;
}
let html = '';
for (let si = 0; si < snapshots.length; si++) {
const s = snapshots[si];
html += '<button onclick="profilerCheckSnapshot(' + i + ',' + si + ')" class="w-full text-left px-3 py-2 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700">' +
'<span class="font-semibold text-gray-800 dark:text-slate-200">' + esc(s.name) + '</span>' +
'<span class="block text-gray-500 dark:text-slate-400">' + esc(s.iccid || '') + ' · ' + esc(new Date(s.created).toLocaleString()) + ' · ' + (s.files ? s.files.length : 0) + ' ' + t('files') + '</span></button>';
}
document.getElementById('snapshot-pick-list').innerHTML = html;
document.getElementById('snapshot-pick-modal').classList.remove('hidden');
}
async function profilerCheckSnapshot(i, si) {
document.getElementById('snapshot-pick-modal').classList.add('hidden');
const p = profiles[i];
const s = snapshots[si];
await profilerRunProfile(p, profilerSnapshotSource(s), p.name + ' — ' + s.name);
}
function profilerMaskFidForFile(f, maskFids) {
if (!maskFids) return null;
const seg = (f.path || '').toUpperCase().split('/').pop();
if (maskFids.has(seg)) return seg;
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
if (maskFids.has(fid) && f.name && PROFILER_MASK_PREFIX4_FIDS[fid] === f.name) return fid;
}
return null;
}
function profilerRulesFromSnapshot(master, maskFids) {
const rules = [];
for (const f of (master.files || [])) {
const rule = {
type: 'file',
path: f.path,
name: f.name,
fileType: f.fileType,
fileSize: f.fileSize,
recordLen: f.recordLen,
numRecords: f.numRecords,
fciMode: 'exact',
fciHex: f.fciHex,
content: null,
};
if (f.content) {
if (f.content.kind === 'record') {
rule.content = { mode: 'exact', kind: 'record', records: f.content.records };
} else if (typeof f.content.data === 'string') {
rule.content = profilerMaskFidForFile(f, maskFids)
? { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(f.content.data) }
: { mode: 'exact', kind: 'transparent', expected: f.content.data };
}
}
rules.push(rule);
}
return rules;
}
function snapshotCompareOpen() {
if (!snapshots.length) {
alert(t('No snapshots defined.'));
return;
}
let opts = '';
for (let i = 0; i < snapshots.length; i++) {
opts += '<option value="' + i + '">' + esc(snapshots[i].name) + '</option>';
}
const master = document.getElementById('snapshot-compare-master');
const check = document.getElementById('snapshot-compare-check');
master.innerHTML = opts;
check.innerHTML = opts;
master.value = '0';
check.value = String(snapshots.length > 1 ? 1 : 0);
let mhtml = '';
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
mhtml += '<label class="flex items-center gap-2 text-sm">' +
'<input type="checkbox" data-mask-fid="' + fid + '" checked>' +
'<span>' + esc(t('Match first 4 bytes for')) + ' ' + esc(PROFILER_MASK_PREFIX4_FIDS[fid]) + '</span></label>';
}
document.getElementById('snapshot-compare-mask').innerHTML = mhtml;
document.getElementById('snapshot-compare-modal').classList.remove('hidden');
}
function profilerExtraFileResults(master, check) {
const masterPaths = new Set((master.files || []).map(f => (f.path || '').toUpperCase()));
return (check.files || []).filter(f => !masterPaths.has((f.path || '').toUpperCase())).map(f => ({
path: f.path,
name: f.name,
status: 'fail',
checks: [{ label: 'extra file', expected: 'absent', actual: 'present', ok: false }],
}));
}
async function snapshotCompareRun() {
const master = snapshots[+document.getElementById('snapshot-compare-master').value];
const check = snapshots[+document.getElementById('snapshot-compare-check').value];
if (!master || !check) return;
const maskFids = new Set();
document.querySelectorAll('#snapshot-compare-mask input[data-mask-fid]').forEach(cb => {
if (cb.checked) maskFids.add(cb.getAttribute('data-mask-fid'));
});
document.getElementById('snapshot-compare-modal').classList.add('hidden');
const pseudo = { name: master.name + ' → ' + check.name, rules: profilerRulesFromSnapshot(master, maskFids) };
await profilerRunProfile(pseudo, profilerSnapshotSource(check), pseudo.name, profilerExtraFileResults(master, check),
{ expected: master.name, actual: check.name });
}
function profilerToggleMismatchOnly(checked) {
profilerMismatchOnly = checked;
profilerRenderResultsView();
}
function profilerVisibleResults(results, mismatchOnly) {
return mismatchOnly ? results.filter(r => r.status !== 'pass') : results;
}
function profilerRenderResultsView() {
if (!profilerResults) return;
let passed = 0, failed = 0, errors = 0;
for (const res of profilerResults) {
if (res.status === 'pass') passed++;
else if (res.status === 'fail') failed++;
else errors++;
}
document.getElementById('profiler-summary').innerHTML = '<span class="text-emerald-600 font-semibold">' + passed + ' ' + t('passed') + '</span>' +
' · <span class="text-red-600 font-semibold">' + failed + ' ' + t('failed') + '</span>' +
' · <span class="text-yellow-600 font-semibold">' + errors + ' ' + t('errors') + '</span>';
const shown = profilerVisibleResults(profilerResults, profilerMismatchOnly);
document.getElementById('profiler-report').innerHTML = shown.length
? profilerRenderReport(shown, profilerResultsLabels)
: '<div class="text-sm text-emerald-600">' + esc(t('No mismatches')) + '</div>';
}
function profilerLiveSource() {
return {
select: path => pysimFetch('/api/select', { path: path }),
read: path => pysimFetch('/api/read', { path: path, mode: 'raw' }),
};
}
function profilerSnapshotSource(snapshot) {
const find = path => (snapshot.files || []).find(f => (f.path || '').toUpperCase() === (path || '').toUpperCase());
return {
select: async path => {
const f = find(path);
if (!f) return { exists: false };
return {
name: f.name,
file_type: f.fileType,
file_size: f.fileSize,
record_len: f.recordLen,
num_of_rec: f.numRecords,
fci_hex: f.fciHex,
exists: true,
};
},
read: async path => {
const f = find(path);
if (!f) return { success: false };
if (!f.content) return { success: false, sw: 'not-captured' };
if (f.content.kind === 'record') return { success: true, records: f.content.records };
return { success: true, data: f.content.data };
},
};
}
async function profilerRunRule(r, source) {
source = source || profilerLiveSource();
const res = { path: r.path, name: null, status: 'pass', checks: [] };
let sizeMismatch = false;
let sel;
try {
sel = await source.select(r.path);
} catch (e) {
res.status = 'error';
res.error = e.message;
return res;
}
if (sel && sel.name) res.name = sel.name;
if (!sel || sel.exists !== true) {
res.status = 'fail';
res.checks.push({ label: 'exists', expected: true, actual: false, ok: false });
return res;
}
res.checks.push({ label: 'exists', expected: true, actual: true, ok: true });
if (r.fileType) {
const ok = sel.file_type === r.fileType;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'fileType', expected: r.fileType, actual: sel.file_type, ok: ok });
}
const isRecord = profilerContentKindForFileType(r.fileType) === 'record';
const fciMode = r.fciMode || 'type_size';
if (fciMode !== 'type') {
if (!isRecord && r.fileSize !== null && r.fileSize !== undefined) {
const ok = sel.file_size === r.fileSize;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
res.checks.push({ label: 'fileSize', expected: r.fileSize, actual: sel.file_size, ok: ok });
}
if (r.recordLen !== null && r.recordLen !== undefined) {
const ok = sel.record_len === r.recordLen;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
res.checks.push({ label: 'recordLen', expected: r.recordLen, actual: sel.record_len, ok: ok });
}
if (r.numRecords !== null && r.numRecords !== undefined) {
const ok = sel.num_of_rec === r.numRecords;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
res.checks.push({ label: 'numRecords', expected: r.numRecords, actual: sel.num_of_rec, ok: ok });
}
}
if (fciMode === 'exact' && r.fciHex) {
const expected = profilerNormHexStrict(r.fciHex);
const actual = profilerNormHexStrict(sel.fci_hex);
const ok = !!expected && expected === actual;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'fci', expected: r.fciHex, actual: sel.fci_hex || '', ok: ok });
}
if (r.content) {
let rd;
try {
rd = await source.read(r.path);
} catch (e) {
res.status = 'error';
res.error = e.message;
return res;
}
if (!rd || !rd.success) {
if (rd && rd.sw === 'not-captured') {
res.status = 'error';
res.error = t('Content not captured in snapshot');
res.checks.push({ label: 'content', expected: 'captured', actual: t('not captured in snapshot'), ok: false });
} else {
res.status = 'fail';
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false });
}
} else if (r.content.kind === 'record') {
const exp = r.content.records || [];
const act = rd.records || [];
const n = Math.min(exp.length, act.length);
let recMismatch = exp.length !== act.length;
if (exp.length !== act.length) {
res.status = 'fail';
const numRec = res.checks.find(c => c.label === 'numRecords');
if (!numRec || numRec.ok) {
res.checks.push({ label: 'content.records', expected: exp.length + ' records', actual: act.length + ' records', ok: false });
}
}
const matched = [];
for (let j = 0; j < n; j++) {
const dataOk = sizeMismatch ? profilerMatchMin(r.content.mode, exp[j].data, act[j].data) : profilerMatch(r.content.mode, exp[j].data, act[j].data);
const ok = exp[j].num === act[j].num && dataOk;
if (!ok) { res.status = 'fail'; recMismatch = true; } else matched.push(exp[j].num);
res.checks.push({ label: 'content.rec' + exp[j].num, expected: exp[j].data, actual: act[j].data, ok: ok });
}
if (matched.length) res.recordsMatched = matched;
if (recMismatch) res.recordsMismatch = true;
} else {
const ok = sizeMismatch ? profilerMatchMin(r.content.mode, r.content.expected, rd.data) : profilerMatch(r.content.mode, r.content.expected, rd.data);
if (!ok) res.status = 'fail';
res.checks.push({ label: 'content', expected: r.content.expected, actual: rd.data, ok: ok });
}
}
return res;
}
function profilerCustomNameForPath(path) {
if (!path) return null;
const norm = path.toUpperCase();
for (const c of pysimCustomFiles) {
const parts = c.path.split('/').filter(Boolean);
if (!parts.length) continue;
let segs;
if (parts[0].toUpperCase() === '3F00') segs = ['MF'].concat(parts.slice(1));
else segs = parts;
if (segs.join('/').toUpperCase() === norm) return c.name;
}
return null;
}
// Group a rule's result checks into display aspects, derived from the checks
// that actually ran. 'Exact FCI' subsumes filetype/size/records; 'exists' is
// implicit and omitted.
function profilerResultAspects(res) {
const groups = { filetype: [], size: [], records: [], 'Exact FCI': [], contents: [] };
for (const c of (res.checks || [])) {
const l = c.label;
if (l === 'fileType') groups.filetype.push(c);
else if (l === 'fileSize') groups.size.push(c);
else if (l === 'recordLen' || l === 'numRecords') groups.records.push(c);
else if (l === 'fci') groups['Exact FCI'].push(c);
else if (l === 'content' || l.startsWith('content.')) groups.contents.push(c);
}
const out = [];
if (groups['Exact FCI'].length) {
out.push({ key: 'Exact FCI', ok: groups['Exact FCI'].every(c => c.ok) });
} else {
if (groups.filetype.length) out.push({ key: 'filetype', ok: groups.filetype.every(c => c.ok) });
if (groups.size.length) out.push({ key: 'size', ok: groups.size.every(c => c.ok) });
if (groups.records.length) out.push({ key: 'records', ok: groups.records.every(c => c.ok) });
}
if (groups.contents.length) out.push({ key: 'contents', ok: groups.contents.every(c => c.ok) });
return out;
}
function profilerAspectSummary(aspects, marked, tr) {
tr = tr || (s => s);
if (!aspects || !aspects.length) return '';
if (marked) return aspects.map(a => tr(a.key) + ' ' + (a.ok ? '✓' : '✗')).join(', ');
const has = k => aspects.some(a => a.key === k);
let fcp = '';
if (has('Exact FCI')) fcp = tr('Exact FCI');
else if (has('filetype') && has('records')) fcp = tr('filetype and records');
else if (has('filetype') && has('size')) fcp = tr('filetype and size');
else if (has('filetype')) fcp = tr('filetype');
else if (has('records')) fcp = tr('records');
else if (has('size')) fcp = tr('size');
const parts = [];
if (fcp) parts.push(fcp);
if (has('contents')) parts.push(tr('contents'));
return parts.join(', ');
}
// Compress a list of record numbers into ranges: [1,2,3,5,7] -> "1-3, 5, 7".
function profilerNumRanges(nums) {
if (!nums || !nums.length) return '';
const sorted = nums.slice().sort((a, b) => a - b);
const parts = [];
let start = null, prev = null;
for (const n of sorted) {
if (start === null) { start = prev = n; continue; }
if (n === prev + 1) { prev = n; continue; }
parts.push(start === prev ? String(start) : start + '-' + prev);
start = prev = n;
}
if (start !== null) parts.push(start === prev ? String(start) : start + '-' + prev);
return parts.join(', ');
}
// Checks whose expected/actual are raw data (hex), rendered as aligned fields.
function profilerRawDataCheck(c) {
if (c.label === 'fci') return true;
if (/^content\.rec\d+$/.test(c.label)) return true;
if (c.label === 'content') return c.expected !== 'readable';
return false;
}
// ===== FCP/FCI decoding (ISO 7816-4 5.3.3 tables 12-14, TS 102 221 11.1.1.4) =====
function fcpInt(hex) { return hex ? parseInt(hex, 16) : 0; }
function fcpFileDescriptor(v) {
if (!v) return '';
const fd = parseInt(v.substr(0, 2), 16);
const parts = [(fd & 0x80) ? 'shareable' : 'not shareable'];
const cat = (fd >> 3) & 0x07;
const st = fd & 0x07;
if (cat === 0x07) {
parts.push(st === 0x01 ? 'BER-TLV EF' : st === 0x02 ? 'SIMPLE-TLV EF' : st === 0x00 ? 'DF/ADF' : 'proprietary file (' + v.substr(0, 2) + ')');
} else {
parts.push(cat === 0 ? 'working EF' : cat === 1 ? 'internal EF' : 'proprietary EF (' + cat + ')');
parts.push(['no info', 'transparent', 'linear fixed', 'linear fixed TLV', 'linear variable', 'linear variable TLV', 'cyclic', 'cyclic TLV'][st] || ('structure ' + st));
}
if (v.length >= 4) parts.push('data coding ' + v.substr(2, 2));
if (v.length >= 10) parts.push('record length ' + fcpInt(v.substr(4, 4)) + ', ' + fcpInt(v.substr(8, 2)) + ' records');
else if (v.length === 8) parts.push('record length ' + fcpInt(v.substr(4, 4)));
else if (v.length === 6) parts.push('record length ' + fcpInt(v.substr(4, 2)));
return parts.join(', ');
}
// ISO 7816-4 table 13 / TS 102 221 table 11.7b.
function fcpLifeCycle(v) {
if (v.length !== 2) return v;
const b = parseInt(v, 16);
if (b === 0x00) return 'no information';
if (b === 0x01) return 'creation';
if (b === 0x03) return 'initialization';
if ((b & 0x0C) === 0x0C) return 'termination';
if ((b & 0x05) === 0x05) return 'operational, activated';
if ((b & 0x05) === 0x04) return 'operational, deactivated';
return 'proprietary (' + v + ')';
}
// TS 102 221 11.1.1.4.8: SFI in b8-b4, b3-b1 zero; length 0 = no short id.
function fcpSfi(v) {
if (!v) return 'not supported';
if (v.length === 2 && (parseInt(v, 16) & 0x07) === 0) return String(parseInt(v, 16) >> 3);
return v;
}
function fcpDo(key, value) {
if (/^[0-9A-F]+$/.test(value) === false) value = value || '';
switch (key) {
case '80': return { name: 'File size', decoded: fcpInt(value) + ' bytes' };
case '81': return { name: 'Total file size', decoded: fcpInt(value) + ' bytes' };
case '82': return { name: 'File descriptor', decoded: fcpFileDescriptor(value) };
case '83': return { name: 'File identifier', decoded: value };
case '84': return { name: 'DF name (AID)', decoded: value };
case '85': return { name: 'Proprietary information', decoded: value };
case '86': return { name: 'Security attribute (proprietary)', decoded: value };
case '87': return { name: 'FCI extension EF', decoded: value };
case '88': return { name: 'Short file identifier', decoded: fcpSfi(value) };
case '8A': return { name: 'Life cycle status', decoded: fcpLifeCycle(value) };
case '8B': return { name: 'Security attributes (referenced)', decoded: value.length >= 6 ? 'EF_ARR ' + value.substr(0, 4) + ', record ' + fcpInt(value.substr(4, 2)) : value };
case '8C': return { name: 'Security attributes (compact)', decoded: value };
case '8D': return { name: 'Security env. template EF', decoded: value };
case '8E': return { name: 'Channel security attribute', decoded: value };
case 'A0': return { name: 'Security attributes (data objects)', decoded: value };
case 'A5': return { name: 'Proprietary information', decoded: null };
case 'AB': return { name: 'Security attributes (expanded)', decoded: value };
case 'C6': return { name: 'PIN status template DO', decoded: null };
case 'A5/80': {
const b = fcpInt(value);
const parts = [(b & 0x01) ? 'clock stop allowed' : 'clock stop not allowed'];
const cls = [];
if (b & 0x10) cls.push('A');
if (b & 0x20) cls.push('B');
if (b & 0x40) cls.push('C');
if (b & 0x80) cls.push('D');
if (cls.length) parts.push('class ' + cls.join('/'));
return { name: 'UICC characteristics', decoded: parts.join(', ') };
}
case 'A5/81': {
if (value.length < 6) return { name: 'Application power consumption', decoded: value };
const vc = fcpInt(value.substr(0, 2));
const cls = [];
if (vc & 0x01) cls.push('A');
if (vc & 0x02) cls.push('B');
if (vc & 0x04) cls.push('C');
if (vc & 0x08) cls.push('D');
if (vc & 0x10) cls.push('E');
const ma = fcpInt(value.substr(2, 2)) & 0x3F;
const f = fcpInt(value.substr(4, 2));
const parts = [];
if (cls.length) parts.push('class ' + cls.join('/'));
if (ma) parts.push(ma + ' mA');
if (f === 0xFF) parts.push('no reference frequency');
else if (f) parts.push((f / 10) + ' MHz');
return { name: 'Application power consumption', decoded: parts.join(', ') };
}
case 'A5/82': {
const f = fcpInt(value);
return { name: 'Minimum application clock frequency', decoded: f === 0xFF ? 'none' : (f / 10) + ' MHz' };
}
case 'A5/83': return { name: 'Available memory', decoded: fcpInt(value) + ' bytes' };
case 'A5/84': return { name: 'File details', decoded: (fcpInt(value) & 0x01) ? 'DER coding only' : 'no details' };
case 'A5/85': return { name: 'Reserved file size', decoded: fcpInt(value) + ' bytes' };
case 'A5/86': return { name: 'Maximum file size', decoded: fcpInt(value) + ' bytes' };
case 'A5/87': return { name: 'Supported system commands', decoded: (fcpInt(value) & 0x01) ? 'TERMINAL CAPABILITY' : 'none' };
case 'A5/88': {
const b = fcpInt(value);
const tc = ['standard temperature range', 'temperature class A', 'temperature class B', 'temperature class C'][b & 0x07] || 'RFU';
return { name: 'Specific UICC environmental conditions', decoded: tc + ((b & 0x08) ? ', high humidity' : '') };
}
case 'A5/89': return { name: 'Platform-to-platform CAT secured APDU', decoded: value };
case 'A5/8A': return { name: 'Test configuration state', decoded: (fcpInt(value) & 0x01) ? 'active' : 'inactive' };
case 'A5/8B': return { name: 'Test configuration criterion type', decoded: value };
case 'A5/C0': return { name: 'Special file info', decoded: value };
case 'A5/C1': return { name: 'Filling pattern', decoded: value };
case 'A5/C2': return { name: 'Repeat pattern', decoded: value };
case 'C6/90': return { name: 'PS_DO', decoded: value };
case 'C6/95': return { name: 'Usage qualifier', decoded: value };
case 'C6/83': return { name: 'Key reference', decoded: value };
default: return { name: 'Unknown TLV ' + key, decoded: value };
}
}
// Decode a SELECT response (FCI): accepts the FCP template '62', an FCI template
// '6F' wrapping FCP/FMD, or bare FCP content. Returns { ok, template, items }.
// Partial-aware BER-TLV walk: parses as many complete TLVs as possible and
// reports where/why it stopped (truncated header/length/value, trailing data).
function fcpParseTlvs(hex) {
const tlvs = [];
let i = 0;
let error = '';
while (i < hex.length) {
const remaining = hex.length - i;
if (remaining < 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete tag'; break; }
const tag = hex.substr(i, 2);
let tagBytes = 2;
if ((parseInt(tag, 16) & 0x1F) === 0x1F) {
tagBytes = 4;
if (remaining < 4) { error = 'Truncated at offset ' + (i / 2) + ': incomplete 2-byte tag'; break; }
}
if (remaining < tagBytes + 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete TLV header'; break; }
const lb = parseInt(hex.substr(i + tagBytes, 2), 16);
const nLen = lb < 128 ? 0 : (lb & 0x7F);
if (i + tagBytes + 2 + nLen * 2 > hex.length) { error = 'Truncated length field at offset ' + ((i + tagBytes) / 2); break; }
const r = parseBerLen(hex, i + tagBytes);
if (!(r.len >= 0)) { error = 'Invalid length form at offset ' + ((i + tagBytes) / 2); break; }
const consumed = tagBytes + r.consumed;
const total = consumed + r.len * 2;
if (remaining < total) {
error = 'TLV ' + tag + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(remaining - consumed, 0) / 2) + ' available';
break;
}
tlvs.push({ tag, length: r.len, value: hex.substr(i + consumed, r.len * 2), raw: hex.substr(i, total) });
i += total;
}
return { tlvs, error, consumed: i };
}
function fcpDecode(hex) {
const h = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!h) return { ok: false, template: null, items: [], error: '' };
let template = null;
let body = h;
let error = '';
const first = h.substr(0, 2);
if ((first === '62' || first === '64' || first === '6F') && h.length >= 4) {
const lb = parseInt(h.substr(2, 2), 16);
const nLen = lb < 128 ? 0 : (lb & 0x7F);
if (4 + nLen * 2 > h.length) {
error = 'TLV ' + first + ' has a truncated length field';
body = '';
} else {
const r = parseBerLen(h, 2);
const consumed = 2 + r.consumed;
const avail = h.length - consumed;
template = first;
body = h.substr(consumed, Math.min(r.len * 2, Math.max(avail, 0)));
if (r.len * 2 > avail) error = 'TLV ' + first + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(avail, 0) / 2) + ' available';
else if (consumed + r.len * 2 < h.length) error = 'Trailing data after TLV ' + first;
}
if (template === '6F' && body) {
const inner = fcpParseTlvs(body);
const fcp = inner.tlvs.find(t => t.tag === '62');
if (fcp) {
template = '62';
body = fcp.value;
if (!error && inner.error) error = inner.error;
}
}
}
const parsed = fcpParseTlvs(body);
if (!error && parsed.error) error = parsed.error;
const items = [];
for (const t of parsed.tlvs) {
const d = fcpDo(t.tag, t.value);
items.push({ key: t.tag, tag: t.tag, name: d.name, value: t.value, decoded: d.decoded });
if (t.tag === 'A5' || t.tag === 'C6') {
const sub = fcpParseTlvs(t.value);
if (!error && sub.error) error = 'In ' + t.tag + ': ' + sub.error;
for (const s of sub.tlvs) {
const sd = fcpDo(t.tag + '/' + s.tag, s.value);
items.push({ key: t.tag + '/' + s.tag, tag: s.tag, name: sd.name, value: s.value, decoded: sd.decoded });
}
}
}
return { ok: !error && items.length > 0, template, items, error };
}
function fcpDiffHtml(expectedHex, actualHex, labels) {
const expLabel = (labels && labels.expected) ? labels.expected : t('expected');
const actLabel = (labels && labels.actual) ? labels.actual : t('actual');
const e = fcpDecode(expectedHex);
const a = fcpDecode(actualHex);
const eBad = !!e.error, aBad = !!a.error;
if (!eBad && !aBad && (!e.items.length || !a.items.length)) return '';
const eMap = {}, aMap = {};
e.items.forEach(it => { eMap[it.key] = it; });
a.items.forEach(it => { aMap[it.key] = it; });
const keys = e.items.map(it => it.key);
for (const it of a.items) if (!eMap[it.key]) keys.push(it.key);
let html = '';
if (keys.length) {
html += '<div class="mt-1 text-xs text-gray-500 dark:text-slate-400">' + esc(t('FCI parameters')) + '</div>';
html += '<table class="w-full text-xs border-collapse">';
html += '<thead><tr class="border-b border-gray-200 dark:border-slate-700">' +
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(t('Parameter')) + '</th>' +
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(expLabel) + '</th>' +
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(actLabel) + '</th></tr></thead><tbody>';
for (const k of keys) {
const ev = eMap[k], av = aMap[k];
const ed = ev ? (ev.decoded === null ? '' : (ev.decoded || ev.value)) : '—';
const ad = av ? (av.decoded === null ? '' : (av.decoded || av.value)) : '—';
const eq = !!ev && !!av && ev.decoded === av.decoded && ev.value === av.value;
const color = eq ? 'text-gray-500 dark:text-slate-400' : 'text-red-600 font-medium';
html += '<tr class="border-b border-gray-100 dark:border-slate-800">' +
'<td class="py-0.5 px-1 ' + color + '">' + esc((ev || av).name) + '</td>' +
'<td class="py-0.5 px-1 font-mono break-all ' + color + '">' + esc(ed) + '</td>' +
'<td class="py-0.5 px-1 font-mono break-all ' + color + '">' + esc(ad) + '</td></tr>';
}
html += '</tbody></table>';
}
if (eBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('expected')) + ': ' + esc(e.error) + '</div>';
if (aBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('actual')) + ': ' + esc(a.error) + '</div>';
return html;
}
function profilerFciPreviewItems(hex) {
const d = fcpDecode(hex);
if (!d.items.length && !d.error) return '';
let html = '';
for (const it of d.items) {
if (it.decoded === null) continue;
html += '<div>' + esc(it.name) + ': <span class="text-gray-800 dark:text-slate-200">' + esc(it.decoded || it.value) + '</span></div>';
}
if (d.error) {
html += '<div class="text-red-600">' + esc(t('Decode failed')) + ': ' + esc(d.error) + '</div>';
}
return html;
}
function profilerUpdateFciPreview(i, value) {
const el = document.getElementById('profiler-fci-preview-' + i);
if (el) el.innerHTML = profilerFciPreviewItems(value);
}
function profilerFciInput(i, value) {
profilerUpdateRule(i, 'fciHex', value);
profilerUpdateFciPreview(i, value);
}
function profilerRenderReport(results, labels) {
const expLabel = (labels && labels.expected) ? labels.expected : t('expected');
const actLabel = (labels && labels.actual) ? labels.actual : t('actual');
const labelCls = 'text-xs text-gray-500 dark:text-slate-400 text-right shrink-0 ' + (labels ? 'w-40 break-all' : 'w-24');
let html = '';
for (const r of results) {
const color = r.status === 'pass' ? 'text-emerald-600' : (r.status === 'fail' ? 'text-red-600' : 'text-yellow-600');
const icon = r.status === 'pass' ? '✓' : (r.status === 'fail' ? '✗' : '⚠');
const name = profilerCustomNameForPath(r.path) || r.name;
const title = name
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(r.path) + ')</span>'
: '<span>' + esc(r.path) + '</span>';
const aspects = profilerResultAspects(r);
const marked = aspects.some(a => !a.ok);
const summary = profilerAspectSummary(aspects, marked, t);
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="flex items-center gap-2"><span class="' + color + ' font-bold">' + icon + '</span><span class="font-mono text-sm">' + title + '</span>' +
(summary ? '<span class="text-xs text-gray-500 dark:text-slate-400">: ' + esc(summary) + '</span>' : '') + '</div>';
if (r.error) html += '<div class="text-xs text-yellow-600 mt-1">' + esc(r.error) + '</div>';
for (const c of r.checks) {
if (c.ok) continue;
if (c.label === 'extra file') {
html += '<div class="text-xs text-red-600 mt-1">' + esc(t('extra file')) + '</div>';
} else if (profilerRawDataCheck(c)) {
html += '<div class="mt-1">';
html += '<div class="text-xs text-red-600 font-mono pl-2">' + esc(c.label) + '</div>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="' + labelCls + '">' + esc(expLabel) + '</span>' +
'<input readonly title="' + escHtml(String(c.expected)) + '" value="' + escHtml(String(c.expected)) + '" 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>';
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="' + labelCls + '">' + esc(actLabel) + '</span>' +
'<input readonly title="' + escHtml(String(c.actual)) + '" value="' + escHtml(String(c.actual)) + '" 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>';
if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual), labels);
html += '</div>';
} else {
html += '<div class="text-xs text-red-600 mt-1">' + esc(c.label) + ': ' + esc(expLabel) + ' <b>' + esc(String(c.expected)) + '</b>, ' + esc(actLabel) + ' <b>' + esc(String(c.actual)) + '</b></div>';
}
}
const recFailed = r.recordsMismatch || (r.checks || []).some(c => /^content\.rec\d+$/.test(c.label) && !c.ok);
if (recFailed && r.recordsMatched && r.recordsMatched.length) {
html += '<div class="text-xs text-emerald-600 mt-1">' + esc(t('matching records')) + ': ' + esc(profilerNumRanges(r.recordsMatched)) + '</div>';
}
html += '</div>';
}
return html;
}
profilerLoad();
snapshotsLoad();
// Init sub-tab pills
document.querySelectorAll('.pysim-subtab').forEach(btn => {
btn.addEventListener('click', () => pysimSwitchSubtab(btn.dataset.pysimSub));
});
// ===== i18n =====
const LANG_RU = {
'Generate APDU': 'Сгенерировать APDU',
'Pack into Secured packet': 'Упаковать в Secured packet',
'Convert': 'Конвертировать',
'Decode': 'Декодировать',
'Generate Expanded Script APDU': 'Сгенерировать Expanded Script APDU',
'Generate Secure Packet': 'Сгенерировать Secure Packet',
'Conversion': 'Конвертация',
'Command': 'Команда',
'Response': 'Ответ',
'Result': 'Результат',
'No response yet': 'Ответа пока нет',
'Start with SELECT': 'Начать с SELECT',
'SELECT method': 'Метод SELECT',
'Path (hex)': 'Путь (hex)',
'FID chain (comma-separated)': 'FID цепочка (через запятую)',
'Record #': 'Запись №',
'Selection mode (P2)': 'Режим выборки (P2)',
'Offset (hex)': 'Смещение (hex)',
'Data (hex)': 'Данные (hex)',
'Record size (bytes)': 'Размер записи (байт)',
'Allow P1/P2 editing': 'Разрешить редактирование P1/P2',
'Privileges': 'Привилегии',
'PIN value': 'Значение PIN',
'Old PIN': 'Старый PIN',
'New PIN': 'Новый PIN',
'Application / Instance AID (hex)': 'AID приложения / экземпляра (hex)',
'Load File AID / Package AID (hex)': 'AID Load File / пакета (hex)',
'Executable Module AID / Applet Class AID (hex)': 'AID исполняемого модуля / класса апплета (hex)',
'Security level (P1)': 'Уровень безопасности (P1)',
'Target': 'Цель',
'Current EF (case 1)': 'Текущий EF (case 1)',
'By FID': 'По FID',
'Path from MF': 'Путь от MF',
'Path from current DF': 'Путь от текущего DF',
'Unblock PIN': 'Разблокировка PIN',
'Byte 1': 'Байт 1',
'Byte 2': 'Байт 2',
'SIM/UICC Toolkit parameters': 'Параметры SIM/UICC Toolkit',
'Toolkit mode': 'Режим Toolkit',
'Max services': 'Максимум сервисов',
'Priority': 'Приоритет',
'Timers (max)': 'Таймеры (макс)',
'Menu text length': 'Длина текста меню',
'Menu items': 'Пункты меню',
'First menu position': 'Первая позиция в меню',
'First menu ID (hex)': 'ID первого пункта меню (hex)',
'Last menu position': 'Позиция последнего пункта меню',
'Last menu ID (hex)': 'ID последнего пункта меню (hex)',
'Channels (max)': 'Каналы (макс)',
'Access domain (hex)': 'Домен доступа (hex)',
'Load data (hex)': 'Данные загрузки (hex)',
'Block number': 'Номер блока',
'Encryption': 'Шифрование',
'DELETE mode': 'Режим DELETE',
'GET STATUS mode': 'Режим GET STATUS',
'GET STATUS format': 'Формат GET STATUS',
'SET STATUS type': 'Тип SET STATUS',
'State': 'Состояние',
'GET DATA tag': 'Tag GET DATA',
'Response data (hex)': 'Данные ответа (hex)',
'— Select command —': '— Выберите команду —',
'— select preset —': '— выберите предустановленный —',
'SIM OTA with a Human Face': 'SIM OTA с человеческим лицом',
'Error: specify SELECT parameters': 'Ошибка: укажите параметры SELECT',
'Error: unknown command': 'Ошибка: неизвестная команда',
'Error: specify data': 'Ошибка: укажите данные',
'Error: specify SD AID': 'Ошибка: укажите SD AID',
'Error: specify AID': 'Ошибка: укажите AID',
'Error: specify data (host cryptogram)': 'Ошибка: укажите данные (host cryptogram)',
'Error: specify data (challenge)': 'Ошибка: укажите данные (challenge)',
'Error: enter a value': 'Ошибка: введите значение',
'Error: IMSI must be 15 digits': 'Ошибка: IMSI должен быть 15 цифр',
'Error: specify MCC and MNC': 'Ошибка: укажите MCC и MNC',
'Error: MCC must be 3 digits': 'Ошибка: MCC должен быть 3 цифры',
'Error: MNC must be 2-3 digits': 'Ошибка: MNC должен быть 2-3 цифры',
'Error: enter an even number of hex characters': 'Ошибка: введите чётное количество hex-символов',
'Error: specify APDU': 'Ошибка: укажите APDU',
'KIc key error: ': 'Ошибка KIc key: ',
'KID key error: ': 'Ошибка KID key: ',
'Error: ciphering requires KIc key': 'Ошибка: ciphering requires KIc key',
'Error: MAC requires KID key': 'Ошибка: MAC requires KID key',
'Response parser': 'Парсер ответов',
'C-APDU Parser': 'Разбор C-APDU',
'Cards': 'Карты',
'Card reader': 'Картридер',
'Card presets are stored locally in your browser. JSON export/import buttons are below the card list.': 'Данные карт хранятся локально в браузере. Кнопки экспорта/импорта JSON — под списком карт.',
'Add': 'Добавить',
'Export as JSON': 'Экспорт в JSON',
'Export to file': 'Экспорт в файл',
'Import from file': 'Импорт из файла',
'Paste & import': 'Вставить и импортировать',
'Import JSON from clipboard': 'Импорт JSON из буфера',
'Name': 'Имя',
'ICCID': 'ICCID',
'Cntr': 'Сч',
'Remove': 'Удалить',
'Delete': 'Удалить',
'Delete All': 'Удалить все',
'ISD (Issuer Security Domain)': 'ISD (домен безопасности эмитента)',
'Applications / Applet Instances': 'Приложения / экземпляры апплетов',
'Executable Load Files (ELFs) / Packages': 'Исполняемые файлы загрузки (ELF) / пакеты',
'Application / Instance AID:': 'AID приложения / экземпляра:',
'Load File AID / Package AID:': 'AID Load File / пакета:',
'Executable Module AIDs / Applet Class AIDs:': 'AID исполняемых модулей / классов апплетов:',
'No cards defined.': 'Карты не заданы.',
'Card presets': 'Выбор карты',
'Card preset': 'Пресет карты',
'— Select card —': '— Выберите карту —',
'Operation': 'Операция',
'Explore Card (all GP data)': 'Обзор карты (все данные GP)',
'Install Package (.cap file)': 'Установка пакета (.cap файл)',
'Server URL': 'URL сервера',
'Connect': 'Подключиться',
'To access local card reader, you need pysim-otaman-server running locally.': 'Для доступа к локальному картридеру необходим запущенный pysim-otaman-server.',
'To connect, install': 'Для подключения установите',
'and start it.': 'и запустите его.',
'Refer to pysim-otaman-server README files for details.': 'Подробности — в README файлах pysim-otaman-server.',
'Click': 'Нажмите кнопку',
'when pysim-otaman-server is ready': 'когда pysim-otaman-server будет готов',
'Equip card': 'Подключить карту',
'Check status': 'Проверить статус',
'Reset card': 'Сброс карты',
'Disconnect from server': 'Отключиться от сервера',
'File manager': 'Файловый менеджер',
'Custom files': 'Пользовательские файлы',
'pySim command line': 'Командная строка pySim',
'Raw APDU': 'Отправка APDU',
'Read': 'Прочитать',
'Save': 'Сохранить',
'Raw': 'Данные как на карте',
'Decoded': 'Декодированные данные',
'Edit': 'Редактировать',
'Execute': 'Выполнить',
'Send': 'Отправить',
'Add known file paths and aliases to the file tree.': 'Добавьте пути и псевдонимы файлов в дерево файлов.',
'File:': 'Файл:',
'Cancel': 'Отмена',
'Timeout': 'Таймаут',
'OK': 'OK',
'Exit': 'Выход',
'← Back': '← Назад',
'Connecting...': 'Подключение...',
'Checking card...': 'Проверка карты...',
'Connected': 'Подключено',
'Connection failed:': 'Ошибка подключения:',
'pysim-blocked-hint': 'Браузер, вероятно, заблокировал доступ к локальному серверу карт. Разрешите доступ к локальной сети для этого сайта (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить). Также проверьте, что pysim-otaman-server запущен по адресу {url}.',
'pysim-unreachable-hint': 'Не удалось подключиться к серверу карт. Проверьте, что pysim-otaman-server запущен и адрес указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить).',
'No card detected. Insert card and click Equip card button': 'Карта не обнаружена. Вставьте карту и нажмите Подключить карту',
'Card inserted — press Equip': 'Карта вставлена — нажмите «Подключить карту»',
'Card inserted — initializing...': 'Карта вставлена — инициализация...',
'Checking...': 'Проверка...',
'Resetting...': 'Сброс...',
'Equipping...': 'Подключение...',
'Sending OTA...': 'Отправка OTA...',
'Verifying against pySim reference...': 'Сверка с эталоном pySim...',
'Verify vs pySim': 'Проверить в pySim',
'Send to Card': 'Отправить на карту',
'Phone simulator': 'Симулятор телефона',
'Sort:': 'Сортировка:',
'Name': 'Имя',
'No server connection': 'Нет соединения с сервером',
'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
'Card equipped': 'Карта подключена',
'Connect to server': 'Подключитесь к серверу',
'Insert and equip a card': 'Вставьте и подключите карту',
'Files': 'Файлов',
'Records': 'Записей',
'Scan time': 'Время сканирования',
'scanned in': 'за',
'sec': 'сек',
'No timing data': 'Нет данных о времени',
'min': 'мин',
'avg': 'сред',
'max': 'макс',
'Phone': 'Телефон',
'TR Config': 'Конфигурация TR',
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
'No events configured.': 'Нет настроенных событий.',
'Fetched proactive commands:': 'Извлечённые проактивные команды:',
'No commands logged yet.': 'Команды пока не регистрировались.',
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
'Event sent.': 'Событие отправлено.',
'Event data sent.': 'Данные события отправлены.',
'Sending...': 'Отправка...',
'Invalid': 'Некорректное значение',
'Service Status': 'Статус сервиса',
'Normal service': 'Обычный сервис',
'Limited service': 'Ограниченный сервис',
'No service': 'Нет сервиса',
'Language code': 'Код языка',
'Registration type': 'Тип регистрации',
'Access Technology': 'Технология доступа',
'Rejection cause code': 'Код причины отклонения',
'Extended EMM cause (optional)': 'Расширенная EMM причина (опционально)',
'Status': 'Статус',
'Card reader removed': 'Картридер извлечён',
'Card reader inserted': 'Картридер вставлен',
'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION',
'Send STATUS': 'Отправить STATUS',
'help': 'справка',
'STATUS and Polling': 'STATUS и опрос',
'STK menu': 'Меню STK',
'No menu set by the card': 'Меню не задано картой',
'Polling': 'Опрос',
'STATUS...': 'STATUS...',
'Saving...': 'Сохранение...',
'Saved': 'Сохранено',
'Save': 'Сохранить',
'Profiler': 'Профайлер',
'New profile': 'Новый профиль',
'Profile from card': 'Профиль с карты',
'Import profile': 'Импорт профиля',
'Profiles': 'Профили',
'Card snapshots': 'Снимки карт',
'Compare snapshots': 'Сравнить снимки',
'Master snapshot': 'Эталонный снимок',
'Snapshot to check': 'Снимок для проверки',
'Compare': 'Сравнить',
'extra file': 'лишний файл',
'Check card snapshot': 'Проверить снимок карты',
'Select card snapshot': 'Выберите снимок карты',
'Content not captured in snapshot': 'Содержимое не захвачено в снимок',
'not captured in snapshot': 'не захвачено в снимок',
'New snapshot': 'Новый снимок',
'Import snapshot': 'Импорт снимка',
'Snapshot name': 'Имя снимка',
'Delete snapshot': 'Удалить снимок',
'No snapshots defined.': 'Снимки не заданы.',
'No files captured.': 'Файлы не захвачены.',
'Please enter a snapshot name': 'Введите имя снимка',
'Open': 'Открыть',
'Contents': 'Содержимое',
'Not captured': 'Не прочитано',
'Back to list': 'Назад к списку',
'Add rule': 'Добавить правило',
'Scan': 'Сканировать',
'Discovering...': 'Поиск файлов...',
'files': 'файлов',
'Ignore contents of files:': 'Игнорировать содержимое файлов:',
'All': 'Все',
'Match first 4 bytes for': 'Сравнивать первые 4 байта для',
'FCP/FCI check': 'Проверка FCP/FCI',
'Filetype only (FCP)': 'Только тип файла (FCP)',
'Filetype + size (FCP)': 'Тип файла + размер (FCP)',
'Exact FCI': 'Полный FCI',
'FCI hex (raw SELECT response)': 'FCI hex (сырой ответ SELECT)',
'FCI parameters': 'Параметры FCI',
'Parameter': 'Параметр',
'Decoded FCI': 'Декодированный FCI',
'Decode failed': 'Ошибка декодирования',
'No profiles defined.': 'Профили не заданы.',
'rules': 'правил',
'Check card': 'Проверить карту',
'Export': 'Экспорт',
'Profile name': 'Имя профиля',
'Please enter a profile name': 'Введите имя профиля',
'Scanning...': 'Сканирование...',
'Delete profile': 'Удалить профиль',
'No rules defined.': 'Правила не заданы.',
'Path': 'Путь',
'File type': 'Тип файла',
'Size': 'Размер',
'Record length': 'Длина записи',
'Record count': 'Кол-во записей',
'Check contents': 'Проверить содержимое',
'filetype': 'тип файла',
'size': 'размер',
'records': 'записи',
'contents': 'содержимое',
'filetype and size': 'тип файла и размер',
'filetype and records': 'тип файла и записи',
'matching records': 'совпадающие записи',
'None': 'Нет',
'Exact': 'Точное',
'Mask': 'Маска',
'Checking': 'Проверка',
'passed': 'пройдено',
'failed': 'не пройдено',
'errors': 'ошибок',
'Only mismatches': 'Только расхождения',
'No mismatches': 'Расхождений нет',
'expected': 'ожидалось',
'actual': 'фактически',
'HTTP OTA': 'HTTP OTA',
'Trigger (Push SMS)': 'Триггер (Push SMS)',
'Store (SD admin params)': 'Store (параметры SD)',
'SD params tag': 'Tag параметров SD',
"Connection parameters ('84')": "Параметры соединения ('84')",
'+ TLV': '+ TLV',
'+ Device Identities': '+ Device Identities',
'+ Alpha (empty)': '+ Alpha (пустой)',
'+ Bearer (GPRS)': '+ Bearer (GPRS)',
"Security parameters ('85')": "Параметры безопасности ('85')",
'PSK Identity (text)': 'PSK Identity (текст)',
"Retry policy ('86')": "Политика повтора ('86')",
'Retry counter (B0,00=unlimited)': 'Счётчик повторов (B0,00=бесконечно)',
'Hours': 'Часы',
'Minutes': 'Минуты',
'Seconds': 'Секунды',
"Report failure (vendor-specific TLV)": "Отчёт о сбое (специфичный TLV)",
"HTTP POST ('89', 8A=host,8B=agent,8C=URI)": "HTTP POST ('89', 8A=host,8B=agent,8C=URI)",
'Host (text→hex)': 'Хост (текст→hex)',
"HTTP agent ID (text, X-Admin-From)": "ID HTTP агента (текст, X-Admin-From)",
'URI (text→hex)': 'URI (текст→hex)',
"Wrapped in Command Scripting template ('AA')": "Обёртка в Command Scripting template ('AA')",
};
let currentLang = 'en';
function t(text) {
if (currentLang === 'ru' && LANG_RU[text]) return LANG_RU[text];
return text;
}
function toggleLang() {
currentLang = currentLang === 'en' ? 'ru' : 'en';
localStorage.setItem('lang', currentLang);
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
translatePage();
refreshDynamicI18n();
updateHelpLink();
}
let currentHelpAnchor = 'sim-rfm';
let pysimHelpAnchor = 'file-manager';
function updateHelpLink() {
const link = document.getElementById('help-link');
if (link) link.href = (currentLang === 'ru' ? 'help-ru.html' : 'help.html') + '#' + currentHelpAnchor;
}
function setHelpAnchor(anchor) {
currentHelpAnchor = anchor;
updateHelpLink();
}
function translatePage() {
document.querySelectorAll('[data-l10n]').forEach(el => {
const key = el.getAttribute('data-l10n');
el.textContent = t(key);
});
const slogan = document.getElementById('slogan');
if (slogan) slogan.textContent = t('SIM OTA with a Human Face');
}
function isViewVisible(id) {
const el = document.getElementById(id);
return !!el && !el.classList.contains('hidden');
}
// Dynamic views build their labels with t() at render time; re-render the
// visible ones after a language switch so they don't stay in the old language.
function refreshDynamicI18n() {
if (isViewVisible('tab-profiler')) {
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
else if (profilerView === 'editor') profilerRenderEditor();
else if (profilerView === 'results') profilerRenderResultsView();
else if (profilerView === 'snapshot') profilerRenderSnapshotData();
}
if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions();
pysimUpdateStateIndicator();
if (isViewVisible('scp80-sub-cards')) cardsRender();
if (isViewVisible('tab-phone')) {
if (isViewVisible('phone-sub-phone')) {
pysimEventsRender();
pysimProactiveLogRender();
}
if (isViewVisible('phone-sub-tr')) pysimPliRender();
}
}
// Init language
const userLang = (navigator.language || '').split('-')[0];
currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en');
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
updateHelpLink();
cardsLoad();
pysimStartAvailabilityProbe();
pysimFsSetSort(pysimFsSort);
if (localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
function toggleTheme() {
const html = document.documentElement;
html.classList.toggle('dark');
localStorage.setItem('theme', html.classList.contains('dark') ? 'dark' : 'light');
document.getElementById('theme-btn').textContent = html.classList.contains('dark') ? '☀️' : '🌙';
}
document.getElementById('theme-btn').textContent = document.documentElement.classList.contains('dark') ? '☀️' : '🌙';
updateConvFields();
updateSp();
updateSpKic();
updateSpKid();
chainInit('chain-sim');
chainInit('chain-usim');
chainInit('chain-ram');
translatePage();
// PWA
let deferredPrompt;
const installBtn = document.getElementById('install-btn');
if (!window.matchMedia('(display-mode: standalone)').matches) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredPrompt = e;
installBtn.style.display = '';
});
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') deferredPrompt = null;
});
window.addEventListener('appinstalled', () => { installBtn.style.display = 'none'; });
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js');
}
</script>
</body>
</html>