esim: local eUICC operations in the Phone simulator tab (v2.8.0)
New eSIM pill (Phone simulator) for SGP.22/32 cards, built on pySim's ES10 static API — no lpac, no new dependencies, no pysim patches, no SM-DP+ interaction: - Chip: EID, EUICCInfo1/2, configured addresses (ES10a/b). - Profiles: GetProfilesInfo with metadata (state, nickname, provider, ICCID, ISD-P AID, class, owner, icon). - Notifications: read-only ListNotification viewer. - Switch: Enable/DisableProfile with RefreshFlag=1; the card's REFRESH (fetched/answered/logged by the transport's proactive handler) or an ok result triggers _esim_reinit() — reset + equip + _apply_equipped_card — so the ICCID, network state and cached views are re-read. - Server: pysim_simple_server/esim.py, GET /api/esim/chip|profiles| notifications, POST /api/esim/profile (all under _CARD_LOCK; 400 not_an_euicc), euicc/eid in /api/status. - Tests: tests/test_esim.py (fake scc, monkeypatched store_data_tlv), frontend/tests/esim.test.js; help EN/RU, docs/api.md, AGENTS.
This commit is contained in:
+10
-1
@@ -444,7 +444,7 @@
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Симулятор телефона</h2>
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>«Телефон»</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>«Конфигурация TR»</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Три подвкладки: <strong>«Телефон»</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), <strong>«Конфигурация TR»</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд) и <strong>«eSIM»</strong> (локальные операции с eUICC).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 Меню STK</h3>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
|
||||
@@ -494,6 +494,15 @@
|
||||
<h4 id="network-state" class="font-medium mb-1">Монитор сетевого состояния</h4>
|
||||
<p class="text-sm mb-3">Рядом с кнопками симуляции компактная панель <strong>«Сетевое состояние»</strong> показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — <strong>сэмулированное состояние сервиса</strong>: <em>Не определено</em>, пока его не задаст сценарий или событие Location status, затем <em>Обычный сервис</em> (зелёный), <em>Ограниченный сервис</em> (жёлтый) или <em>Нет сервиса</em> (красный), с красной пометкой <em>PLMN не разрешён</em>, если location-файлы или EF.FPLMN указывают на отказ регистрации — плюс текущее <strong>местоположение</strong>: PLMN, страна и оператор (из необязательного мирового списка MCC/MNC, если он загружен), LAI/RAI/TAI и <strong>класс роуминга</strong> (<em>Домашняя сеть</em>, если PLMN совпадает с HPLMN; <em>Эквивалентная домашней</em>, если он есть в EF.EHPLMN; иначе <em>Гостевая (роуминг)</em>). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (<code class="font-mono text-sm">init</code>, <code class="font-mono text-sm">write</code>, <code class="font-mono text-sm">read</code>, <code class="font-mono text-sm">refresh</code>); при наведении — все декодированные поля; длинные списки PLMN сокращаются (EF.HPLMNwAcT показывает только первую сеть и пометку <code class="font-mono text-sm">… +N</code>, а технологии доступа — в подсказке). Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status (мульти-IMSI апплеты) и никогда не опрашивает карту — кнопка <strong>«Обновить»</strong> перечитывает все файлы по требованию.</p>
|
||||
|
||||
<h3 id="esim" class="text-lg font-medium mb-2">8.8 eSIM / LPA (локальные операции)</h3>
|
||||
<p class="text-sm mb-2">Для eUICC (SGP.22/SGP.32) подвкладка <strong>«eSIM»</strong> читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+):</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Чип</strong> — EID, EUICCInfo1/2 (версия профиля, SVN, прошивка, версия GlobalPlatform, возможности RSP, категория, …) и настроенные адреса SM-DP+ по умолчанию / корневого DS.</li>
|
||||
<li><strong>Профили</strong> — все профили с состоянием (включён/отключён), ником, оператором, именем, ICCID, ISD-P AID, классом, владельцем и типом значка. Кнопки <strong>«Включить»</strong>/<strong>«Отключить»</strong> переключают профиль; карта обычно сначала отправляет REFRESH (виден в журнале проактивных команд), а после переключения сессия карты переинициализируется как при equip — ICCID, сетевое состояние и все кэшированные данные перечитываются.</li>
|
||||
<li><strong>Уведомления</strong> — список ожидающих уведомлений только для чтения (номер последовательности, операция, адрес, ICCID); ничего не обрабатывается и не удаляется.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c. Если карта не eUICC, подвкладка сообщает об этом.</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
|
||||
+10
-1
@@ -444,7 +444,7 @@
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Phone simulator</h2>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has three pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log), <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands) and <strong>eSIM</strong> (local eUICC operations).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 STK menu</h3>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
|
||||
@@ -494,6 +494,15 @@
|
||||
<h4 id="network-state" class="font-medium mb-1">Network state monitor</h4>
|
||||
<p class="text-sm mb-3">Next to the simulation buttons a compact <strong>Network state</strong> panel shows what the card currently holds and what was last simulated. Its header carries the <strong>simulated service state</strong> — <em>Undefined</em> until a scenario or a Location status event sets it, then <em>Normal service</em> (green), <em>Limited service</em> (amber) or <em>No service</em> (red), with a red <em>PLMN not allowed</em> marker when the location files or EF.FPLMN show a rejection — plus the current <strong>location</strong>: PLMN, country and operator (from the optional worldwide MCC/MNC list when loaded), the LAI/RAI/TAI, and the <strong>roaming class</strong> (<em>Home</em> when the PLMN equals the HPLMN, <em>Home equivalent</em> when it is in EF.EHPLMN, otherwise <em>Guest</em>). Below it, one compact line per monitored file (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) with its decoded summary and how it was last updated (<code class="font-mono text-sm">init</code>, <code class="font-mono text-sm">write</code>, <code class="font-mono text-sm">read</code>, <code class="font-mono text-sm">refresh</code>); hover for the full decoded fields — long PLMN lists are abbreviated (EF.HPLMNwAcT shows only the first network plus a <code class="font-mono text-sm">… +N</code> counter, with the access technologies in the tooltip). The panel reads the files once at equip (only when the ICCID was readable), updates them in place from the bytes the simulator wrote, re-reads EF.IMSI after every scenario and Location-status event (multi-IMSI applets) and never polls the card — use <strong>Refresh</strong> to re-read all files on demand.</p>
|
||||
|
||||
<h3 id="esim" class="text-lg font-medium mb-2">8.8 eSIM / LPA (local operations)</h3>
|
||||
<p class="text-sm mb-2">For an eUICC (SGP.22/SGP.32) the <strong>eSIM</strong> pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact):</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Chip</strong> — EID, EUICCInfo1/2 (profile version, SVN, firmware, GlobalPlatform version, RSP capabilities, category, …) and the configured default SM-DP+ / root DS addresses.</li>
|
||||
<li><strong>Profiles</strong> — every profile with its state (enabled/disabled), nickname, provider, name, ICCID, ISD-P AID, class, owner and icon type. <strong>Enable</strong>/<strong>Disable</strong> switches a profile; the card usually sends REFRESH first (logged in the proactive command log) and after the switch the card session is re-initialized like an equip, so the ICCID, network state and every cached card view are re-read.</li>
|
||||
<li><strong>Notifications</strong> — read-only list of pending notifications (sequence number, operation, address, ICCID); nothing is processed or removed.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used. The card must be an eUICC; otherwise the pill says so.</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
|
||||
+298
-3
@@ -906,6 +906,7 @@
|
||||
<div class="flex flex-wrap justify-center gap-2 mb-3">
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-phone-sub="phone" onclick="phoneSwitchSubtab('phone')" data-l10n="Phone">Phone</button>
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-phone-sub="tr" onclick="phoneSwitchSubtab('tr')" data-l10n="TR Config">TR Config</button>
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-phone-sub="esim" onclick="phoneSwitchSubtab('esim')" data-l10n="eSIM">eSIM</button>
|
||||
</div>
|
||||
<div id="phone-sub-phone">
|
||||
<div class="flex flex-wrap items-start gap-4 mb-4">
|
||||
@@ -1055,6 +1056,27 @@
|
||||
<span class="text-gray-400" data-l10n="Loading...">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="phone-sub-esim" class="hidden">
|
||||
<div id="esim-not-euicc" class="text-sm text-gray-500 dark:text-slate-400 hidden" data-l10n="The equipped card is not an eUICC">The equipped card is not an eUICC</div>
|
||||
<div id="esim-body" class="space-y-4">
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Chip">Chip</legend>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button id="esim-refresh-btn" data-needs="card" onclick="esimFetchAll()" class="px-2.5 py-1 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Refresh">Refresh</button>
|
||||
<span id="esim-status" class="text-xs text-gray-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<div id="esim-chip" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||||
</fieldset>
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profiles">Profiles</legend>
|
||||
<div id="esim-profiles" class="text-xs"></div>
|
||||
</fieldset>
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Notifications">Notifications</legend>
|
||||
<div id="esim-notifications" class="text-xs"></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-scp81" class="tab-content hidden">
|
||||
@@ -1414,7 +1436,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// by the server version check in pysimConnect().
|
||||
const SIMPLE_VERSION = '2.7.21';
|
||||
const SIMPLE_VERSION = '2.8.0';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
@@ -1483,11 +1505,13 @@ function phoneSwitchSubtab(name) {
|
||||
btn.classList.toggle('text-gray-700', !active);
|
||||
btn.classList.toggle('dark:text-slate-300', !active);
|
||||
});
|
||||
['phone', 'tr'].forEach(s => {
|
||||
['phone', 'tr', 'esim'].forEach(s => {
|
||||
document.getElementById('phone-sub-' + s).classList.toggle('hidden', s !== name);
|
||||
});
|
||||
if (name === 'tr') {
|
||||
pysimPliRender();
|
||||
} else if (name === 'esim') {
|
||||
esimFetchAll();
|
||||
} else {
|
||||
stkCheckMenu();
|
||||
pysimEventsRender();
|
||||
@@ -1496,7 +1520,221 @@ function phoneSwitchSubtab(name) {
|
||||
pysimPollStatusInit();
|
||||
tpRefresh();
|
||||
}
|
||||
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict' }[name] || 'stk-menu');
|
||||
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict', esim: 'esim' }[name] || 'stk-menu');
|
||||
}
|
||||
|
||||
// ===== eSIM / LPA (local ES10a/b/c operations) =====
|
||||
let _esimBusy = false;
|
||||
let _esimChip = null;
|
||||
let _esimProfiles = [];
|
||||
let _esimNotifications = [];
|
||||
|
||||
// Chip-info keys -> display labels (unknown keys fall back to a prettified name).
|
||||
const ESIM_CHIP_LABELS = {
|
||||
eid: 'EID', svn: 'SVN', profile_version: 'Profile version',
|
||||
euicc_firmware_ver: 'Firmware version', ext_card_resource: 'Card resource',
|
||||
uicc_capability: 'UICC capability', ts102241_version: 'TS 102 241 version',
|
||||
globalplatform_version: 'GlobalPlatform version', rsp_capability: 'RSP capability',
|
||||
euicc_category: 'Category', pp_version: 'PP version',
|
||||
ss_acreditation_number: 'SS accreditation number',
|
||||
default_dp_address: 'Default SM-DP+ address', root_ds_address: 'Root DS address',
|
||||
euicc_ci_pki_list_for_verification: 'CI PKI (verification)',
|
||||
euicc_ci_pki_list_for_signing: 'CI PKI (signing)',
|
||||
iot_specific_info: 'IoT specific info',
|
||||
};
|
||||
|
||||
const ESIM_RESULT_KEYS = {
|
||||
iccidOrAidNotFound: 'profile not found',
|
||||
profileNotInDisabledState: 'profile is not disabled',
|
||||
profileNotInEnabledState: 'profile is not enabled',
|
||||
disallowedByPolicy: 'disallowed by policy',
|
||||
wrongProfileReenabling: 'wrong profile re-enabling',
|
||||
catBusy: 'card is busy with a CAT session',
|
||||
undefinedError: 'undefined error',
|
||||
};
|
||||
|
||||
function esimLabel(key) {
|
||||
if (ESIM_CHIP_LABELS[key]) return ESIM_CHIP_LABELS[key];
|
||||
return String(key).replace(/_/g, ' ').replace(/^./, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function esimGroupEid(eid) {
|
||||
const s = String(eid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||||
return s.replace(/(.{4})(?=.)/g, '$1 ');
|
||||
}
|
||||
|
||||
// Chip-info value -> [label, value] rows; nested dicts recurse, arrays are
|
||||
// joined, empty values skipped.
|
||||
function esimFieldRows(obj, prefix) {
|
||||
const rows = [];
|
||||
if (obj === null || obj === undefined) return rows;
|
||||
if (typeof obj !== 'object') {
|
||||
rows.push([prefix ? esimLabel(prefix) : '', String(obj)]);
|
||||
return rows;
|
||||
}
|
||||
for (const k of Object.keys(obj)) {
|
||||
const v = obj[k];
|
||||
if (v === null || v === undefined || v === '') continue;
|
||||
const label = prefix ? prefix + ' / ' + k : k;
|
||||
if (Array.isArray(v)) {
|
||||
rows.push([esimLabel(label), v.map(x => (typeof x === 'object' ? JSON.stringify(x) : String(x))).join(', ')]);
|
||||
} else if (typeof v === 'object') {
|
||||
rows.push(...esimFieldRows(v, label));
|
||||
} else {
|
||||
rows.push([esimLabel(label), String(v)]);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function esimResultText(res) {
|
||||
if (!res) return '';
|
||||
if (res.error && !res.result) return String(res.error);
|
||||
return t(ESIM_RESULT_KEYS[res.result] || res.message || res.result || 'Error');
|
||||
}
|
||||
|
||||
function esimStateLabel(state) {
|
||||
if (state === 'enabled') return t('Enabled');
|
||||
if (state === 'disabled') return t('Disabled');
|
||||
return state || '?';
|
||||
}
|
||||
|
||||
function esimOperationsText(ops) {
|
||||
return (ops || []).map(o => t(o)).join(', ');
|
||||
}
|
||||
|
||||
function esimProfileRows(p) {
|
||||
const rows = [];
|
||||
if (p.iccid) rows.push([t('ICCID'), p.iccid]);
|
||||
if (p.isdp_aid) rows.push([t('ISD-P AID'), p.isdp_aid]);
|
||||
if (p.provider) rows.push([t('Provider'), p.provider]);
|
||||
if (p.name) rows.push([t('Profile name'), p.name]);
|
||||
if (p.class) rows.push([t('Class'), p.class]);
|
||||
if (p.owner) rows.push([t('Owner'), p.owner]);
|
||||
if (p.icon_type) rows.push([t('Icon'), p.icon_type]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function esimRenderChip() {
|
||||
const el = document.getElementById('esim-chip');
|
||||
if (!el) return;
|
||||
const c = _esimChip || {};
|
||||
const rows = [];
|
||||
if (c.eid) rows.push(['EID', esimGroupEid(c.eid)]);
|
||||
rows.push(...esimFieldRows(c.info1 || {}, ''));
|
||||
rows.push(...esimFieldRows(c.info2 || {}, ''));
|
||||
rows.push(...esimFieldRows(c.addresses || {}, ''));
|
||||
let html = '';
|
||||
for (const [k, v] of rows) {
|
||||
html += '<div class="flex gap-2 mb-0.5"><span class="w-56 shrink-0 text-right text-gray-500 dark:text-slate-400 break-all">' + esc(k) + '</span>' +
|
||||
'<span class="font-mono break-all">' + esc(v) + '</span></div>';
|
||||
}
|
||||
const errs = Object.keys(c.errors || {});
|
||||
if (errs.length) {
|
||||
html += '<div class="text-xs text-yellow-600 dark:text-yellow-500 mt-1">' + esc(t('Not available')) + ': ' + esc(errs.join(', ')) + '</div>';
|
||||
}
|
||||
el.innerHTML = html || '<span class="text-gray-400">' + esc(t('No data')) + '</span>';
|
||||
}
|
||||
|
||||
function esimRenderProfiles() {
|
||||
const el = document.getElementById('esim-profiles');
|
||||
if (!el) return;
|
||||
if (!_esimProfiles.length) {
|
||||
el.innerHTML = '<span class="text-gray-400">' + esc(t('No profiles')) + '</span>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (const p of _esimProfiles) {
|
||||
const enabled = p.state === 'enabled';
|
||||
const title = p.nickname || p.name || p.iccid || '?';
|
||||
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
|
||||
html += '<div class="flex items-center gap-2 mb-1">' +
|
||||
'<span class="' + (enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-400') + ' font-bold">' + (enabled ? '●' : '○') + '</span>' +
|
||||
'<span class="font-medium">' + esc(title) + '</span>' +
|
||||
'<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(esimStateLabel(p.state)) + '</span>' +
|
||||
'<button data-needs="card" onclick="esimSetProfile(\'' + esc(p.iccid || '') + '\', \'' + esc(p.isdp_aid || '') + '\', \'' + (enabled ? 'disable' : 'enable') + '\')" ' +
|
||||
'class="ml-auto px-2 py-0.5 text-xs rounded text-white disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||
(enabled ? 'bg-gray-600 hover:bg-gray-700' : 'bg-emerald-600 hover:bg-emerald-700') + '">' +
|
||||
esc(t(enabled ? 'Disable' : 'Enable')) + '</button></div>';
|
||||
for (const [k, v] of esimProfileRows(p)) {
|
||||
html += '<div class="flex gap-2"><span class="w-40 shrink-0 text-right text-gray-500 dark:text-slate-400 break-all">' + esc(k) + '</span>' +
|
||||
'<span class="font-mono break-all">' + esc(v) + '</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function esimRenderNotifications() {
|
||||
const el = document.getElementById('esim-notifications');
|
||||
if (!el) return;
|
||||
if (!_esimNotifications.length) {
|
||||
el.innerHTML = '<span class="text-gray-400">' + esc(t('No notifications')) + '</span>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (const n of _esimNotifications) {
|
||||
html += '<div class="mb-1"><span class="text-gray-500 dark:text-slate-400">#' + esc(String(n.seq_number)) + '</span> ' +
|
||||
esc(esimOperationsText(n.operations)) +
|
||||
(n.address ? ' · ' + esc(n.address) : '') +
|
||||
(n.iccid ? ' · <span class="font-mono">' + esc(n.iccid) + '</span>' : '') + '</div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function esimFetchAll() {
|
||||
const statusEl = document.getElementById('esim-status');
|
||||
if (statusEl) statusEl.textContent = t('Loading...');
|
||||
try {
|
||||
const st = await pysimFetch('/api/status');
|
||||
const euicc = !!(st && st.euicc);
|
||||
document.getElementById('esim-not-euicc').classList.toggle('hidden', euicc);
|
||||
document.getElementById('esim-body').classList.toggle('hidden', !euicc);
|
||||
if (!euicc) {
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const chip = await pysimFetch('/api/esim/chip');
|
||||
const prof = await pysimFetch('/api/esim/profiles');
|
||||
const notif = await pysimFetch('/api/esim/notifications');
|
||||
_esimChip = chip && !chip.error ? chip : null;
|
||||
_esimProfiles = (prof && prof.profiles) || [];
|
||||
_esimNotifications = (notif && notif.notifications) || [];
|
||||
esimRenderChip();
|
||||
esimRenderProfiles();
|
||||
esimRenderNotifications();
|
||||
const err = (chip && chip.error) || (prof && prof.error) || (notif && notif.error);
|
||||
if (statusEl) statusEl.textContent = err ? t('Error') + ': ' + err : '';
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function esimSetProfile(iccid, isdpAid, action) {
|
||||
if (_esimBusy) return;
|
||||
const statusEl = document.getElementById('esim-status');
|
||||
const what = action === 'enable' ? t('Enable') : t('Disable');
|
||||
if (!confirm(what + ' — ' + t('the card will be re-initialized. Continue?'))) return;
|
||||
_esimBusy = true;
|
||||
if (statusEl) statusEl.textContent = t('Switching profile — card re-initializing...');
|
||||
try {
|
||||
const body = { action: action, refresh: true };
|
||||
if (iccid) body.iccid = iccid;
|
||||
if (isdpAid) body.isdp_aid = isdpAid;
|
||||
const res = await pysimFetch('/api/esim/profile', body);
|
||||
if (res && res.ok) {
|
||||
if (statusEl) statusEl.textContent = t('Profile switched') +
|
||||
(res.reinitialized ? ' — ' + t('card re-initialized') : '') +
|
||||
(res.refresh_seen ? ' (' + t('REFRESH received') + ')' : '');
|
||||
} else if (statusEl) {
|
||||
statusEl.textContent = t('Profile switch failed') + ': ' + esimResultText(res);
|
||||
}
|
||||
await esimFetchAll();
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
|
||||
} finally {
|
||||
_esimBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTTP OTA constructor =====
|
||||
@@ -13327,6 +13565,58 @@ const LANG_RU = {
|
||||
'from EF.HPLMNwAcT': 'из EF.HPLMNwAcT',
|
||||
'from IMSI': 'из IMSI',
|
||||
'Home PLMN not available (no HPLMNwAcT/IMSI)': 'Домашняя сеть недоступна (нет HPLMNwAcT/IMSI)',
|
||||
'eSIM': 'eSIM',
|
||||
'The equipped card is not an eUICC': 'Подключённая карта — не eUICC',
|
||||
'Chip': 'Чип',
|
||||
'Profiles': 'Профили',
|
||||
'Notifications': 'Уведомления',
|
||||
'Not available': 'Недоступно',
|
||||
'No profiles': 'Нет профилей',
|
||||
'No notifications': 'Нет уведомлений',
|
||||
'Enabled': 'Включён',
|
||||
'Disabled': 'Отключён',
|
||||
'Enable': 'Включить',
|
||||
'Disable': 'Отключить',
|
||||
'ISD-P AID': 'ISD-P AID',
|
||||
'Provider': 'Оператор',
|
||||
'Profile name': 'Имя профиля',
|
||||
'Class': 'Класс',
|
||||
'Owner': 'Владелец',
|
||||
'Icon': 'Значок',
|
||||
'EID': 'EID',
|
||||
'SVN': 'SVN',
|
||||
'Profile version': 'Версия профиля',
|
||||
'Firmware version': 'Версия прошивки',
|
||||
'Card resource': 'Ресурс карты',
|
||||
'UICC capability': 'Возможности UICC',
|
||||
'TS 102 241 version': 'Версия TS 102 241',
|
||||
'GlobalPlatform version': 'Версия GlobalPlatform',
|
||||
'RSP capability': 'Возможности RSP',
|
||||
'Category': 'Категория',
|
||||
'PP version': 'Версия PP',
|
||||
'SS accreditation number': 'Номер аккредитации SS',
|
||||
'Default SM-DP+ address': 'Адрес SM-DP+ по умолчанию',
|
||||
'Root DS address': 'Адрес корневого DS',
|
||||
'CI PKI (verification)': 'CI PKI (проверка)',
|
||||
'CI PKI (signing)': 'CI PKI (подпись)',
|
||||
'IoT specific info': 'IoT-информация',
|
||||
'install': 'установка',
|
||||
'enable': 'включение',
|
||||
'disable': 'отключение',
|
||||
'delete': 'удаление',
|
||||
'profile not found': 'профиль не найден',
|
||||
'profile is not disabled': 'профиль не отключён',
|
||||
'profile is not enabled': 'профиль не включён',
|
||||
'disallowed by policy': 'запрещено политикой',
|
||||
'wrong profile re-enabling': 'неверное повторное включение профиля',
|
||||
'card is busy with a CAT session': 'карта занята CAT-сессией',
|
||||
'undefined error': 'неопределённая ошибка',
|
||||
'the card will be re-initialized. Continue?': 'карта будет переинициализирована. Продолжить?',
|
||||
'Switching profile — card re-initializing...': 'Переключение профиля — карта переинициализируется...',
|
||||
'Profile switched': 'Профиль переключён',
|
||||
'card re-initialized': 'карта переинициализирована',
|
||||
'REFRESH received': 'получен REFRESH',
|
||||
'Profile switch failed': 'Не удалось переключить профиль',
|
||||
'No commands logged yet.': 'Команды пока не регистрировались.',
|
||||
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
|
||||
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
|
||||
@@ -13691,6 +13981,11 @@ function refreshDynamicI18n() {
|
||||
pysimProactiveLogRender();
|
||||
}
|
||||
if (isViewVisible('phone-sub-tr')) pysimPliRender();
|
||||
if (isViewVisible('phone-sub-esim')) {
|
||||
esimRenderChip();
|
||||
esimRenderProfiles();
|
||||
esimRenderNotifications();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v224';
|
||||
const CACHE = 'simple-v225';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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('(?:async\\s+)?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 fn of ['esimLabel', 'esimGroupEid', 'esimFieldRows', 'esimResultText',
|
||||
'esimStateLabel', 'esimOperationsText', 'esimProfileRows']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) {
|
||||
code += html.match(new RegExp('const ' + c + ' = \\{[\\s\\S]*?\\n\\};'))[0]
|
||||
.replace('const ', 'var ') + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
eval(code);
|
||||
|
||||
test('esimGroupEid groups the hex digits in fours', () => {
|
||||
assert.strictEqual(esimGroupEid('89049032000000000000000000000001'),
|
||||
'8904 9032 0000 0000 0000 0000 0000 0001');
|
||||
assert.strictEqual(esimGroupEid(''), '');
|
||||
});
|
||||
|
||||
test('esimLabel maps known chip keys and prettifies the rest', () => {
|
||||
assert.strictEqual(esimLabel('svn'), 'SVN');
|
||||
assert.strictEqual(esimLabel('default_dp_address'), 'Default SM-DP+ address');
|
||||
assert.strictEqual(esimLabel('some_unknown_key'), 'Some unknown key');
|
||||
});
|
||||
|
||||
test('esimFieldRows flattens nested values and skips empties', () => {
|
||||
const rows = esimFieldRows({
|
||||
svn: '1.2.3', empty: null, blank: '', nested: { profile_version: '2.2' },
|
||||
list: ['a', 'b'], zero: 0,
|
||||
});
|
||||
assert.deepStrictEqual(rows, [
|
||||
['SVN', '1.2.3'],
|
||||
['Nested / profile version', '2.2'],
|
||||
['List', 'a, b'],
|
||||
['Zero', '0'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('esimResultText localizes result codes and passes errors through', () => {
|
||||
assert.strictEqual(esimResultText({ result: 'catBusy' }), 'card is busy with a CAT session');
|
||||
assert.strictEqual(esimResultText({ result: 'ok' }), 'ok');
|
||||
assert.strictEqual(esimResultText({ error: 'no card' }), 'no card');
|
||||
assert.strictEqual(esimResultText(null), '');
|
||||
});
|
||||
|
||||
test('esimStateLabel and esimOperationsText map the profile metadata', () => {
|
||||
assert.strictEqual(esimStateLabel('enabled'), 'Enabled');
|
||||
assert.strictEqual(esimStateLabel('disabled'), 'Disabled');
|
||||
assert.strictEqual(esimStateLabel('other'), 'other');
|
||||
assert.strictEqual(esimOperationsText(['enable', 'delete']), 'enable, delete');
|
||||
assert.strictEqual(esimOperationsText([]), '');
|
||||
});
|
||||
|
||||
test('esimProfileRows renders the metadata in a stable order', () => {
|
||||
const rows = esimProfileRows({
|
||||
iccid: '8970119000004002667', isdp_aid: 'A0000005591010FFFFFFFF8900000100',
|
||||
provider: 'Miranda', name: 'Miranda LTE', class: 'operational',
|
||||
owner: '250-99', icon_type: 'png',
|
||||
});
|
||||
assert.deepStrictEqual(rows.map(r => r[0]),
|
||||
['ICCID', 'ISD-P AID', 'Provider', 'Profile name', 'Class', 'Owner', 'Icon']);
|
||||
assert.strictEqual(rows[0][1], '8970119000004002667');
|
||||
});
|
||||
|
||||
test('the eSIM pill is wired into the Phone simulator tab', () => {
|
||||
assert.ok(html.includes('data-phone-sub="esim"'));
|
||||
assert.ok(html.includes('id="phone-sub-esim"'));
|
||||
assert.ok(html.includes("'phone', 'tr', 'esim'"));
|
||||
assert.ok(html.includes('id="esim-refresh-btn"'));
|
||||
assert.ok(html.includes('id="esim-profiles"'));
|
||||
assert.ok(html.includes('id="esim-notifications"'));
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
|
||||
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
|
||||
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n' +
|
||||
'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n' +
|
||||
'globalThis.esimFetchAll = () => { globalThis._esim = (globalThis._esim || 0) + 1; };\n' +
|
||||
'globalThis.netStateFetch = () => { globalThis._netstate = (globalThis._netstate || 0) + 1; };\n';
|
||||
eval(code);
|
||||
|
||||
@@ -44,10 +45,12 @@ function setup() {
|
||||
const buttons = [
|
||||
{ dataset: { phoneSub: 'phone' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'tr' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'esim' }, classList: makeClassList() },
|
||||
];
|
||||
const panels = {
|
||||
'phone-sub-phone': { classList: makeClassList() },
|
||||
'phone-sub-tr': { classList: makeClassList() },
|
||||
'phone-sub-esim': { classList: makeClassList() },
|
||||
};
|
||||
globalThis.document = {
|
||||
querySelectorAll: sel => (sel === '.phone-subtab' ? buttons : []),
|
||||
@@ -55,6 +58,7 @@ function setup() {
|
||||
};
|
||||
globalThis._anchor = null;
|
||||
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
|
||||
globalThis._esim = globalThis._netstate = 0;
|
||||
return { buttons, panels };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user