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
+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';