esim: drive the profile switch ourselves; guard the FCP metadata
Disabling a profile failed with 6985 and left the card stuck until an equip. pySim's send_apdu_checksw auto-handler keeps flushing proactive commands after the REFRESH TERMINAL RESPONSE; the card is then mid-switch and answers 6985 to the next FETCH, which propagated as a 500 and skipped the re-initialization. Per SGP.22 v2.6 5.7.16/5.7.17 a 91XX answer is the ISD-R's 'result OK before REFRESH' (step 6) and the switch completes on the TERMINAL RESPONSE or the following RESET (step 8) - lpac treats 91XX the same way and never retries. - esim.py: build_switch_apdu/parse_switch_response/switch_profile split out of set_profile_state; the switch is one raw STORE DATA via scc._tp.send_apdu, a 91XX runs our own FETCH/TR chain (status_poll=False) and is reported as ok, the STORE DATA is never retried and a chain failure still counts the accepted switch. - server.py: /api/esim/profile answers the REFRESH with our chain, then re-initializes the card and re-reads the profile list, returning verified/state_after; _handle_proactive_chain grew status_poll. - /api/status and /api/select: FCP metadata via _fcp_value - an ADF or a failed select (card with the active profile disabled) has no file_descriptor and used to crash the request handler; _get_file_type no longer raises either. - esim._restore logs a failed selection restore instead of swallowing it. - PWA: esimSwitchStatus shows the verified state / not-confirmed warning. - tests: the switch flow (9000 / 91XX / error SW / chain failure), the FCP guards and the status helper; docs and sw simple-v230.
This commit is contained in:
@@ -598,7 +598,7 @@ Values persist on the server until restart. Apply → hex updates; Save → POST
|
|||||||
|
|
||||||
**Network state monitor** — a compact **Network state** panel next to the simulation buttons shows what the card currently holds and what was last simulated. Its header carries the **simulated service state** (*Undefined* until a scenario or a Location status event sets it, then *Normal service* / *Limited service* / *No service*) with a *PLMN not allowed* marker when the location files or EF.FPLMN show a rejection, plus the current location: PLMN, country and operator, the LAI/RAI/TAI, and the **roaming class** (*Home* when the PLMN equals the HPLMN, *Home equivalent* when it is in EF.EHPLMN, otherwise *Guest*). 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 (`init`, `write`, `read`, `refresh`); hover for the full decoded fields — long PLMN lists are abbreviated (EF.HPLMNwAcT shows only the first network plus a `… +N` counter). 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, and never polls the card — **Refresh** re-reads all files on demand.
|
**Network state monitor** — a compact **Network state** panel next to the simulation buttons shows what the card currently holds and what was last simulated. Its header carries the **simulated service state** (*Undefined* until a scenario or a Location status event sets it, then *Normal service* / *Limited service* / *No service*) with a *PLMN not allowed* marker when the location files or EF.FPLMN show a rejection, plus the current location: PLMN, country and operator, the LAI/RAI/TAI, and the **roaming class** (*Home* when the PLMN equals the HPLMN, *Home equivalent* when it is in EF.EHPLMN, otherwise *Guest*). 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 (`init`, `write`, `read`, `refresh`); hover for the full decoded fields — long PLMN lists are abbreviated (EF.HPLMNwAcT shows only the first network plus a `… +N` counter). 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, and never polls the card — **Refresh** re-reads all files on demand.
|
||||||
|
|
||||||
**eSIM** — for an eUICC (SGP.22/SGP.32) the **eSIM** pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact): **Chip** (EID, EUICCInfo1/2 with decoded capability bit lists, card resources, CI PKI identifiers, category, forbidden PPRs, certification data; configured default SM-DP+ / root DS addresses; the rules authorisation table from ES10b GetRat), **Profiles** (state, nickname, provider, ICCID, ISD-P AID, class, owner; **Enable**/**Disable** switches a profile — the card usually sends REFRESH first and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read) and **Notifications** (read-only pending list). No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used; a non-eUICC card is reported as such.
|
**eSIM** — for an eUICC (SGP.22/SGP.32) the **eSIM** pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact): **Chip** (EID, EUICCInfo1/2 with decoded capability bit lists, card resources, CI PKI identifiers, category, forbidden PPRs, certification data; configured default SM-DP+ / root DS addresses; the rules authorisation table from ES10b GetRat), **Profiles** (state, nickname, provider, ICCID, ISD-P AID, class, owner; **Enable**/**Disable** switches a profile — the card usually sends REFRESH first and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read, and the requested state is verified against the re-read profile list) and **Notifications** (read-only pending list). No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used; a non-eUICC card is reported as such.
|
||||||
|
|
||||||
## SCP81
|
## SCP81
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -569,7 +569,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
|
|
||||||
**Монитор сетевого состояния** — компактная панель **«Сетевое состояние»** рядом с кнопками симуляции показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — **сэмулированное состояние сервиса** (*Не определено*, пока его не задаст сценарий или событие Location status, затем *Обычный сервис* / *Ограниченный сервис* / *Нет сервиса*) с пометкой *PLMN не разрешён*, если location-файлы или EF.FPLMN указывают на отказ, плюс текущее местоположение: PLMN, страна и оператор, LAI/RAI/TAI и **класс роуминга** (*Домашняя сеть*, если PLMN совпадает с HPLMN; *Эквивалентная домашней*, если он есть в EF.EHPLMN; иначе *Гостевая (роуминг)*). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (`init`, `write`, `read`, `refresh`); при наведении — все декодированные поля; длинные списки PLMN сокращаются (EF.HPLMNwAcT показывает только первую сеть и пометку `… +N`). Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status и никогда не опрашивает карту — кнопка **«Обновить»** перечитывает все файлы по требованию.
|
**Монитор сетевого состояния** — компактная панель **«Сетевое состояние»** рядом с кнопками симуляции показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — **сэмулированное состояние сервиса** (*Не определено*, пока его не задаст сценарий или событие Location status, затем *Обычный сервис* / *Ограниченный сервис* / *Нет сервиса*) с пометкой *PLMN не разрешён*, если location-файлы или EF.FPLMN указывают на отказ, плюс текущее местоположение: PLMN, страна и оператор, LAI/RAI/TAI и **класс роуминга** (*Домашняя сеть*, если PLMN совпадает с HPLMN; *Эквивалентная домашней*, если он есть в EF.EHPLMN; иначе *Гостевая (роуминг)*). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (`init`, `write`, `read`, `refresh`); при наведении — все декодированные поля; длинные списки PLMN сокращаются (EF.HPLMNwAcT показывает только первую сеть и пометку `… +N`). Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status и никогда не опрашивает карту — кнопка **«Обновить»** перечитывает все файлы по требованию.
|
||||||
|
|
||||||
**eSIM** — для eUICC (SGP.22/SGP.32) подвкладка **«eSIM»** читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+): **Chip** (EID, EUICCInfo1/2 с декодированными битовыми списками возможностей, ресурсами карты, идентификаторами CI PKI, категорией, запрещёнными PPR, данными сертификации; настроенные адреса SM-DP+ по умолчанию / корневого DS; таблица авторизации правил из ES10b GetRat), **Profiles** (состояние, nickname, провайдер, ICCID, AID ISD-P, класс, владелец; **Enable**/**Disable** переключает профиль — карта обычно сначала присылает REFRESH, после чего сессия карты переинициализируется как при equip, и ICCID, сетевое состояние и все кэшированные представления перечитываются) и **Notifications** (список ожидающих уведомлений, только чтение). Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c; для карты не-eUICC подвкладка сообщает об этом.
|
**eSIM** — для eUICC (SGP.22/SGP.32) подвкладка **«eSIM»** читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+): **Chip** (EID, EUICCInfo1/2 с декодированными битовыми списками возможностей, ресурсами карты, идентификаторами CI PKI, категорией, запрещёнными PPR, данными сертификации; настроенные адреса SM-DP+ по умолчанию / корневого DS; таблица авторизации правил из ES10b GetRat), **Profiles** (состояние, nickname, провайдер, ICCID, AID ISD-P, класс, владелец; **Enable**/**Disable** переключает профиль — карта обычно сначала присылает REFRESH, после чего сессия карты переинициализируется как при equip, и ICCID, сетевое состояние и все кэшированные представления перечитываются, а запрошенное состояние проверяется по перечитанному списку профилей) и **Notifications** (список ожидающих уведомлений, только чтение). Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c; для карты не-eUICC подвкладка сообщает об этом.
|
||||||
|
|
||||||
## SCP81
|
## SCP81
|
||||||
|
|
||||||
|
|||||||
+22
-9
@@ -230,16 +230,29 @@ lock; `GET /api/status` reports `euicc` and `eid` for the PWA.
|
|||||||
"operations": ["enable"], "address": "smdp.example.org",
|
"operations": ["enable"], "address": "smdp.example.org",
|
||||||
"iccid": "8970…"}], "error": null}`.
|
"iccid": "8970…"}], "error": null}`.
|
||||||
- `POST /api/esim/profile` — `{"action": "enable"|"disable", "iccid"?: …,
|
- `POST /api/esim/profile` — `{"action": "enable"|"disable", "iccid"?: …,
|
||||||
"isdp_aid"?: …, "refresh"?: true}` (one identifier required). The card
|
"isdp_aid"?: …, "refresh"?: true}` (one identifier required). With the
|
||||||
usually answers with a REFRESH proactive command first (logged in
|
refresh flag set the ISD-R returns OK *before* the REFRESH (SGP.22 v2.6
|
||||||
`/api/proactive-log`); after a successful switch — or whenever a REFRESH
|
§5.7.16/§5.7.17 step 6) and the switch completes upon the TERMINAL RESPONSE
|
||||||
was seen — the server re-initializes the card like an equip (reset,
|
or the following RESET (step 8): a `91xx` answer is that OK, so the server
|
||||||
re-read ICCID/network state, new `card_session`) and returns
|
answers the REFRESH proactive command (logged in `/api/proactive-log`),
|
||||||
`{"ok": true, "result": "ok", "refresh_seen": true, "reinitialized": true,
|
**never retries the STORE DATA** (the mid-switch card answers `6985`) and
|
||||||
"iccid": …, "card_session": N}`. Failures carry the ES10c result code
|
re-initializes the card like an equip (physical reset, re-read
|
||||||
(`iccidOrAidNotFound`, `profileNotInDisabledState`,
|
ICCID/network state, new `card_session`). It then re-reads the profile
|
||||||
|
list and reports the verified result:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok": true, "result": "ok", "refresh_seen": true, "reinitialized": true,
|
||||||
|
"verified": true, "state_after": "disabled", "iccid": "8970…",
|
||||||
|
"card_session": 7}
|
||||||
|
```
|
||||||
|
|
||||||
|
`verified` is `false` when the target profile is not in the requested state
|
||||||
|
after the re-init (`null` when the state could not be read). Failures carry
|
||||||
|
the ES10c result code (`iccidOrAidNotFound`, `profileNotInDisabledState`,
|
||||||
`profileNotInEnabledState`, `disallowedByPolicy`, `wrongProfileReenabling`,
|
`profileNotInEnabledState`, `disallowedByPolicy`, `wrongProfileReenabling`,
|
||||||
`catBusy`, `undefinedError`) and a short `message`.
|
`catBusy`, `undefinedError`) and a short `message`; an unexpected status
|
||||||
|
word is returned as `{"ok": false, "result": "undefinedError", "sw": "6985",
|
||||||
|
"message": "SW 6985"}`.
|
||||||
|
|
||||||
### `POST /api/help`
|
### `POST /api/help`
|
||||||
|
|
||||||
|
|||||||
@@ -498,7 +498,7 @@
|
|||||||
<p class="text-sm mb-2">Для eUICC (SGP.22/SGP.32) подвкладка <strong>«eSIM»</strong> читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+):</p>
|
<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">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Чип</strong> — EID, EUICCInfo1/2 со всеми декодированными полями (версия профиля, SVN, прошивка, расширенные ресурсы карты — число установленных приложений и свободная энергонезависимая/энергозависимая память —, битовые списки возможностей UICC и RSP, идентификаторы CI PKI, категория, запрещённые PPR, версия PP, номер аккредитации SS, данные сертификации), настроенные адреса SM-DP+ по умолчанию / корневого DS и <strong>таблица авторизации правил</strong> (ES10b GetRat: идентификаторы PPR, разрешённые операторы, флаг согласия). Неизвестные TLV сохраняются как сырой hex.</li>
|
<li><strong>Чип</strong> — EID, EUICCInfo1/2 со всеми декодированными полями (версия профиля, SVN, прошивка, расширенные ресурсы карты — число установленных приложений и свободная энергонезависимая/энергозависимая память —, битовые списки возможностей UICC и RSP, идентификаторы CI PKI, категория, запрещённые PPR, версия PP, номер аккредитации SS, данные сертификации), настроенные адреса SM-DP+ по умолчанию / корневого DS и <strong>таблица авторизации правил</strong> (ES10b GetRat: идентификаторы PPR, разрешённые операторы, флаг согласия). Неизвестные TLV сохраняются как сырой hex.</li>
|
||||||
<li><strong>Профили</strong> — все профили с состоянием (включён/отключён), ником, оператором, именем, ICCID, ISD-P AID, классом, владельцем и типом значка. Кнопки <strong>«Включить»</strong>/<strong>«Отключить»</strong> переключают профиль; карта обычно сначала отправляет REFRESH (виден в журнале проактивных команд), а после переключения сессия карты переинициализируется как при equip — ICCID, сетевое состояние и все кэшированные данные перечитываются.</li>
|
<li><strong>Профили</strong> — все профили с состоянием (включён/отключён), ником, оператором, именем, ICCID, ISD-P AID, классом, владельцем и типом значка. Кнопки <strong>«Включить»</strong>/<strong>«Отключить»</strong> переключают профиль; карта обычно сначала отправляет REFRESH (виден в журнале проактивных команд) — терминальный ответ завершает переключение, после чего сессия карты переинициализируется как при equip: ICCID, сетевое состояние и все кэшированные данные перечитываются, а запрошенное состояние проверяется по перечитанному списку профилей.</li>
|
||||||
<li><strong>Уведомления</strong> — список ожидающих уведомлений только для чтения (номер последовательности, операция, адрес, ICCID); ничего не обрабатывается и не удаляется.</li>
|
<li><strong>Уведомления</strong> — список ожидающих уведомлений только для чтения (номер последовательности, операция, адрес, ICCID); ничего не обрабатывается и не удаляется.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="text-sm mb-3">Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c. Если карта не eUICC, подвкладка сообщает об этом.</p>
|
<p class="text-sm mb-3">Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c. Если карта не eUICC, подвкладка сообщает об этом.</p>
|
||||||
|
|||||||
+1
-1
@@ -498,7 +498,7 @@
|
|||||||
<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>
|
<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">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Chip</strong> — EID, EUICCInfo1/2 with every field decoded (profile version, SVN, firmware, extended card resource — installed applications and free non-volatile/volatile memory —, UICC and RSP capability bit lists, CI PKI identifiers, category, forbidden profile policy rules, PP version, SS accreditation number, certification data), the configured default SM-DP+ / root DS addresses, and the <strong>rules authorisation table</strong> (ES10b GetRat: PPR IDs, allowed operators, consent flag). Unknown TLVs are preserved as raw hex.</li>
|
<li><strong>Chip</strong> — EID, EUICCInfo1/2 with every field decoded (profile version, SVN, firmware, extended card resource — installed applications and free non-volatile/volatile memory —, UICC and RSP capability bit lists, CI PKI identifiers, category, forbidden profile policy rules, PP version, SS accreditation number, certification data), the configured default SM-DP+ / root DS addresses, and the <strong>rules authorisation table</strong> (ES10b GetRat: PPR IDs, allowed operators, consent flag). Unknown TLVs are preserved as raw hex.</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>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) — the terminal response completes the switch and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read, and the requested state is verified against the re-read profile list.</li>
|
||||||
<li><strong>Notifications</strong> — read-only list of pending notifications (sequence number, operation, address, ICCID); nothing is processed or removed.</li>
|
<li><strong>Notifications</strong> — read-only list of pending notifications (sequence number, operation, address, ICCID); nothing is processed or removed.</li>
|
||||||
</ul>
|
</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>
|
<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>
|
||||||
|
|||||||
+16
-7
@@ -1612,6 +1612,20 @@ function esimResultText(res) {
|
|||||||
return t(ESIM_RESULT_KEYS[res.result] || res.message || res.result || 'Error');
|
return t(ESIM_RESULT_KEYS[res.result] || res.message || res.result || 'Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status text after an Enable/Disable switch: the card result, the re-init and
|
||||||
|
// the verified resulting profile state.
|
||||||
|
function esimSwitchStatus(res) {
|
||||||
|
if (!res || !res.ok) return t('Profile switch failed') + ': ' + esimResultText(res);
|
||||||
|
let msg = t('Profile switched');
|
||||||
|
if (res.reinitialized) msg += ' — ' + t('card re-initialized');
|
||||||
|
if (res.refresh_seen) msg += ' (' + t('REFRESH received') + ')';
|
||||||
|
if (res.verified === false) {
|
||||||
|
msg += ' — ' + t('not confirmed');
|
||||||
|
if (res.state_after) msg += ' (' + esimStateLabel(res.state_after) + ')';
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
function esimStateLabel(state) {
|
function esimStateLabel(state) {
|
||||||
if (state === 'enabled') return t('Enabled');
|
if (state === 'enabled') return t('Enabled');
|
||||||
if (state === 'disabled') return t('Disabled');
|
if (state === 'disabled') return t('Disabled');
|
||||||
@@ -1757,13 +1771,7 @@ async function esimSetProfile(iccid, isdpAid, action) {
|
|||||||
if (iccid) body.iccid = iccid;
|
if (iccid) body.iccid = iccid;
|
||||||
if (isdpAid) body.isdp_aid = isdpAid;
|
if (isdpAid) body.isdp_aid = isdpAid;
|
||||||
const res = await pysimFetch('/api/esim/profile', body);
|
const res = await pysimFetch('/api/esim/profile', body);
|
||||||
if (res && res.ok) {
|
if (statusEl) statusEl.textContent = esimSwitchStatus(res);
|
||||||
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();
|
await esimFetchAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
|
if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
|
||||||
@@ -13687,6 +13695,7 @@ const LANG_RU = {
|
|||||||
'Profile switched': 'Профиль переключён',
|
'Profile switched': 'Профиль переключён',
|
||||||
'card re-initialized': 'карта переинициализирована',
|
'card re-initialized': 'карта переинициализирована',
|
||||||
'REFRESH received': 'получен REFRESH',
|
'REFRESH received': 'получен REFRESH',
|
||||||
|
'not confirmed': 'не подтверждено',
|
||||||
'Profile switch failed': 'Не удалось переключить профиль',
|
'Profile switch failed': 'Не удалось переключить профиль',
|
||||||
'No commands logged yet.': 'Команды пока не регистрировались.',
|
'No commands logged yet.': 'Команды пока не регистрировались.',
|
||||||
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
|
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v229';
|
const CACHE = 'simple-v230';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
|
|||||||
|
|
||||||
let code = '';
|
let code = '';
|
||||||
for (const fn of ['esimLabel', 'esimGroupEid', 'esimFieldRows', 'esimResultText',
|
for (const fn of ['esimLabel', 'esimGroupEid', 'esimFieldRows', 'esimResultText',
|
||||||
'esimStateLabel', 'esimOperationsText', 'esimProfileRows']) {
|
'esimStateLabel', 'esimOperationsText', 'esimProfileRows', 'esimSwitchStatus']) {
|
||||||
code += extractFunc(html, fn) + '\n';
|
code += extractFunc(html, fn) + '\n';
|
||||||
}
|
}
|
||||||
for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) {
|
for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) {
|
||||||
@@ -98,6 +98,20 @@ test('esimResultText localizes result codes and passes errors through', () => {
|
|||||||
assert.strictEqual(esimResultText(null), '');
|
assert.strictEqual(esimResultText(null), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('esimSwitchStatus reports the switch, re-init, REFRESH and verification', () => {
|
||||||
|
assert.strictEqual(esimSwitchStatus({ ok: true }), 'Profile switched');
|
||||||
|
assert.strictEqual(
|
||||||
|
esimSwitchStatus({ ok: true, reinitialized: true, refresh_seen: true }),
|
||||||
|
'Profile switched — card re-initialized (REFRESH received)');
|
||||||
|
assert.strictEqual(
|
||||||
|
esimSwitchStatus({ ok: true, reinitialized: true, verified: false, state_after: 'disabled' }),
|
||||||
|
'Profile switched — card re-initialized — not confirmed (Disabled)');
|
||||||
|
assert.strictEqual(esimSwitchStatus({ ok: false, result: 'catBusy' }),
|
||||||
|
'Profile switch failed: card is busy with a CAT session');
|
||||||
|
assert.strictEqual(esimSwitchStatus({ ok: false, error: 'SW 6985' }),
|
||||||
|
'Profile switch failed: SW 6985');
|
||||||
|
});
|
||||||
|
|
||||||
test('esimStateLabel and esimOperationsText map the profile metadata', () => {
|
test('esimStateLabel and esimOperationsText map the profile metadata', () => {
|
||||||
assert.strictEqual(esimStateLabel('enabled'), 'Enabled');
|
assert.strictEqual(esimStateLabel('enabled'), 'Enabled');
|
||||||
assert.strictEqual(esimStateLabel('disabled'), 'Disabled');
|
assert.strictEqual(esimStateLabel('disabled'), 'Disabled');
|
||||||
|
|||||||
+57
-15
@@ -13,6 +13,7 @@ restored afterwards. The caller holds ``_CARD_LOCK``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
from osmocom.tlv import BER_TLV_IE, bertlv_parse_one_rawtag, flatten_dict_lists
|
from osmocom.tlv import BER_TLV_IE, bertlv_parse_one_rawtag, flatten_dict_lists
|
||||||
from pySim.euicc import (
|
from pySim.euicc import (
|
||||||
@@ -65,11 +66,15 @@ def _select_isdr(app):
|
|||||||
|
|
||||||
|
|
||||||
def _restore(app):
|
def _restore(app):
|
||||||
"""Return to MF so later server operations start from a known selection."""
|
"""Return to MF so later server operations start from a known selection.
|
||||||
|
|
||||||
|
Best effort: a card whose active profile is disabled has no filesystem to
|
||||||
|
select, so the restore can legitimately fail - the selection metadata is
|
||||||
|
then guarded by the status endpoint instead of crashing it."""
|
||||||
try:
|
try:
|
||||||
app.rs.soft_reset()
|
app.rs.soft_reset()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
sys.stderr.write('ESIM: selection restore failed: %s\n' % e)
|
||||||
|
|
||||||
|
|
||||||
def _run(app, fn):
|
def _run(app, fn):
|
||||||
@@ -449,8 +454,8 @@ def notifications(app):
|
|||||||
return {'notifications': out, 'error': None}
|
return {'notifications': out, 'error': None}
|
||||||
|
|
||||||
|
|
||||||
def set_profile_state(app, action, iccid=None, isdp_aid=None, refresh=True):
|
def build_switch_apdu(action, iccid=None, isdp_aid=None, refresh=True):
|
||||||
"""ES10c Enable/DisableProfile for one profile (by ICCID or ISD-P AID)."""
|
"""STORE DATA APDU hex for ES10c Enable/DisableProfile (no sending)."""
|
||||||
if action not in ('enable', 'disable'):
|
if action not in ('enable', 'disable'):
|
||||||
raise EsimError('bad_action')
|
raise EsimError('bad_action')
|
||||||
ident = []
|
ident = []
|
||||||
@@ -469,14 +474,51 @@ def set_profile_state(app, action, iccid=None, isdp_aid=None, refresh=True):
|
|||||||
else:
|
else:
|
||||||
raise EsimError('missing_profile')
|
raise EsimError('missing_profile')
|
||||||
flag = RefreshFlag(decoded=1 if refresh else 0)
|
flag = RefreshFlag(decoded=1 if refresh else 0)
|
||||||
if action == 'enable':
|
req_cls = EnableProfileReq if action == 'enable' else DisableProfileReq
|
||||||
cmd = EnableProfileReq(children=[ProfileIdentifier(children=ident), flag])
|
tx_do = req_cls(children=[ProfileIdentifier(children=ident), flag]).to_tlv()
|
||||||
resp_cls, key = EnableProfileResp, 'enable_result'
|
return '80E29100%02x%s00' % (len(tx_do), tx_do.hex().upper())
|
||||||
else:
|
|
||||||
cmd = DisableProfileReq(children=[ProfileIdentifier(children=ident), flag])
|
|
||||||
resp_cls, key = DisableProfileResp, 'disable_result'
|
def parse_switch_response(action, data_hex):
|
||||||
flat = _flatten(_transceive(app, cmd, resp_cls))
|
"""STORE DATA response TLV -> {'ok', 'result', 'message'}."""
|
||||||
result = flat.get(key)
|
resp_cls = EnableProfileResp if action == 'enable' else DisableProfileResp
|
||||||
ok = result == 'ok'
|
key = 'enable_result' if action == 'enable' else 'disable_result'
|
||||||
return {'ok': ok, 'result': result if isinstance(result, str) else 'undefinedError',
|
result = None
|
||||||
|
if data_hex:
|
||||||
|
resp = resp_cls()
|
||||||
|
resp.from_tlv(bytes.fromhex(data_hex))
|
||||||
|
result = _flatten(resp).get(key)
|
||||||
|
return {'ok': result == 'ok',
|
||||||
|
'result': result if isinstance(result, str) else 'undefinedError',
|
||||||
'message': _error_text(result)}
|
'message': _error_text(result)}
|
||||||
|
|
||||||
|
|
||||||
|
def switch_profile(send_apdu, run_chain, action, iccid=None, isdp_aid=None,
|
||||||
|
refresh=True):
|
||||||
|
"""Run an ES10c Enable/DisableProfile switch.
|
||||||
|
|
||||||
|
``send_apdu(apdu_hex) -> (data_hex, sw)`` performs one raw STORE DATA and
|
||||||
|
``run_chain(sw91)`` answers the proactive command(s) the card sends
|
||||||
|
alongside the switch (True when a REFRESH was answered).
|
||||||
|
|
||||||
|
With the refresh flag set the ISD-R returns OK *before* the REFRESH
|
||||||
|
(SGP.22 v2.6 §5.7.16/§5.7.17 step 6) and the switch completes upon the
|
||||||
|
TERMINAL RESPONSE or the following RESET (step 8). A 91XX status is that
|
||||||
|
OK: the STORE DATA is never retried (the mid-switch card answers 6985 to
|
||||||
|
the retry) and the caller re-initializes the card afterwards."""
|
||||||
|
apdu = build_switch_apdu(action, iccid, isdp_aid, refresh)
|
||||||
|
data, sw = send_apdu(apdu)
|
||||||
|
if sw == '9000':
|
||||||
|
out = parse_switch_response(action, data)
|
||||||
|
out['refresh_seen'] = False
|
||||||
|
return out
|
||||||
|
if sw and sw.startswith('91'):
|
||||||
|
refresh_seen = False
|
||||||
|
try:
|
||||||
|
refresh_seen = bool(run_chain(sw))
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('ESIM: REFRESH chain failed: %s\n' % e)
|
||||||
|
return {'ok': True, 'result': 'ok', 'message': 'ok',
|
||||||
|
'refresh_seen': refresh_seen}
|
||||||
|
return {'ok': False, 'result': 'undefinedError', 'sw': sw,
|
||||||
|
'message': 'SW %s' % sw}
|
||||||
|
|||||||
+100
-29
@@ -255,20 +255,34 @@ def _parse_help_text(text):
|
|||||||
|
|
||||||
|
|
||||||
def _get_file_type(lchan, cur_file):
|
def _get_file_type(lchan, cur_file):
|
||||||
if cur_file and cur_file.name:
|
try:
|
||||||
if cur_file.name.startswith('EF.'):
|
if cur_file and cur_file.name:
|
||||||
if lchan and lchan.selected_file_fcp:
|
if cur_file.name.startswith('EF.'):
|
||||||
ft = lchan.selected_file_type()
|
if lchan and lchan.selected_file_fcp:
|
||||||
if ft != 'df':
|
ft = lchan.selected_file_type()
|
||||||
return lchan.selected_file_structure()
|
if ft != 'df':
|
||||||
return 'transparent'
|
return lchan.selected_file_structure()
|
||||||
if cur_file.name.startswith('DF.') or cur_file.name.startswith('ADF.') or cur_file.name == 'MF':
|
return 'transparent'
|
||||||
return 'df'
|
if cur_file.name.startswith('DF.') or cur_file.name.startswith('ADF.') or cur_file.name == 'MF':
|
||||||
if lchan and lchan.selected_file_fcp:
|
return 'df'
|
||||||
return lchan.selected_file_structure()
|
if lchan and lchan.selected_file_fcp:
|
||||||
|
return lchan.selected_file_structure()
|
||||||
|
except Exception:
|
||||||
|
# No usable FCP (an ADF, or a failed select while the card has no
|
||||||
|
# active profile) - report no file type instead of crashing.
|
||||||
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fcp_value(lchan, name):
|
||||||
|
"""One FCP-derived selection value, or None when the card delivered no
|
||||||
|
usable FCP (an ADF, or a failed select) - never raises."""
|
||||||
|
try:
|
||||||
|
return getattr(lchan, name)()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _fid4(sel):
|
def _fid4(sel):
|
||||||
"""True if sel is a 4-digit hex FID."""
|
"""True if sel is a 4-digit hex FID."""
|
||||||
return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or '')))
|
return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or '')))
|
||||||
@@ -2589,6 +2603,52 @@ def _esim_reinit(server):
|
|||||||
server.equipping = False
|
server.equipping = False
|
||||||
|
|
||||||
|
|
||||||
|
def _esim_refresh_chain(server, sw91):
|
||||||
|
"""Answer the proactive command(s) that accompany a profile switch.
|
||||||
|
|
||||||
|
With the refresh flag set the card sends REFRESH (SGP.22 §5.7.16 step 7);
|
||||||
|
it is answered and the chain stops - the card reset that follows performs
|
||||||
|
the switch. No STATUS poll is sent: the card is mid-switch and refuses
|
||||||
|
further commands (6985) until the reset."""
|
||||||
|
seen = {'refresh': False}
|
||||||
|
|
||||||
|
def on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst):
|
||||||
|
if cmd_type == 0x01:
|
||||||
|
seen['refresh'] = True
|
||||||
|
return 'exit'
|
||||||
|
return None
|
||||||
|
|
||||||
|
_handle_proactive_chain(server.scc, sw91, on_fetch=on_fetch,
|
||||||
|
status_poll=False)
|
||||||
|
return seen['refresh']
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_iccid(value):
|
||||||
|
"""ICCID comparison form: hex digits without the trailing F pad."""
|
||||||
|
s = re.sub(r'[^0-9a-fA-F]', '', str(value or '')).upper()
|
||||||
|
return s[:-1] if s.endswith('F') else s
|
||||||
|
|
||||||
|
|
||||||
|
def _esim_verify_switch(app, action, iccid=None, isdp_aid=None):
|
||||||
|
"""Re-read the profile list and check the requested state took effect."""
|
||||||
|
expected = 'enabled' if action == 'enable' else 'disabled'
|
||||||
|
try:
|
||||||
|
profs = esim.profiles(app).get('profiles') or []
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('ESIM: state verification failed: %s\n' % e)
|
||||||
|
return {'verified': None, 'state_after': None}
|
||||||
|
want_iccid = _norm_iccid(iccid) if iccid else None
|
||||||
|
want_aid = re.sub(r'[^0-9a-fA-F]', '', str(isdp_aid or '')).upper() or None
|
||||||
|
state_after = None
|
||||||
|
for p in profs:
|
||||||
|
if (want_iccid and _norm_iccid(p.get('iccid')) == want_iccid) or \
|
||||||
|
(want_aid and re.sub(r'[^0-9a-fA-F]', '',
|
||||||
|
str(p.get('isdp_aid') or '')).upper() == want_aid):
|
||||||
|
state_after = p.get('state')
|
||||||
|
break
|
||||||
|
return {'verified': state_after == expected, 'state_after': state_after}
|
||||||
|
|
||||||
|
|
||||||
_AUTO_EQUIP = True
|
_AUTO_EQUIP = True
|
||||||
_AUTO_EQUIP_BUSY = False
|
_AUTO_EQUIP_BUSY = False
|
||||||
|
|
||||||
@@ -3112,18 +3172,20 @@ def _parse_setup_menu_items(raw):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def _handle_proactive_chain(scc, sw91, on_fetch=None):
|
def _handle_proactive_chain(scc, sw91, on_fetch=None, status_poll=True):
|
||||||
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
|
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
|
||||||
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait."""
|
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait.
|
||||||
|
``status_poll=False`` skips the trailing STATUS (used by the profile
|
||||||
|
switch, where the card is mid-switch and must not be queried further)."""
|
||||||
global _PROACTIVE_BUSY
|
global _PROACTIVE_BUSY
|
||||||
_PROACTIVE_BUSY = True
|
_PROACTIVE_BUSY = True
|
||||||
try:
|
try:
|
||||||
return _run_proactive_chain(scc, sw91, on_fetch)
|
return _run_proactive_chain(scc, sw91, on_fetch, status_poll)
|
||||||
finally:
|
finally:
|
||||||
_PROACTIVE_BUSY = False
|
_PROACTIVE_BUSY = False
|
||||||
|
|
||||||
|
|
||||||
def _run_proactive_chain(scc, sw91, on_fetch=None):
|
def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True):
|
||||||
sys.stderr.write('91XX chain: sw=%s\n' % sw91)
|
sys.stderr.write('91XX chain: sw=%s\n' % sw91)
|
||||||
sw = sw91
|
sw = sw91
|
||||||
paused = False
|
paused = False
|
||||||
@@ -3155,7 +3217,7 @@ def _run_proactive_chain(scc, sw91, on_fetch=None):
|
|||||||
sys.stderr.write('TR: cmd=%02x type=%02x -> %s %s\n' % (cmd_num, cmd_type, tr_rv[1], ('(%d bytes)' % len(tr_tlv))))
|
sys.stderr.write('TR: cmd=%02x type=%02x -> %s %s\n' % (cmd_num, cmd_type, tr_rv[1], ('(%d bytes)' % len(tr_tlv))))
|
||||||
_record_tr(entry, tr_tlv, tr_rv[1])
|
_record_tr(entry, tr_tlv, tr_rv[1])
|
||||||
sw = tr_rv[1]
|
sw = tr_rv[1]
|
||||||
if sw == '9000':
|
if sw == '9000' and status_poll:
|
||||||
sys.stderr.write('STATUS poll (chain ended)\n')
|
sys.stderr.write('STATUS poll (chain ended)\n')
|
||||||
st_data, st_sw = _send_status(scc)
|
st_data, st_sw = _send_status(scc)
|
||||||
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
||||||
@@ -3508,9 +3570,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'type': cur_file.__class__.__name__ if cur_file else None,
|
'type': cur_file.__class__.__name__ if cur_file else None,
|
||||||
'path': str(lchan.get_cwd()) if lchan else None,
|
'path': str(lchan.get_cwd()) if lchan else None,
|
||||||
'file_type': _get_file_type(lchan, cur_file),
|
'file_type': _get_file_type(lchan, cur_file),
|
||||||
'file_size': lchan.selected_file_size() if lchan else None,
|
'file_size': _fcp_value(lchan, 'selected_file_size'),
|
||||||
'record_len': lchan.selected_file_record_len() if lchan else None,
|
'record_len': _fcp_value(lchan, 'selected_file_record_len'),
|
||||||
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
|
'num_of_rec': _fcp_value(lchan, 'selected_file_num_of_rec'),
|
||||||
} if cur_file else None,
|
} if cur_file else None,
|
||||||
'channels': [str(i) for i, ch in rs.lchan.items() if ch] if rs else [],
|
'channels': [str(i) for i, ch in rs.lchan.items() if ch] if rs else [],
|
||||||
}
|
}
|
||||||
@@ -3798,24 +3860,33 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
_finish_pending_menu(self.server, self.server.scc)
|
_finish_pending_menu(self.server, self.server.scc)
|
||||||
cursor = _PROACTIVE_ENTRY_ID
|
cursor = _PROACTIVE_ENTRY_ID
|
||||||
resp = esim.set_profile_state(
|
resp = esim.switch_profile(
|
||||||
app, action,
|
self.server.scc._tp.send_apdu,
|
||||||
iccid=body.get('iccid'), isdp_aid=body.get('isdp_aid'),
|
lambda sw: _esim_refresh_chain(self.server, sw),
|
||||||
|
action, iccid=body.get('iccid'),
|
||||||
|
isdp_aid=body.get('isdp_aid'),
|
||||||
refresh=body.get('refresh', True))
|
refresh=body.get('refresh', True))
|
||||||
# A REFRESH during the command means the card wants the
|
# A REFRESH during the command means the card wants the
|
||||||
# terminal to re-initialize; a lost STORE DATA response
|
# terminal to re-initialize; the switch itself completes on
|
||||||
# (T=0 after REFRESH) is covered by the same re-init.
|
# the TERMINAL RESPONSE or the reset (SGP.22 5.7.16 step 8).
|
||||||
resp['refresh_seen'] = any(
|
resp['refresh_seen'] = resp.get('refresh_seen') or any(
|
||||||
e.get('type_hex') == '01' and e.get('id', 0) > cursor
|
e.get('type_hex') == '01' and e.get('id', 0) > cursor
|
||||||
for e in _PROACTIVE_LOG)
|
for e in _PROACTIVE_LOG)
|
||||||
resp['reinitialized'] = False
|
resp['reinitialized'] = False
|
||||||
|
resp['verified'] = None
|
||||||
|
resp['state_after'] = None
|
||||||
if resp['ok'] or resp['refresh_seen']:
|
if resp['ok'] or resp['refresh_seen']:
|
||||||
resp['reinitialized'] = _esim_reinit(self.server)
|
resp['reinitialized'] = _esim_reinit(self.server)
|
||||||
|
if resp['reinitialized']:
|
||||||
|
resp.update(_esim_verify_switch(
|
||||||
|
app, action, iccid=body.get('iccid'),
|
||||||
|
isdp_aid=body.get('isdp_aid')))
|
||||||
resp['iccid'] = getattr(self.server, 'iccid', None)
|
resp['iccid'] = getattr(self.server, 'iccid', None)
|
||||||
resp['card_session'] = getattr(self.server, 'card_session', None)
|
resp['card_session'] = getattr(self.server, 'card_session', None)
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp({k: resp.get(k) for k in
|
self._log_resp({k: resp.get(k) for k in
|
||||||
('ok', 'result', 'refresh_seen', 'reinitialized')})
|
('ok', 'result', 'refresh_seen', 'reinitialized',
|
||||||
|
'verified', 'state_after')})
|
||||||
except esim.EsimError as e:
|
except esim.EsimError as e:
|
||||||
resp = {'ok': False, 'error': e.code, 'message': str(e)}
|
resp = {'ok': False, 'error': e.code, 'message': str(e)}
|
||||||
self._send_json(resp, 400)
|
self._send_json(resp, 400)
|
||||||
@@ -4005,9 +4076,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'name': cur.name if cur else None,
|
'name': cur.name if cur else None,
|
||||||
'fid': cur.fid.upper() if cur and cur.fid else None,
|
'fid': cur.fid.upper() if cur and cur.fid else None,
|
||||||
'file_type': _get_file_type(lchan, cur),
|
'file_type': _get_file_type(lchan, cur),
|
||||||
'file_size': lchan.selected_file_size() if lchan else None,
|
'file_size': _fcp_value(lchan, 'selected_file_size'),
|
||||||
'record_len': lchan.selected_file_record_len() if lchan else None,
|
'record_len': _fcp_value(lchan, 'selected_file_record_len'),
|
||||||
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
|
'num_of_rec': _fcp_value(lchan, 'selected_file_num_of_rec'),
|
||||||
'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None,
|
'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None,
|
||||||
'apdu_times': apdu_times,
|
'apdu_times': apdu_times,
|
||||||
'exists': True,
|
'exists': True,
|
||||||
|
|||||||
+123
-21
@@ -171,32 +171,73 @@ class EsimTests(unittest.TestCase):
|
|||||||
self.assertEqual(out['rat'], [{'ppr_ids': ['ppr1', 'ppr2'],
|
self.assertEqual(out['rat'], [{'ppr_ids': ['ppr1', 'ppr2'],
|
||||||
'allowed_operators': [], 'ppr_flags': []}])
|
'allowed_operators': [], 'ppr_flags': []}])
|
||||||
|
|
||||||
def test_set_profile_state_enable_uses_iccid_and_refresh(self):
|
def test_build_switch_apdu_encodes_iccid_and_refresh(self):
|
||||||
app, _ = make_app()
|
apdu = esim.build_switch_apdu('enable', iccid='8970119000004002667')
|
||||||
self.patch([EnableProfileResp(children=[EnableResult(decoded='ok')])])
|
self.assertTrue(apdu.startswith('80E29100'))
|
||||||
out = esim.set_profile_state(app, 'enable', iccid='8970119000004002667')
|
self.assertTrue(apdu.endswith('00'))
|
||||||
self.assertTrue(out['ok'])
|
self.assertIn('5A0A980711090000042066F7', apdu) # ProfileIdentifier/ICCID
|
||||||
self.assertEqual(out['result'], 'ok')
|
self.assertIn('810101', apdu) # RefreshFlag = 1
|
||||||
tlv = self.calls[0].to_tlv().hex().upper()
|
apdu = esim.build_switch_apdu(
|
||||||
self.assertIn('5A0A980711090000042066F7', tlv) # ProfileIdentifier/ICCID
|
'disable', isdp_aid='A0000005591010FFFFFFFF8900000100', refresh=False)
|
||||||
self.assertTrue(tlv.endswith('810101')) # RefreshFlag = 1
|
self.assertIn('4F10A0000005591010FFFFFFFF8900000100', apdu)
|
||||||
|
self.assertIn('810100', apdu) # RefreshFlag = 0
|
||||||
|
|
||||||
def test_set_profile_state_maps_cat_busy(self):
|
def test_build_switch_apdu_validation(self):
|
||||||
app, _ = make_app()
|
with self.assertRaises(esim.EsimError):
|
||||||
self.patch([DisableProfileResp(children=[DisableResult(decoded='catBusy')])])
|
esim.build_switch_apdu('delete', iccid='8970119000004002667')
|
||||||
out = esim.set_profile_state(app, 'disable', iccid='8970119000004002667')
|
with self.assertRaises(esim.EsimError):
|
||||||
|
esim.build_switch_apdu('enable')
|
||||||
|
with self.assertRaises(esim.EsimError):
|
||||||
|
esim.build_switch_apdu('enable', iccid='abc')
|
||||||
|
|
||||||
|
def test_parse_switch_response_maps_result_codes(self):
|
||||||
|
resp = DisableProfileResp(children=[DisableResult(decoded='catBusy')])
|
||||||
|
out = esim.parse_switch_response('disable', resp.to_tlv().hex())
|
||||||
self.assertFalse(out['ok'])
|
self.assertFalse(out['ok'])
|
||||||
self.assertEqual(out['result'], 'catBusy')
|
self.assertEqual(out['result'], 'catBusy')
|
||||||
self.assertIn('busy', out['message'])
|
self.assertIn('busy', out['message'])
|
||||||
|
out = esim.parse_switch_response('disable', '')
|
||||||
|
self.assertFalse(out['ok'])
|
||||||
|
self.assertEqual(out['result'], 'undefinedError')
|
||||||
|
|
||||||
def test_set_profile_state_validation(self):
|
def test_switch_profile_parses_ok_response(self):
|
||||||
app, _ = make_app()
|
sent = []
|
||||||
with self.assertRaises(esim.EsimError):
|
resp = EnableProfileResp(children=[EnableResult(decoded='ok')])
|
||||||
esim.set_profile_state(app, 'delete', iccid='8970119000004002667')
|
out = esim.switch_profile(
|
||||||
with self.assertRaises(esim.EsimError):
|
lambda apdu: sent.append(apdu) or (resp.to_tlv().hex(), '9000'),
|
||||||
esim.set_profile_state(app, 'enable')
|
lambda sw: self.fail('no chain expected'),
|
||||||
with self.assertRaises(esim.EsimError):
|
'enable', iccid='8970119000004002667')
|
||||||
esim.set_profile_state(app, 'enable', iccid='abc')
|
self.assertTrue(out['ok'])
|
||||||
|
self.assertEqual(out['result'], 'ok')
|
||||||
|
self.assertFalse(out['refresh_seen'])
|
||||||
|
self.assertEqual(len(sent), 1)
|
||||||
|
|
||||||
|
def test_switch_profile_treats_91xx_as_ok_and_runs_the_chain(self):
|
||||||
|
chain = []
|
||||||
|
out = esim.switch_profile(
|
||||||
|
lambda apdu: ('', '9111'),
|
||||||
|
lambda sw: chain.append(sw) or True,
|
||||||
|
'disable', iccid='8970119000004002667')
|
||||||
|
self.assertTrue(out['ok'])
|
||||||
|
self.assertEqual(out['result'], 'ok')
|
||||||
|
self.assertTrue(out['refresh_seen'])
|
||||||
|
self.assertEqual(chain, ['9111'])
|
||||||
|
|
||||||
|
def test_switch_profile_chain_failure_keeps_the_accepted_switch(self):
|
||||||
|
def boom(sw):
|
||||||
|
raise RuntimeError('fetch failed')
|
||||||
|
out = esim.switch_profile(lambda apdu: ('', '910f'), boom,
|
||||||
|
'disable', iccid='8970119000004002667')
|
||||||
|
self.assertTrue(out['ok'])
|
||||||
|
self.assertFalse(out['refresh_seen'])
|
||||||
|
|
||||||
|
def test_switch_profile_reports_error_sw(self):
|
||||||
|
out = esim.switch_profile(lambda apdu: ('', '6985'),
|
||||||
|
lambda sw: self.fail('no chain expected'),
|
||||||
|
'disable', iccid='8970119000004002667')
|
||||||
|
self.assertFalse(out['ok'])
|
||||||
|
self.assertEqual(out['sw'], '6985')
|
||||||
|
self.assertEqual(out['result'], 'undefinedError')
|
||||||
|
|
||||||
def test_select_isdr_requires_an_euicc(self):
|
def test_select_isdr_requires_an_euicc(self):
|
||||||
app, _ = make_app(isdr=False)
|
app, _ = make_app(isdr=False)
|
||||||
@@ -296,6 +337,67 @@ class EsimInfoDecodeTests(unittest.TestCase):
|
|||||||
self.assertEqual(out['root_ds_address'], 'testrootsmds.gsma.com')
|
self.assertEqual(out['root_ds_address'], 'testrootsmds.gsma.com')
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionMetadataTests(unittest.TestCase):
|
||||||
|
"""Status/select FCP metadata must never crash when the card has no usable
|
||||||
|
FCP (an ADF selected, or a failed select while the active profile is
|
||||||
|
disabled)."""
|
||||||
|
|
||||||
|
class FakeLchan:
|
||||||
|
def __init__(self, fcp):
|
||||||
|
self.selected_file_fcp = fcp
|
||||||
|
|
||||||
|
def selected_file_size(self):
|
||||||
|
return self.selected_file_fcp.get('file_size')
|
||||||
|
|
||||||
|
def selected_file_record_len(self):
|
||||||
|
return self.selected_file_fcp['file_descriptor'].get('record_len')
|
||||||
|
|
||||||
|
def selected_file_num_of_rec(self):
|
||||||
|
return self.selected_file_fcp['file_descriptor'].get('num_of_rec')
|
||||||
|
|
||||||
|
def selected_file_structure(self):
|
||||||
|
return self.selected_file_fcp['file_descriptor']['file_descriptor_byte']['structure']
|
||||||
|
|
||||||
|
def selected_file_type(self):
|
||||||
|
return self.selected_file_fcp['file_descriptor']['file_descriptor_byte'].get('file_type', 'ef')
|
||||||
|
|
||||||
|
def test_missing_fcp_yields_none(self):
|
||||||
|
from pysim_simple_server import server
|
||||||
|
lchan = self.FakeLchan(None)
|
||||||
|
self.assertIsNone(server._fcp_value(lchan, 'selected_file_size'))
|
||||||
|
self.assertIsNone(server._fcp_value(lchan, 'selected_file_record_len'))
|
||||||
|
self.assertIsNone(server._fcp_value(lchan, 'selected_file_num_of_rec'))
|
||||||
|
# an EF without FCP falls back to 'transparent' (the historical default)
|
||||||
|
self.assertEqual(server._get_file_type(lchan, SimpleNamespace(name='EF.ICCID')),
|
||||||
|
'transparent')
|
||||||
|
|
||||||
|
def test_adf_fcp_without_file_descriptor_yields_none(self):
|
||||||
|
from pysim_simple_server import server
|
||||||
|
lchan = self.FakeLchan({'file_size': None})
|
||||||
|
self.assertIsNone(server._fcp_value(lchan, 'selected_file_record_len'))
|
||||||
|
self.assertIsNone(server._fcp_value(lchan, 'selected_file_num_of_rec'))
|
||||||
|
self.assertIsNone(server._get_file_type(lchan, SimpleNamespace(name='EF.ICCID')))
|
||||||
|
|
||||||
|
def test_usable_fcp_reports_values(self):
|
||||||
|
from pysim_simple_server import server
|
||||||
|
fcp = {'file_size': 10,
|
||||||
|
'file_descriptor': {'record_len': 5, 'num_of_rec': 2,
|
||||||
|
'file_descriptor_byte': {'structure': 'linear_fixed',
|
||||||
|
'file_type': 'ef'}}}
|
||||||
|
lchan = self.FakeLchan(fcp)
|
||||||
|
self.assertEqual(server._fcp_value(lchan, 'selected_file_size'), 10)
|
||||||
|
self.assertEqual(server._fcp_value(lchan, 'selected_file_record_len'), 5)
|
||||||
|
self.assertEqual(server._get_file_type(lchan, SimpleNamespace(name='EF.ADN')),
|
||||||
|
'linear_fixed')
|
||||||
|
|
||||||
|
def test_norm_iccid_drops_the_f_pad(self):
|
||||||
|
from pysim_simple_server import server
|
||||||
|
self.assertEqual(server._norm_iccid('8970119000004002667'),
|
||||||
|
'8970119000004002667')
|
||||||
|
self.assertEqual(server._norm_iccid('98 90 71 11 90 00 00 40 02 66 7F'),
|
||||||
|
'989071119000004002667')
|
||||||
|
|
||||||
|
|
||||||
class EsimRoutingTests(unittest.TestCase):
|
class EsimRoutingTests(unittest.TestCase):
|
||||||
def test_esim_routes_are_in_the_right_http_handlers(self):
|
def test_esim_routes_are_in_the_right_http_handlers(self):
|
||||||
from pysim_simple_server import server
|
from pysim_simple_server import server
|
||||||
|
|||||||
Reference in New Issue
Block a user