Profile from card
@@ -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 += '
';
+ }
+ 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 = '
' + esc(t('Custom')) + ' ';
+ TP_PRESETS.forEach((p, i) => {
+ html += '
' + esc(p.name) + ' ';
+ });
+ 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 = '
' + t('Loading...') + ' ';
@@ -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 => '
' + esc(r) + ' ').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 = '
' + esc(t('— root —')) + ' ';
+ for (const c of pysimCustomFiles) {
+ if (c.kind !== 'df' || pysimCustomRoot(c) !== root) continue;
+ html += '
' + esc(c.path + ' → ' + c.name) + ' ';
}
- 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 = '
No custom files defined. '; return; }
+ if (!el) return;
let html = '';
+ if (_pysimCustomDropped) {
+ html += '
' + esc(t('Dropped invalid legacy entries:')) +
+ ' ' + _pysimCustomDropped + '
';
+ }
+ if (!pysimCustomFiles.length) {
+ html += '
' + esc(t('No custom files defined.')) + ' ';
+ el.innerHTML = html;
+ return;
+ }
for (let i = 0; i < pysimCustomFiles.length; i++) {
const c = pysimCustomFiles[i];
html += '
';
html += '' + esc(c.path) + ' → ' + esc(c.name) + ' ';
+ html += '(' + esc(c.kind.toUpperCase()) + ') ';
html += '';
html += '' + esc(t('Edit')) + ' ';
html += '' + esc(t('Delete')) + ' ';
@@ -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';
diff --git a/frontend/sw.js b/frontend/sw.js
index 615c3e7..9742473 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v165';
+const CACHE = 'otaman-v167';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/custom_files.test.js b/frontend/tests/custom_files.test.js
index 36c47d9..465c5cb 100644
--- a/frontend/tests/custom_files.test.js
+++ b/frontend/tests/custom_files.test.js
@@ -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;
});
diff --git a/frontend/tests/custom_inject.test.js b/frontend/tests/custom_inject.test.js
new file mode 100644
index 0000000..a5ae495
--- /dev/null
+++ b/frontend/tests/custom_inject.test.js
@@ -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');
+});
diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js
index 4cb8be7..78700ad 100644
--- a/frontend/tests/html.test.js
+++ b/frontend/tests/html.test.js
@@ -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'));
});
diff --git a/frontend/tests/phone_tabs.test.js b/frontend/tests/phone_tabs.test.js
index 4419535..5cc6e34 100644
--- a/frontend/tests/phone_tabs.test.js
+++ b/frontend/tests/phone_tabs.test.js
@@ -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);
});
diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js
index dd0d03b..716801b 100644
--- a/frontend/tests/profiler.test.js
+++ b/frontend/tests/profiler.test.js
@@ -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';
diff --git a/frontend/tests/terminal_profile.test.js b/frontend/tests/terminal_profile.test.js
new file mode 100644
index 0000000..ed4d24d
--- /dev/null
+++ b/frontend/tests/terminal_profile.test.js
@@ -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"'));
+});
diff --git a/pyproject.toml b/pyproject.toml
index 8af706a..4366664 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
-version = "2.2.1"
+version = "2.2.3"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_otaman_server/__main__.py b/pysim_otaman_server/__main__.py
index 6b98ea5..6066160 100644
--- a/pysim_otaman_server/__main__.py
+++ b/pysim_otaman_server/__main__.py
@@ -179,6 +179,7 @@ def main():
server.sms_sc = opts.sms_sm_sc
server.log_requests = opts.log_requests
server.terminal_profile = opts.terminal_profile
+ server.cli_terminal_profile = opts.terminal_profile
server.web_dir = opts.web_dir
server.sim_menu = sim_menu
server.event_list = event_list
diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py
index 0924879..3fd5b51 100644
--- a/pysim_otaman_server/server.py
+++ b/pysim_otaman_server/server.py
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
-VERSION = '2.2.1'
+VERSION = '2.2.3'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -2876,6 +2876,36 @@ def _send_terminal_profile(scc, tp_hex):
return sim_menu, event_list
+def _validate_tp_hex(hex_str):
+ """Validate a TERMINAL PROFILE hex string. Returns (hex_or_None, error)."""
+ h = re.sub(r'\s', '', str(hex_str or '')).upper()
+ if not h:
+ return None, 'profile is empty'
+ if not re.fullmatch(r'[0-9A-F]+', h):
+ return None, 'profile is not valid hex'
+ if len(h) % 2:
+ return None, 'profile must have an even number of hex digits'
+ if len(h) // 2 > 255:
+ return None, 'profile exceeds 255 bytes'
+ return h, None
+
+
+def _resend_terminal_profile(server, scc):
+ """Re-send the current TERMINAL PROFILE and reset the STK session state.
+ Shared by /api/rescue and the runtime profile update; the value lives in
+ server.terminal_profile (CLI default, overridable at runtime)."""
+ server.stk_pending = None
+ server.menu_active = False
+ _cancel_menu_timeout()
+ server.event_list = None
+ _reset_proactive_log()
+ sm, el = _send_terminal_profile(scc, server.terminal_profile)
+ server.sim_menu = sm
+ server.event_list = el
+ return {'ok': True, 'profile': server.terminal_profile,
+ 'menu': sm is not None, 'events': el}
+
+
def _make_menu_fetch_handler(server, resp):
"""on_fetch callback for the menu chain: pauses on user-interactive commands
and stores the pending command so a TERMINAL RESPONSE can be sent later."""
@@ -3135,6 +3165,13 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = self.server.event_list or []
self._send_json(resp)
self._log_resp(resp)
+ elif self.path == '/api/terminal-profile':
+ self._log_req()
+ tp = getattr(self.server, 'terminal_profile', None) or ''
+ resp = {'profile': tp or None, 'bytes': len(tp) // 2,
+ 'cli_default': getattr(self.server, 'cli_terminal_profile', None)}
+ self._send_json(resp)
+ self._log_resp(resp)
elif self.path == '/api/pli-qualifiers':
qualifiers = [{'code': '%02X' % q, 'name': PLI_QUALIFIER_NAMES[q]} for q in PLI_QUALIFIER_NAMES]
self._send_json(qualifiers)
@@ -3285,15 +3322,37 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp({'error': 'no terminal profile configured'})
return
sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n')
- self.server.stk_pending = None
- self.server.menu_active = False
- _cancel_menu_timeout()
- self.server.event_list = None
- _reset_proactive_log()
- sm, el = _send_terminal_profile(scc, self.server.terminal_profile)
- self.server.sim_menu = sm
- self.server.event_list = el
- resp = {'menu': sm is not None, 'events': el}
+ resp = _resend_terminal_profile(self.server, scc)
+ self._send_json(resp)
+ self._log_resp(resp)
+ elif self.path == '/api/terminal-profile':
+ body = self._read_body()
+ self._log_req(body)
+ scc = self.server.scc
+ if not scc:
+ self._send_json({'error': _err('reader_not_init', lang)}, 503)
+ self._log_resp({'error': _err('reader_not_init', lang)})
+ return
+ profile = body.get('profile')
+ if profile is not None:
+ h, perr = _validate_tp_hex(profile)
+ if perr:
+ self._send_json({'error': perr}, 400)
+ self._log_resp({'error': perr})
+ return
+ self.server.terminal_profile = h
+ if not self.server.terminal_profile:
+ self._send_json({'error': 'no terminal profile configured'}, 400)
+ self._log_resp({'error': 'no terminal profile configured'})
+ return
+ sys.stderr.write('TERMINAL-PROFILE: re-sending %s\n' % self.server.terminal_profile)
+ try:
+ resp = _resend_terminal_profile(self.server, scc)
+ except Exception as e:
+ resp = {'ok': False, 'error': str(e)}
+ self._send_json(resp, 502)
+ self._log_resp(resp)
+ return
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/help':
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index b41e0aa..eea22b3 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -1028,3 +1028,43 @@ class CapApduSequenceTest(unittest.TestCase):
resp = _scp81_gen_install({'cap_hex': '00'})
self.assertFalse(resp['ok'])
self.assertIn('cap parse failed', resp['error'])
+
+
+class TerminalProfileTest(unittest.TestCase):
+ """Runtime TERMINAL PROFILE: hex validation and re-send."""
+
+ def test_validate_tp_hex(self):
+ from pysim_otaman_server.server import _validate_tp_hex
+ self.assertEqual(_validate_tp_hex('ff 00 80'), ('FF0080', None))
+ self.assertEqual(_validate_tp_hex('80FF'), ('80FF', None))
+ for bad in ('', ' ', 'F', 'XYZ', 'FF0', 'FF' * 256):
+ h, err = _validate_tp_hex(bad)
+ self.assertIsNone(h, bad)
+ self.assertTrue(err, bad)
+
+ def test_resend_terminal_profile_resets_state_and_sends(self):
+ import types
+ from pysim_otaman_server import server as srv
+ server_obj = types.SimpleNamespace(
+ terminal_profile='FF00', stk_pending={'type': 'display_text'},
+ menu_active=True, event_list=[0x03], sim_menu='old')
+ seen = []
+ old_send = srv._send_terminal_profile
+
+ def fake_send(scc, tp):
+ seen.append(tp)
+ return 'menu', [0x09]
+
+ srv._send_terminal_profile = fake_send
+ try:
+ resp = srv._resend_terminal_profile(server_obj, object())
+ finally:
+ srv._send_terminal_profile = old_send
+ self.assertTrue(resp['ok'])
+ self.assertEqual(resp['profile'], 'FF00')
+ self.assertEqual(seen, ['FF00'])
+ self.assertIsNone(server_obj.stk_pending)
+ self.assertFalse(server_obj.menu_active)
+ self.assertEqual(server_obj.event_list, [0x09])
+ self.assertEqual(server_obj.sim_menu, 'menu')
+ self.assertTrue(resp['menu'])