terminal profile at runtime + canonical custom-file paths (v2.2.3)

TERMINAL PROFILE:
- GET /api/terminal-profile returns the profile in effect + the CLI default;
  POST /api/terminal-profile validates ({profile}, hex, even, 1-255 bytes),
  stores it in memory and re-sends it, resetting the STK session like
  /api/rescue (the shared _resend_terminal_profile helper; rescue now
  delegates to it). __main__ keeps server.cli_terminal_profile.
- Phone tab: a TERMINAL PROFILE block next to STATUS and Polling with the
  current hex/byte count, Send (re-send) and Configure. The Configure
  dialog has a device-model preset selector, a hex field and a per-bit
  form generated from a 264-entry table for TS 102 223 V18.3.0 5.2 bytes
  1-33 (pySim's table as scaffold, later bytes/3GPP bits added from the
  spec; beyond the table generic RFU labels). Form <-> hex sync both
  ways, hex authoritative, bits preserved. Apply posts the new value.
- Presets: Xiaomi Mi A1 (project default), Quectel GSM module example.
  In-memory only, no persistence.

Custom files:
- Canonical paths rooted at MF / ADF.USIM / ADF.ISIM; entries are
  {path, name, kind}. The editor now uses root + parent-DF selector +
  4-hex FID + alias, requires the parent DF to be defined first, rejects
  duplicates, rewrites descendants when a DF's path changes and cascades
  deletes after a confirmation.
- Legacy forms are normalized on load/import (3F00/... -> MF/..., relative
  a153/4954 resolved against the custom DFs); unresolvable entries are
  dropped and reported in the list.
- Tree injection matches by exact parent path via the new pysimFsNodePath
  (same-FID DFs under different parents no longer collide);
  profilerCustomNameForPath uses the same normalization.

SW cache otaman-v167; help EN/RU + docs/api.md + AGENTS updated.
Tests: 236 Python + 371 frontend.
This commit is contained in:
2026-09-16 20:46:40 +03:00
parent 20fbfd99a5
commit aa33d25466
15 changed files with 1057 additions and 180 deletions
+6 -3
View File
@@ -409,7 +409,7 @@
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве &laquo;Файловый менеджер&raquo;. В каждой строке есть кнопки <strong>&laquo;Редактировать&raquo;</strong> (загружает запись в форму — кнопка становится <strong>&laquo;Сохранить&raquo;</strong>, появляется <strong>&laquo;Отмена&raquo;</strong>) и <strong>&laquo;Удалить&raquo;</strong> (без подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim. Пользовательский файл всегда имеет полный путь от <strong>MF</strong> или ADF (<strong>ADF.USIM</strong>, <strong>ADF.ISIM</strong>): выберите корень, <strong>родительский DF</strong> (сам корень или DF, уже добавленный здесь &mdash; родитель должен существовать до добавления его файлов), 4-hex <strong>FID</strong> и псевдоним (<code class="font-mono text-sm">EF.&hellip;</code>/<code class="font-mono text-sm">DF.&hellip;</code>; префикс определяет, EF это или DF). Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Добавленные файлы появляются в дереве &laquo;Файловый менеджер&raquo;; в каждой строке есть <strong>&laquo;Редактировать&raquo;</strong> (путь + псевдоним; изменение пути DF перепривязывает дочерние записи &mdash; кнопка становится <strong>&laquo;Сохранить&raquo;</strong>, <strong>&laquo;Отмена&raquo;</strong> отменяет) и <strong>&laquo;Удалить&raquo;</strong> (удаление DF удаляет и его дочерние записи после подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен &mdash; <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> / <strong>Импорт</strong>; старые относительные пути (например, <code class="font-mono text-sm">a153/4954</code>) разрешаются при загрузке, неразрешимые отбрасываются и отмечаются в списке.</p>
<section class="mb-10">
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Симулятор телефона</h2>
@@ -431,10 +431,13 @@
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Журнал проактивных команд</h3>
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Раскрытие строки показывает декодированную команду: текст и пункты DISPLAY TEXT / SELECT ITEM / SET UP MENU, параметры BIP-каналов, действия TIMER MANAGEMENT, названия квалификаторов PROVIDE LOCAL INFORMATION (все стандартные, включая ESN 07, MEID 0B и Supported RATs 1A) и SEND SHORT MESSAGE — разбирается SMS TPDU (тип, TP-MR, TP-DA, TP-PID, TP-DCS, срок действия, TP-UDL), а пользовательские данные показываются как текст для текстовых кодировок (GSM&nbsp;7-бит, UCS2, 8-бит) или как secured-пакет (TS&nbsp;31.115) при TP-PID&nbsp;=&nbsp;<code class="font-mono text-sm">7F</code> (SIM data download); заголовки конкатенации UDH декодируются. Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 Опрос STATUS</h3>
<h3 id="terminal-profile" class="text-lg font-medium mb-2">6.4 TERMINAL PROFILE</h3>
<p class="text-sm mb-3">Блок <strong>TERMINAL PROFILE</strong> (рядом с «STATUS и опрос») показывает действующий профиль (hex + число байт) и кнопки <strong>«Отправить»</strong> (повторная отправка, как «Спасение») и <strong>«Настроить»</strong>. В диалоге настройки: селектор пресетов (модели устройств, например профиль BIP-совместимого аппарата из этого проекта), поле hex и форма с флажком на каждый бит профиля по <strong>TS 102 223 §5.2</strong> (байты 1&ndash;33; далее — обобщённые подписи); переключение бита обновляет hex, а правка hex перерисовывает форму &mdash; поле hex основное, неизвестные байты и биты сохраняются. Кнопка <strong>«Применить»</strong> отправляет новое значение на сервер (и далее на карту), сбрасывая STK-сессию как «Спасение»; изменение хранится только в памяти (значение <code class="font-mono text-sm">--terminal-profile</code> — стартовое по умолчанию).</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.5 Опрос STATUS</h3>
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;с, по умолчанию 30&nbsp;с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 &laquo;Конфигурация TR&raquo; &mdash; данные ответа PROVIDE LOCAL INFORMATION</h3>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.6 &laquo;Конфигурация TR&raquo; &mdash; данные ответа PROVIDE LOCAL INFORMATION</h3>
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 &sect;8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
+6 -3
View File
@@ -409,7 +409,7 @@
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
<p class="text-sm mb-3">Add files that pySim&rsquo;s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. Each row has <strong>Edit</strong> (reloads the entry into the form — the button becomes <strong>Save</strong> and a <strong>Cancel</strong> button appears) and <strong>Delete</strong> (no confirmation) buttons. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste &amp; import</strong> / <strong>Import JSON from clipboard</strong>.</p>
<p class="text-sm mb-3">Add files that pySim&rsquo;s model does not cover. A custom file always has a full path rooted at <strong>MF</strong> or an ADF (<strong>ADF.USIM</strong>, <strong>ADF.ISIM</strong>): pick the root, the <strong>parent DF</strong> (the root itself, or a DF that was already added here &mdash; a parent must exist before its files can be added), the 4-hex <strong>FID</strong> and an alias (<code class="font-mono text-sm">EF.&hellip;</code>/<code class="font-mono text-sm">DF.&hellip;</code>; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has <strong>Edit</strong> (path + alias; changing a DF&rsquo;s path re-points its child entries &mdash; the button becomes <strong>Save</strong>, <strong>Cancel</strong> aborts) and <strong>Delete</strong> (deleting a DF also deletes its child entries after a confirmation). The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> / <strong>Import</strong>; legacy relative paths (e.g. <code class="font-mono text-sm">a153/4954</code>) are resolved on load, unresolvable ones are dropped and reported in the list.</p>
<section class="mb-10">
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Phone simulator</h2>
@@ -431,10 +431,13 @@
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Proactive command log</h3>
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Expanding a row shows the decoded command: DISPLAY TEXT / SELECT ITEM / SET UP MENU text and items, BIP channel parameters, TIMER MANAGEMENT actions, PROVIDE LOCAL INFORMATION qualifier names (all standard qualifiers, including ESN 07, MEID 0B and Supported RATs 1A), and SEND SHORT MESSAGE &mdash; the SMS TPDU is parsed (type, TP-MR, TP-DA, TP-PID, TP-DCS, validity period, TP-UDL) with the user data shown as text for text codings (GSM&nbsp;7-bit, UCS2, 8-bit) or as a secured packet (TS&nbsp;31.115) when TP-PID&nbsp;=&nbsp;<code class="font-mono text-sm">7F</code> (SIM data download); UDH concatenation headers are decoded. Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 STATUS polling</h3>
<h3 id="terminal-profile" class="text-lg font-medium mb-2">6.4 TERMINAL PROFILE</h3>
<p class="text-sm mb-3">The <strong>TERMINAL PROFILE</strong> block (next to STATUS and Polling) shows the profile currently in effect (hex + byte count) and offers <strong>Send</strong> (re-sends it, like Rescue) and <strong>Configure</strong>. The Configure dialog has a preset selector (device models, e.g. this project's BIP-capable handset profile), a hex field and a form with one checkbox per profile bit decoded per <strong>TS 102 223 §5.2</strong> (bytes 1&ndash;33; later bytes get generic labels); toggling a bit updates the hex and editing the hex re-renders the form &mdash; the hex field is authoritative and unknown bytes/bits are preserved. <strong>Apply</strong> sends the new value to the server (and on to the card), resetting the STK session like Rescue; the change is in-memory only (the <code class="font-mono text-sm">--terminal-profile</code> CLI value is the startup default).</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.5 STATUS polling</h3>
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;s, default 30&nbsp;s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 TR Config &mdash; PROVIDE LOCAL INFORMATION response data</h3>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.6 TR Config &mdash; PROVIDE LOCAL INFORMATION response data</h3>
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 &sect;8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
+536 -62
View File
@@ -18,7 +18,7 @@
<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.2.1</span></h1>
<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.2.3</span></h1>
<div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -737,12 +737,27 @@
<div id="profiler-list-custom" class="hidden">
<div class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400" data-l10n="Custom files are added in file manager tree and included in card scan.">Custom files are added in file manager tree and included in card scan.</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')pysimCustomSubmit()">
<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')pysimCustomSubmit()">
<button id="pysim-cf-add-btn" onclick="pysimCustomSubmit()" 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>
<button id="pysim-cf-cancel-btn" onclick="pysimCustomEditCancel()" class="hidden px-3 py-2.5 bg-gray-600 text-white text-sm font-medium rounded hover:bg-gray-700 whitespace-nowrap" data-l10n="Cancel">Cancel</button>
<div class="flex flex-wrap gap-2 items-end mb-2">
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Root (MF / ADF)">Root (MF / ADF)</label>
<select id="pysim-cf-root" onchange="pysimCustomRootChanged()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-2 dark:bg-slate-800"></select>
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Parent DF">Parent DF</label>
<select id="pysim-cf-parent" class="font-mono w-56 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-2 dark:bg-slate-800"></select>
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="FID (4 hex)">FID (4 hex)</label>
<input id="pysim-cf-fid" maxlength="4" class="font-mono w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800" placeholder="6F46" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
</div>
<div class="flex-1 min-w-[180px]">
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Alias (EF. / DF.)">Alias (EF. / DF.)</label>
<input id="pysim-cf-name" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
</div>
<button id="pysim-cf-add-btn" onclick="pysimCustomSubmit()" class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
<button id="pysim-cf-cancel-btn" onclick="pysimCustomEditCancel()" class="hidden px-3 py-2 bg-gray-600 text-white text-sm font-medium rounded hover:bg-gray-700 whitespace-nowrap" data-l10n="Cancel">Cancel</button>
</div>
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Full path from MF or an ADF; the parent DF must be defined here first.">Full path from MF or an ADF; the parent DF must be defined here first.</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">
@@ -909,6 +924,15 @@
<span id="pli-poll-stat" class="text-xs text-gray-400"></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="TERMINAL PROFILE">TERMINAL PROFILE</div>
<div id="tp-current" class="text-xs font-mono text-gray-600 dark:text-slate-400 break-all max-w-xs mb-2" data-l10n="Loading...">Loading...</div>
<div class="flex items-center gap-2">
<button id="tp-send-btn" data-needs="card" onclick="tpSend()" 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">Send</button>
<button id="tp-configure-btn" data-needs="server" onclick="tpOpen()" 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="Configure">Configure</button>
<span id="tp-status" class="text-xs text-gray-400 dark:text-slate-500"></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>
@@ -1128,6 +1152,29 @@
</div>
</div>
<div id="tp-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-3xl 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="TERMINAL PROFILE">TERMINAL PROFILE</h3>
<div class="flex flex-wrap items-end gap-3 mb-3">
<div>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Preset (device model)">Preset (device model)</label>
<select id="tp-preset" onchange="tpPresetChanged()" class="w-72 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></select>
</div>
<div class="flex-1 min-w-[280px]">
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile (hex)">Profile (hex)</label>
<input id="tp-hex" oninput="tpHexInput()" spellcheck="false" 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">
</div>
</div>
<div id="tp-error" class="hidden text-xs text-red-500 mb-2"></div>
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Bits are decoded per TS 102 223 5.2; the hex field is authoritative and bytes/bits outside the table are preserved as-is.">Bits are decoded per TS 102 223 5.2; the hex field is authoritative and bytes/bits outside the table are preserved as-is.</div>
<div id="tp-form" class="max-h-[45vh] overflow-auto border border-gray-200 dark:border-slate-700 rounded p-2 text-xs font-mono text-gray-600 dark:text-slate-400"></div>
<div class="flex gap-2 justify-end mt-3">
<button onclick="tpClose()" class="px-3 py-1.5 text-sm 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="tp-apply-btn" onclick="tpApply()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Apply">Apply</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>
@@ -1276,6 +1323,7 @@ function phoneSwitchSubtab(name) {
pysimEventsRender();
pysimProactiveLogRender();
pysimPollStatusInit();
tpRefresh();
}
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict' }[name] || 'stk-menu');
}
@@ -6245,25 +6293,43 @@ function getParentPath(node) {
return segs;
}
// Canonical path of a tree node from MF or an ADF root, matching the custom
// file paths ("MF/7F20/6F46", "ADF.USIM/6F07").
function pysimFsNodePath(node) {
const segs = [];
let n = node;
while (n) {
if (n.name === 'MF') { segs.unshift('MF'); break; }
if (n.name && n.name.startsWith('ADF.')) { segs.unshift(n.name); break; }
segs.unshift(((n.fid || n.name) || '').toUpperCase());
n = n.parent;
}
return segs.join('/');
}
function pysimCustomInject(node) {
if (!node || !node.fid) return;
const pfid = node.fid.toLowerCase();
if (!node) return;
const basePath = pysimFsNodePath(node);
for (const c of pysimCustomFiles) {
if (c.parentFid.toLowerCase() !== pfid) continue;
const existing = (node.children || []).find(ch => ch.fid?.toLowerCase() === c.fid.toLowerCase());
if (pysimCustomParent(c) !== basePath) continue;
const fid = pysimCustomFid(c);
const existing = (node.children || []).find(ch => (ch.fid || '').toUpperCase() === fid);
if (existing) {
existing.name = c.name;
existing.kind = c.kind;
existing.custom = true;
existing.customPath = c.path;
} else {
node.children = node.children || [];
node.children.push({
name: c.name,
fid: c.fid,
isDir: c.name.startsWith('DF.') || c.name.startsWith('ADF.'),
fid: fid,
isDir: c.kind === 'df',
children: null,
expanded: false,
exists: null,
custom: true,
customPath: c.path,
parent: node,
});
}
@@ -6897,6 +6963,244 @@ const PLI_QUALIFIERS = [
{code:'1A',name:'Supported Radio Access Technologies'},
];
// TERMINAL PROFILE bit allocation (TS 102 223 V18.3.0 5.2 + 3GPP
// annex bits; bytes 1..33). Index 0 = bit 1 = first byte b8; bit n is
// byte floor(n/8)+1, mask 0x80 >> (n%8). Bytes beyond 33 fall back to
// generic labels in the UI.
const TP_BITS = [
'Profile download', 'SMS-PP data download', 'Cell Broadcast data download', 'Menu selection',
'SMS-PP data download', 'Timer expiration', 'USSD string DO support in CC by USIM', 'Call Control by NAA',
'Command result', 'Call Control by NAA', 'Call Control by NAA', 'MO short message control support',
'Call Control by NAA', 'UCS2 Entry supported', 'UCS2 Display supported', 'Display Text',
'Proactive UICC: DISPLAY TEXT', 'Proactive UICC: GET INKEY', 'Proactive UICC: GET INPUT', 'Proactive UICC: MORE TIME',
'Proactive UICC: PLAY TONE', 'Proactive UICC: POLL INTERVAL', 'Proactive UICC: POLLING OFF', 'Proactive UICC: REFRESH',
'Proactive UICC: SELECT ITEM', 'Proactive UICC: SEND SHORT MESSAGE with 3GPP-SMS-TPDU', 'Proactive UICC: SEND SS', 'Proactive UICC: SEND USSD',
'Proactive UICC: SET UP CALL', 'Proactive UICC: SET UP MENU', 'Proactive UICC: PROVIDE LOCAL INFORMATION (MCC, MNC, LAC, Cell ID & IMEI)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (NMR)',
'Proactive UICC: SET UP EVENT LIST', 'Event: MT call', 'Event: Call connected', 'Event: Call disconnected',
'Event: Location status', 'Event: User activity', 'Event: Idle screen available', 'Event: Card reader status',
'Event: Language selection', 'Event: Browser Termination', 'Event: Data available', 'Event: Channel status',
'Event: Access Technology Change', 'Event: Display parameters changed', 'Event: Local Connection', 'Event: Network Search Mode Change',
'Proactive UICC: POWER ON CARD', 'Proactive UICC: POWER OFF CARD', 'Proactive UICC: PERFORM CARD RESET', 'Proactive UICC: GET READER STATUS (Card reader status)',
'Proactive UICC: GET READER STATUS (Card reader identifier)', 'RFU', 'RFU', 'RFU',
'Proactive UICC: TIMER MANAGEMENT (start, stop)', 'Proactive UICC: TIMER MANAGEMENT (get current value)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (date, time and time zone)', 'GET INKEY',
'SET UP IDLE MODE TEXT', 'RUN AT COMMAND', 'SETUP CALL', 'Call Control by NAA',
'DISPLAY TEXT', 'SEND DTMF command', 'Proactive UICC: PROVIDE LOCAL INFORMATION (NMR)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (language)',
'Proactive UICC: PROVIDE LOCAL INFORMATION (Timing Advance)', 'Proactive UICC: LANGUAGE NOTIFICATION', 'Proactive UICC: LAUNCH BROWSER', 'Proactive UICC: PROVIDE LOCAL INFORMATION (Access Technology)',
'Soft keys support for SELECT ITEM', 'Soft keys support for SET UP MENU ITEM', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'Proactive UICC: OPEN CHANNEL', 'Proactive UICC: CLOSE CHANNEL', 'Proactive UICC: RECEIVE DATA', 'Proactive UICC: SEND DATA',
'Proactive UICC: GET CHANNEL STATUS', 'Proactive UICC: SERVICE SEARCH', 'Proactive UICC: GET SERVICE INFORMATION', 'Proactive UICC: DECLARE SERVICE',
'BIP supported Bearer: CSD', 'BIP supported Bearer: GPRS', 'BIP supported Bearer: Bluetooth', 'BIP supported Bearer: IrDA',
'BIP supported Bearer: RS232', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'BIP: TCP, UICC in client mode, remote connection', 'BIP: UDP, UICC in client mode, remote connection', 'BIP: TCP, UICC in server mode', 'BIP: TCP, UICC in client mode, local connection',
'BIP: UDP, UICC in client mode, local connection', 'BIP: direct communication channel', 'reserved by 3GPP (E-UTRAN)', 'reserved by 3GPP (HSDPA)',
'Proactive UICC: DISPLAY TEXT (variable timeout)', 'Proactive UICC: GET INKEY (help while waiting for immediate response or variable timeout)', 'USB (BIP supported bearer)', 'Proactive UICC: GET INKEY (variable timeout)',
'Proactive UICC: PROVIDE LOCAL INFORMATION (ESN)', 'reserved by 3GPP (Call control on GPRS)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (IMEISV)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (Search mode change)',
'reserved by TIA/EIA-136-270 (protocol version support)', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'reserved by CCAT', 'reserved by CCAT', 'reserved by CCAT', 'reserved by CCAT',
'reserved by CCAT', 'reserved by CCAT', 'reserved by CCAT', 'reserved by CCAT',
'WML', 'XHTML', 'HTML', 'CHTML',
'RFU', 'RFU', 'RFU', 'RFU',
'reserved by 3GPP (UTRAN PS with extended parameters)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (battery state)', 'Proactive UICC: PLAY TONE (melody/themed tones)', 'Multi-media calls in SET UP CALL',
'reserved by 3GPP (Toolkit-initiated GBA)', 'Proactive UICC: RETRIEVE MULTIMEDIA MESSAGE', 'Proactive UICC: SUBMIT MULTIMEDIA MESSAGE', 'Proactive UICC: DISPLAY MULTIMEDIA MESSAGE',
'Proactive UICC: SET FRAMES', 'Proactive UICC: GET FRAMES STATUS', 'MMS notification download', 'Alpha identifier in REFRESH',
'reserved by 3GPP (Geographical location reporting)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (MEID)', 'reserved by 3GPP (PLI NMR for multiple access technologies)', 'reserved by 3GPP/3GPP2 (USSD data download and application mode)',
'Maximum number of frames supported (value field)', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'Event: Browsing status', 'Event: MMS transfer status', 'Event: Frame information changed', 'reserved by 3GPP (Event: I-WLAN access status)',
'reserved by 3GPP (Event: Network rejection)', 'Event: HCI connectivity', 'reserved by 3GPP (E-UTRAN in Event Network Rejection)', 'Multiple access technologies supported (Event Access Technology Change / PLI)',
'reserved by 3GPP (Event: CSG cell selection)', 'Event: Contactless state request', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'RFU', 'RFU', 'RFU', 'RFU',
'Text alignment left', 'Text alignment centre', 'Text alignment right', 'Font size normal',
'Font size large', 'Font size small', 'RFU', 'RFU',
'Style normal', 'Style bold', 'Style italic', 'Style underlined',
'Style strikethrough', 'Style text foreground colour', 'Style text background colour', 'RFU',
'reserved by 3GPP (I-WLAN bearer)', 'reserved by 3GPP (PLI WSID of the current I-WLAN connection)', 'TERMINAL APPLICATIONS', 'reserved by 3GPP (Steering of Roaming REFRESH)',
'RFU', 'RFU', 'RFU', 'RFU',
'Proactive UICC: Contactless State Changed', 'reserved by 3GPP (CSG cell discovery)', 'Confirmation parameters for OPEN CHANNEL in Terminal Server mode', 'reserved by 3GPP (Communication control for IMS)',
'CAT over the modem interface', 'reserved by 3GPP (Incoming IMS data event)', 'reserved by 3GPP (IMS registration event)', 'Proactive UICC: Profile/Envelope/Command Container, ENCAPSULATED SESSION CONTROL',
'reserved by 3GPP (IMS as BIP bearer)', 'reserved by 3GPP (PLI H(e)NB IP address)', 'reserved by 3GPP (PLI H(e)NB surrounding macrocells)', 'Launch parameters for OPEN CHANNEL in Terminal Server mode',
'Direct communication channel for OPEN CHANNEL in Terminal Server mode', 'Security for Profile/Envelope/Command Container and ENCAPSULATED SESSION CONTROL', 'CAT service list for eCAT client', 'Refresh enforcement policy',
'DNS server address request for OPEN CHANNEL (packet data bearer)', 'Network Access Name reuse indication for CLOSE CHANNEL', 'Event: Poll Interval Negotiation', 'reserved by 3GPP (ProSe usage information reporting)',
'Proactive UICC: PROVIDE LOCAL INFORMATION (Supported Radio Access Technologies)', 'reserved by 3GPP (Event: WLAN access status)', 'reserved by 3GPP (WLAN bearer)', 'reserved by 3GPP (PLI WLAN identifier)',
];
// TERMINAL PROFILE presets (device/model -> profile hex). Add new entries
// here; the Configure dialog lists them by name.
const TP_PRESETS = [
{ name: 'Xiaomi Mi A1 (BIP-capable handset, project default)', profile: 'FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003' },
{ name: 'Quectel GSM module (AT+STKPD manual example)', profile: 'FFFFFFFF7F1F007FFF00001F230811060700' },
];
function tpNorm(hex) { return (hex || '').toUpperCase().replace(/[^0-9A-F]/g, ''); }
function tpValid(hex) { return hex.length > 0 && hex.length % 2 === 0 && hex.length <= 510; }
function tpGetBit(hex, n) {
const byteIdx = n >> 3;
if (byteIdx * 2 + 2 > hex.length) return null;
const b = parseInt(hex.substr(byteIdx * 2, 2), 16);
return !!(b & (0x80 >> (n & 7)));
}
function tpSetBit(hex, n, on) {
const bytes = hex.match(/../g) || [];
const byteIdx = n >> 3;
while (bytes.length <= byteIdx) bytes.push('00');
let b = parseInt(bytes[byteIdx], 16);
const mask = 0x80 >> (n & 7);
b = on ? (b | mask) : (b & ~mask);
bytes[byteIdx] = b.toString(16).padStart(2, '0').toUpperCase();
return bytes.join('');
}
function tpBitLabel(n) {
if (n < TP_BITS.length) return TP_BITS[n];
return 'RFU (byte ' + ((n >> 3) + 1) + ' b' + (8 - (n & 7)) + ')';
}
function tpRenderForm() {
const el = document.getElementById('tp-form');
if (!el) return;
const hex = tpNorm(document.getElementById('tp-hex').value);
if (!tpValid(hex)) { el.innerHTML = ''; return; }
const bytes = hex.length / 2;
let html = '';
for (let bi = 0; bi < bytes; bi++) {
html += '<div class="mb-1.5"><div class="text-gray-500 dark:text-slate-400">' +
esc(t('Byte')) + ' ' + (bi + 1) + '</div><div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4">';
for (let k = 0; k < 8; k++) {
const n = bi * 8 + k;
const label = tpBitLabel(n);
html += '<label class="flex items-center gap-1 min-w-0"><input type="checkbox" ' +
(tpGetBit(hex, n) ? 'checked ' : '') +
'onchange="tpToggle(' + n + ', this.checked)"> ' +
'<span class="truncate" title="' + escHtml('b' + (8 - k) + ' ' + label) + '">b' + (8 - k) + ' ' + esc(label) + '</span></label>';
}
html += '</div></div>';
}
el.innerHTML = html;
}
function tpToggle(n, on) {
const hex = tpNorm(document.getElementById('tp-hex').value);
if (!tpValid(hex)) return;
document.getElementById('tp-hex').value = tpSetBit(hex, n, on);
tpSyncPreset();
}
function tpHexInput() {
const err = document.getElementById('tp-error');
const hex = tpNorm(document.getElementById('tp-hex').value);
if (!tpValid(hex)) {
err.textContent = t('Profile must be non-empty hex with an even number of digits');
err.classList.remove('hidden');
document.getElementById('tp-form').innerHTML = '';
return;
}
err.classList.add('hidden');
tpRenderForm();
tpSyncPreset();
}
function tpSyncPreset() {
const sel = document.getElementById('tp-preset');
if (!sel) return;
const hex = tpNorm(document.getElementById('tp-hex').value);
const idx = TP_PRESETS.findIndex(p => p.profile === hex);
sel.value = idx >= 0 ? String(idx) : 'custom';
}
function tpPresetChanged() {
const sel = document.getElementById('tp-preset');
if (!sel || sel.value === 'custom') return;
const p = TP_PRESETS[parseInt(sel.value, 10)];
if (!p) return;
document.getElementById('tp-hex').value = p.profile;
tpHexInput();
}
function tpStatusMsg(text, cls) {
const el = document.getElementById('tp-status');
if (!el) return;
el.textContent = text || '';
el.className = 'text-xs ' + (cls || 'text-gray-400 dark:text-slate-500');
}
async function tpRefresh() {
try {
const d = await pysimFetch('/api/terminal-profile');
const el = document.getElementById('tp-current');
if (el) el.textContent = d.profile
? d.profile + ' (' + d.bytes + ' ' + t('bytes') + ')'
: t('not configured');
return d;
} catch (e) {
const el = document.getElementById('tp-current');
if (el && el.textContent === t('Loading...')) el.textContent = '—';
return null;
}
}
async function tpOpen() {
const d = await tpRefresh();
const sel = document.getElementById('tp-preset');
let html = '<option value="custom">' + esc(t('Custom')) + '</option>';
TP_PRESETS.forEach((p, i) => {
html += '<option value="' + i + '">' + esc(p.name) + '</option>';
});
sel.innerHTML = html;
document.getElementById('tp-hex').value = (d && d.profile) || '';
document.getElementById('tp-error').classList.add('hidden');
document.getElementById('tp-modal').classList.remove('hidden');
tpHexInput();
}
function tpClose() { document.getElementById('tp-modal').classList.add('hidden'); }
async function tpApply() {
const hex = tpNorm(document.getElementById('tp-hex').value);
if (!tpValid(hex)) { tpHexInput(); return; }
try {
const resp = await pysimFetch('/api/terminal-profile', { profile: hex });
if (!resp.ok) { tpStatusMsg(resp.error || t('Error'), 'text-red-500'); return; }
tpClose();
tpStatusMsg(t('Applied') + ' (' + resp.profile.length / 2 + ' ' + t('bytes') + ')' +
(resp.menu ? ' · ' + t('menu updated') : ''), 'text-emerald-600 dark:text-emerald-400');
tpRefresh();
} catch (e) {
tpStatusMsg(String(e.message || e), 'text-red-500');
}
}
async function tpSend() {
try {
const resp = await pysimFetch('/api/terminal-profile', {});
if (!resp.ok) { tpStatusMsg(resp.error || t('Error'), 'text-red-500'); return; }
tpStatusMsg(t('Sent') + (resp.menu ? ' · ' + t('menu updated') : ''),
'text-emerald-600 dark:text-emerald-400');
tpRefresh();
stkCheckMenu();
pysimEventsRender();
pysimProactiveLogRender();
} catch (e) {
tpStatusMsg(String(e.message || e), 'text-red-500');
}
}
async function pysimEventsRender() {
const el = document.getElementById('pysim-event-list');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
@@ -8334,13 +8638,122 @@ async function pysimPliSave() {
}
// ===== custom files =====
// A custom file has a canonical full path rooted at MF or an ADF
// ("MF/A153/4954", "ADF.USIM/6F07"); the parent DF must be defined as a
// custom file before a file under it can be added. Entries are stored as
// {path, name, kind} with kind derived from the alias prefix (EF./DF.).
let pysimCustomFiles = [];
let _pysimCustomDropped = 0;
const CUSTOM_ROOTS = ['MF', 'ADF.USIM', 'ADF.ISIM'];
function pysimCustomNormPath(path) {
let p = (path || '').trim().toUpperCase().replace(/\s+/g, '');
if (!p) return '';
const parts = p.split('/').filter(Boolean);
if (parts[0] === '3F00') parts[0] = 'MF';
return parts.join('/');
}
function pysimCustomKindForName(name) {
const n = (name || '').toUpperCase();
if (n.startsWith('DF.')) return 'df';
if (n.startsWith('EF.')) return 'ef';
return null;
}
function pysimCustomFid(entry) { return entry.path.split('/').pop(); }
function pysimCustomParent(entry) { return entry.path.split('/').slice(0, -1).join('/'); }
function pysimCustomRoot(entry) { return entry.path.split('/')[0]; }
function pysimCustomValidate(root, parentPath, fid, name, entries, editIndex) {
if (!CUSTOM_ROOTS.includes(root)) {
return {error: t('Root must be MF, ADF.USIM or ADF.ISIM')};
}
fid = (fid || '').trim().toUpperCase();
if (!/^[0-9A-F]{4}$/.test(fid)) return {error: t('FID must be 4 hex characters')};
const kind = pysimCustomKindForName(name);
if (!kind) return {error: t('Alias must start with "EF." or "DF."')};
if (parentPath !== root) {
const idx = entries.findIndex((e, i) => i !== editIndex && e.path === parentPath);
if (idx < 0) return {error: t('Parent DF is not defined as a custom file:') + ' ' + parentPath};
if (entries[idx].kind !== 'df') return {error: t('Parent is not a DF:') + ' ' + parentPath};
}
const path = (parentPath === root ? root : parentPath) + '/' + fid;
if (entries.some((e, i) => i !== editIndex && e.path === path)) {
return {error: t('File already defined:') + ' ' + path};
}
return {error: null, path: path, kind: kind};
}
function pysimCustomRewriteDescendants(entries, oldPath, newPath) {
for (const e of entries) {
if (e.path.startsWith(oldPath + '/')) {
e.path = newPath + e.path.slice(oldPath.length);
}
}
}
// Normalize a list of raw entries (storage or import): legacy "3F00/..." and
// relative "a153/4954" forms are resolved against the custom DFs; entries
// that cannot be resolved to a valid canonical path are dropped.
function pysimCustomNormalizeEntries(raw, existing) {
const pending = raw.filter(e => e && e.path && e.name)
.map((e, i) => ({idx: i, path: pysimCustomNormPath(e.path),
name: String(e.name).toUpperCase()}));
const base = (existing || []).slice();
const accepted = base.slice();
const resolved = [];
let dropped = 0;
let progress = true;
while (pending.length && progress) {
progress = false;
for (let i = 0; i < pending.length; i++) {
const e = pending[i];
let path = e.path;
const parts = path.split('/');
if (!CUSTOM_ROOTS.includes(parts[0])) {
// relative: resolve the leading FID against a known custom DF
const df = accepted.find(x => x.kind === 'df' && pysimCustomFid(x) === parts[0]);
if (!df) continue;
path = df.path + '/' + parts.slice(1).join('/');
}
const root = path.split('/')[0];
const res = pysimCustomValidate(root, pysimCustomParent({path: path}),
pysimCustomFid({path: path}), e.name, accepted, null);
if (res.error) {
pending.splice(i, 1);
dropped++;
i--;
progress = true;
continue;
}
accepted.push({path: res.path, name: e.name, kind: res.kind});
resolved.push({idx: e.idx, entry: accepted[accepted.length - 1]});
pending.splice(i, 1);
i--;
progress = true;
}
}
dropped += pending.length;
resolved.sort((a, b) => a.idx - b.idx);
return {files: base.concat(resolved.map(r => r.entry)), dropped: dropped};
}
function pysimCustomLoad() {
let raw = [];
try {
const d = localStorage.getItem('pysim_custom_files');
pysimCustomFiles = d ? JSON.parse(d) : [];
} catch (e) { pysimCustomFiles = []; }
raw = d ? JSON.parse(d) : [];
} catch (e) { raw = []; }
if (!Array.isArray(raw)) raw = [];
const res = pysimCustomNormalizeEntries(raw, []);
pysimCustomFiles = res.files;
_pysimCustomDropped = res.dropped;
if (res.dropped) {
console.warn('custom files: dropped ' + res.dropped + ' invalid legacy entr' +
(res.dropped === 1 ? 'y' : 'ies'));
}
pysimCustomSave();
}
function pysimCustomSave() {
@@ -8349,25 +8762,48 @@ function pysimCustomSave() {
let pysimCustomEditIndex = null;
function pysimCustomSubmit() {
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;
function pysimCustomRenderRoots() {
const sel = document.getElementById('pysim-cf-root');
if (!sel) return;
const prev = sel.value;
sel.innerHTML = CUSTOM_ROOTS.map(r => '<option value="' + esc(r) + '">' + esc(r) + '</option>').join('');
if (CUSTOM_ROOTS.includes(prev)) sel.value = prev;
}
function pysimCustomRenderParents() {
const sel = document.getElementById('pysim-cf-parent');
if (!sel) return;
const root = document.getElementById('pysim-cf-root').value || CUSTOM_ROOTS[0];
const prev = sel.value;
let html = '<option value="' + esc(root) + '">' + esc(t('— root —')) + '</option>';
for (const c of pysimCustomFiles) {
if (c.kind !== 'df' || pysimCustomRoot(c) !== root) continue;
html += '<option value="' + esc(c.path) + '">' + esc(c.path + ' → ' + c.name) + '</option>';
}
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, i) => i !== pysimCustomEditIndex && c.path.toUpperCase() === path)) { alert('Path already exists'); return; }
sel.innerHTML = html;
if (prev && (prev === root || pysimCustomFiles.some(c => c.path === prev && c.kind === 'df'))) {
sel.value = prev;
}
}
function pysimCustomRootChanged() {
pysimCustomRenderParents();
}
function pysimCustomSubmit() {
const root = document.getElementById('pysim-cf-root').value;
const parentPath = document.getElementById('pysim-cf-parent').value || root;
const fid = document.getElementById('pysim-cf-fid').value;
const nameEl = document.getElementById('pysim-cf-name');
const name = nameEl.value.trim();
const res = pysimCustomValidate(root, parentPath, fid, name, pysimCustomFiles, pysimCustomEditIndex);
if (res.error) { alert(res.error); return; }
if (pysimCustomEditIndex !== null) {
pysimCustomFiles[pysimCustomEditIndex] = { path, name, fid, parentFid };
const oldPath = pysimCustomFiles[pysimCustomEditIndex].path;
if (oldPath !== res.path) pysimCustomRewriteDescendants(pysimCustomFiles, oldPath, res.path);
pysimCustomFiles[pysimCustomEditIndex] = {path: res.path, name: name, kind: res.kind};
} else {
pysimCustomFiles.push({ path, name, fid, parentFid });
pysimCustomFiles.push({path: res.path, name: name, kind: res.kind});
}
pysimCustomSave();
pysimCustomRender();
@@ -8378,20 +8814,24 @@ function pysimCustomEdit(i) {
const c = pysimCustomFiles[i];
if (!c) return;
pysimCustomEditIndex = i;
const pathEl = document.getElementById('pysim-cf-path');
pysimCustomRenderRoots();
document.getElementById('pysim-cf-root').value = pysimCustomRoot(c);
pysimCustomRenderParents();
document.getElementById('pysim-cf-parent').value = pysimCustomParent(c);
document.getElementById('pysim-cf-fid').value = pysimCustomFid(c);
document.getElementById('pysim-cf-name').value = c.name;
pathEl.value = c.path;
const btn = document.getElementById('pysim-cf-add-btn');
btn.textContent = t('Save');
btn.setAttribute('data-l10n', 'Save');
document.getElementById('pysim-cf-cancel-btn').classList.remove('hidden');
pathEl.focus();
document.getElementById('pysim-cf-fid').focus();
}
function pysimCustomEditCancel() {
pysimCustomEditIndex = null;
document.getElementById('pysim-cf-path').value = '';
document.getElementById('pysim-cf-fid').value = '';
document.getElementById('pysim-cf-name').value = '';
pysimCustomRenderParents();
const btn = document.getElementById('pysim-cf-add-btn');
btn.textContent = t('Add');
btn.setAttribute('data-l10n', 'Add');
@@ -8399,23 +8839,41 @@ function pysimCustomEditCancel() {
}
function pysimCustomRemove(i) {
if (pysimCustomEditIndex !== null) {
if (i === pysimCustomEditIndex) pysimCustomEditCancel();
else if (i < pysimCustomEditIndex) pysimCustomEditIndex--;
const c = pysimCustomFiles[i];
if (!c) return;
const children = pysimCustomFiles.filter(e => e.path.startsWith(c.path + '/'));
if (children.length) {
if (!confirm(t('Delete') + ' ' + c.path + ' ' + t('and') + ' ' + children.length + ' ' +
t('child file(s)?'))) return;
}
pysimCustomFiles = pysimCustomFiles.filter((e, j) =>
j !== i && !e.path.startsWith(c.path + '/'));
if (pysimCustomEditIndex !== null) {
if (pysimCustomEditIndex === i) pysimCustomEditCancel();
else if (pysimCustomEditIndex > i) pysimCustomEditIndex--;
}
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; }
if (!el) return;
let html = '';
if (_pysimCustomDropped) {
html += '<div class="text-amber-600 dark:text-amber-400 py-1">' + esc(t('Dropped invalid legacy entries:')) +
' ' + _pysimCustomDropped + '</div>';
}
if (!pysimCustomFiles.length) {
html += '<span class="text-gray-400">' + esc(t('No custom files defined.')) + '</span>';
el.innerHTML = html;
return;
}
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 class="text-gray-400">(' + esc(c.kind.toUpperCase()) + ')</span>';
html += '<span class="ml-auto flex gap-2">';
html += '<button onclick="pysimCustomEdit(' + 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="pysimCustomRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
@@ -8441,25 +8899,13 @@ async function pysimCustomImport(text) {
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++;
}
}
const res = pysimCustomNormalizeEntries(imported, pysimCustomFiles);
const added = res.files.length - pysimCustomFiles.length;
pysimCustomFiles = res.files;
pysimCustomSave();
pysimCustomRender();
const el = document.getElementById('pysim-cf-io');
el.value = 'Imported ' + added + ' files.';
el.value = 'Imported ' + added + ' file(s)' + (res.dropped ? ', dropped ' + res.dropped : '') + '.';
setTimeout(() => { el.classList.add('hidden'); }, 2000);
} catch (e) {
alert('Import error: ' + e.message);
@@ -8487,6 +8933,8 @@ function pysimCustomImportField() {
// Init custom files
pysimCustomLoad();
pysimCustomRenderRoots();
pysimCustomRenderParents();
// ===== Profiler =====
let profiles = [];
@@ -9922,14 +10370,10 @@ async function profilerRunRule(r, source) {
function profilerCustomNameForPath(path) {
if (!path) return null;
const norm = path.toUpperCase();
const norm = pysimCustomNormPath(path);
if (!norm) return null;
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;
if (pysimCustomNormPath(c.path) === norm) return c.name;
}
return null;
}
@@ -10786,6 +11230,36 @@ const LANG_RU = {
'Pass-through requires the target host and port': 'Для режима проброса нужны host и port внешнего сервера',
'ADM verified': 'ADM подтверждён',
'ADM not verified': 'ADM не подтверждён',
'TERMINAL PROFILE': 'TERMINAL PROFILE',
'Preset (device model)': 'Пресет (модель устройства)',
'Profile (hex)': 'Профиль (hex)',
'Apply': 'Применить',
'Configure': 'Настроить',
'Custom': 'Другой',
'not configured': 'не задан',
'bytes': 'байт',
'Byte': 'Байт',
'Applied': 'Применено',
'Sent': 'Отправлено',
'menu updated': 'меню обновлено',
'Profile must be non-empty hex with an even number of digits': 'Профиль должен быть непустым hex с чётным числом цифр',
'Bits are decoded per TS 102 223 5.2; the hex field is authoritative and bytes/bits outside the table are preserved as-is.': 'Биты декодируются по TS 102 223 5.2; поле hex — основное, байты и биты вне таблицы сохраняются как есть.',
'Root (MF / ADF)': 'Корень (MF / ADF)',
'Parent DF': 'Родительский DF',
'FID (4 hex)': 'FID (4 hex)',
'Alias (EF. / DF.)': 'Псевдоним (EF. / DF.)',
'Full path from MF or an ADF; the parent DF must be defined here first.': 'Полный путь от MF или ADF; родительский DF должен быть создан здесь заранее.',
'Root must be MF, ADF.USIM or ADF.ISIM': 'Корень должен быть MF, ADF.USIM или ADF.ISIM',
'FID must be 4 hex characters': 'FID должен состоять из 4 hex-символов',
'Alias must start with "EF." or "DF."': 'Псевдоним должен начинаться с «EF.» или «DF.»',
'Parent DF is not defined as a custom file:': 'Родительский DF не создан как пользовательский файл:',
'Parent is not a DF:': 'Родитель не является DF:',
'File already defined:': 'Файл уже задан:',
'— root —': '— корень —',
'and': 'и',
'child file(s)?': 'дочерних файлов?',
'Dropped invalid legacy entries:': 'Отброшено некорректных старых записей:',
'No custom files defined.': 'Пользовательские файлы не заданы.',
};
let currentLang = 'en';
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v165';
const CACHE = 'otaman-v167';
const URLS = [
'index.html',
'help.html',
+169 -95
View File
@@ -21,22 +21,23 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1);
}
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\n';
for (const fn of ['pysimCustomSave', 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender']) {
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\n';
for (const fn of ['pysimCustomNormPath', 'pysimCustomKindForName', 'pysimCustomFid',
'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomValidate',
'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries', 'pysimCustomSave',
'pysimCustomRenderRoots', 'pysimCustomRenderParents', 'pysimCustomRootChanged',
'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel',
'pysimCustomRemove', 'pysimCustomRender']) {
code += extractFunc(html, fn) + '\n';
}
code += html.match(/const CUSTOM_ROOTS = \[[^\]]*\];/)[0].replace('const ', 'var ') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);
function fakeEl(id) {
const classes = new Set();
return {
id,
value: '',
innerHTML: '',
textContent: '',
attrs: {},
focused: 0,
id, value: '', innerHTML: '', textContent: '', attrs: {}, focused: 0,
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
@@ -44,12 +45,15 @@ function fakeEl(id) {
},
setAttribute(k, v) { this.attrs[k] = v; },
focus() { this.focused++; },
options() { return [...this.innerHTML.matchAll(/value="([^"]+)"/g)].map(m => m[1]); },
};
}
function setup(entries) {
const els = {
'pysim-cf-path': fakeEl('pysim-cf-path'),
'pysim-cf-root': fakeEl('pysim-cf-root'),
'pysim-cf-parent': fakeEl('pysim-cf-parent'),
'pysim-cf-fid': fakeEl('pysim-cf-fid'),
'pysim-cf-name': fakeEl('pysim-cf-name'),
'pysim-cf-list': fakeEl('pysim-cf-list'),
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
@@ -64,102 +68,172 @@ function setup(entries) {
globalThis.document = { getElementById: id => els[id] || null };
globalThis.alertCalls = [];
globalThis.alert = m => { globalThis.alertCalls.push(m); };
globalThis.confirmResult = true;
globalThis.confirm = () => globalThis.confirmResult;
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
pysimCustomEditIndex = null;
_pysimCustomDropped = 0;
pysimCustomRenderRoots();
pysimCustomRenderParents();
return els;
}
const entry = (fid, name, parentFid) => ({ path: (parentFid || '3F00') + '/' + fid, name, fid, parentFid: parentFid || '3F00' });
function fill(els, root, parent, fid, name) {
els['pysim-cf-root'].value = root;
pysimCustomRenderParents();
els['pysim-cf-parent'].value = parent;
els['pysim-cf-fid'].value = fid;
els['pysim-cf-name'].value = name;
}
test('rows render Edit and Delete buttons instead of the X glyph', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
test('helpers derive canonical roots, parents, FIDs and kinds', () => {
assert.strictEqual(pysimCustomNormPath('3f00/7f20/6f46'), 'MF/7F20/6F46');
assert.strictEqual(pysimCustomNormPath(' mf / a153 '), 'MF/A153');
assert.strictEqual(pysimCustomNormPath(''), '');
assert.strictEqual(pysimCustomRoot({ path: 'ADF.USIM/6F07' }), 'ADF.USIM');
assert.strictEqual(pysimCustomParent({ path: 'MF/A153/4954' }), 'MF/A153');
assert.strictEqual(pysimCustomParent({ path: 'MF/6F46' }), 'MF');
assert.strictEqual(pysimCustomFid({ path: 'MF/A153/4954' }), '4954');
assert.strictEqual(pysimCustomKindForName('EF.SPN'), 'ef');
assert.strictEqual(pysimCustomKindForName('DF.GSM'), 'df');
assert.strictEqual(pysimCustomKindForName('XX.SPN'), null);
});
test('migration resolves legacy relative paths and drops the unresolvable', () => {
const res = pysimCustomNormalizeEntries([
{ path: '3f00/a153', name: 'DF.A1' },
{ path: 'a153/4954', name: 'EF.SPNS', fid: '4954', parentFid: 'a153' },
{ path: 'a153/4955', name: 'EF.SMSCS' },
{ path: 'ffff/1111', name: 'EF.ORPHAN' }, // no such custom DF -> dropped
{ path: 'MF/6F46', name: 'BAD.NAME' }, // invalid alias -> dropped
], []);
assert.deepStrictEqual(res.files, [
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
]);
assert.strictEqual(res.dropped, 2);
});
test('validation requires a defined parent DF and a valid FID/alias', () => {
const files = [{ path: 'MF/A153', name: 'DF.A1', kind: 'df' }];
// parent not defined
assert.match(pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null).error, /not defined/);
// parent defined -> ok
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46');
// bad FID
assert.match(pysimCustomValidate('MF', 'MF', '6F4', 'EF.SPN', files, null).error, /4 hex/);
// bad alias
assert.match(pysimCustomValidate('MF', 'MF', '6F46', 'SPN', files, null).error, /EF\.|DF\./);
// duplicate
assert.match(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /already defined/);
// editing the same entry is not a duplicate
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), 1).error, null);
// root must be known
assert.match(pysimCustomValidate('MFX', 'MFX', '6F46', 'EF.SPN', files, null).error, /Root/);
});
test('root and parent selectors list roots and defined DFs', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'ADF.USIM/6F07', name: 'EF.IMSI', kind: 'ef' },
]);
assert.deepStrictEqual(els['pysim-cf-root'].options(), ['MF', 'ADF.USIM', 'ADF.ISIM']);
const parents = els['pysim-cf-parent'].options();
assert.ok(parents.includes('MF'));
assert.ok(parents.includes('MF/A153'));
assert.ok(!parents.includes('MF/A153/4954'), 'EFs are not parent options');
// the ADF root is always a valid parent
els['pysim-cf-root'].value = 'ADF.USIM';
pysimCustomRootChanged();
assert.deepStrictEqual(els['pysim-cf-parent'].options(), ['ADF.USIM']);
});
test('submit adds files under the root and under a defined DF', () => {
const els = setup([]);
fill(els, 'MF', 'MF', '6F46', 'EF.SPN');
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles, [{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' }]);
assert.strictEqual(els['pysim-cf-fid'].value, '');
// add a DF, then a file under it
fill(els, 'MF', 'MF', 'A153', 'DF.A1');
pysimCustomSubmit();
fill(els, 'MF', 'MF/A153', '4954', 'EF.SPNS');
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
['MF/6F46', 'MF/A153', 'MF/A153/4954']);
// adding under an undefined parent is rejected
fill(els, 'MF', 'MF', '1111', 'EF.X');
els['pysim-cf-parent'].value = 'MF/A153';
els['pysim-cf-fid'].value = '2222';
els['pysim-cf-name'].value = 'BAD';
pysimCustomSubmit();
assert.ok(globalThis.alertCalls.length === 1);
});
test('editing a DF FID rewrites its descendants', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
]);
pysimCustomEdit(0);
assert.strictEqual(els['pysim-cf-fid'].value, 'A153');
els['pysim-cf-fid'].value = 'A154';
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
['MF/A154', 'MF/A154/4954', 'MF/A154/4955']);
assert.strictEqual(pysimCustomEditIndex, null);
});
test('editing an entry to another defined path is rejected', () => {
const els = setup([
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
{ path: 'MF/6F44', name: 'EF.SPN2', kind: 'ef' },
]);
pysimCustomEdit(0);
els['pysim-cf-fid'].value = '6F44';
pysimCustomSubmit();
assert.deepStrictEqual(globalThis.alertCalls, ['File already defined: MF/6F44']);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46', 'MF/6F44']);
assert.strictEqual(pysimCustomEditIndex, 0);
});
test('deleting a DF cascades to its children only after confirmation', () => {
setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
]);
globalThis.confirmResult = false;
pysimCustomRemove(0);
assert.strictEqual(pysimCustomFiles.length, 3, 'cancelled delete must keep everything');
globalThis.confirmResult = true;
pysimCustomRemove(0);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46']);
});
test('render shows the canonical path, kind and row actions', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
]);
pysimCustomRender();
const out = els['pysim-cf-list'].innerHTML;
assert.match(out, /MF\/A153/);
assert.match(out, /MF\/A153\/4954/);
assert.match(out, /\(DF\)/);
assert.match(out, /pysimCustomEdit\(0\)/);
assert.match(out, /pysimCustomRemove\(0\)/);
assert.match(out, />Edit</);
assert.match(out, />Delete</);
assert.ok(!out.includes('✕'), 'old X glyph must be gone');
assert.match(out, /pysimCustomRemove\(1\)/);
});
test('edit fills the top form and switches it to Save mode', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
assert.strictEqual(pysimCustomEditIndex, 0);
assert.strictEqual(els['pysim-cf-path'].value, '3F00/6F46');
assert.strictEqual(els['pysim-cf-name'].value, 'EF.SPN');
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Save');
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Save');
assert.ok(!els['pysim-cf-cancel-btn'].classList.contains('hidden'));
assert.strictEqual(els['pysim-cf-path'].focused, 1);
});
test('submit in edit mode updates the entry in place', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
els['pysim-cf-path'].value = '3F00/7F20/6F46';
els['pysim-cf-name'].value = 'EF.SPNX';
pysimCustomSubmit();
assert.strictEqual(pysimCustomFiles.length, 1);
assert.deepStrictEqual(pysimCustomFiles[0], { path: '3F00/7F20/6F46', name: 'EF.SPNX', fid: '6F46', parentFid: '7F20' });
assert.strictEqual(pysimCustomEditIndex, null);
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
});
test('saving an edited entry with its own path is not a duplicate', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
pysimCustomSubmit();
assert.strictEqual(pysimCustomFiles.length, 1);
assert.deepStrictEqual(globalThis.alertCalls, []);
});
test('editing to another entry path is rejected as duplicate', () => {
const els = setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2')]);
pysimCustomEdit(0);
els['pysim-cf-path'].value = '3F00/6F44';
pysimCustomSubmit();
assert.deepStrictEqual(globalThis.alertCalls, ['Path already exists']);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['3F00/6F46', '3F00/6F44']);
assert.strictEqual(pysimCustomEditIndex, 0);
});
test('cancel restores the Add mode and clears the inputs', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
pysimCustomEditCancel();
assert.strictEqual(pysimCustomEditIndex, null);
assert.strictEqual(els['pysim-cf-path'].value, '');
assert.strictEqual(els['pysim-cf-name'].value, '');
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Add');
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
});
test('deleting the entry being edited cancels the edit', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
pysimCustomRemove(0);
assert.deepStrictEqual(pysimCustomFiles, []);
assert.strictEqual(pysimCustomEditIndex, null);
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
});
test('deleting before the edited row shifts the edit index', () => {
setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2'), entry('6F42', 'EF.SPN3')]);
pysimCustomEdit(2);
pysimCustomRemove(0);
assert.strictEqual(pysimCustomEditIndex, 1);
assert.strictEqual(pysimCustomFiles[1].name, 'EF.SPN3');
});
test('submit adds a new entry when not editing', () => {
test('render flags dropped legacy entries', () => {
const els = setup([]);
els['pysim-cf-path'].value = '3f00/6f46';
els['pysim-cf-name'].value = 'EF.SPN';
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles, [{ path: '3F00/6F46', name: 'EF.SPN', fid: '6F46', parentFid: '3F00' }]);
assert.strictEqual(els['pysim-cf-path'].value, '');
assert.strictEqual(els['pysim-cf-name'].value, '');
_pysimCustomDropped = 3;
pysimCustomRender();
assert.match(els['pysim-cf-list'].innerHTML, /3/);
_pysimCustomDropped = 0;
});
+102
View File
@@ -0,0 +1,102 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimCustomFiles = [];\n';
for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomParent', 'pysimCustomFid']) {
code += extractFunc(html, fn) + '\n';
}
eval(code);
function node(name, fid, parent, children) {
return { name, fid, parent: parent || null, children: children || null, isDir: true };
}
function tree() {
const mf = node('MF', '3F00', null, []);
const dfA = node('DF.A', '5F01', mf);
const dfB = node('DF.B', '5F02', mf);
const dfC = node('DF.C', '5F03', mf, [node('EF.X', '6F46', null)]);
dfC.children[0].parent = dfC;
const adf = node('ADF.USIM', '7FFF', mf);
const efImsi = node('EF.IMSI', '6F07', adf);
adf.children = [efImsi];
mf.children = [dfA, dfB, dfC, adf];
return { mf, dfA, dfB, dfC, adf, efImsi };
}
test('pysimFsNodePath builds canonical paths from MF and ADF roots', () => {
const t = tree();
assert.strictEqual(pysimFsNodePath(t.mf), 'MF');
assert.strictEqual(pysimFsNodePath(t.dfA), 'MF/5F01');
assert.strictEqual(pysimFsNodePath(t.dfC.children[0]), 'MF/5F03/6F46');
assert.strictEqual(pysimFsNodePath(t.adf), 'ADF.USIM');
assert.strictEqual(pysimFsNodePath(t.efImsi), 'ADF.USIM/6F07');
});
test('injection matches full paths, so same-FID DFs stay apart', () => {
const t = tree();
pysimCustomFiles = [
{ path: 'MF/5F01/6F46', name: 'EF.ONLY-A', kind: 'ef' },
{ path: 'MF/5F02/6F46', name: 'EF.ONLY-B', kind: 'ef' },
{ path: 'ADF.USIM/6F07', name: 'EF.MY-IMSI', kind: 'ef' },
];
pysimCustomInject(t.mf);
assert.strictEqual(t.mf.children.length, 4, 'MF-level injection adds nothing');
pysimCustomInject(t.dfA);
assert.strictEqual(t.dfA.children.length, 1);
assert.strictEqual(t.dfA.children[0].name, 'EF.ONLY-A');
assert.strictEqual(t.dfA.children[0].customPath, 'MF/5F01/6F46');
pysimCustomInject(t.dfB);
assert.strictEqual(t.dfB.children[0].name, 'EF.ONLY-B');
pysimCustomInject(t.dfC);
assert.strictEqual(t.dfC.children.length, 1, 'unrelated DF is untouched');
pysimCustomInject(t.adf);
assert.strictEqual(t.adf.children.length, 1);
assert.strictEqual(t.adf.children[0].name, 'EF.MY-IMSI');
assert.strictEqual(t.adf.children[0].customPath, 'ADF.USIM/6F07');
});
test('injection renames and marks an existing model node', () => {
const t = tree();
pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }];
pysimCustomInject(t.dfC);
assert.strictEqual(t.dfC.children.length, 1);
assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED');
assert.strictEqual(t.dfC.children[0].custom, true);
});
test('injected DFs are directories and can host their own children', () => {
const t = tree();
pysimCustomFiles = [
{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' },
{ path: 'MF/5F10/6F46', name: 'EF.UNDER-NEW', kind: 'ef' },
];
pysimCustomInject(t.mf);
const df = t.mf.children.find(c => c.fid === '5F10');
assert.ok(df, 'custom DF injected into MF');
assert.strictEqual(df.isDir, true);
assert.strictEqual(pysimFsNodePath(df), 'MF/5F10');
pysimCustomInject(df);
assert.strictEqual(df.children.length, 1);
assert.strictEqual(df.children[0].name, 'EF.UNDER-NEW');
});
+5
View File
@@ -100,6 +100,11 @@ test('file manager keeps sort/probe controls above the scrolling tree', () => {
test('custom files form has add/save and cancel controls', () => {
assert.match(html, /id="pysim-cf-add-btn"[^>]*data-l10n="Add"/);
assert.match(html, /id="pysim-cf-cancel-btn"[^>]*class="hidden[^"]*"[^>]*data-l10n="Cancel"/);
// canonical path form: root + parent DF + 4-hex FID, no free-form path
assert.ok(html.includes('id="pysim-cf-root"'));
assert.ok(html.includes('id="pysim-cf-parent"'));
assert.ok(html.includes('id="pysim-cf-fid"'));
assert.ok(!html.includes('id="pysim-cf-path"'));
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
assert.ok(!html.includes('pysimCustomAdd'));
});
+4 -2
View File
@@ -27,7 +27,8 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
'globalThis.pysimEventsRender = () => { globalThis._events = (globalThis._events || 0) + 1; };\n' +
'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' +
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n';
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n' +
'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n';
eval(code);
function makeClassList() {
@@ -52,7 +53,7 @@ function setup() {
getElementById: id => panels[id] || null,
};
globalThis._anchor = null;
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = 0;
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
return { buttons, panels };
}
@@ -80,4 +81,5 @@ test('Phone pill shows the phone panel and renders CAT views', () => {
assert.strictEqual(globalThis._log, 1);
assert.strictEqual(globalThis._poll, 1);
assert.strictEqual(globalThis._pli, 0);
assert.strictEqual(globalThis._tp, 1);
});
+1 -1
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
+87
View File
@@ -0,0 +1,87 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const f of ['tpNorm', 'tpValid', 'tpGetBit', 'tpSetBit', 'tpBitLabel']) {
code += extractFunc(html, f) + '\n';
}
code += html.match(/const TP_BITS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
code += html.match(/const TP_PRESETS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
eval(code);
test('tpNorm / tpValid normalize and validate profile hex', () => {
assert.strictEqual(tpNorm(' ff ee 00 '), 'FFEE00');
assert.strictEqual(tpNorm('zz'), '');
assert.ok(tpValid('FF'));
assert.ok(tpValid('00FF'));
assert.ok(!tpValid(''));
assert.ok(!tpValid('F'));
});
test('tpGetBit / tpSetBit address bits MSB-first per byte', () => {
const hex = '80' + '01'; // byte 1 b8 set, byte 2 b1 set
assert.strictEqual(tpGetBit(hex, 0), true); // byte 1 b8
assert.strictEqual(tpGetBit(hex, 7), false); // byte 1 b1
assert.strictEqual(tpGetBit(hex, 8), false); // byte 2 b8
assert.strictEqual(tpGetBit(hex, 15), true); // byte 2 b1
assert.strictEqual(tpGetBit(hex, 16), null); // beyond the profile
// toggling keeps the other bits untouched
assert.strictEqual(tpSetBit('00', 0, true), '80');
assert.strictEqual(tpSetBit('80', 0, false), '00');
assert.strictEqual(tpSetBit('FF', 7, false), 'FE');
// a bit beyond the current length grows the profile with zero bytes
assert.strictEqual(tpSetBit('FF', 24, true), 'FF000080');
});
test('TERMINAL PROFILE bit table matches TS 102 223 5.2 spot checks', () => {
assert.ok(TP_BITS.length >= 264, 'expected the full byte 1..33 table');
assert.strictEqual(TP_BITS[0], 'Profile download'); // byte 1 b8
assert.strictEqual(TP_BITS[16], 'Proactive UICC: DISPLAY TEXT'); // byte 3 b8
assert.strictEqual(TP_BITS[32], 'Proactive UICC: SET UP EVENT LIST'); // byte 5 b8
assert.strictEqual(TP_BITS[42], 'Event: Data available'); // byte 6 b3
assert.strictEqual(TP_BITS[88], 'Proactive UICC: OPEN CHANNEL'); // byte 12 b8
assert.strictEqual(TP_BITS[260], 'Proactive UICC: PROVIDE LOCAL INFORMATION (Supported Radio Access Technologies)');
assert.strictEqual(tpBitLabel(0), 'Profile download');
assert.match(tpBitLabel(400), /^RFU \(byte 51 b/); // beyond the table
});
test('TERMINAL PROFILE presets are valid even-length hex', () => {
assert.ok(TP_PRESETS.length >= 2);
assert.match(TP_PRESETS[0].name, /Xiaomi/i);
for (const p of TP_PRESETS) {
assert.ok(p.name && p.profile, p.name);
assert.ok(/^[0-9A-F]+$/.test(p.profile) && p.profile.length % 2 === 0, p.name);
assert.ok(p.profile.length <= 510, p.name);
}
});
test('Phone tab exposes the TERMINAL PROFILE block and Configure dialog', () => {
assert.ok(html.includes('id="tp-current"'));
assert.match(html, /id="tp-send-btn"[^>]*data-needs="card"/);
assert.match(html, /id="tp-configure-btn"[^>]*data-needs="server"/);
assert.ok(html.includes('id="tp-modal"'));
assert.ok(html.includes('id="tp-preset"'));
assert.ok(html.includes('id="tp-hex"'));
assert.ok(html.includes('id="tp-form"'));
assert.ok(html.includes('id="tp-apply-btn"'));
});