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:
@@ -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)}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user