From d3f5aaa443016d0155396f75e8496feab4eeb6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 22 Sep 2026 00:21:43 +0300 Subject: [PATCH] esim: show the profile icon image and its data size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pySim already requests the Icon tag (0x94) together with the other ProfileInfo tags; the mapping dropped it, so the PWA only had the icon type. - esim.profiles(): each profile now carries `icon` (the image bytes as hex) and `icon_size` (byte count), null when the card sent no image. - PWA: esimIconDataUrl() builds a data: URL (png/jpg -> image/png|jpeg) and the profile card shows the image unscaled to the left of the metadata rows; the Icon row keeps the type and adds the data size (e.g. 'png · 1234 B'). - tests: profiles icon/icon_size mapping (+ the absent case) and the frontend data URL / row / render checks; docs and sw simple-v231. --- README.md | 2 +- README_RUS.md | 2 +- docs/api.md | 6 +++++- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 28 ++++++++++++++++++++++++++-- frontend/sw.js | 2 +- frontend/tests/esim.test.js | 26 ++++++++++++++++++++++++-- pysim_simple_server/esim.py | 3 +++ tests/test_esim.py | 16 +++++++++++++--- 10 files changed, 76 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fbb12e0..816404c 100644 --- a/README.md +++ b/README.md @@ -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. -**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. +**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, and the icon image at its natural size with its type/byte size; **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 diff --git a/README_RUS.md b/README_RUS.md index 4ebfd38..13cb10e 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -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 и никогда не опрашивает карту — кнопка **«Обновить»** перечитывает все файлы по требованию. -**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 diff --git a/docs/api.md b/docs/api.md index d1d7fea..18d27b0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -225,7 +225,11 @@ lock; `GET /api/status` reports `euicc` and `eid` for the PWA. - `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}`. + "owner": "250-99", "icon_type": "png"|"jpg", "icon": "89504E47…", + "icon_size": 1234}], "error": null}`. `icon` is the SGP.22 icon data + (tag `0x94`) as hex and `icon_size` its byte count, so a client can show + the profile image (`data:image/;base64,…`); both are `null` when the + card sent no icon. - `GET /api/esim/notifications` — `{"notifications": [{"seq_number": 3, "operations": ["enable"], "address": "smdp.example.org", "iccid": "8970…"}], "error": null}`. diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 4813661..35837ed 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -498,7 +498,7 @@

Для eUICC (SGP.22/SGP.32) подвкладка «eSIM» читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+):

Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c. Если карта не eUICC, подвкладка сообщает об этом.

diff --git a/frontend/help.html b/frontend/help.html index cdb8b1a..6df5b62 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -498,7 +498,7 @@

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):

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.

diff --git a/frontend/index.html b/frontend/index.html index 8546af4..6d018c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1644,10 +1644,27 @@ function esimProfileRows(p) { 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]); + if (p.icon_type) { + let icon = p.icon_type; + if (p.icon_size) icon += ' · ' + p.icon_size + ' B'; + rows.push([t('Icon'), icon]); + } return rows; } +// Profile icon -> data URL at its natural size ('' when the card sent no +// image). The icon type is the SGP.22 iconType: png or jpg. +function esimIconDataUrl(p) { + if (!p || !p.icon || !p.icon_type) return ''; + const hex = String(p.icon).replace(/[^0-9a-fA-F]/g, ''); + let bin = ''; + for (let i = 0; i + 1 < hex.length; i += 2) { + bin += String.fromCharCode(parseInt(hex.substr(i, 2), 16)); + } + const mime = p.icon_type === 'jpg' ? 'image/jpeg' : 'image/' + p.icon_type; + return 'data:' + mime + ';base64,' + btoa(bin); +} + function esimRenderChip() { const el = document.getElementById('esim-chip'); if (!el) return; @@ -1705,10 +1722,17 @@ function esimRenderProfiles() { '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')) + ''; + let rowsHtml = ''; for (const [k, v] of esimProfileRows(p)) { - html += '
' + esc(k) + '' + + rowsHtml += '
' + esc(k) + '' + '' + esc(v) + '
'; } + const iconUrl = esimIconDataUrl(p); + html += '
'; + if (iconUrl) { + html += ''; + } + html += '
' + rowsHtml + '
'; html += '
'; } el.innerHTML = html; diff --git a/frontend/sw.js b/frontend/sw.js index 8cc14d1..0ca8a2b 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v230'; +const CACHE = 'simple-v231'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/esim.test.js b/frontend/tests/esim.test.js index b834ee9..4ecf8f9 100644 --- a/frontend/tests/esim.test.js +++ b/frontend/tests/esim.test.js @@ -23,7 +23,8 @@ function extractFunc(src, name) { let code = ''; for (const fn of ['esimLabel', 'esimGroupEid', 'esimFieldRows', 'esimResultText', - 'esimStateLabel', 'esimOperationsText', 'esimProfileRows', 'esimSwitchStatus']) { + 'esimStateLabel', 'esimOperationsText', 'esimProfileRows', 'esimIconDataUrl', + 'esimSwitchStatus']) { code += extractFunc(html, fn) + '\n'; } for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) { @@ -124,11 +125,32 @@ 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', + owner: '250-99', icon_type: 'png', icon_size: 1234, }); assert.deepStrictEqual(rows.map(r => r[0]), ['ICCID', 'ISD-P AID', 'Provider', 'Profile name', 'Class', 'Owner', 'Icon']); assert.strictEqual(rows[0][1], '8970119000004002667'); + assert.strictEqual(rows[rows.length - 1][1], 'png · 1234 B'); +}); + +test('esimProfileRows shows the bare icon type when the card sent no image', () => { + assert.deepStrictEqual(esimProfileRows({ icon_type: 'jpg' }), [['Icon', 'jpg']]); +}); + +test('esimIconDataUrl builds a data URL for png and jpg icons', () => { + assert.strictEqual(esimIconDataUrl({ icon_type: 'png', icon: '89504E47' }), + 'data:image/png;base64,iVBORw=='); + assert.strictEqual(esimIconDataUrl({ icon_type: 'jpg', icon: 'FFD8' }), + 'data:image/jpeg;base64,/9g='); + assert.strictEqual(esimIconDataUrl({ icon_type: 'png' }), ''); + assert.strictEqual(esimIconDataUrl({ icon: '89504E47' }), ''); + assert.strictEqual(esimIconDataUrl(null), ''); +}); + +test('the profile card renders the icon image next to the rows', () => { + const fn = extractFunc(html, 'esimRenderProfiles'); + assert.match(fn, /esimIconDataUrl\(p\)/); + assert.match(fn, / { diff --git a/pysim_simple_server/esim.py b/pysim_simple_server/esim.py index 11a86b4..5999ee9 100644 --- a/pysim_simple_server/esim.py +++ b/pysim_simple_server/esim.py @@ -419,6 +419,7 @@ def profiles(app): out = [] for p in _repeated(seq, 'profile_info'): owner = p.get('profile_owner') + icon = p.get('icon') out.append({ 'iccid': p.get('iccid'), 'isdp_aid': p.get('isdp_aid'), @@ -428,6 +429,8 @@ def profiles(app): 'name': p.get('profile_name'), 'class': p.get('profile_class'), 'icon_type': p.get('icon_type'), + 'icon': icon, + 'icon_size': len(icon) // 2 if icon else None, 'owner': owner.get('profile_owner_plmn') if isinstance(owner, dict) else None, }) return {'profiles': out, 'error': None} diff --git a/tests/test_esim.py b/tests/test_esim.py index 6cb36c4..3936669 100644 --- a/tests/test_esim.py +++ b/tests/test_esim.py @@ -12,7 +12,7 @@ from types import SimpleNamespace from pySim.euicc import ( AID_ISD_R, CardApplicationISDR, DisableProfileResp, DisableResult, - EnableProfileResp, EnableResult, + EnableProfileResp, EnableResult, Icon, IconType, Iccid, IsdpAid, ListNotificationResp, NotificationAddress, NotificationMetadata, NotificationMetadataList, ProfileClass, ProfileInfo, ProfileInfoListResp, ProfileInfoSeq, ProfileMgmtOperation, ProfileNickname, @@ -55,7 +55,8 @@ def make_app(profile='Consumer eUICC (SGP.22)', isdr=True): return SimpleNamespace(rs=rs), lchan -def profile_info(iccid, aid, state=None, nickname=None, cls=None, owner=None): +def profile_info(iccid, aid, state=None, nickname=None, cls=None, owner=None, + icon=None): children = [Iccid(decoded=iccid), IsdpAid(decoded=bytes.fromhex(aid))] if state: children.append(ProfileState(decoded=state)) @@ -65,6 +66,10 @@ def profile_info(iccid, aid, state=None, nickname=None, cls=None, owner=None): children.append(ProfileClass(decoded=cls)) if owner: children.append(ProfileOwner(children=[ProfileOwnerPLMN(decoded=owner)])) + if icon: + icon_type, icon_data = icon + children.append(IconType(decoded=icon_type)) + children.append(Icon(decoded=bytes.fromhex(icon_data))) return ProfileInfo(children=children) @@ -102,7 +107,7 @@ class EsimTests(unittest.TestCase): resp = ProfileInfoListResp(children=[ProfileInfoSeq(children=[ profile_info('8970119000004002667', 'A0000005591010FFFFFFFF8900000100', state='enabled', nickname='Work', cls='operational', - owner='250-99'), + owner='250-99', icon=('png', '89504E470D0A1A0A')), profile_info('8970119000004002668', 'A0000005591010FFFFFFFF8900000200', state='disabled', cls='test'), ])]) @@ -117,7 +122,12 @@ class EsimTests(unittest.TestCase): self.assertEqual(first['nickname'], 'Work') self.assertEqual(first['class'], 'operational') self.assertEqual(first['owner'], '250-99') + self.assertEqual(first['icon_type'], 'png') + self.assertEqual(first['icon'], '89504E470D0A1A0A') + self.assertEqual(first['icon_size'], 8) self.assertEqual(out['profiles'][1]['state'], 'disabled') + self.assertIsNone(out['profiles'][1]['icon']) + self.assertIsNone(out['profiles'][1]['icon_size']) # ISD-R was selected and the previous selection restored self.assertEqual(lchan.selected.name, 'ISD-R') self.assertEqual(app.rs.resets, 1)