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:
2026-09-22 00:14:02 +03:00
parent 91c642a646
commit 86808ce55d
11 changed files with 338 additions and 87 deletions
+16 -7
View File
@@ -1612,6 +1612,20 @@ function esimResultText(res) {
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) {
if (state === 'enabled') return t('Enabled');
if (state === 'disabled') return t('Disabled');
@@ -1757,13 +1771,7 @@ async function esimSetProfile(iccid, isdpAid, action) {
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);
}
if (statusEl) statusEl.textContent = esimSwitchStatus(res);
await esimFetchAll();
} catch (e) {
if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
@@ -13687,6 +13695,7 @@ const LANG_RU = {
'Profile switched': 'Профиль переключён',
'card re-initialized': 'карта переинициализирована',
'REFRESH received': 'получен REFRESH',
'not confirmed': 'не подтверждено',
'Profile switch failed': 'Не удалось переключить профиль',
'No commands logged yet.': 'Команды пока не регистрировались.',
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',