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:
2026-09-21 23:10:52 +03:00
parent e3502c059e
commit a4d5415034
11 changed files with 1051 additions and 8 deletions
+35
View File
@@ -31,6 +31,10 @@ a 2.x PWA).
| `/api/write` | POST | Write raw hex data to a file |
| `/api/apdu` | POST | Raw APDU send |
| `/api/verify-adm` | POST | Verify the card's ADM PIN (from the matched card preset) |
| `/api/esim/chip` | GET | eUICC chip details (EID, EUICCInfo1/2, configured addresses) |
| `/api/esim/profiles` | GET | Installed eSIM profiles with their metadata |
| `/api/esim/notifications` | GET | Pending eSIM notifications (read-only) |
| `/api/esim/profile` | POST | Enable/disable an eSIM profile (card re-initialized after the switch) |
| `/api/help` | POST | pySim help for a given command |
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
@@ -162,6 +166,37 @@ On a wrong key (`63Cx`, x attempts left):
A blocked ADM (`6983`/`9804`) reports `{"ok": false, "sw": "9804",
"blocked": true}` and cannot be recovered without the card's unblock key.
### eSIM / LPA (local ES10 operations)
These endpoints work only when the equipped card is an eUICC (SGP.22/32);
otherwise they answer `400` with `{"error": "The equipped card is not an
eUICC"}`. They use pySim's ES10 static API — no lpac, no SM-DP+ contact, no
profile downloads and no notification processing. All run under the card
lock; `GET /api/status` reports `euicc` and `eid` for the PWA.
- `GET /api/esim/chip` — `{"eid": "8904…", "info1": {…}, "info2": {…},
"addresses": {"default_dp_address": …, "root_ds_address": …},
"errors": {"<part>": "<reason>"}}` (a part the card does not support is
reported in `errors` instead of failing the whole request).
- `GET /api/esim/profiles` — `{"profiles": [{"iccid": "8970…",
"isdp_aid": "A000…", "state": "enabled"|"disabled", "nickname": …,
"provider": …, "name": …, "class": "test"|"provisioning"|"operational",
"owner": "250-99", "icon_type": "png"|"jpg"}], "error": null}`.
- `GET /api/esim/notifications` — `{"notifications": [{"seq_number": 3,
"operations": ["enable"], "address": "smdp.example.org",
"iccid": "8970…"}], "error": null}`.
- `POST /api/esim/profile` — `{"action": "enable"|"disable", "iccid"?: …,
"isdp_aid"?: …, "refresh"?: true}` (one identifier required). The card
usually answers with a REFRESH proactive command first (logged in
`/api/proactive-log`); after a successful switch — or whenever a REFRESH
was seen — the server re-initializes the card like an equip (reset,
re-read ICCID/network state, new `card_session`) and returns
`{"ok": true, "result": "ok", "refresh_seen": true, "reinitialized": true,
"iccid": …, "card_session": N}`. Failures carry the ES10c result code
(`iccidOrAidNotFound`, `profileNotInDisabledState`,
`profileNotInEnabledState`, `disallowedByPolicy`, `wrongProfileReenabling`,
`catBusy`, `undefinedError`) and a short `message`.
### `POST /api/help`
Get structured help for a shell command.
+10 -1
View File
@@ -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>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Три подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд) и <strong>&laquo;eSIM&raquo;</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, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают 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>&laquo;Сетевое состояние&raquo;</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>&laquo;Обновить&raquo;</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>&laquo;eSIM&raquo;</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>&laquo;Включить&raquo;</strong>/<strong>&laquo;Отключить&raquo;</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
View File
@@ -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 &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; 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, &hellip;) 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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v224';
const CACHE = 'simple-v225';
const URLS = [
'index.html',
'help.html',
+94
View File
@@ -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"'));
});
+4
View File
@@ -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 };
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "2.7.21"
version = "2.8.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+246
View File
@@ -0,0 +1,246 @@
# coding=utf-8
"""eSIM / LPA local operations (ES10a/b/c) on the equipped eUICC.
Scope: chip details (EID, EUICCInfo1/2, configured addresses), profile
listing with metadata (GetProfilesInfo), profile switching (Enable/Disable)
and a read-only notification viewer (ListNotification). No profile
downloads, no notification processing/removal and no SM-DP+ interaction.
Every command goes through pySim's ES10 static API
(``CardApplicationISDR.store_data_tlv`` / ``get_eid``) on the card's own
logical channel; the ISD-R is selected first and the previous selection is
restored afterwards. The caller holds ``_CARD_LOCK``.
"""
import re
from osmocom.tlv import flatten_dict_lists
from pySim.euicc import (
AID_ISD_R, CardApplicationISDR, DisableProfileReq, DisableProfileResp,
EnableProfileReq, EnableProfileResp, EuiccConfiguredAddresses, EuiccInfo1,
EuiccInfo2, Iccid, IsdpAid, ListNotificationReq, ListNotificationResp,
ProfileIdentifier, ProfileInfo, ProfileInfoListReq, ProfileInfoListResp,
RefreshFlag, TagList,
)
from pySim.utils import b2h
class EsimError(Exception):
"""eSIM operation failed; ``code`` is a stable identifier for the API."""
def __init__(self, code, message=None):
super().__init__(message or code)
self.code = code
# ES10c result codes -> short English fallback (the PWA localizes by code).
RESULT_MESSAGES = {
'ok': 'ok',
'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',
}
def is_euicc(app):
"""True when the equipped card runs an eUICC profile (SGP.02/22/32)."""
rs = getattr(app, 'rs', None)
return 'eUICC' in str(getattr(rs, 'profile', '') or '')
def _select_isdr(app):
"""Select ISD-R on logical channel 0; returns the lchan's scc."""
rs = getattr(app, 'rs', None)
apps = getattr(getattr(rs, 'mf', None), 'applications', None) or {}
isd_r = apps.get(AID_ISD_R.lower())
if isd_r is None:
raise EsimError('not_an_euicc')
lchan = rs.lchan[0]
lchan.select_file(isd_r)
return lchan.scc
def _restore(app):
"""Return to MF so later server operations start from a known selection."""
try:
app.rs.soft_reset()
except Exception:
pass
def _run(app, fn):
scc = _select_isdr(app)
try:
return fn(scc)
finally:
_restore(app)
def _normalize(value):
"""bytes -> hex string, recursively; scalars pass through."""
if isinstance(value, (bytes, bytearray)):
return b2h(bytes(value)).upper()
if isinstance(value, dict):
return {k: _normalize(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize(v) for v in value]
return value
def _flatten(resp):
"""ES10 response object -> flat dict (single root key unwrapped)."""
if resp is None:
return {}
flat = flatten_dict_lists(resp.to_dict())
if len(flat) == 1:
flat = next(iter(flat.values()))
return _normalize(flat)
def _as_list(value):
"""A repeated TLV child decodes to a dict for one entry, a list for many."""
if value is None:
return []
return value if isinstance(value, list) else [value]
def _repeated(container, key):
"""Collect `key` children from a container that may itself be a dict or a
list of dicts (flatten_dict_lists collapses repeated parents either way)."""
out = []
for item in _as_list(container):
if isinstance(item, dict):
out.extend(_as_list(item.get(key)))
return out
def _transceive(app, cmd_do, resp_cls):
return _run(app, lambda scc: CardApplicationISDR.store_data_tlv(scc, cmd_do, resp_cls))
def _error_text(result):
if isinstance(result, int):
return RESULT_MESSAGES.get('undefinedError')
return RESULT_MESSAGES.get(result, result)
def chip_info(app):
"""EID (ES10b GetEuiccData), EUICCInfo1/2 and the configured addresses."""
out = {'eid': None, 'info1': None, 'info2': None, 'addresses': None,
'errors': {}}
scc = _select_isdr(app)
try:
parts = (
('eid', lambda: CardApplicationISDR.get_eid(scc)),
('info1', lambda: _flatten(CardApplicationISDR.store_data_tlv(
scc, EuiccInfo1(), EuiccInfo1))),
('info2', lambda: _flatten(CardApplicationISDR.store_data_tlv(
scc, EuiccInfo2(), EuiccInfo2))),
('addresses', lambda: _flatten(CardApplicationISDR.store_data_tlv(
scc, EuiccConfiguredAddresses(), EuiccConfiguredAddresses))),
)
for key, fn in parts:
try:
out[key] = fn()
except Exception as e: # one unsupported part must not fail the rest
out['errors'][key] = str(e)
finally:
_restore(app)
return out
def _profile_tag_list():
"""TagList requesting every ProfileInfo tag (pySim-shell's --all set)."""
tags = [nest.tag for nest in ProfileInfo.nested_collection_cls().nested]
u8 = []
for tag in tags:
if tag <= 255:
u8.append(tag)
elif tag <= 65535:
u8.append(tag >> 8)
u8.append(tag & 0xff)
return TagList(decoded=u8)
def profiles(app):
"""ES10c GetProfilesInfo: the profile list with its metadata."""
resp = _transceive(app, ProfileInfoListReq(children=[_profile_tag_list()]),
ProfileInfoListResp)
flat = _flatten(resp)
err = flat.get('profile_info_list_error')
if err is not None:
return {'profiles': [], 'error': _error_text(err)}
seq = flat.get('profile_info_seq')
out = []
for p in _repeated(seq, 'profile_info'):
owner = p.get('profile_owner')
out.append({
'iccid': p.get('iccid'),
'isdp_aid': p.get('isdp_aid'),
'state': p.get('profile_state'),
'nickname': p.get('profile_nickname'),
'provider': p.get('service_provider_name'),
'name': p.get('profile_name'),
'class': p.get('profile_class'),
'icon_type': p.get('icon_type'),
'owner': owner.get('profile_owner_plmn') if isinstance(owner, dict) else None,
})
return {'profiles': out, 'error': None}
def notifications(app):
"""ES10b ListNotification: read-only list of pending notifications."""
resp = _transceive(app, ListNotificationReq(), ListNotificationResp)
flat = _flatten(resp)
err = flat.get('list_notifications_result_error')
if err is not None:
return {'notifications': [], 'error': _error_text(err)}
lst = flat.get('notification_metadata_list')
out = []
for n in _repeated(lst, 'notification_metadata'):
op = n.get('profile_mgmt_operation')
operations = sorted(k for k, v in op.items() if v) if isinstance(op, dict) else []
out.append({
'seq_number': n.get('seq_number'),
'operations': operations,
'address': n.get('notification_address'),
'iccid': n.get('iccid'),
})
return {'notifications': out, 'error': None}
def set_profile_state(app, action, iccid=None, isdp_aid=None, refresh=True):
"""ES10c Enable/DisableProfile for one profile (by ICCID or ISD-P AID)."""
if action not in ('enable', 'disable'):
raise EsimError('bad_action')
ident = []
if isdp_aid:
aid = re.sub(r'[^0-9a-fA-F]', '', str(isdp_aid))
if not aid:
raise EsimError('bad_aid')
ident.append(IsdpAid(decoded=bytes.fromhex(aid)))
elif iccid:
digits = re.sub(r'[^0-9a-fA-F]', '', str(iccid))
if digits[-1:] in ('f', 'F'):
digits = digits[:-1]
if not digits.isdigit():
raise EsimError('bad_iccid')
ident.append(Iccid(decoded=digits))
else:
raise EsimError('missing_profile')
flag = RefreshFlag(decoded=1 if refresh else 0)
if action == 'enable':
cmd = EnableProfileReq(children=[ProfileIdentifier(children=ident), flag])
resp_cls, key = EnableProfileResp, 'enable_result'
else:
cmd = DisableProfileReq(children=[ProfileIdentifier(children=ident), flag])
resp_cls, key = DisableProfileResp, 'disable_result'
flat = _flatten(_transceive(app, cmd, resp_cls))
result = flat.get(key)
ok = result == 'ok'
return {'ok': ok, 'result': result if isinstance(result, str) else 'undefinedError',
'message': _error_text(result)}
+156 -1
View File
@@ -17,6 +17,7 @@ from pysim_simple_server import httpota
from pysim_simple_server import netsim
from pysim_simple_server import netstate
from pysim_simple_server import scp81
from pysim_simple_server import esim
from smartcard.CardMonitoring import CardMonitor, CardObserver
import gsm0338 # registers 'gsm03.38' codec
@@ -26,7 +27,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '2.7.21'
VERSION = '2.8.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -192,12 +193,14 @@ ERROR_MSGS = {
'no_card_state': 'No card state available',
'reader_not_init': 'Reader not initialized',
'not_found': 'Not found',
'not_an_euicc': 'The equipped card is not an eUICC',
},
'ru': {
'app_not_init': 'Сервер не инициализирован',
'no_card_state': 'Состояние карты недоступно',
'reader_not_init': 'Считыватель не инициализирован',
'not_found': 'Не найдено',
'not_an_euicc': 'Подключённая карта — не eUICC',
},
}
@@ -2550,6 +2553,42 @@ def _apply_equipped_card(server):
_tlog('equip: terminal profile done')
def _esim_reinit(server):
"""Full card re-initialization after a profile switch.
A profile switch is logically an equip: the active application (and the
ICCID) changes, so every cached card view is flushed and re-read. The
card is physically reset first (after REFRESH it restarts on the newly
active profile), then the standard equip path runs.
"""
app = server.app
server.equipping = True
try:
try:
server.scc.reset_card()
except Exception as e:
sys.stderr.write('ESIM: card reset failed: %s\n' % e)
old_stdout, old_stderr = app.stdout, sys.stderr
app.stdout = StringIO()
sys.stderr = app.stdout
try:
app.onecmd_plus_hooks('equip')
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
if server.app.card is None:
sys.stderr.write('ESIM: card gone during re-initialization\n')
return False
_apply_equipped_card(server)
sys.stderr.write('ESIM: re-initialized after profile switch\n')
return True
except Exception as e:
sys.stderr.write('ESIM: re-initialization failed: %s\n' % e)
return False
finally:
server.equipping = False
_AUTO_EQUIP = True
_AUTO_EQUIP_BUSY = False
@@ -3453,6 +3492,9 @@ class PysimHandler(BaseHTTPRequestHandler):
'auto_equip': bool(_AUTO_EQUIP),
'card': card.name if card else None,
'profile': str(rs.profile) if rs and rs.profile else None,
'eid': (rs.identity.get('EID') if rs and rs.identity else None)
if connected else None,
'euicc': bool(esim.is_euicc(app)) if connected else False,
'iccid': getattr(self.server, 'iccid', None) if connected else None,
'app_ready': app is not None,
'adm_verified': rs.adm_verified if rs else False,
@@ -3670,6 +3712,119 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('VERIFY ADM → ERROR: %s\n' % e)
self._send_json(resp, 500)
self._log_resp(resp)
elif self.path == '/api/esim/chip':
app = self.server.app
if not app or not self.server.scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
self._log_req()
if not esim.is_euicc(app):
resp = {'error': _err('not_an_euicc', lang)}
self._send_json(resp, 400)
self._log_resp(resp)
return
try:
with _CARD_LOCK:
resp = esim.chip_info(app)
self._send_json(resp)
self._log_resp({'eid': resp.get('eid'), 'errors': resp.get('errors')})
except Exception as e:
resp = {'error': str(e)}
sys.stderr.write('ESIM chip: %s\n' % e)
self._send_json(resp, 500)
self._log_resp(resp)
elif self.path == '/api/esim/profiles':
app = self.server.app
if not app or not self.server.scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
self._log_req()
if not esim.is_euicc(app):
resp = {'error': _err('not_an_euicc', lang)}
self._send_json(resp, 400)
self._log_resp(resp)
return
try:
with _CARD_LOCK:
resp = esim.profiles(app)
self._send_json(resp)
self._log_resp({'profiles': len(resp.get('profiles') or []),
'error': resp.get('error')})
except Exception as e:
resp = {'error': str(e)}
sys.stderr.write('ESIM profiles: %s\n' % e)
self._send_json(resp, 500)
self._log_resp(resp)
elif self.path == '/api/esim/notifications':
app = self.server.app
if not app or not self.server.scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
self._log_req()
if not esim.is_euicc(app):
resp = {'error': _err('not_an_euicc', lang)}
self._send_json(resp, 400)
self._log_resp(resp)
return
try:
with _CARD_LOCK:
resp = esim.notifications(app)
self._send_json(resp)
self._log_resp({'notifications': len(resp.get('notifications') or []),
'error': resp.get('error')})
except Exception as e:
resp = {'error': str(e)}
sys.stderr.write('ESIM notifications: %s\n' % e)
self._send_json(resp, 500)
self._log_resp(resp)
elif self.path == '/api/esim/profile':
app = self.server.app
if not app or not self.server.scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
if not esim.is_euicc(app):
resp = {'error': _err('not_an_euicc', lang)}
self._send_json(resp, 400)
self._log_resp(resp)
return
action = str(body.get('action') or '')
try:
with _CARD_LOCK:
_finish_pending_menu(self.server, self.server.scc)
cursor = _PROACTIVE_ENTRY_ID
resp = esim.set_profile_state(
app, action,
iccid=body.get('iccid'), isdp_aid=body.get('isdp_aid'),
refresh=body.get('refresh', True))
# A REFRESH during the command means the card wants the
# terminal to re-initialize; a lost STORE DATA response
# (T=0 after REFRESH) is covered by the same re-init.
resp['refresh_seen'] = any(
e.get('type_hex') == '01' and e.get('id', 0) > cursor
for e in _PROACTIVE_LOG)
resp['reinitialized'] = False
if resp['ok'] or resp['refresh_seen']:
resp['reinitialized'] = _esim_reinit(self.server)
resp['iccid'] = getattr(self.server, 'iccid', None)
resp['card_session'] = getattr(self.server, 'card_session', None)
self._send_json(resp)
self._log_resp({k: resp.get(k) for k in
('ok', 'result', 'refresh_seen', 'reinitialized')})
except esim.EsimError as e:
resp = {'ok': False, 'error': e.code, 'message': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
except Exception as e:
resp = {'ok': False, 'error': str(e)}
sys.stderr.write('ESIM profile switch: %s\n' % e)
self._send_json(resp, 500)
self._log_resp(resp)
elif self.path == '/api/status-poll':
scc = self.server.scc
if not scc:
+196
View File
@@ -0,0 +1,196 @@
# coding=utf-8
"""Tests for the local eSIM/LPA operations (ES10a/b/c) in esim.py.
The ES10 transport (``CardApplicationISDR.store_data_tlv``/``get_eid``) is
monkeypatched with canned responses, so the mapping to the JSON API and the
ISD-R selection/restore logic are tested without hardware.
"""
import unittest
from types import SimpleNamespace
from pySim.euicc import (
AID_ISD_R, CardApplicationISDR, DisableProfileResp, DisableResult,
EnableProfileResp, EnableResult, EuiccConfiguredAddresses, EuiccInfo1,
Iccid, IsdpAid, ListNotificationResp, NotificationAddress,
NotificationMetadata, NotificationMetadataList, ProfileClass, ProfileInfo,
ProfileInfoListResp, ProfileInfoSeq, ProfileMgmtOperation, ProfileNickname,
ProfileOwner, ProfileOwnerPLMN, ProfileState, SeqNumber,
)
from pySim.utils import h2b
from pysim_simple_server import esim
class FakeLchan:
def __init__(self):
self.scc = SimpleNamespace(name='lchan-scc')
self.selected = None
def select_file(self, app):
self.selected = app
def make_app(profile='Consumer eUICC (SGP.22)', isdr=True):
lchan = FakeLchan()
apps = {}
if isdr:
apps[AID_ISD_R.lower()] = SimpleNamespace(name='ISD-R')
rs = SimpleNamespace(profile=profile,
mf=SimpleNamespace(applications=apps),
lchan=[lchan],
resets=0)
def soft_reset():
rs.resets += 1
rs.soft_reset = soft_reset
return SimpleNamespace(rs=rs), lchan
def profile_info(iccid, aid, state=None, nickname=None, cls=None, owner=None):
children = [Iccid(decoded=iccid), IsdpAid(decoded=bytes.fromhex(aid))]
if state:
children.append(ProfileState(decoded=state))
if nickname:
children.append(ProfileNickname(decoded=nickname))
if cls:
children.append(ProfileClass(decoded=cls))
if owner:
children.append(ProfileOwner(children=[ProfileOwnerPLMN(decoded=owner)]))
return ProfileInfo(children=children)
class EsimTests(unittest.TestCase):
def setUp(self):
self._orig_store = CardApplicationISDR.store_data_tlv
self._orig_eid = CardApplicationISDR.get_eid
self.calls = []
def tearDown(self):
CardApplicationISDR.store_data_tlv = self._orig_store
CardApplicationISDR.get_eid = self._orig_eid
def patch(self, responses):
"""Queue canned responses for store_data_tlv (Exception entries raise)."""
def fake(scc, cmd_do, resp_cls, exp_sw='9000'):
self.calls.append(cmd_do)
r = responses.pop(0)
if isinstance(r, Exception):
raise r
return r
CardApplicationISDR.store_data_tlv = staticmethod(fake)
def patch_eid(self, eid):
CardApplicationISDR.get_eid = staticmethod(lambda scc: eid)
def test_is_euicc(self):
app, _ = make_app()
self.assertTrue(esim.is_euicc(app))
other, _ = make_app(profile='UICC')
self.assertFalse(esim.is_euicc(other))
def test_profiles_maps_metadata(self):
app, lchan = make_app()
resp = ProfileInfoListResp(children=[ProfileInfoSeq(children=[
profile_info('8970119000004002667', 'A0000005591010FFFFFFFF8900000100',
state='enabled', nickname='Work', cls='operational',
owner='250-99'),
profile_info('8970119000004002668', 'A0000005591010FFFFFFFF8900000200',
state='disabled', cls='test'),
])])
self.patch([resp])
out = esim.profiles(app)
self.assertIsNone(out['error'])
self.assertEqual(len(out['profiles']), 2)
first = out['profiles'][0]
self.assertEqual(first['iccid'], '8970119000004002667')
self.assertEqual(first['isdp_aid'], 'A0000005591010FFFFFFFF8900000100')
self.assertEqual(first['state'], 'enabled')
self.assertEqual(first['nickname'], 'Work')
self.assertEqual(first['class'], 'operational')
self.assertEqual(first['owner'], '250-99')
self.assertEqual(out['profiles'][1]['state'], 'disabled')
# ISD-R was selected and the previous selection restored
self.assertEqual(lchan.selected.name, 'ISD-R')
self.assertEqual(app.rs.resets, 1)
# the request asks for every ProfileInfo tag
self.assertIn('9F70', self.calls[0].to_tlv().hex().upper())
def test_profiles_error_result(self):
app, _ = make_app()
resp = ProfileInfoListResp()
resp.from_tlv(h2b('BF2D0381017F'))
self.patch([resp])
out = esim.profiles(app)
self.assertEqual(out['profiles'], [])
self.assertEqual(out['error'], 'undefined error')
def test_notifications_maps_operations(self):
app, _ = make_app()
meta = NotificationMetadata(children=[
SeqNumber(decoded=3),
ProfileMgmtOperation(decoded={'install': False, 'enable': True,
'disable': False, 'delete': False}),
NotificationAddress(decoded='smdp.example.org'),
Iccid(decoded='8970119000004002667'),
])
self.patch([ListNotificationResp(
children=[NotificationMetadataList(children=[meta])])])
out = esim.notifications(app)
self.assertIsNone(out['error'])
self.assertEqual(out['notifications'], [{
'seq_number': 3, 'operations': ['enable'],
'address': 'smdp.example.org', 'iccid': '8970119000004002667',
}])
def test_chip_info_collects_parts_and_errors(self):
app, _ = make_app()
self.patch_eid('89049032000000000000000000000001')
info1 = EuiccInfo1()
info1.from_tlv(h2b('BF20058203010203'))
addresses = EuiccConfiguredAddresses()
addresses.from_tlv(h2b('BF3C13800E736D64702E6578616D706C652E6F72678100'))
self.patch([info1, RuntimeError('no info2'), addresses])
out = esim.chip_info(app)
self.assertEqual(out['eid'], '89049032000000000000000000000001')
self.assertEqual(out['info1'], {'svn': '1.2.3'})
self.assertIsNone(out['info2'])
self.assertIn('info2', out['errors'])
self.assertEqual(out['addresses'].get('default_dp_address'), 'smdp.example.o')
def test_set_profile_state_enable_uses_iccid_and_refresh(self):
app, _ = make_app()
self.patch([EnableProfileResp(children=[EnableResult(decoded='ok')])])
out = esim.set_profile_state(app, 'enable', iccid='8970119000004002667')
self.assertTrue(out['ok'])
self.assertEqual(out['result'], 'ok')
tlv = self.calls[0].to_tlv().hex().upper()
self.assertIn('5A0A980711090000042066F7', tlv) # ProfileIdentifier/ICCID
self.assertTrue(tlv.endswith('810101')) # RefreshFlag = 1
def test_set_profile_state_maps_cat_busy(self):
app, _ = make_app()
self.patch([DisableProfileResp(children=[DisableResult(decoded='catBusy')])])
out = esim.set_profile_state(app, 'disable', iccid='8970119000004002667')
self.assertFalse(out['ok'])
self.assertEqual(out['result'], 'catBusy')
self.assertIn('busy', out['message'])
def test_set_profile_state_validation(self):
app, _ = make_app()
with self.assertRaises(esim.EsimError):
esim.set_profile_state(app, 'delete', iccid='8970119000004002667')
with self.assertRaises(esim.EsimError):
esim.set_profile_state(app, 'enable')
with self.assertRaises(esim.EsimError):
esim.set_profile_state(app, 'enable', iccid='abc')
def test_select_isdr_requires_an_euicc(self):
app, _ = make_app(isdr=False)
with self.assertRaises(esim.EsimError):
esim.chip_info(app)
if __name__ == '__main__':
unittest.main()