From d036ef79a1f2ba61b6fd310768799bf530ccc29e 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: Fri, 23 Oct 2026 11:55:31 +0300
Subject: [PATCH] =?UTF-8?q?v3.33.0:=20Edit=20decoded=20=E2=80=94=20SPN,=20?=
=?UTF-8?q?PLMNwAcT=20family,=20ADN=20family,=20PNN?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The file manager gains a client-side decoded editor. EF_ENCODERS pairs
each editable EF with a pure encoder and a form schema (fields / list /
items); the file is read raw, the decoder fills the form, the browser
re-encodes it and the existing update path writes it.
Shapes: SPN (display-condition bits + Annex A text), PLMNwAcT/
OPLMNwAcT/HPLMNwAcT (rows with PLMN + ACT, add/remove),
ADN/FDN/SDN/MSISDN/LND/BDN (one form per record with a checkbox that
auto-checks on change; BCD, length and EXT handling; BDN's own X+15
layout) and PNN (per-record TLV items 43/45/80, add/remove, unknown
TLVs preserved).
Unchanged fields are copied verbatim (SSC/extended-BCD numbers, PLMN
rows including unmodelled satellite ACT bits, PNN codings/AddCI bits,
all-FF files, the BDN comparison-method byte), every checked record is
validated and encoded before anything is written, and edits that would
break a reference are refused.
Also: docs/decoded-editing.md (the spec survey behind the design and
the release waves), the efPlmnAct EC-GSM-IoT-only 0x88 fix, the PNN
spare-bit decode fix, and the /api/write|read cmd2-exception
false-success fix.
1022 Python + 1013 frontend green; simple-v466.
---
CHANGELOG.md | 31 +
README.md | 3 +-
README_RUS.md | 3 +-
docs/decoded-editing.md | 185 ++++++
frontend/help-ru.html | 3 +-
frontend/help.html | 3 +-
frontend/index.html | 951 ++++++++++++++++++++++++++++-
frontend/sw.js | 2 +-
frontend/tests/adm_verify.test.js | 5 +-
frontend/tests/ef_decode.test.js | 27 +-
frontend/tests/ef_encode.test.js | 513 ++++++++++++++++
frontend/tests/fs_dec_edit.test.js | 655 ++++++++++++++++++++
frontend/tests/fs_edit.test.js | 2 +
pyproject.toml | 2 +-
pysim_simple_server/server.py | 32 +-
tests/test_cmd_exception.py | 118 ++++
16 files changed, 2508 insertions(+), 27 deletions(-)
create mode 100644 docs/decoded-editing.md
create mode 100644 frontend/tests/ef_encode.test.js
create mode 100644 frontend/tests/fs_dec_edit.test.js
create mode 100644 tests/test_cmd_exception.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f22b0cb..d709026 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,37 @@ every change carries a descriptive commit message.
## [Unreleased]
+## [3.33.0] — 2026-10-23
+
+### Added
+
+- **Edit decoded** in the file manager: a file is read raw, decoded into its own editable
+ form, re-encoded in the browser and written through the normal update path. The first
+ wave covers **EF.SPN**, **EF.PLMNwAcT / EF.OPLMNwAcT / EF.HPLMNwAcT**, the dialling-number
+ files **EF.ADN / EF.FDN / EF.SDN / EF.MSISDN / EF.LND / EF.BDN** and **EF.PNN**. Shapes:
+ a flat transparent form (SPN), a transparent list editor with add/remove rows (PLMN+ACT),
+ per-record forms with a selection checkbox that is auto-checked when a field changes
+ (dialling numbers, PNN) and per-record TLV item rows with add/remove/edit (PNN's full and
+ short names plus the PLMN additional information). All checked records are encoded and
+ validated before anything is written; unchanged fields, record lengths, extension
+ pointers (EF.EXT1–EXT5), the BDN comparison-method byte and unknown PNN TLVs are
+ preserved, and an edit that would break a reference (a number continued in an extension
+ file, a dropped mandatory PNN full name) is refused.
+- `docs/decoded-editing.md`: the file-format survey behind the design (every known EF
+ classified by structure/coding into the editor's shapes S0–S10, with the release waves).
+
+### Fixed
+
+- **EF.BDN** decodes and encodes with its own `X+15` layout (the trailing Comparison Method
+ Pointer); the shared ADN codec had treated it as `X+14`.
+- `efPlmnAct` decoded the EC-GSM-IoT-only ACT (`b8=1,b4=1,b3=0`, `0x0088`) as nothing while
+ checking the impossible `0x0086` (TS 31.102 Table 4.2.5.2).
+- PNN network names: the decoder now trusts a non-zero spare-bit count before the
+ "all bytes < 0x80 → uncompressed" heuristic, so a valid packed name can no longer be
+ misread (and later written back wrong).
+- `/api/write` and `/api/read` report a cmd2 exception in the captured output as a failure
+ instead of `success: true` with `SW: 9000`.
+
## [3.32.7] — 2026-10-23
### Removed
diff --git a/README.md b/README.md
index ad087ca..bde2d71 100644
--- a/README.md
+++ b/README.md
@@ -594,7 +594,8 @@ Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills pinned above the tree together with **Probe all files**, remembered in `localStorage`)
- **Read raw** / **Read decoded** — read the selected file as a hex dump or as a decoded field table (client-side decoders for IMSI, ICCID, SPN, PLMN lists, LOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the highlighted pill is the current view and clicking either pill (re-)reads the file. The decoded table is built in the browser from the same raw bytes (no server-side decode); **Read decoded** is disabled when the client-side decoders do not cover the file (resolved by name/FID before reading)
-- **Edit raw** — edit the raw hex data and **Save** to write back (or **Cancel**); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — **Edit raw** switches to the hex view first and reads the file if needed
+- **Edit raw** — edit the raw hex data and **Save** to write back (or **Cancel**); transparent files use a textarea, record files one input per record with selection checkboxes. **Edit raw** switches to the hex view first and reads the file if needed
+- **Edit decoded** — edit through the file's own decoded form (client-side encoders; written via the normal update path): **EF.SPN**, **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, the dialling-number files **ADN / FDN / SDN / MSISDN / LND / BDN**, and **EF.PNN**. Transparent files get one form (PLMN rows and PNN items with add/remove); record files get one form per record with a checkbox that is auto-checked when a field changes, so only edited records are written. **Save** validates every selected record before writing; extension pointers (EF.EXT1–EF.EXT5) and the BDN comparison-method byte are preserved, and editing a number continued in an extension file is refused. The shape survey and the planned coverage are in `docs/decoded-editing.md`
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy. Missing files are shown in red (✗); a present but empty DF shows `(empty)`
- **ADM** — files that need the administrator PIN fail with `6982`/`9804`; when the matching card preset (same ICCID) carries an ADM key, a **Verify ADM** button appears next to the error and the header badge (`ADM ✓/✗ ⚿`) becomes clickable. Every wrong key consumes an attempt (the remaining attempts are shown and a retry asks for confirmation); a blocked ADM needs the card's unblock key
diff --git a/README_RUS.md b/README_RUS.md
index e0af258..9cce3e8 100644
--- a/README_RUS.md
+++ b/README_RUS.md
@@ -569,7 +569,8 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. таблица декодируется в браузере из тех же сырых байт (без серверного декодирования); **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
-- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. Декодированный вид только для чтения — **Edit raw** сначала переключается на hex и при необходимости читает файл
+- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. **Edit raw** сначала переключается на hex и при необходимости читает файл
+- **Edit decoded** — редактирование в собственной декодированной форме файла (клиентские кодировщики; запись обычным путём): **EF.SPN**, **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, файлы номеров **ADN / FDN / SDN / MSISDN / LND / BDN** и **EF.PNN**. Для transparent-файла — одна форма (строки PLMN и элементы PNN с добавлением/удалением); для record-файла — форма на запись с флажком, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. **Save** проверяет все выбранные записи до записи; указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется. Обзор форматов и план охвата — в `docs/decoded-editing.md`
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
diff --git a/docs/decoded-editing.md b/docs/decoded-editing.md
new file mode 100644
index 0000000..828cec5
--- /dev/null
+++ b/docs/decoded-editing.md
@@ -0,0 +1,185 @@
+# Decoded editing — file-format survey and design
+
+Status: design note for the **Edit decoded** feature (v3.33.0). It classifies
+the EFs the workbench knows (the client-side decoder registry) by the spec's
+own structure and content coding, assigns each an editing *shape*, and sets the
+release waves. The reference is the pinned specs (`projects/docs/uicc/`):
+3GPP TS 31.102 v18.10 (USIM), TS 51.011 v4.15 (SIM), TS 24.008 v18.8 (network
+name / PLMN / address coding), TS 102 221 (file structure / Annex A text).
+pySim is a cross-check only.
+
+The editor is client-side, like the decoders: the file is read raw, decoded
+into its schema's values, edited, re-encoded and written with `/api/write`.
+Only the files listed with a shape have an encoder; everything else keeps the
+raw editor.
+
+## 1. Editing shapes
+
+Each shape is one renderer + one encoder contract. `fields` are the flat form
+values, `list` is a transparent file holding N items, `items` is a record
+holding a TLV list; the record-scoped variants reuse the same field/item
+renderers inside a per-record block with a "write this record" checkbox
+(auto-checked when an input changes).
+
+| Shape | File / record content | Editor |
+|---|---|---|
+| **S0** read-only | card- or network-managed values (location, keys, counters) | no editor; the button says why |
+| **S1** scalar/flag TR | one or a few bytes / flags | one form |
+| **S2** text TR | Annex A (TS 102 221) or NAI text | one form, text field |
+| **S3** BCD TR | ICCID / IMSI (nibble-swapped BCD, parity/type nibble) | one form, digits |
+| **S4** PLMN list TR | n × 3-byte PLMN (TS 24.008) | item rows, add/remove |
+| **S5** PLMN+ACT list TR | n × (3-byte PLMN + 2-byte ACT) | item rows, add/remove, tech multi-select |
+| **S6** bit/service list TR | service or identifier bit lists | item list with labels (checkbox per service/id) |
+| **S7** fixed-record fields | alpha + BCD address + ids (ADN family), LAI+id (OPL), code+alpha+category (ECC), TP parameters (SMSP) | per-record form + checkbox |
+| **S8** record TLV list | PNN (name TLVs), DIR (application templates), ARR (access rules), PBR (A8/A9/AA containers) | per-record item rows, add/remove/edit; unknown TLVs preserved |
+| **S9** nested structures | PBR containers, DIR templates, EXT chains | later: a tree, or read-only with a structured view |
+| **S10** dedicated builders | SMS (TPDU), SMSP (TPDU parameter set), key material | later: a dedicated form, not a generic encoder |
+
+Rules that apply to every encoder (and are what the tests pin):
+
+- encode **only the edited values**, from the original record/file bytes:
+ length, padding and anything the schema does not expose are preserved;
+- never change the file/record length; refuse when the new value does not fit;
+- preserve the original text coding when the text is unchanged, and choose a
+ spec-legal coding when it changed (Annex A: unpacked GSM 7-bit / UCS-2; PNN
+ network name: packed GSM 7-bit with the spare-bit count / UCS-2);
+- refuse (not silently truncate) when an edit would invalidate a cross-file
+ reference (`EF.EXT1` chain, PBR/IAP, OPL→PNN record id, CMI pointer).
+
+## 2. Survey of the known files
+
+`Wave` is the release that adds the encoder. Files we do not decode yet are
+listed only where they matter for the plan; the spec's ~120 USIM EFs include
+many specialized ones (MMS, VGCS/VBS, ePDG, IMS configuration, …) that are not
+editing candidates.
+
+### 2.1 Transparent files
+
+| EF | FID | Spec | Size | Content | Shape | Wave |
+|---|---|---|---|---|---|---|
+| EF.ICCID | 2FE2 | TS 51.011 10.3.1 | 10 B | BCD (nibble-swapped), F padding | S3 | 3.34 |
+| EF.IMSI | 6F07 | TS 31.102 4.2.2 | 9 B | length byte + '9'-prefixed BCD | S3 | 3.34 |
+| EF.IST | 6F07 | TS 31.102 4.2.2 (SAIP) | 1–2 B | service bits | S6 | 3.34 |
+| EF.UST | 6F38 | TS 31.102 4.2.8 | X ≥ 1 | service bits | S6 | 3.34 |
+| EF.SST | 6F38 | TS 51.011 10.3.7 | X ≥ 1 | service bits | S6 | 3.34 |
+| EF.EST | 6F56 | TS 31.102 4.2.47 | X ≥ 1 | enabled-service bits | S6 | 3.34 |
+| EF.LI | 6F05 | TS 31.102 4.2.1 | 2n | ISO 639 language codes | S6 | 3.34 |
+| EF.LP / EF.PL | 6F05 / 2F05 | TS 51.011 10.3.1/10.3.2 | 1–n | language codes | S6 | 3.34 |
+| EF.SPN | 6F46 | TS 31.102 4.2.12 | 17 B | condition byte + Annex A text | S2 | **3.33** |
+| EF.PLMNsel | 6F30 | TS 51.011 10.3.4 | 3n (n ≥ 8) | PLMN list | S4 | 3.34 |
+| EF.FPLMN | 6F7B | TS 31.102 4.2.16 | 3n (n ≥ 4) | PLMN list | S4 | 3.34 |
+| EF.EHPLMN | 6FD9 | TS 31.102 4.2.84 | 3n | PLMN list | S4 | 3.34 |
+| EF.PLMNwAcT | 6F60 | TS 31.102 4.2.5 | 5n (n ≥ 8) | PLMN + ACT | S5 | **3.33** |
+| EF.OPLMNwAcT | 6F61 | TS 31.102 4.2.53 | 5n (n ≥ 8) | PLMN + ACT | S5 | **3.33** |
+| EF.HPLMNwAcT | 6F62 | TS 31.102 4.2.54 | 5n (n ≥ 1) | PLMN + ACT | S5 | **3.33** |
+| EF.SPDI | 6FCD | TS 31.102 4.2.66 | x | TLV: A3 > 80 > PLMN list | S4 | 3.34 |
+| EF.GID1 / GID2 | 6F3E / 6F3F | TS 31.102 4.2.10/4.2.11 | n | text (Annex A) | S2 | 3.34 |
+| EF.SUUME | 6F54 | TS 51.011 10.3.34 | X+Y | alpha (Annex A) | S2 | 3.35 |
+| EF.IMPI/DOMAIN/IMPU | 6F02/6F03/6F04 | TS 31.102 (ISIM) | X | NAI text | S2 | 3.35 |
+| EF.ACC | 6F78 | TS 31.102 4.2.15 | 2 B | class bits | S1/S6 | 3.34 |
+| EF.PHASE | 6FAE | TS 51.011 10.3.19 | 1 B | phase id | S1 | 3.34 |
+| EF.HPPLMN | 6F31 | TS 31.102 4.2.6 | 1 B | search period | S1 | 3.34 |
+| EF.AD | 6FAD | TS 31.102 4.2.18 | 4+X | op mode, info, MNC length | S1 | 3.34 |
+| EF.CBMI | 6F45 | TS 31.102 4.2.14 | 2n | CB message ids | S6 | 3.34 |
+| EF.CBMID | 6F48 | TS 31.102 4.2.20 | 2n | CB message ids | S6 | 3.34 |
+| EF.CBMIR | 6F50 | TS 31.102 4.2.22 | 4n | CB id ranges | S6 | 3.34 |
+| EF.ACL | 6F57 | TS 31.102 4.2.48 | X | version byte + TLV list (DD APNs) | S8-TR | 3.35 |
+| EF.LOCI | 6F7E | TS 31.102 4.2.17 | 11 B | TMSI/LAI/status | **S0** | — |
+| EF.PSLOCI | 6F73 | TS 31.102 4.2.23 | 14 B | P-TMSI/RAI/status | **S0** | — |
+| EF.EPSLOCI | 6FE3 | TS 31.102 4.2.91 | 18 B | GUTI/TAI/status | **S0** | — |
+| EF.LOCIGPRS | 6F53 | TS 51.011 10.3.33 | 14 B | GPRS location | **S0** | — |
+| EF.Kc / KcGPRS | 6F20 / 6F52 | TS 31.102 4.4.3.1/4.4.3.2 | 9 B | ciphering keys | **S0** | — |
+| EF.Keys / KeysPS | 6F08 / 6F09 | TS 31.102 4.2.3/4.2.4 | 33 B | keys + status | **S0** | — |
+
+### 2.2 Linear fixed / cyclic files
+
+| EF | FID | Spec | Record | Content | Shape | Wave |
+|---|---|---|---|---|---|---|
+| EF.ADN | 6F3A | TS 31.102 4.4.2.3 | X+14 | alpha + len + TON/NPI + 10 B BCD + CCP + EXT1 | S7 | **3.33** |
+| EF.FDN | 6F3B | TS 31.102 4.2.24 | X+14 | ADN layout (EXT2) | S7 | **3.33** |
+| EF.SDN | 6F49 | TS 31.102 4.2.29 | X+14 | ADN layout (EXT3) | S7 | **3.33** |
+| EF.MSISDN | 6F40 | TS 31.102 4.2.26 | X+14 | ADN layout (EXT5) | S7 | **3.33** |
+| EF.LND | 6F44 | TS 51.011 10.5.8 | X+14 | ADN layout, **cyclic** | S7 | **3.33** |
+| EF.BDN | 6F4D | TS 31.102 4.2.44 | **X+15** | ADN layout + **Comparison Method Pointer** | S7 | **3.33** (fix) |
+| EF.PNN | 6FC5 | TS 31.102 4.2.58 | X ≥ 3 | TLVs: 43 full / 45 short / 80 PLMN info | S8 | **3.33** |
+| EF.OPL | 6FC6 | TS 31.102 4.2.59 | 8 | LAI (PLMN+LAC) + PNN record id | S7 | 3.35 |
+| EF.ECC (SIM) | 6FB7 | TS 51.011 10.3.27 | 3n | emergency codes (TR) | S6 | 3.34 |
+| EF.ECC (USIM) | 6FB7 | TS 31.102 4.2.21 | X+4 | code(3) + alpha(X) + category(1) | S7 | 3.35 |
+| EF.EXT1–EXT5 | 6F4A–6F55 | TS 31.102 4.4.2.4 / 4.2.30/31/45/37 | 13 | type-dependent: additional data / subaddress | S9 | 3.36 |
+| EF.SMSP | 6F42 | TS 31.102 4.2.27 | 28+Y | alpha + parameter indicators + TP-DA/SCA/PID/DCS/VP | S10 | 3.36 |
+| EF.SMS | 6F3C | TS 31.102 4.2.25 | 176 | status byte + TPDU | S10 | 3.36 |
+| EF.ARR | 2F06/6F06 | TS 31.102 4.2.55 | X | access-rule TLVs (ISO 7816-4) | S8/S9 | 3.36 |
+| EF.DIR | 2F00 | TS 51.011 10.3.3 | X | application template TLVs (61 > 4F/50/73…) | S8/S9 | 3.36 |
+| EF.PBR | 4F30/4FXX | TS 31.102 4.4.2.1 | X | BER-TLV: A8/A9/AA containers, C0–CC links | S9 | later |
+| EF.EPSNSC | 6FE4 | TS 31.102 4.2.92 | — | NAS security context | **S0** | — |
+| EF.ICI/OCI (call logs) | 6F80/6F81 | TS 31.102 4.2.33/4.2.34 | X+28/X+27 | ADN-like + time/date → cyclic, ME-written | **S0** (plan) | — |
+
+### 2.3 Coverage vs the spec
+
+The decoder registry covers ~45 EFs. The spec defines many more; the
+remaining ones are either specialized protocols (MMS, VGCS/VBS, ePDG, IMS
+configuration, GBA, 5G slice configuration) or administrative/key files that
+must not be edited from a workbench without a dedicated flow (Keys, DCK,
+network parameters, START-HFN). Nothing in the survey forces a new shape
+beyond S9/S10.
+
+## 3. Findings (defects and constraints the survey exposed)
+
+1. **EF.BDN is X+15, not X+14.** The shared ADN codec assumes the last two
+ bytes are CCP1/EXT1; BDN adds a trailing **Comparison Method Pointer**
+ (`X+15`, TS 31.102 4.2.44). The read-only decoded view currently
+ mis-parses BDN records, and the v3.33.0 encoder would corrupt them. The
+ codec must take the layout (ADN vs BDN) and preserve the CMI pointer.
+2. **EF.ECC has two layouts.** SIM: transparent 3n (TS 51.011 10.3.27);
+ USIM: linear fixed X+4 (TS 31.102 4.2.21). The decoder implements the SIM
+ layout only; on a USIM the alpha bytes are read as more codes. A
+ structure-aware decode is needed before an ECC editor.
+3. **S8 is not one shape.** PNN (flat tag+length+value TLVs) is the simple
+ case. DIR/ARR/PBR nest TLVs and cross-reference other files; they need
+ either a tree editor or a structured read-only view first.
+4. **References are part of the model.** EXT1–5 chains (ADN/MSISDN/BDN),
+ OPL→PNN record ids, PBR→(ADN/PBC/EXT/IAP) by FID/SFI. An editor that
+ ignores them can leave a card in a contradictory state: encoders must
+ preserve untouched references and refuse edits that would break one.
+5. **Cyclic files edit like linear ones** for our purposes (write by record
+ number), but the card owns the order (LND/ICI/OCI/ACM); the editor notes
+ it and the write is still a plain UPDATE.
+6. **FID collisions across DFs** (e.g. 6F02 = EF.IMPI in ISIM and EF.OCST in
+ USIM) make the fid-only decoder fallback unsafe; the name-based lookup is
+ the primary one and encoders should not be fid-only where a collision is
+ possible.
+7. **Text coding is per file.** Annex A (TS 102 221) for SPN/ADN/SUUME;
+ the Network Name IE (TS 24.008 10.5.3.5a) for PNN, where the first byte is
+ `0x80 | spare` for packed GSM 7-bit and `0x90` for UCS-2 — the real-card
+ vector `43 0A 82 D4 32 BB BC 7E B7 5D E4 32 …` confirms it.
+
+## 4. Release waves
+
+- **v3.33.0 (current)** — framework: encoder registry, field/list/items
+ schemas, record checkboxes + auto-check, save/validation; shapes S2, S5,
+ S7, S8 for SPN, PLMNwAcT/OPLMNwAcT/HPLMNwAcT, ADN/FDN/SDN/MSISDN/LND,
+ PNN; the BDN layout fix; the `efPlmnAct` EC-GSM-IoT-only (`0x88`) decode fix.
+- **v3.34.0** — S1/S3/S6 wave: UST/SST/IST/EST service toggles, LI/LP/PL,
+ ACC, PHASE, HPPLMN, AD, CBMI/CBMID/CBMIR, ECC (SIM), ICCID, IMSI,
+ PLMNsel/FPLMN/EHPLMN, SPDI, GID1/GID2.
+- **v3.35.0** — remaining S7 records: OPL (PNN record select), ECC (USIM),
+ SUUME, IMPI/DOMAIN/IMPU; EXT1–5 as a read-only structured view.
+- **v3.36.0+** — S10/complex: SMSP (parameter form), SMS (TPDU editor,
+ reusing the existing PDU builders), ARR (rule editor — the decoder already
+ renders rule lines), DIR (application templates), PBR (nested tree).
+- **Read-only forever (S0)** unless a dedicated flow appears: LOCI/PSLOCI/
+ EPSLOCI/EPSNSC/LOCIGPRS, Kc/KcGPRS, Keys/KeysPS, ACM, START-HFN/THRESHOLD,
+ call logs (ME-written), DCK/NETPAR.
+
+## 5. Testing strategy
+
+- **Round-trip**: for every encoder, `encode(decode(original), original)`
+ must reproduce the original bytes for spec-canonical records (the vectors
+ come from the spec's own examples, pySim's test data and real-card dumps).
+- **Edit-one-field**: changing a field changes only that field's bytes
+ (padding, coding of untouched fields, ids and extension pointers stay).
+- **Refusals**: over-capacity values, broken references, unsupported layouts
+ (BDN with the CMI pointer, EXT1 chains) throw an actionable error.
+- **No capability by accident**: a file without an encoder must never show
+ the editor (gating test), and a schema/label drift guard keeps the editors
+ and decoders aligned.
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 4f6ec08..15834db 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -408,7 +408,8 @@
Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по FID или символьному имени (пиллы и кнопка «Проверить все файлы» закреплены над прокручиваемым деревом; выбор сохраняется в localStorage). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла. Если FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности 8B), правила указанной записи показываются с отступом под этой строкой: они берутся из копий EF.ARR карты — копии MF (2F06), копий DF.TELECOM/DF.GSM (6F06) и копии ADF.USIM (6F06): читаются один раз за сессию при equip, обновляются при сбросе/equip и после записи EF.ARR; снимок разрешает те же ссылки из собственной захваченной копии. Побеждает копия собственного DF файла, с подъёмом до MF; ссылка, для которой в этой цепочке копии нет, остаётся неразрешённой, если только кандидат не единственный.
Читать сырые данные / Декодировать — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); таблица декодируется в браузере из тех же сырых байт (без серверного декодирования). Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл. «Декодировать» недоступна, если для файла нет клиентского декодера (проверка по имени/FID до чтения).
-
Редактировать сырые данные — изменение hex-данных, Сохранить для записи (или Отмена); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. Декодированный вид доступен только для чтения — «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.
+
Редактировать сырые данные — изменение hex-данных, Сохранить для записи (или Отмена); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.
+
Редактировать декодированные — редактирование файла в его собственной декодированной форме; кодирование выполняется в браузере, запись идёт обычным путём обновления. Кодировщики покрывают EF.SPN, списки PLMN с технологиями доступа (PLMNwAcT / OPLMNwAcT / HPLMNwAcT), файлы номеров (ADN / FDN / SDN / MSISDN / LND / BDN) и EF.PNN. Для transparent-файла — одна форма (поля SPN, строки PLMN с добавлением/удалением, элементы PNN с добавлением/удалением); для record-файла — по форме на запись с флажком выбора, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. Сохранить проверяет все выбранные записи до записи; для файлов без кодировщика кнопка недоступна с подсказкой. Указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется.
Проверить все файлы — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают (пусто). Отображается прогресс N / всего, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.
ADM — файлы, требующие администраторский PIN, возвращают 6982/9804; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка «Проверить ADM», а значок в заголовке (ADM ✓/✗ ⚿) становится кликабельным. Каждый неверный ключ расходует попытку: остаток показывается, а повторная попытка требует подтверждения. Заблокированный ADM здесь не восстановить — нужен ключ разблокировки карты.
The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by FID or symbolic Name (pills pinned above the scrolling tree together with Probe all files; the choice is remembered in localStorage). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane. When the FCI references its access rules through an EF.ARR record (security attribute 8B), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card’s EF.ARR copies — the MF copy (2F06), the DF.TELECOM/DF.GSM copies (6F06) and the ADF.USIM copy (6F06): read once per card session at equip, refreshed on reset/equip and after an EF.ARR write; a snapshot resolves the same references from its own captured copy. The copy of the file’s own DF wins, walking up to MF; a reference with no copy in that chain stays unresolved unless a single candidate exists.
Read raw / Read decoded — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the decoded table is built in the browser from the same raw bytes (no server-side decode). The highlighted pill is the current view; clicking either pill (re-)reads the file. Read decoded is disabled when the client-side decoders do not cover the file (name/FID check, before reading).
-
Edit raw — edit the raw hex data, Save to write back (or Cancel); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — Edit raw switches to the hex view first and reads the file if needed.
+
Edit raw — edit the raw hex data, Save to write back (or Cancel); transparent files use a textarea, record files one input per record with selection checkboxes. Edit raw switches to the hex view first and reads the file if needed.
+
Edit decoded — edit the file through its own decoded form, encoded in the browser and written with the normal update path. The encoders cover EF.SPN, the PLMN lists with access technology (PLMNwAcT / OPLMNwAcT / HPLMNwAcT), the dialling-number files (ADN / FDN / SDN / MSISDN / LND / BDN) and EF.PNN. A transparent file gets one form (SPN fields, PLMN rows with add/remove, PNN items with add/remove); a record file gets one form per record with a selection checkbox that is auto-checked when a field changes, so only the edited records are written. Save validates every selected record before writing anything; files without an encoder keep the button disabled with a tooltip. Extension pointers (EF.EXT1–EF.EXT5) and the BDN comparison-method byte are preserved, and editing a number that continues in an extension file is refused.
Probe all files — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show (empty). Shows progress N / total, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.
ADM — files that need the administrator PIN fail with 6982/9804; if the matching card preset (same ICCID) carries an ADM key, a Verify ADM button appears next to the error and the header badge (ADM ✓/✗ ⚿) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.
diff --git a/frontend/index.html b/frontend/index.html
index a6d7562..ace7ce4 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1135,6 +1135,7 @@
+
@@ -1943,7 +1944,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 = '3.32.7';
+const SIMPLE_VERSION = '3.33.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -9406,6 +9407,11 @@ function pysimApplyAvailability() {
disabled = true;
if (!hint) hint = t('No decoder for this file');
}
+ if (el.hasAttribute('data-needs-encoder') && !pysimFsHasEncoder()) {
+ // Client-side encoder required (Edit decoded).
+ disabled = true;
+ if (!hint) hint = t('No decoded editor for this file');
+ }
const capGate = el.getAttribute('data-cap-gate');
if (capGate && !capGateOk(capGate)) {
// A .cap must be analyzed successfully before its action runs.
@@ -15930,6 +15936,15 @@ function pysimFsHasDecoder() {
return !!efFindDecoder(node && node.name, fid);
}
+// Encoder availability for the selected file (Edit decoded), resolved the
+// same way as the decoder above.
+function pysimFsHasEncoder() {
+ if (!pysimFsSelected) return false;
+ const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot);
+ const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null);
+ return !!efFindEncoder(node && node.name, fid);
+}
+
// Find a node by its canonical path ("MF/7F20/6F46", "ADF.USIM/6F07"), so
// custom-file parents can be resolved against the loaded tree (standard DFs
// included, at any depth). Returns null when the path is not loaded yet.
@@ -16112,6 +16127,8 @@ function pysimFsDecodedHtml(node, data) {
let pysimFsEditMode = false;
let pysimFsEditData = null;
+let pysimFsDecEditMode = false;
+let pysimFsDecModel = null;
// Edit raw: transparent files become an editable textarea, record files
// per-record inputs with selection checkboxes. The decoded view is a
@@ -16124,8 +16141,12 @@ async function pysimFsEdit() {
pysimFsSetMode('raw', true);
if (!await pysimFsRead()) return;
}
+ pysimFsDecEditMode = false;
+ pysimFsDecModel = null;
pysimFsEditMode = true;
document.getElementById('pysim-fs-edit-btn').classList.add('hidden');
+ const decBtn = document.getElementById('pysim-fs-edit-dec-btn');
+ if (decBtn) decBtn.classList.add('hidden');
document.getElementById('pysim-fs-save-btn').classList.remove('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.remove('hidden');
pysimFsPillsEnabled(false);
@@ -16141,14 +16162,86 @@ async function pysimFsEdit() {
pysimFsSyncContentVisibility();
}
+// Edit decoded: read the file raw, decode it into the file's encoder model
+// and render the per-file form (a field grid, a PLMN row editor or a record
+// block list); Save re-encodes the values (pysimFsDecSave). Cancel restores
+// the read-only view.
+async function pysimFsDecEdit() {
+ if (pysimFsEditMode) return;
+ pysimFsResetEdit();
+ const rawName = pysimFsSelected;
+ if (!rawName) return;
+ const out = document.getElementById('pysim-fs-content');
+ const statusEl = document.getElementById('pysim-fs-status');
+ out.innerHTML = '';
+ pysimFsSyncContentVisibility();
+ statusEl.textContent = t('Loading...');
+ // the read is in flight: keep the read pills inert until we either enter
+ // edit mode (they stay disabled) or bail out (re-enabled below)
+ pysimFsPillsEnabled(false);
+ try {
+ const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
+ const body = pysimFsSelectBody(node || { name: rawName, fid: null });
+ body.mode = 'raw';
+ const data = await pysimFetch('/api/read', body);
+ if (rawName !== pysimFsSelected) {
+ // another file was selected while this read ran: its own view owns
+ // the pane now
+ pysimFsPillsEnabled(true);
+ return;
+ }
+ if (!data.success) {
+ pysimFsShowError(statusEl, data.sw, data.error);
+ pysimFsPillsEnabled(true);
+ return;
+ }
+ const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : null);
+ const content = data.records ? { kind: 'record', records: data.records }
+ : { kind: 'transparent', data: data.data };
+ const model = efDecEditModel(node && node.name, fid, content);
+ if (!model) {
+ statusEl.textContent = t('No decoded editor for this file');
+ pysimFsPillsEnabled(true);
+ return;
+ }
+ pysimFsDecModel = model;
+ pysimFsDecEditMode = true;
+ out.innerHTML = efDecEditHtml(model, data.data || '');
+ document.getElementById('pysim-fs-edit-btn').classList.add('hidden');
+ const decBtn = document.getElementById('pysim-fs-edit-dec-btn');
+ if (decBtn) decBtn.classList.add('hidden');
+ document.getElementById('pysim-fs-save-btn').classList.remove('hidden');
+ document.getElementById('pysim-fs-cancel-btn').classList.remove('hidden');
+ pysimFsPillsEnabled(false);
+ out.querySelectorAll('.pysim-fs-record-cb').forEach(cb => cb.classList.remove('hidden'));
+ statusEl.textContent = 'SW: ' + data.sw + ' OK';
+ } catch (e) {
+ statusEl.textContent = 'Error: ' + e.message;
+ pysimFsPillsEnabled(true);
+ } finally {
+ pysimFsSyncContentVisibility();
+ }
+}
+
function pysimFsCancel() {
+ const wasDec = pysimFsDecEditMode;
pysimFsEditMode = false;
+ pysimFsDecEditMode = false;
+ pysimFsDecModel = null;
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
+ const decBtn = document.getElementById('pysim-fs-edit-dec-btn');
+ if (decBtn) decBtn.classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
pysimFsPillsEnabled(true);
const out = document.getElementById('pysim-fs-content');
- if (pysimFsEditData) out.innerHTML = pysimFsEditData;
+ if (wasDec) {
+ // the decoded form has no read-only copy to restore: re-read the file
+ // in the current mode
+ if (pysimFsSelected) pysimFsRead();
+ } else if (pysimFsEditData) {
+ out.innerHTML = pysimFsEditData;
+ }
pysimFsEditData = null;
pysimFsSyncContentVisibility();
}
@@ -16163,6 +16256,10 @@ async function pysimFsSave() {
const wroteFid = String((node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : '')).toUpperCase();
const arrTouched = wroteFid === '2F06' || wroteFid === '6F06';
statusEl.textContent = 'Saving...';
+ if (pysimFsDecEditMode) {
+ await pysimFsDecSave(node, rawName, statusEl, out, arrTouched);
+ return;
+ }
try {
const textareas = out.querySelectorAll('textarea');
if (textareas.length === 1) {
@@ -16213,12 +16310,87 @@ async function pysimFsSave() {
}
}
+// Save the decoded form: a transparent file is one write of the re-encoded
+// hex; a record file collects and encodes ALL checked records before writing
+// anything (a validation error on a later record must not leave earlier ones
+// written), then writes them sequentially. The per-file encoder is the
+// inverse of the decoder (EF_ENCODERS); a value it cannot encode throws and
+// the error lands in the status line with the record number.
+async function pysimFsDecSave(node, rawName, statusEl, out, arrTouched) {
+ const model = pysimFsDecModel;
+ if (!model) {
+ statusEl.textContent = t('No decoded editor for this file');
+ return;
+ }
+ const entry = model.entry || efFindEncoder(node && node.name,
+ (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : null));
+ if (!entry) { statusEl.textContent = t('No decoded editor for this file'); return; }
+ const root = out.querySelector('.pysim-dec-edit');
+ try {
+ if (model.mode === 'tr') {
+ const orig = root ? (root.getAttribute('data-orig') || '') : '';
+ const values = entry.list
+ ? efDecCollectRows(root, entry.list.itemFields)
+ : efDecCollectValues(root, entry.fields);
+ const body = pysimFsSelectBody(node || { name: rawName, fid: null });
+ body.data = entry.enc(values, orig);
+ const data = await pysimFetch('/api/write', body);
+ if (!data.success) {
+ pysimFsShowError(statusEl, data.sw, data.error);
+ return;
+ }
+ statusEl.textContent = 'SW: ' + data.sw + ' OK';
+ if (arrTouched) _pysimArrCache = null;
+ pysimFsCancel();
+ return;
+ }
+ const plans = [];
+ for (const block of out.querySelectorAll('.pysim-dec-rec')) {
+ const cb = block.querySelector('.pysim-fs-record-cb');
+ if (cb && !cb.checked) continue;
+ const num = block.getAttribute('data-rec');
+ if (!num) continue;
+ const body = pysimFsSelectBody(node || { name: rawName, fid: null });
+ const collected = entry.items
+ ? efDecCollectItems(block)
+ : efDecCollectValues(block, entry.fields);
+ try {
+ body.data = entry.enc(collected, block.getAttribute('data-orig') || '');
+ } catch (e) {
+ statusEl.textContent = 'Error: record ' + num + ': ' + e.message;
+ return;
+ }
+ body.record_nr = parseInt(num);
+ plans.push({ num: num, body: body });
+ }
+ if (!plans.length) {
+ statusEl.textContent = t('No records selected — check the ones you want to update');
+ return;
+ }
+ let written = 0;
+ for (const plan of plans) {
+ const data = await pysimFetch('/api/write', plan.body);
+ if (!data.success) {
+ pysimFsShowError(statusEl, data.sw, (data.error || 'Error') + ' (record ' + plan.num +
+ '; ' + written + ' of ' + plans.length + ' records written)');
+ return;
+ }
+ written++;
+ }
+ statusEl.textContent = 'SW: 9000 OK';
+ if (arrTouched) _pysimArrCache = null;
+ pysimFsCancel();
+ } catch (e) {
+ statusEl.textContent = 'Error: ' + e.message;
+ }
+}
+
// The Read raw / Read decoded pills are the read actions: they switch the
// view mode and (re-)read the selected file. `skipRead` updates only the
// pills — used by pysimFsEdit() right before its own read. While editing
// they are inert: re-rendering would discard unsaved changes.
function pysimFsSetMode(mode, skipRead) {
- if (pysimFsEditMode) return;
+ if (pysimFsEditMode || pysimFsDecEditMode) return;
if (mode === 'dec' && !pysimFsHasDecoder()) return;
pysimFsDecodedMode = mode === 'dec';
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
@@ -16244,8 +16416,12 @@ function pysimFsPillsEnabled(enabled) {
function pysimFsResetEdit() {
pysimFsEditMode = false;
+ pysimFsDecEditMode = false;
+ pysimFsDecModel = null;
pysimFsEditData = null;
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
+ const decBtn = document.getElementById('pysim-fs-edit-dec-btn');
+ if (decBtn) decBtn.classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
pysimFsPillsEnabled(true);
@@ -22576,10 +22752,12 @@ const EF_FIELD_LABELS = {
short: 'Short name', eap_types: 'EAP types', dfs: 'DFs', uplink_nas_count: 'Uplink NAS count',
downlink_nas_count: 'Downlink NAS count', nas_algorithms: 'NAS algorithms', k_asme: 'K-ASME',
ksi_asme: 'KSI-ASME', tmsi_time: 'TMSI time', lac_range_low: 'LAC range low',
- lac_range_high: 'LAC range high', show_in_hplmn: 'Show in HPLMN', hide_in_oplmn: 'Hide in OPLMN',
+ lac_range_high: 'LAC range high', show_in_hplmn: 'Display the network name in HPLMN / an SPDI PLMN',
+ hide_in_oplmn: 'Hide the service provider name outside HPLMN / the SPDI PLMNs',
location_update_status: 'Location update status', eps_update_status: 'EPS update status',
update_status: 'Update status', access_tech: 'Access technology', p_tmsi_signature: 'P-TMSI signature',
master: 'Master EF', iap: 'IAP (EF.IAP)', links: 'Links', files: 'Files', note: 'Note',
+ ext1: 'Extension record', cmi: 'Comparison method pointer',
};
const EF_GSM7_EXT = { 0x0A: '\n', 0x14: '^', 0x28: '{', 0x29: '}', 0x2F: '\\', 0x3C: '[', 0x3D: '~', 0x3E: ']', 0x40: '|', 0x65: '\u20ac' };
@@ -22754,7 +22932,7 @@ function efPlmnAct(u16) {
const g = u16 & 0x008C;
if (g === 0x0080 || g === 0x008C) { techs.add('GSM'); techs.add('EC-GSM-IoT'); }
else if (g === 0x0084) techs.add('GSM');
- else if (g === 0x0086) techs.add('EC-GSM-IoT');
+ else if (g === 0x0088) techs.add('EC-GSM-IoT');
return [...techs].sort();
}
@@ -22776,13 +22954,16 @@ function efPnnText(value) {
return efUcs2Be(data);
}
if (coding === 0) {
+ const nbits = data.length * 8;
+ // A non-zero spare count identifies the packed form (and its exact
+ // septet count); the all-low "unpacked octets" heuristic is only for
+ // the no-information (spare 0) coding, where a low-byte packed value
+ // would otherwise be misread (0x82 41 21 is packed 'AB', not 'A!').
+ if (spare > 0) return gsm7Decode(data, Math.floor((nbits - spare) / 7));
let allLow = data.length > 0;
for (let i = 0; i < data.length; i++) if (data[i] >= 0x80) { allLow = false; break; }
if (allLow) return efGsm7Octets(data);
- const nbits = data.length * 8;
- const septets = spare > 0 ? Math.floor((nbits - spare) / 7)
- : Math.floor(nbits / 7);
- return gsm7Decode(data, septets);
+ return gsm7Decode(data, Math.floor(nbits / 7));
}
return '';
}
@@ -23211,16 +23392,21 @@ function efDecPnn(b) {
return Object.keys(out).length ? out : { raw: efHex(b) };
}
-// ADN-format record: alpha + len(1) + TON/NPI(1) + BCD(10) + CCP(1) + EXT1(1).
-function efDecAdn(b) {
+// ADN-format record (TS 31.102 4.4.2.3): alpha | len(1) | TON/NPI(1) |
+// BCD(10) | CCP1 | EXT1. The BDN layout (§4.2.44) is one byte longer: the
+// extension slot is EXT4 and the record ends with a comparison-method
+// pointer (an EF.CMI record number, kept as `cmi` and never edited).
+function efDecAdn(b, layout) {
+ const L = layout === 15 ? 15 : 14;
if (efAllFf(b)) return { empty: true };
- if (b.length < 14) return { raw: efHex(b) };
- const alpha = efRstripFf(b.subarray(0, b.length - 14));
+ if (b.length < L) return { raw: efHex(b) };
+ const alpha = efRstripFf(b.subarray(0, b.length - L));
const name = efAnnexA(alpha);
- const number = efBcdAddress(b.subarray(b.length - 13, b.length - 2));
+ const number = efBcdAddress(b.subarray(b.length - L + 1, b.length - L + 12));
const out = { name: name, number: number };
- if (b[b.length - 2] !== 0xFF) out.ccp = b[b.length - 2];
- if (b[b.length - 1] !== 0xFF) out.ext1 = b[b.length - 1];
+ if (b[b.length - L + 12] !== 0xFF) out.ccp = b[b.length - L + 12];
+ if (b[b.length - L + 13] !== 0xFF) out.ext1 = b[b.length - L + 13];
+ if (L === 15 && b[b.length - L + 14] !== 0xFF) out.cmi = b[b.length - L + 14];
return out;
}
@@ -23399,7 +23585,7 @@ const EF_DECODERS = [
{ name: 'EF.SDN', fid: '6f49', fn: efDecAdn },
{ name: 'EF.MSISDN', fid: '6f40', fn: efDecAdn },
{ name: 'EF.LND', fid: '6f44', fn: efDecAdn },
- { name: 'EF.BDN', fid: '6f4d', fn: efDecAdn },
+ { name: 'EF.BDN', fid: '6f4d', fn: b => efDecAdn(b, 15) },
{ name: 'EF.EXT1', fid: '6f4a', fn: efDecExt1 },
{ name: 'EF.EXT2', fid: '6f4b', fn: efDecExt1 },
{ name: 'EF.EXT3', fid: '6f4c', fn: efDecExt1 },
@@ -23423,6 +23609,416 @@ function efFidFromPath(path) {
return /^[0-9a-fA-F]{4}$/.test(seg) ? seg.toUpperCase() : null;
}
+// ---- content encoders (the decoded editor) ----
+// The inverse of the decoders for the files the file manager can edit as
+// values: the edited field values are re-encoded here and written through
+// the raw write path. Every encoder returns uppercase hex; `origHex` is the
+// untouched file/record, so unchanged text keeps its original coding and
+// every length comes from the card, never from the edit.
+
+// Access technology names exactly as efPlmnAct prints them (TS 31.102 4.2.5).
+const EF_ACT_TECHS = ['UTRAN', 'NG-RAN', 'GSM', 'EC-GSM-IoT', 'E-UTRAN WB-S1',
+ 'E-UTRAN NB-S1', 'cdma2000 1xRTT', 'cdma2000 HRPD', 'GSM COMPACT'];
+
+function efGsm7PlainText(text) {
+ for (const c of String(text)) if (GSM7_ALPHABET.indexOf(c) === -1) return false;
+ return true;
+}
+
+// Encoder validation errors are user-facing (the file manager's status line),
+// so they go through t() when the runtime provides it; the pure unit tests
+// extract the encoders without the dictionary and get the English text.
+function efEncError(msg, detail) {
+ return new Error((typeof t === 'function' ? t(msg) : msg) + (detail || ''));
+}
+
+// Alpha identifier / service provider name coding (TS 31.102 4.2.12, 4.4.2.3):
+// one octet per character with bit 8 = 0 when every character is in the SMS
+// default 7-bit alphabet, else 0x80 + UCS-2BE (TS 31.101 annex). FF padding
+// to the field capacity; a value that does not fit throws.
+function efEncTextBytes(text, capacity, tooLong) {
+ const s = text === undefined || text === null ? '' : String(text);
+ const data = [];
+ if (efGsm7PlainText(s)) {
+ for (const c of s) data.push(GSM7_ALPHABET.indexOf(c));
+ } else {
+ data.push(0x80);
+ for (let i = 0; i < s.length; i++) {
+ const code = s.charCodeAt(i);
+ data.push(code >> 8, code & 0xFF);
+ }
+ }
+ if (data.length > capacity) throw efEncError(tooLong);
+ const out = new Uint8Array(capacity);
+ out.fill(0xFF);
+ out.set(data);
+ return out;
+}
+
+// 3-byte PLMN (TS 24.008): b0 = mcc[1]mcc[0], b1 = (mnc[2] or F) + mcc[2],
+// b2 = mnc[1]mnc[0].
+function efPlmnEnc(mcc, mnc) {
+ const m = String(mcc === undefined || mcc === null ? '' : mcc).trim();
+ const n = String(mnc === undefined || mnc === null ? '' : mnc).trim();
+ if (!/^\d{3}$/.test(m)) throw efEncError('MCC must be 3 digits');
+ if (!/^\d{2,3}$/.test(n)) throw efEncError('MNC must be 2-3 digits');
+ const b0 = m[1] + m[0];
+ const b1 = (n.length === 3 ? n[2] : 'F') + m[2];
+ const b2 = n[1] + n[0];
+ return (b0 + b1 + b2).toUpperCase();
+}
+
+// 2-byte Access Technology (TS 31.102 4.2.5 + Tables 4.2.5.1/4.2.5.2):
+// first byte b8 UTRAN, b7 E-UTRAN selected, b6 WB-S1, b5 NB-S1, b4 NG-RAN
+// (both E-UTRAN forms = b7 only); second byte b8 GSM family, b7 GSM COMPACT,
+// b6 HRPD, b5 1xRTT, b4 EC-GSM-IoT, b3 GSM (GSM+EC = b8 only).
+function efActEnc(techs) {
+ const list = Array.isArray(techs)
+ ? techs.map(x => String(x).trim()).filter(Boolean)
+ : String(techs === undefined || techs === null ? '' : techs).split(',').map(x => x.trim()).filter(Boolean);
+ if (!list.length) throw efEncError('Select at least one access technology');
+ for (const name of list) if (EF_ACT_TECHS.indexOf(name) === -1) {
+ throw efEncError('Unknown access technology', ': ' + name);
+ }
+ const has = name => list.indexOf(name) !== -1;
+ let hi = 0, lo = 0;
+ if (has('UTRAN')) hi |= 0x80;
+ if (has('NG-RAN')) hi |= 0x08;
+ const wb = has('E-UTRAN WB-S1'), nb = has('E-UTRAN NB-S1');
+ if (wb && nb) hi |= 0x40;
+ else if (wb) hi |= 0x60;
+ else if (nb) hi |= 0x50;
+ if (has('GSM COMPACT')) lo |= 0x40;
+ if (has('cdma2000 HRPD')) lo |= 0x20;
+ if (has('cdma2000 1xRTT')) lo |= 0x10;
+ const gsm = has('GSM'), ec = has('EC-GSM-IoT');
+ if (gsm && ec) lo |= 0x80;
+ else if (gsm) lo |= 0x84;
+ else if (ec) lo |= 0x88;
+ return efHex([hi, lo]);
+}
+
+// EF.SPN (TS 31.102 4.2.12): condition byte + the provider name.
+function efEncSpn(values, origHex) {
+ values = values || {};
+ const orig = efBytes(origHex || '');
+ const len = Math.max(orig.length, 17);
+ const out = new Uint8Array(len);
+ out.fill(0xFF);
+ const origDec = efDecSpn(orig);
+ const sameFlags = !!values.show_in_hplmn === !!origDec.show_in_hplmn &&
+ !!values.hide_in_oplmn === !!origDec.hide_in_oplmn;
+ // unchanged display bits keep the original condition byte (spare bits
+ // included): an untouched all-FF file stays all-FF
+ out[0] = (sameFlags && orig.length) ? orig[0]
+ : ((values.show_in_hplmn ? 0x01 : 0) | (values.hide_in_oplmn ? 0x02 : 0));
+ const name = values.name === undefined || values.name === null ? '' : String(values.name);
+ const origName = origDec.name || '';
+ if (name === origName) {
+ // unchanged text keeps the original coding (and any quirk in it)
+ for (let i = 1; i < orig.length && i < len; i++) out[i] = orig[i];
+ } else {
+ out.set(efEncTextBytes(name, len - 1, 'The service provider name is too long for this file'), 1);
+ }
+ return efHex(out);
+}
+
+// The 5-byte entry at row `i` of an original PLMNwAcT-style list, decoded the
+// same way the form shows it (unknown ACT codings keep their raw text).
+function efPlmnRowOrig(orig, i) {
+ if (i * 5 + 5 > orig.length) return null;
+ const chunk = orig.subarray(i * 5, i * 5 + 5);
+ if (efAllFf(chunk.subarray(0, 3))) return null;
+ const p = efPlmn(chunk.subarray(0, 3));
+ if (!p) return null;
+ const act = (chunk[3] << 8) | chunk[4];
+ const techs = efPlmnAct(act);
+ return {
+ mcc: p.mcc, mnc: p.mnc,
+ access_tech: techs.length ? techs.join(', ') : '0x' + act.toString(16).toUpperCase().padStart(4, '0'),
+ hex: efHex(chunk),
+ };
+}
+
+// EF.PLMNwAcT / OPLMNwAcT / HPLMNwAcT: 5-byte entries (PLMN + ACT), the tail
+// FF-padded to the file's own length. An unchanged row is copied from the
+// original bytes - the form's technology list does not model every ACT bit
+// (Rel-18 satellite bits) and equal codings must not be canonicalised.
+function efEncPlmnList(rows, origHex) {
+ const orig = efBytes(origHex || '');
+ const capacity = Math.floor(orig.length / 5);
+ const list = rows || [];
+ if (list.length > capacity) {
+ throw efEncError('Too many entries for this file', ' (' + capacity + ' max)');
+ }
+ const out = new Uint8Array(orig.length);
+ out.fill(0xFF);
+ let pos = 0;
+ for (let i = 0; i < list.length; i++) {
+ const row = list[i];
+ const old = efPlmnRowOrig(orig, i);
+ const sameAct = old && String(row.access_tech === undefined || row.access_tech === null ? '' : row.access_tech) === old.access_tech;
+ if (old && String(row.mcc).trim() === old.mcc && String(row.mnc).trim() === old.mnc && sameAct) {
+ out.set(efBytes(old.hex), pos);
+ } else if (sameAct) {
+ const oldBytes = efBytes(old.hex);
+ out.set(efBytes(efPlmnEnc(row.mcc, row.mnc)), pos);
+ out[pos + 3] = oldBytes[3];
+ out[pos + 4] = oldBytes[4];
+ } else {
+ out.set(efBytes(efPlmnEnc(row.mcc, row.mnc) + efActEnc(row.access_tech)), pos);
+ }
+ pos += 5;
+ }
+ return efHex(out);
+}
+
+// ADN/BDN-format record: layout 14 is the ADN family (TS 31.102 4.4.2.3:
+// alpha | len | TON/NPI | BCD(10) | CCP1 | EXT1), layout 15 is EF.BDN
+// (§4.2.44: the same fields plus an EXT4 slot and a trailing EF.CMI pointer,
+// both preserved from the original). The record length comes from the card;
+// `ext` names the extension EF for the refusal message (EXT1 for the ADN/
+// LND slots, EXT2/EXT3/EXT4/EXT5 for FDN/SDN/BDN/MSISDN).
+function efEncAdn(values, origHex, layout, ext) {
+ const LAY = layout === 15 ? 15 : 14;
+ const extName = ext || (LAY === 15 ? 'EXT4' : 'EXT1');
+ values = values || {};
+ const orig = efBytes(origHex || '');
+ const L = orig.length;
+ if (L < LAY) {
+ throw efEncError(LAY === 15 ? 'The record is too short for the BDN layout'
+ : 'The record is too short for the ADN layout');
+ }
+ const alphaCap = L - LAY;
+ const dec = efDecAdn(orig, LAY);
+ const name = values.name === undefined || values.name === null ? '' : String(values.name);
+ const alpha = new Uint8Array(alphaCap);
+ alpha.fill(0xFF);
+ if (name === (dec.name || '')) {
+ for (let i = 0; i < alphaCap && i < orig.length; i++) alpha[i] = orig[i];
+ } else {
+ alpha.set(efEncTextBytes(name, alphaCap, 'The name is too long for this record'));
+ }
+ const raw = String(values.number === undefined || values.number === null ? '' : values.number).replace(/\s+/g, '');
+ if (raw !== '' && !/^\+?\d+$/.test(raw)) throw efEncError('The number must be digits with an optional leading +');
+ const digits = raw.replace(/^\+/, '');
+ if (digits.length > 20) throw efEncError('The number is too long for this record');
+ const lenAt = L - LAY;
+ const ccpAt = lenAt + 12;
+ const extAt = lenAt + 13;
+ const cmiAt = lenAt + 14;
+ const changed = raw !== (dec.number || '');
+ if (changed && orig[extAt] !== 0xFF) {
+ throw efEncError('The number continues in EF.' + extName + ' - editing it is not supported');
+ }
+ const out = new Uint8Array(L);
+ out.set(alpha, 0);
+ let pos = alphaCap;
+ if (!changed) {
+ // The decoder is lossy for control-string/extended-BCD numbers
+ // (0xA-0xE nibbles, SSC digits), so an untouched number is copied
+ // verbatim - [length, TON/NPI, 10 BCD/SSC bytes]. An all-FF record
+ // therefore stays all-FF.
+ for (let i = 0; i < 12; i++) out[pos++] = orig[lenAt + i];
+ } else if (digits === '') {
+ out[pos++] = 0x00;
+ out[pos++] = 0xFF;
+ for (let i = 0; i < 10; i++) out[pos++] = 0xFF;
+ } else {
+ out[pos++] = 1 + Math.ceil(digits.length / 2);
+ out[pos++] = raw.charAt(0) === '+' ? 0x91 : 0x81;
+ for (let i = 0; i < 10; i++) {
+ const lo = i * 2 < digits.length ? digits[i * 2] : 'F';
+ const hi = i * 2 + 1 < digits.length ? digits[i * 2 + 1] : 'F';
+ out[pos++] = parseInt(hi + lo, 16);
+ }
+ }
+ out[pos++] = orig[ccpAt];
+ out[pos++] = orig[extAt];
+ if (LAY === 15) out[pos++] = orig[cmiAt];
+ return efHex(out);
+}
+
+// The ADN-family registry entries: each file's record layout and the extension
+// EF its dialling number continues in (EXT1/EXT2/EXT3/EXT4/EXT5).
+function efEncAdnExt(layout, ext) {
+ return (values, origHex) => efEncAdn(values, origHex, layout, ext);
+}
+
+// Network Name contents (TS 24.008 10.5.3.5a, octet 3 onward): the coding
+// octet followed by the text - GSM 7-bit packed septets with the coding octet
+// 0x80 | spare (the decoder's spare heuristic: 0 when the packed text fills
+// the last octet) or UCS-2BE behind 0x90. The caller guarantees a GSM7
+// candidate is in the default alphabet.
+function efPnnNameBytes(text) {
+ const s = String(text === undefined || text === null ? '' : text);
+ let gsm = true;
+ for (const c of s) if (GSM7_ALPHABET.indexOf(c) === -1) { gsm = false; break; }
+ if (gsm) {
+ const septets = gsm7TextToSeptets(s);
+ const packed = gsm7Encode(septets);
+ const spare = (8 - ((septets.length * 7) - (packed.length - 1) * 8)) & 7;
+ const out = new Uint8Array(1 + packed.length);
+ out[0] = 0x80 | spare;
+ out.set(packed, 1);
+ return out;
+ }
+ const out = new Uint8Array(1 + s.length * 2);
+ out[0] = 0x90;
+ for (let i = 0; i < s.length; i++) {
+ const code = s.charCodeAt(i);
+ out[1 + i * 2] = code >> 8;
+ out[2 + i * 2] = code & 0xFF;
+ }
+ return out;
+}
+
+function efPnnHexBytes(hex) {
+ const s = String(hex === undefined || hex === null ? '' : hex).trim();
+ if (!/^([0-9a-fA-F]{2})*$/.test(s)) {
+ throw efEncError('The TLV value must be an even number of hex digits');
+ }
+ return efBytes(s);
+}
+
+// The original TLV bytes of an item whose text still matches the row's, so an
+// untouched item is re-emitted verbatim instead of being re-coded from the
+// (heuristic) decode. Only single-byte TLV lengths occur in EF.PNN.
+function efPnnOrigTlv(item, text) {
+ if (!item || !item.orig) return null;
+ const b = efBytes(item.orig);
+ if (b.length < 2 || (b[1] & 0x80) || b[1] + 2 !== b.length) return null;
+ if (b[0] !== parseInt(item.tag, 16)) return null;
+ return efPnnText(b.subarray(2)) === text ? b : null;
+}
+
+// EF.PNN (TS 31.102 4.2.58): a record is a list of Network name TLVs - '43'
+// full name, '45' short name (TS 24.008 10.5.3.5a contents) and '80' PLMN
+// additional information (UCS-2 bytes) - FF-padded to the record length.
+// Unknown tags are re-emitted verbatim. An empty name omits its TLV only
+// when the record ends up with no items at all: the full name (43) is
+// mandatory as soon as the record carries any TLV.
+function efEncPnn(items, origHex) {
+ const orig = efBytes(origHex || '');
+ const out = [];
+ const seen = {};
+ let emptyFull = false;
+ for (const item of (items || [])) {
+ const tag = String(item.tag === undefined || item.tag === null ? '' : item.tag).toUpperCase();
+ if (!/^[0-9A-F]{2}$/.test(tag)) throw efEncError('The TLV tag must be one hex byte');
+ if (tag === '43' || tag === '45') {
+ if (seen[tag]) throw efEncError('Only one full name / short name per record');
+ seen[tag] = true;
+ }
+ if ((tag === '43' || tag === '45') && !item.raw) {
+ const text = item.text === undefined || item.text === null ? '' : String(item.text);
+ if (!text) {
+ if (tag === '43') emptyFull = true;
+ continue; // an empty name omits its TLV
+ }
+ const origTlv = efPnnOrigTlv(item, text);
+ if (origTlv) {
+ out.push({ tag: tag, tlv: origTlv });
+ continue;
+ }
+ out.push({ tag: tag, value: efPnnNameBytes(text) });
+ continue;
+ }
+ out.push({ tag: tag, value: efPnnHexBytes(item.hex) });
+ }
+ if (out.length && emptyFull) {
+ throw efEncError('The full name (43) is required when the record has other TLVs');
+ }
+ let size = 0;
+ for (const t of out) {
+ if (t.value && t.value.length > 0xFF) {
+ throw efEncError('The TLV value is longer than 255 bytes');
+ }
+ size += t.tlv ? t.tlv.length : 2 + t.value.length;
+ }
+ if (size > orig.length) throw efEncError('The record is too short for these items');
+ const bytes = new Uint8Array(orig.length);
+ bytes.fill(0xFF);
+ let pos = 0;
+ for (const t of out) {
+ if (t.tlv) {
+ bytes.set(t.tlv, pos);
+ pos += t.tlv.length;
+ continue;
+ }
+ bytes[pos++] = parseInt(t.tag, 16);
+ bytes[pos++] = t.value.length;
+ bytes.set(t.value, pos);
+ pos += t.value.length;
+ }
+ return efHex(bytes);
+}
+
+// The decoded editor's field schemas: one per file family, the keys matching
+// the decoder output. `kind` is text, digits, bool, select or techs (the
+// access technology checkboxes, the value being the decoder's ', '-joined
+// string).
+const EF_SPN_FIELDS = [
+ { key: 'show_in_hplmn', label: 'Display the network name in HPLMN / an SPDI PLMN', kind: 'bool' },
+ { key: 'hide_in_oplmn', label: 'Hide the service provider name outside HPLMN / the SPDI PLMNs', kind: 'bool' },
+ { key: 'name', label: 'Name', kind: 'text' },
+];
+
+const EF_ADN_FIELDS = [
+ { key: 'name', label: 'Name', kind: 'text' },
+ { key: 'number', label: 'Number', kind: 'text' },
+];
+
+// The PLMNwAcT family's row editor: a variable list of 5-byte entries.
+const EF_PLMN_LIST = {
+ key: 'plmns', addLabel: 'Add network',
+ itemFields: [
+ { key: 'mcc', label: 'MCC', kind: 'digits', max: 3, required: true },
+ { key: 'mnc', label: 'MNC', kind: 'digits', max: 3, required: true },
+ { key: 'access_tech', label: 'Access technology', kind: 'techs' },
+ ],
+};
+
+// The EF.PNN record editor: a list of Network name TLVs. `kind` says how the
+// value is edited/encoded (a Network Name text or raw hex bytes); unknown
+// tags parsed from the record are preserved raw.
+const EF_PNN_ITEMS = {
+ addLabel: 'Add TLV',
+ tags: [
+ { tag: '43', label: 'Full name', kind: 'name' },
+ { tag: '45', label: 'Short name', kind: 'name' },
+ { tag: '80', label: 'PLMN additional information', kind: 'hex' },
+ ],
+ preserveUnknown: true,
+};
+
+const EF_ENCODERS = [
+ { name: 'EF.SPN', fid: '6f46', enc: efEncSpn, fields: EF_SPN_FIELDS },
+ { name: 'EF.PLMNwAcT', fid: '6f60', enc: efEncPlmnList, list: EF_PLMN_LIST },
+ { name: 'EF.OPLMNwAcT', fid: '6f61', enc: efEncPlmnList, list: EF_PLMN_LIST },
+ { name: 'EF.HPLMNwAcT', fid: '6f62', enc: efEncPlmnList, list: EF_PLMN_LIST },
+ { name: 'EF.PNN', fid: '6fc5', enc: efEncPnn, items: EF_PNN_ITEMS },
+ { name: 'EF.ADN', fid: '6f3a', enc: efEncAdnExt(14, 'EXT1'), ext: 'EXT1', fields: EF_ADN_FIELDS },
+ { name: 'EF.FDN', fid: '6f3b', enc: efEncAdnExt(14, 'EXT2'), ext: 'EXT2', fields: EF_ADN_FIELDS },
+ { name: 'EF.SDN', fid: '6f49', enc: efEncAdnExt(14, 'EXT3'), ext: 'EXT3', fields: EF_ADN_FIELDS },
+ { name: 'EF.MSISDN', fid: '6f40', enc: efEncAdnExt(14, 'EXT5'), ext: 'EXT5', fields: EF_ADN_FIELDS },
+ { name: 'EF.LND', fid: '6f44', enc: efEncAdnExt(14, 'EXT1'), ext: 'EXT1', fields: EF_ADN_FIELDS },
+ { name: 'EF.BDN', fid: '6f4d', enc: efEncAdnExt(15, 'EXT4'), ext: 'EXT4', fields: EF_ADN_FIELDS },
+];
+
+function efFindEncoder(name, fid) {
+ let n = (name || '').toUpperCase();
+ if (n.includes('/')) n = n.split('/').pop();
+ for (const e of EF_ENCODERS) if (e.name.toUpperCase() === n) return e;
+ const f = (fid || '').toLowerCase();
+ if (f) for (const e of EF_ENCODERS) if (e.fid === f) return e;
+ return null;
+}
+
+function efHasEncoder(name, fid) {
+ return !!efFindEncoder(name, fid);
+}
+
// ---- content decoding, flattening and diffs ----
function efDecodeBytes(fn, bytes) {
@@ -23569,6 +24165,293 @@ function efContentDiff(name, fid, expectedHex, actualHex) {
return { rows: rows, sameDecoded: rows.length === 0 };
}
+// ===== decoded editing: model and form =====
+// The decoded editor is the inverse of the read-only decoded view: the file
+// is read raw, decoded into editable values (the decoders' own keys), edited
+// and re-encoded by the file's EF_ENCODERS entry. Only the files with an
+// encoder get the form; everything else keeps the raw editor.
+
+// A decoded value set for the edit form: a raw fallback (a decoder that could
+// not read the file's shape) has nothing to edit ({}). An all-FF file is
+// decoded normally - EF.SPN's FF condition byte exposes both display bits, so
+// an untouched save stays byte-identical - and an all-FF record's own
+// `empty: true` marker still yields {}.
+function efDecModelValues(dec, hex) {
+ if (!dec || !dec.fn) return {};
+ const bytes = efBytes(hex || '');
+ const d = efDecodeBytes(dec.fn, bytes);
+ if (!d || d.empty === true || efIsRawOnly(d)) return {};
+ return d;
+}
+
+// Parse a record whose contents are a TLV list (EF.PNN): known name tags come
+// back as text (the decoder's own efPnnText), a known hex tag as its value
+// hex, anything else as {tag, hex, raw: true} preserved verbatim. `orig` is
+// the item's original TLV bytes, so an unchanged item is re-emitted exactly
+// (its coding, AddCI bit, padding and length).
+function efDecItemsFromHex(entry, hex) {
+ const tags = (entry && entry.items && entry.items.tags) || [];
+ const bytes = efBytes(hex || '');
+ const items = [];
+ let start = 0;
+ for (const t of efTlv(bytes)) {
+ const tlv = efHex(bytes.subarray(start, t.end));
+ start = t.end;
+ const tag = t.tag.toString(16).toUpperCase().padStart(2, '0');
+ const spec = tags.find(x => String(x.tag).toUpperCase() === tag);
+ if (spec && spec.kind === 'name') items.push({ tag: tag, text: efPnnText(t.value), orig: tlv });
+ else if (spec) items.push({ tag: tag, hex: efHex(t.value), orig: tlv });
+ else items.push({ tag: tag, hex: efHex(t.value), raw: true, orig: tlv });
+ }
+ return items;
+}
+
+// content: {kind:'transparent', data:'HEX'} | {kind:'record', records:[{num,data}]}
+function efDecEditModel(name, fid, content) {
+ const entry = efFindEncoder(name, fid);
+ if (!entry) return null;
+ const dec = efFindDecoder(name, fid);
+ if (content && content.kind === 'record') {
+ return {
+ entry: entry,
+ mode: 'records',
+ records: (content.records || []).map(r => entry.items
+ ? { num: r.num, orig: r.data || '', items: efDecItemsFromHex(entry, r.data || '') }
+ : { num: r.num, orig: r.data || '', values: efDecModelValues(dec, r.data || '') }),
+ };
+ }
+ return { entry: entry, mode: 'tr', values: efDecModelValues(dec, (content && content.data) || '') };
+}
+
+// The value of a techs field (the decoder's ', '-joined string) as a list.
+function efActTechList(value) {
+ if (Array.isArray(value)) return value.map(x => String(x).trim()).filter(Boolean);
+ return String(value === undefined || value === null ? '' : value)
+ .split(',').map(x => x.trim()).filter(Boolean);
+}
+
+function efDecFieldHtml(f, value) {
+ const key = esc(f.key);
+ let input;
+ if (f.kind === 'bool') {
+ input = '';
+ } else if (f.kind === 'techs') {
+ const sel = efActTechList(value);
+ input = '' + EF_ACT_TECHS.map(name =>
+ '').join('') + '';
+ } else if (f.kind === 'select') {
+ const cur = String(value === undefined || value === null ? '' : value);
+ input = '';
+ } else {
+ const max = f.max ? ' maxlength="' + f.max + '"' : '';
+ input = '';
+ }
+ const body = '' + esc(t(f.label)) + '' + input;
+ return f.kind === 'techs'
+ ? '
' + body + '
'
+ : '';
+}
+
+// One PLMN list row: the item fields + a remove button.
+function efDecRowHtml(entry, row) {
+ let html = '
';
+ for (const f of entry.list.itemFields) html += efDecFieldHtml(f, row ? row[f.key] : undefined);
+ html += '
';
+ return html;
+}
+
+function efDecGridHtml(fields, values) {
+ let html = '
';
+ for (const f of fields) html += efDecFieldHtml(f, values ? values[f.key] : undefined);
+ return html + '
';
+}
+
+// One record TLV row (the EF.PNN editor): a tag select with the known tags,
+// the value input (text for a Network Name, hex for a raw tag) and a remove
+// button. A tag the entry does not know stays read-only with its tag and
+// bytes (never silently dropped).
+function efDecItemRowHtml(entry, item) {
+ const tags = (entry.items && entry.items.tags) || [];
+ const wanted = String(item && item.tag ? item.tag : (tags[0] ? tags[0].tag : '43')).toUpperCase();
+ const spec = tags.find(x => String(x.tag).toUpperCase() === wanted);
+ const raw = !spec || !!(item && item.raw);
+ const tagHtml = raw
+ ? '' + esc(wanted) + ''
+ : '';
+ const kind = raw ? 'hex' : spec.kind;
+ const value = raw || kind === 'hex' ? ((item && item.hex) || '') : ((item && item.text) || '');
+ const input = '';
+ const remove = raw ? '' : '';
+ const origAttr = item && item.orig ? ' data-iorig="' + esc(item.orig) + '"' : '';
+ return '
' +
+ tagHtml + input + remove + '
';
+}
+
+// One record's TLV list + its Add TLV button.
+function efDecItemsHtml(entry, record) {
+ let html = '
';
+ for (const item of (record && record.items) || []) html += efDecItemRowHtml(entry, item);
+ html += '
';
+ return html;
+}
+
+function efDecRecordsHtml(entry, records) {
+ let html = '';
+ for (const r of records || []) {
+ const empty = efAllFf(efBytes(r.orig || ''));
+ html += '
';
+ }
+ return html;
+}
+
+// The form HTML. Inputs carry data-key (and data-tech), so the save path
+// collects them from the DOM; the container carries the original transparent
+// hex the encoder needs, each record block its own.
+function efDecEditHtml(model, origHex) {
+ const entry = model.entry;
+ const title = '
' + esc(entry.name) + '
';
+ if (model.mode === 'records') {
+ return '
' + title + efDecRecordsHtml(entry, model.records) + '
';
+ }
+ if (entry.list) {
+ const rows = (model.values && model.values[entry.list.key]) || [];
+ let html = '
';
+ for (const row of rows) html += efDecRowHtml(entry, row);
+ html += '
';
+ return '
' + title + html + '
';
+ }
+ return '
' + title +
+ efDecGridHtml(entry.fields, model.values) + '
';
+}
+
+// Collect the form values of one scope (the whole TR form or one record
+// block): a techs field becomes the decoder's ', '-joined string.
+function efDecCollectValues(root, fields) {
+ const out = {};
+ if (!root) return out;
+ for (const f of (fields || [])) {
+ if (f.kind === 'techs') {
+ const techs = [];
+ root.querySelectorAll('[data-tech]').forEach(cb => {
+ if (cb.checked) techs.push(cb.getAttribute('data-tech'));
+ });
+ out[f.key] = techs.join(', ');
+ } else {
+ const el = root.querySelector('[data-key="' + f.key + '"]');
+ if (!el) { out[f.key] = f.kind === 'bool' ? false : ''; continue; }
+ out[f.key] = f.kind === 'bool' ? !!el.checked : el.value;
+ }
+ }
+ return out;
+}
+
+// Collect one list row per `.pysim-dec-row` block (the PLMNwAcT family).
+function efDecCollectRows(root, itemFields) {
+ const rows = [];
+ if (!root) return rows;
+ root.querySelectorAll('.pysim-dec-row').forEach(row => rows.push(efDecCollectValues(row, itemFields)));
+ return rows;
+}
+
+// Collect one record's TLV rows in DOM order: a Network Name row carries its
+// text, a hex row its raw value (validated by the encoder). A row parsed
+// from the card also carries its original TLV bytes (data-iorig), so an
+// untouched item keeps its coding.
+function efDecCollectItems(root) {
+ const items = [];
+ if (!root) return items;
+ root.querySelectorAll('.pysim-dec-item-row').forEach(row => {
+ const tag = String(row.getAttribute('data-tag') || '').toUpperCase();
+ const input = row.querySelector('[data-ikey]');
+ const kind = input ? input.getAttribute('data-ikey') : 'hex';
+ const value = input ? input.value : '';
+ const item = kind === 'text' ? { tag: tag, text: value } : { tag: tag, hex: String(value).trim() };
+ const orig = row.getAttribute('data-iorig');
+ if (orig) item.orig = orig;
+ items.push(item);
+ });
+ return items;
+}
+
+// Auto-check a record block when any of its inputs changes (so Save touches
+// exactly the records the user edited).
+function pysimFsDecRowCheck(el) {
+ const rec = el && el.closest ? el.closest('.pysim-dec-rec') : null;
+ if (!rec) return;
+ const cb = rec.querySelector('.pysim-fs-record-cb');
+ if (cb) cb.checked = true;
+}
+
+function pysimFsDecAddRow() {
+ if (!pysimFsSelected) return;
+ const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot);
+ const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null);
+ const entry = efFindEncoder(node && node.name, fid);
+ if (!entry || !entry.list) return;
+ const out = document.getElementById('pysim-fs-content');
+ const rows = out && out.querySelector('.pysim-dec-rows');
+ if (rows) rows.insertAdjacentHTML('beforeend', efDecRowHtml(entry, null));
+}
+
+function pysimFsDecRemoveRow(btn) {
+ const row = btn && btn.closest ? btn.closest('.pysim-dec-row') : null;
+ if (row) row.remove();
+}
+
+// A TLV row's tag changed: keep the row's data-tag and the value input's kind
+// (text for a Network Name, hex for the raw tags) in step.
+function pysimFsDecItemTag(sel) {
+ const row = sel && sel.closest ? sel.closest('.pysim-dec-item-row') : null;
+ if (!row) return;
+ row.setAttribute('data-tag', String(sel.value).toUpperCase());
+ const input = row.querySelector('[data-ikey]');
+ const opt = sel.options && sel.options[sel.selectedIndex];
+ const kind = opt && opt.getAttribute('data-kind');
+ if (input) input.setAttribute('data-ikey', kind === 'hex' ? 'hex' : 'text');
+ pysimFsDecRowCheck(sel);
+}
+
+function pysimFsDecAddItem(btn) {
+ const rec = btn && btn.closest ? btn.closest('.pysim-dec-rec') : null;
+ const box = rec ? rec.querySelector('.pysim-dec-items') : null;
+ if (!box || !pysimFsSelected) return;
+ const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot);
+ const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null);
+ const entry = efFindEncoder(node && node.name, fid);
+ if (!entry || !entry.items) return;
+ const first = (entry.items.tags || [])[0];
+ box.insertAdjacentHTML('beforeend', efDecItemRowHtml(entry, { tag: first ? first.tag : '43' }));
+ pysimFsDecRowCheck(btn);
+}
+
+function pysimFsDecRemoveItem(btn) {
+ const row = btn && btn.closest ? btn.closest('.pysim-dec-item-row') : null;
+ if (!row) return;
+ pysimFsDecRowCheck(btn);
+ row.remove();
+}
+
// ===== Network simulation (Phone tab) =====
// Sends the scenario parameters to POST /api/net-sim and renders the step
// log. The scenarios are implemented in pysim_simple_server/netsim.py
@@ -24216,6 +25099,40 @@ const LANG_RU = {
'Read raw': 'Читать сырые данные',
'Read decoded': 'Декодировать',
'Edit raw': 'Редактировать сырые данные',
+ 'Edit decoded': 'Редактировать декодированные данные',
+ 'Add network': 'Добавить сеть',
+ 'Add TLV': 'Добавить TLV',
+ 'Full name': 'Полное имя',
+ 'Short name': 'Короткое имя',
+ 'PLMN additional information': 'Дополнительная информация PLMN',
+ 'Only one full name / short name per record': 'На запись допускается только одно полное и одно короткое имя',
+ 'The full name (43) is required when the record has other TLVs': 'Полное имя (43) обязательно, если в записи есть другие TLV',
+ 'The record is too short for these items': 'Запись слишком коротка для этих элементов',
+ 'The TLV value must be an even number of hex digits': 'Значение TLV должно содержать чётное число hex-цифр',
+ 'The TLV value is longer than 255 bytes': 'Значение TLV длиннее 255 байт',
+ 'The TLV tag must be one hex byte': 'Тег TLV должен состоять из одного hex-байта',
+ 'No decoded editor for this file': 'Нет редактора для этого файла',
+ 'No records selected — check the ones you want to update': 'Записи не выбраны — отметьте те, которые нужно обновить',
+ 'Display the network name in HPLMN / an SPDI PLMN': 'Показывать имя сети в HPLMN / PLMN из SPDI',
+ 'Hide the service provider name outside HPLMN / the SPDI PLMNs': 'Скрывать имя оператора вне HPLMN / PLMN из SPDI',
+ 'Number': 'Номер',
+ 'Access technology': 'Технология доступа',
+ 'Unknown access technology': 'Неизвестная технология доступа',
+ 'MCC must be 3 digits': 'MCC должен состоять из 3 цифр',
+ 'MNC must be 2-3 digits': 'MNC должен состоять из 2-3 цифр',
+ 'Select at least one access technology': 'Выберите хотя бы одну технологию доступа',
+ 'The service provider name is too long for this file': 'Имя оператора слишком длинное для этого файла',
+ 'Too many entries for this file': 'Слишком много записей для этого файла',
+ 'The record is too short for the ADN layout': 'Запись слишком короткая для формата ADN',
+ 'The record is too short for the BDN layout': 'Запись слишком короткая для формата BDN',
+ 'The name is too long for this record': 'Имя слишком длинное для этой записи',
+ 'The number must be digits with an optional leading +': 'Номер должен состоять из цифр с необязательным ведущим +',
+ 'The number is too long for this record': 'Номер слишком длинный для этой записи',
+ 'The number continues in EF.EXT1 - editing it is not supported': 'Номер продолжается в EF.EXT1 — редактирование не поддерживается',
+ 'The number continues in EF.EXT2 - editing it is not supported': 'Номер продолжается в EF.EXT2 — редактирование не поддерживается',
+ 'The number continues in EF.EXT3 - editing it is not supported': 'Номер продолжается в EF.EXT3 — редактирование не поддерживается',
+ 'The number continues in EF.EXT4 - editing it is not supported': 'Номер продолжается в EF.EXT4 — редактирование не поддерживается',
+ 'The number continues in EF.EXT5 - editing it is not supported': 'Номер продолжается в EF.EXT5 — редактирование не поддерживается',
'Refresh': 'Обновить',
'Last refresh': 'Последнее обновление',
'Save': 'Сохранить',
diff --git a/frontend/sw.js b/frontend/sw.js
index 75d197e..8c69e99 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v465';
+const CACHE = 'simple-v466';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/adm_verify.test.js b/frontend/tests/adm_verify.test.js
index a65cd96..8a4e07b 100644
--- a/frontend/tests/adm_verify.test.js
+++ b/frontend/tests/adm_verify.test.js
@@ -57,9 +57,10 @@ test('pysimAdmSecuritySw flags only the access-condition SWs', () => {
});
test('file-manager security failures route through the ADM hint', () => {
- // no raw SW error rendering is left (all three sites use the hint helper)
+ // no raw SW error rendering is left (every read/write site, the raw and
+ // the decoded edit paths, uses the hint helper)
assert.ok(!/statusEl\.textContent = 'SW: ' \+ \(data\.sw/.test(html), 'raw SW error rendering is gone');
- assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 3);
+ assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 6);
assert.ok(html.includes("btn.textContent = t('Verify ADM')"));
assert.ok(html.includes("el.setAttribute('onclick', 'pysimVerifyAdm()')"));
});
diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js
index 2755ef6..3247e18 100644
--- a/frontend/tests/ef_decode.test.js
+++ b/frontend/tests/ef_decode.test.js
@@ -325,7 +325,7 @@ test('efFlatten renders fields, services and PLMNs for display', () => {
[['Service 5', 'Call Control by USIM']]);
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
[['PLMN 1', '262-01 (GSM)']]);
- assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Show in HPLMN', 'yes']]);
+ assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Display the network name in HPLMN / an SPDI PLMN', 'yes']]);
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
});
@@ -438,3 +438,28 @@ test('profilerRenderReport shows raw and decoded comparisons for content mismatc
assert.match(same, /Decoded values match/);
assert.doesNotMatch(same, /Decoded comparison/);
});
+
+test('EF.BDN uses the 15-byte ADN layout (TS 31.102 4.2.44)', () => {
+ // alpha 'TEST' | len 06 | TON/NPI 91 | BCD 21 43 65 87 09 + FF padding |
+ // CCP FF | EXT4 FF | EF.CMI comparison-method pointer 03
+ const hex = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(7) + '03';
+ const bdn = efBytes(hex);
+ const want = { name: 'TEST', number: '+1234567890', cmi: 3 };
+ assert.strictEqual(bdn.length, 19);
+ assert.deepStrictEqual(efDecAdn(bdn, 15), want);
+ assert.deepStrictEqual(efFindDecoder('EF.BDN').fn(bdn), want);
+ // the 14-byte default and the EF.ADN registry entry are unchanged
+ const adn = efBytes('42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF');
+ const adnWant = { name: 'B HA 1 Sic', number: '6082658001' };
+ assert.deepStrictEqual(efDecAdn(adn), adnWant);
+ assert.deepStrictEqual(efFindDecoder('EF.ADN').fn(adn), adnWant);
+ assert.notDeepStrictEqual(efDecAdn(adn, 15), adnWant);
+});
+
+test('the BDN extension and CMI fields carry readable labels', () => {
+ assert.deepStrictEqual(efFlatten({ cmi: 3 }), [['Comparison method pointer', '3']]);
+ assert.deepStrictEqual(efFlatten({ ext1: 2 }), [['Extension record', '2']]);
+ const bdn = efDecAdn(efBytes('54455354' + '06' + '91' + '2143658709' +
+ 'FF'.repeat(7) + '03'), 15);
+ assert.ok(efFlatten(bdn).some(r => r[0] === 'Comparison method pointer' && r[1] === '3'));
+});
diff --git a/frontend/tests/ef_encode.test.js b/frontend/tests/ef_encode.test.js
new file mode 100644
index 0000000..cf928d1
--- /dev/null
+++ b/frontend/tests/ef_encode.test.js
@@ -0,0 +1,513 @@
+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('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);
+}
+
+// Pull a const's initializer by bracket depth (the schemas/registries are
+// nested objects and arrays, so the line-based extraction used elsewhere does
+// not work for them).
+function extractConst(src, name) {
+ const m = new RegExp('const ' + name + ' = ').exec(src);
+ if (!m) throw new Error('const ' + name + ' not found');
+ const begin = m.index + m[0].length;
+ const opener = src[begin];
+ const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
+ if (!closer) throw new Error('unsupported const ' + name);
+ let depth = 0, i = begin;
+ for (; i < src.length; i++) {
+ if (src[i] === opener) depth++;
+ else if (src[i] === closer) {
+ depth--;
+ if (depth === 0) { i++; break; }
+ }
+ }
+ return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
+}
+
+const FNS = [
+ 'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efUcs2Be',
+ 'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct',
+ 'efDecSpn', 'efDecPlmnWact', 'efDecAdn', 'efDecPnn', 'efFindDecoder',
+ 'efDecodeBytes', 'efTlv', 'efPnnText', 'efDecItemsFromHex',
+ 'efGsm7PlainText', 'efEncTextBytes', 'efPlmnEnc', 'efActEnc', 'efEncSpn',
+ 'efEncPlmnList', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
+ 'efFindEncoder', 'efHasEncoder', 'gsm7Decode', 'gsm7TextToSeptets',
+ 'gsm7Encode', 'efEncError',
+];
+
+let code = '';
+for (const f of FNS) code += extractFunc(html, f) + '\n';
+for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
+ 'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_ENCODERS']) {
+ code += extractConst(html, c);
+}
+eval(code);
+
+test('efPlmnEnc builds the TS 24.008 PLMN and round-trips through efPlmn', () => {
+ assert.strictEqual(efPlmnEnc('262', '01'), '62F210');
+ assert.strictEqual(efPlmnEnc('228', '06'), '22F860');
+ assert.deepStrictEqual(efPlmn(efBytes('22F860')), { mcc: '228', mnc: '06' });
+ assert.deepStrictEqual(efPlmn(efBytes(efPlmnEnc('310', '260'))), { mcc: '310', mnc: '260' });
+ assert.deepStrictEqual(efPlmn(efBytes(efPlmnEnc('250', '99'))), { mcc: '250', mnc: '99' });
+ assert.throws(() => efPlmnEnc('26', '01'), /MCC/);
+ assert.throws(() => efPlmnEnc('262', '1'), /MNC/);
+ assert.throws(() => efPlmnEnc('26a', '01'), /MCC/);
+ assert.throws(() => efPlmnEnc('262', '01a'), /MNC/);
+});
+
+test('efActEnc encodes every technology name and the spec bit tables', () => {
+ assert.strictEqual(efActEnc('UTRAN'), '8000');
+ assert.strictEqual(efActEnc('NG-RAN'), '0800');
+ assert.strictEqual(efActEnc('E-UTRAN WB-S1, E-UTRAN NB-S1'), '4000');
+ assert.strictEqual(efActEnc('E-UTRAN NB-S1'), '5000');
+ assert.strictEqual(efActEnc('E-UTRAN WB-S1'), '6000');
+ assert.strictEqual(efActEnc('GSM'), '0084');
+ assert.strictEqual(efActEnc('EC-GSM-IoT'), '0088');
+ assert.strictEqual(efActEnc('GSM, EC-GSM-IoT'), '0080');
+ assert.strictEqual(efActEnc('GSM COMPACT'), '0040');
+ assert.strictEqual(efActEnc('cdma2000 HRPD'), '0020');
+ assert.strictEqual(efActEnc('cdma2000 1xRTT'), '0010');
+ // every name the decoder prints survives an encode/decode round trip
+ for (const name of EF_ACT_TECHS) {
+ const act = parseInt(efActEnc(name), 16);
+ assert.ok(efPlmnAct(act).indexOf(name) !== -1, name + ' round trip');
+ }
+ assert.throws(() => efActEnc(''), /at least one/);
+ assert.throws(() => efActEnc([]), /at least one/);
+ assert.throws(() => efActEnc('BOGUS'), /Unknown access technology/);
+});
+
+test('efPlmnAct decodes the EC-GSM-IoT-only 0x0088 (the old 0x0086 branch was unreachable)', () => {
+ // Table 4.2.5.2: b8=1, b4=1, b3=0 = 0x0088; the mask 0x008C makes 0x0086
+ // collapse to 0x0084 (GSM), so the EC-only branch could never fire.
+ assert.deepStrictEqual(efPlmnAct(0x0088), ['EC-GSM-IoT']);
+ assert.deepStrictEqual(efPlmnAct(0x008C), ['EC-GSM-IoT', 'GSM']);
+ assert.deepStrictEqual(efPlmnAct(0x0080), ['EC-GSM-IoT', 'GSM']);
+ assert.deepStrictEqual(efPlmnAct(0x0084), ['GSM']);
+ assert.deepStrictEqual(efPlmnAct(0x0040), ['GSM COMPACT']);
+ assert.deepStrictEqual(efPlmnAct(0x0020), ['cdma2000 HRPD']);
+ assert.deepStrictEqual(efPlmnAct(0x0010), ['cdma2000 1xRTT']);
+ assert.deepStrictEqual(efPlmnAct(0x8000), ['UTRAN']);
+ assert.deepStrictEqual(efPlmnAct(0x0800), ['NG-RAN']);
+ assert.deepStrictEqual(efPlmnAct(0x4000), ['E-UTRAN NB-S1', 'E-UTRAN WB-S1']);
+ assert.deepStrictEqual(efPlmnAct(0x5000), ['E-UTRAN NB-S1']);
+ assert.deepStrictEqual(efPlmnAct(0x6000), ['E-UTRAN WB-S1']);
+});
+
+const SPN_ORIG = '0147534d2d52204348ffffffffffffffff';
+
+test('EF.SPN round-trips, and an unchanged name keeps the original coding', () => {
+ const dec = efDecSpn(efBytes(SPN_ORIG));
+ assert.deepStrictEqual(dec, { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
+ assert.strictEqual(efEncSpn(dec, SPN_ORIG), SPN_ORIG.toUpperCase());
+ // only the condition byte changes: the text bytes are copied verbatim
+ const flags = efEncSpn({ show_in_hplmn: false, hide_in_oplmn: true, name: 'GSM-R CH' }, SPN_ORIG);
+ assert.strictEqual(flags, '02' + SPN_ORIG.slice(2).toUpperCase());
+ // a different name is re-coded (GSM-7 octets, FF padding to 16 bytes)
+ const text = efEncSpn({ show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH2' }, SPN_ORIG);
+ assert.strictEqual(text.slice(0, 2), '01');
+ assert.strictEqual(text.length, 34);
+ assert.strictEqual(text.slice(2), '47534D2D5220434832FFFFFFFFFFFFFF');
+ assert.strictEqual(efDecSpn(efBytes(text)).name, 'GSM-R CH2');
+ // a character outside the GSM 7-bit alphabet goes UCS-2BE behind 0x80
+ const ucs = efEncSpn({ name: '\u0416' }, SPN_ORIG);
+ assert.strictEqual(ucs, '00800416' + 'FF'.repeat(13));
+ assert.strictEqual(efDecSpn(efBytes(ucs)).name, '\u0416');
+ assert.throws(() => efEncSpn({ name: 'A'.repeat(17) }, SPN_ORIG), /too long/);
+});
+
+const PLMN_ORIG = '62F2100084';
+
+test('EF.PLMNwAcT round-trips, adds/removes rows and refuses an overflow', () => {
+ const dec = efDecPlmnWact(efBytes(PLMN_ORIG));
+ assert.deepStrictEqual(dec.plmns, [{ mcc: '262', mnc: '01', access_tech: 'GSM' }]);
+ assert.strictEqual(efEncPlmnList(dec.plmns, PLMN_ORIG), PLMN_ORIG);
+ assert.strictEqual(efEncPlmnList([], PLMN_ORIG), 'FFFFFFFFFF');
+ // two entries in a ten-byte file
+ const ten = PLMN_ORIG + '22F8608000';
+ const rows = [
+ { mcc: '262', mnc: '01', access_tech: 'GSM' },
+ { mcc: '228', mnc: '06', access_tech: 'UTRAN' },
+ ];
+ assert.strictEqual(efEncPlmnList(rows, ten), ten);
+ assert.strictEqual(efEncPlmnList([rows[1]], ten), '22F8608000' + 'FF'.repeat(5));
+ // Table 4.2.5.1 both-form round trip through the list encoder
+ const both = efEncPlmnList([{ mcc: '262', mnc: '01', access_tech: 'E-UTRAN WB-S1, E-UTRAN NB-S1' }], ten);
+ assert.strictEqual(both.slice(0, 10), '62F2104000');
+ assert.deepStrictEqual(efDecPlmnWact(efBytes(both)).plmns[0].access_tech, 'E-UTRAN NB-S1, E-UTRAN WB-S1');
+ assert.throws(() => efEncPlmnList(rows, PLMN_ORIG), /Too many entries/);
+ assert.throws(() => efEncPlmnList([{ mcc: '26', mnc: '01', access_tech: 'GSM' }], ten), /MCC/);
+});
+
+const ADN_1 = '42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF';
+const ADN_2 = 'ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff';
+
+test('EF.ADN round-trips the decoder vectors byte-for-byte', () => {
+ const d1 = efDecAdn(efBytes(ADN_1));
+ assert.strictEqual(d1.name, 'B HA 1 Sic');
+ assert.strictEqual(d1.number, '6082658001');
+ assert.strictEqual(efEncAdn(d1, ADN_1), ADN_1.toUpperCase());
+ const d2 = efDecAdn(efBytes(ADN_2));
+ assert.strictEqual(d2.name, '');
+ assert.strictEqual(d2.number, '12345');
+ assert.strictEqual(efEncAdn(d2, ADN_2), ADN_2.toUpperCase());
+ assert.strictEqual(efEncAdn(d2, ADN_2).slice(-4), 'FFFF');
+});
+
+test('EF.ADN re-encodes an edited name and number in the record length', () => {
+ const out = efEncAdn({ name: 'Test', number: '+79001234567' }, ADN_1);
+ assert.strictEqual(out.length, ADN_1.length);
+ assert.strictEqual(out.slice(0, 8), '54657374'); // 'Test' as GSM-7 octets
+ assert.strictEqual(out.slice(32, 36), '0791'); // len 7 (1 + 6 bytes), TON/NPI +
+ const dec = efDecAdn(efBytes(out));
+ assert.strictEqual(dec.name, 'Test');
+ assert.strictEqual(dec.number, '+79001234567');
+ assert.strictEqual(dec.ext1, undefined);
+ // spaces are ignored
+ assert.strictEqual(efEncAdn({ name: 'Test', number: '+7 900 123 45 67' }, ADN_1), out);
+ // an emptied number: length 0, TON/NPI FF, digits FF
+ const empty = efEncAdn({ name: 'Test', number: '' }, ADN_1);
+ assert.strictEqual(empty.slice(32, 36), '00FF');
+ assert.strictEqual(efDecAdn(efBytes(empty)).number, '');
+ // a name-only edit of an all-FF record fills that record (32-byte record)
+ const fill = efEncAdn({ name: 'Ann', number: '123' }, 'FF'.repeat(32));
+ assert.strictEqual(fill.length, 64);
+ assert.strictEqual(efDecAdn(efBytes(fill)).name, 'Ann');
+ assert.strictEqual(efDecAdn(efBytes(fill)).number, '123');
+});
+
+test('EF.ADN refuses a number extension, a short record and a bad number', () => {
+ const ext1 = ADN_1.slice(0, -2) + '03';
+ assert.strictEqual(efEncAdn({ name: 'B HA 1 Sic', number: '6082658001' }, ext1).slice(-4), 'FF03');
+ assert.throws(() => efEncAdn({ name: 'B HA 1 Sic', number: '6082658002' }, ext1), /EF\.EXT1/);
+ assert.throws(() => efEncAdn({}, '00'), /too short/);
+ assert.throws(() => efEncAdn({ number: '+' }, ADN_1), /digits/);
+ assert.throws(() => efEncAdn({ number: '12a' }, ADN_1), /digits/);
+ assert.throws(() => efEncAdn({ number: '1'.repeat(21) }, ADN_1), /too long/);
+});
+
+test('EF.SPN and the ADN family share the encoder registry with the PLMN list', () => {
+ assert.strictEqual(efFindEncoder('EF.SPN', null).fid, '6f46');
+ assert.strictEqual(efFindEncoder('ef.adn', null).fid, '6f3a');
+ assert.strictEqual(efFindEncoder('ADF.USIM/EF.MSISDN', null).name, 'EF.MSISDN');
+ assert.strictEqual(efFindEncoder('EF.ALIAS', '6f60').enc, efEncPlmnList);
+ assert.strictEqual(efFindEncoder('EF.NOPE', 'abcd'), null);
+ assert.strictEqual(efHasEncoder('EF.ADN', null), true);
+ assert.strictEqual(efHasEncoder('EF.IMSI', null), false);
+ // one shared schema for the ADN family and one for the three PLMN files
+ assert.strictEqual(efFindEncoder('EF.ADN', null).fields, efFindEncoder('EF.BDN', null).fields);
+ assert.strictEqual(efFindEncoder('EF.PLMNwAcT', null).list, efFindEncoder('EF.HPLMNwAcT', null).list);
+});
+
+test('encoder validation errors go through t() when the runtime provides it', () => {
+ globalThis.t = k => (k === 'Select at least one access technology' ? 'Выберите технологию' : k);
+ try {
+ assert.throws(() => efActEnc(''), /Выберите технологию/);
+ } finally {
+ delete globalThis.t;
+ }
+});
+
+// EF.PNN (TS 31.102 4.2.58): a record is a list of Network name TLVs. These
+// are pySim's reference records (the 43/45 values decoded by efPnnText).
+const PNN_1 = '430a82d432bbbc7eb75de432450a82d432bbbc7eb75de432ffffffff';
+const PNN_2 = '430a82c596b34cbfbfe5eb39ffffffffffffffffffffffffffffffffffff';
+
+test('EF.PNN round-trips the reference vectors byte-for-byte', () => {
+ assert.deepStrictEqual(efDecPnn(efBytes(PNN_1)), { full: 'Telekom.de', short: 'Telekom.de' });
+ assert.deepStrictEqual(efDecPnn(efBytes(PNN_2)), { full: 'E-Networks' });
+ const entry = efFindEncoder('EF.PNN', null);
+ assert.strictEqual(entry.fid, '6fc5');
+ assert.strictEqual(entry.items.addLabel, 'Add TLV');
+ for (const hex of [PNN_1, PNN_2]) {
+ const items = efDecItemsFromHex(entry, hex);
+ assert.strictEqual(efEncPnn(items, hex), hex.toUpperCase());
+ }
+ // a known name tag is decoded to its text, not kept raw
+ assert.deepStrictEqual(efDecItemsFromHex(entry, PNN_1), [
+ { tag: '43', text: 'Telekom.de', orig: '430A82D432BBBC7EB75DE432' },
+ { tag: '45', text: 'Telekom.de', orig: '450A82D432BBBC7EB75DE432' },
+ ]);
+});
+
+test('EF.PNN encodes a non-GSM7 name as UCS-2 behind 0x90', () => {
+ const entry = efFindEncoder('EF.PNN', null);
+ const out = efEncPnn([{ tag: '43', text: 'Сеть' }], PNN_1);
+ assert.strictEqual(out.slice(0, 4), '4309'); // 1 + 4 UCS-2 chars
+ assert.strictEqual(out.slice(4, 6), '90');
+ assert.deepStrictEqual(efDecItemsFromHex(entry, out), [{ tag: '43', text: 'Сеть', orig: '430990042104350442044C' }]);
+ // the empty rest is FF padding up to the record length
+ assert.strictEqual(out.length, PNN_1.length);
+ assert.match(out.slice((2 + 9) * 2), /^F+$/);
+});
+
+test('EF.PNN re-encodes an edited name and keeps the other TLVs', () => {
+ const entry = efFindEncoder('EF.PNN', null);
+ const items = efDecItemsFromHex(entry, PNN_1).map(i =>
+ i.tag === '43' ? { tag: i.tag, text: 'NewNet' } : i);
+ const out = efEncPnn(items, PNN_1);
+ assert.ok(out.includes('450A82D432BBBC7EB75DE432'), 'the 45 TLV is unchanged');
+ assert.deepStrictEqual(efDecItemsFromHex(entry, out), [
+ { tag: '43', text: 'NewNet', orig: '430786CEF2DD59A603' },
+ { tag: '45', text: 'Telekom.de', orig: '450A82D432BBBC7EB75DE432' },
+ ]);
+ assert.strictEqual(out.length, PNN_1.length);
+});
+
+test('EF.PNN adds and removes TLVs', () => {
+ const entry = efFindEncoder('EF.PNN', null);
+ const withInfo = efEncPnn([{ tag: '43', text: 'E-Networks' }, { tag: '80', hex: '00410042' }], PNN_2);
+ assert.deepStrictEqual(efDecItemsFromHex(entry, withInfo), [
+ { tag: '43', text: 'E-Networks', orig: '430A82C596B34CBFBFE5EB39' },
+ { tag: '80', hex: '00410042', orig: '800400410042' },
+ ]);
+ const onlyFull = efEncPnn([{ tag: '43', text: 'E-Networks' }], PNN_2);
+ assert.deepStrictEqual(efDecItemsFromHex(entry, onlyFull), [{ tag: '43', text: 'E-Networks', orig: '430A82C596B34CBFBFE5EB39' }]);
+});
+
+test('EF.PNN encodes tag 80 as raw bytes and preserves an unknown tag verbatim', () => {
+ const entry = efFindEncoder('EF.PNN', null);
+ const out = efEncPnn([{ tag: '80', hex: '00410042' }], 'FF'.repeat(16));
+ assert.strictEqual(out.slice(0, 12), '800400410042');
+ assert.deepStrictEqual(efDecItemsFromHex(entry, out), [{ tag: '80', hex: '00410042', orig: '800400410042' }]);
+ // an unknown tag parsed from the card is read-only and re-emitted as-is
+ const hex = '430a82d432bbbc7eb75de4325a02aabb' + 'ff'.repeat(14);
+ const items = efDecItemsFromHex(entry, hex);
+ assert.deepStrictEqual(items, [
+ { tag: '43', text: 'Telekom.de', orig: '430A82D432BBBC7EB75DE432' },
+ { tag: '5A', hex: 'AABB', raw: true, orig: '5A02AABB' },
+ ]);
+ assert.strictEqual(efEncPnn(items, hex), hex.toUpperCase());
+});
+
+test('EF.PNN refuses duplicate names, bad hex and an overflowing record', () => {
+ assert.throws(() => efEncPnn([{ tag: '43', text: 'A' }, { tag: '43', text: 'B' }], PNN_1), /Only one full name/);
+ assert.throws(() => efEncPnn([{ tag: '45', text: 'A' }, { tag: '45', text: 'B' }], PNN_1), /Only one full name/);
+ assert.throws(() => efEncPnn([{ tag: '80', hex: '0G' }], PNN_1), /even number of hex/);
+ assert.throws(() => efEncPnn([{ tag: '80', hex: '0' }], PNN_1), /even number of hex/);
+ assert.throws(() => efEncPnn([{ tag: '4', hex: '00' }], PNN_1), /one hex byte/);
+ assert.throws(() => efEncPnn([{ tag: '43', text: 'X'.repeat(40) }], 'FF'.repeat(10)), /too short for these items/);
+ assert.throws(() => efEncPnn([{ tag: '80', hex: '00'.repeat(20) }], 'FF'.repeat(10)), /too short for these items/);
+ // an empty name omits its TLV (nothing but FF padding)
+ assert.strictEqual(efEncPnn([{ tag: '43', text: '' }], 'FF'.repeat(8)), 'FF'.repeat(8));
+});
+
+test('EF.PNN writes spare 0 when the packed text fills the last octet', () => {
+ const entry = efFindEncoder('EF.PNN', null);
+ // 8 septets = 56 bits = 7 octets exactly -> coding octet 0x80
+ const out = efEncPnn([{ tag: '43', text: 'Vodafone' }], 'FF'.repeat(16));
+ assert.strictEqual(out.slice(0, 4), '4308');
+ assert.strictEqual(out.slice(4, 6), '80');
+ assert.deepStrictEqual(efDecItemsFromHex(entry, out), [{ tag: '43', text: 'Vodafone', orig: '430880D637396C7EBBCB' }]);
+});
+
+// EF.BDN (TS 31.102 4.2.44) is the X+15 layout: alpha, len, TON/NPI, BCD(10),
+// CCP, EXT4, then the EF.CMI comparison-method pointer.
+const BDN = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(7) + '03';
+
+test('EF.BDN round-trips the X+15 layout and preserves the CMI pointer', () => {
+ const bytes = efBytes(BDN);
+ assert.strictEqual(bytes.length, 19);
+ const dec = efDecAdn(bytes, 15);
+ assert.deepStrictEqual(dec, { name: 'TEST', number: '+1234567890', cmi: 3 });
+ assert.strictEqual(efEncAdn(dec, BDN, 15), BDN);
+ // the registry's BDN entry applies the 15-byte layout
+ const entry = efFindEncoder('EF.BDN', null);
+ assert.strictEqual(entry.fid, '6f4d');
+ assert.strictEqual(entry.enc(dec, BDN), BDN);
+ // editing the number keeps the alpha and the trailing CMI pointer
+ const edited = entry.enc({ name: 'TEST', number: '+79001234567' }, BDN);
+ assert.strictEqual(edited.slice(0, 8), '54455354');
+ assert.strictEqual(edited.slice(-2), '03');
+ const back = efDecAdn(efBytes(edited), 15);
+ assert.strictEqual(back.name, 'TEST');
+ assert.strictEqual(back.number, '+79001234567');
+ assert.strictEqual(back.cmi, 3);
+ assert.strictEqual(back.ext1, undefined);
+});
+
+test('the ADN family stays on the 14-byte layout', () => {
+ // the default is the old 14-byte layout: the registry entry and the
+ // 14-byte round trip are unchanged
+ assert.strictEqual(efFindEncoder('EF.ADN', null).enc(efDecAdn(efBytes(ADN_1)), ADN_1), ADN_1.toUpperCase());
+ assert.strictEqual(efEncAdn(efDecAdn(efBytes(ADN_1)), ADN_1), ADN_1.toUpperCase());
+ assert.notDeepStrictEqual(efDecAdn(efBytes(ADN_1)), efDecAdn(efBytes(ADN_1), 15));
+ // a BDN record decoded with the default layout is not the BDN result; a
+ // number EDIT through the 14-byte layout is refused (its EXT slot is the
+ // BDN's EF.CMI pointer), while the BDN layout writes it
+ assert.notDeepStrictEqual(efDecAdn(efBytes(BDN)), efDecAdn(efBytes(BDN), 15));
+ assert.throws(() => efEncAdn({ name: 'TEST', number: '+7900' }, BDN), /EF\.EXT1/);
+ assert.ok(efEncAdn({ name: 'TEST', number: '+7900' }, BDN, 15));
+});
+
+// A control-string/extended-BCD dialling number (SSC): the decoder only sees
+// the digit fragment, so an untouched number must not be rebuilt from it.
+const SSC_ADN = '54455354' + 'FF'.repeat(12) + '04' + 'FF' + 'BA01B0' + 'FF'.repeat(7) + 'FF' + 'FF';
+
+test('F1: an untouched SSC number block is copied verbatim', () => {
+ const hex = SSC_ADN;
+ assert.strictEqual(efBytes(hex).length, 30);
+ assert.deepStrictEqual(efDecAdn(efBytes(hex)), { name: 'TEST', number: '100' });
+ // name-only edit: the [length, TON/NPI, 10 BCD/SSC] block is untouched
+ const out = efEncAdn({ name: 'NEW', number: '100' }, hex);
+ assert.strictEqual(out.slice(0, 6), '4E4557');
+ assert.strictEqual(out.slice(32, 56), hex.toUpperCase().slice(32, 56));
+ // no edit at all: byte-identical
+ assert.strictEqual(efEncAdn(efDecAdn(efBytes(hex)), hex), hex.toUpperCase());
+ // an all-FF record stays all-FF (the old encoder wrote a 00 length byte)
+ assert.strictEqual(efEncAdn({}, 'FF'.repeat(64)), 'FF'.repeat(64));
+});
+
+test('F4: an untouched all-FF SPN stays all-FF', () => {
+ const ff = 'FF'.repeat(17);
+ // the model exposes the FF display bits (both set), not a blank object
+ assert.deepStrictEqual(efDecSpn(efBytes(ff)), { show_in_hplmn: true, hide_in_oplmn: true });
+ assert.strictEqual(efEncSpn(efDecSpn(efBytes(ff)), ff), ff);
+});
+
+test('F5: unchanged PLMN rows keep unmodelled ACT bits and codings', () => {
+ // 0x8C00: UTRAN + NG-RAN + the Rel-18 satellite bit the form does not model
+ const rec = '62F2108C00';
+ const dec = efDecPlmnWact(efBytes(rec));
+ assert.strictEqual(dec.plmns[0].access_tech, 'NG-RAN, UTRAN');
+ assert.strictEqual(efEncPlmnList(dec.plmns, rec), rec);
+ // editing the MCC keeps the original ACT bytes (not 0x8800)
+ assert.strictEqual(efEncPlmnList([{ mcc: '250', mnc: '01', access_tech: 'NG-RAN, UTRAN' }], rec),
+ '52F010' + '8C00');
+ // equal codings are not canonicalised either
+ assert.strictEqual(efEncPlmnList(efDecPlmnWact(efBytes('62F210008C')).plmns, '62F210008C'), '62F210008C');
+ assert.strictEqual(efEncPlmnList(efDecPlmnWact(efBytes('62F2107000')).plmns, '62F2107000'), '62F2107000');
+ // a changed ACT is encoded canonically
+ assert.strictEqual(efEncPlmnList([{ mcc: '262', mnc: '01', access_tech: 'NG-RAN' }], rec), '62F2100800');
+});
+
+test('F9: an emptied full name is refused while other TLVs remain', () => {
+ assert.throws(() => efEncPnn([{ tag: '43', text: '' }, { tag: '45', text: 'Short' }], PNN_1),
+ /full name \(43\) is required/);
+ assert.throws(() => efEncPnn([{ tag: '43', text: '' }, { tag: '80', hex: '00' }], PNN_1),
+ /full name/);
+ // the empty omission is only for a record that ends up with no items
+ assert.strictEqual(efEncPnn([{ tag: '43', text: '' }], 'FF'.repeat(8)), 'FF'.repeat(8));
+});
+
+test('F10: a TLV value longer than 255 bytes is refused', () => {
+ const big = '00'.repeat(256);
+ assert.throws(() => efEncPnn([{ tag: '80', hex: big }], 'FF'.repeat(40)), /longer than 255/);
+ assert.throws(() => efEncPnn([{ tag: '5A', hex: big }], 'FF'.repeat(40)), /longer than 255/);
+});
+
+test('F14: the encoder messages go through t() (unknown technology, too many rows)', () => {
+ globalThis.t = k => (k === 'Unknown access technology' ? 'U'
+ : (k === 'Too many entries for this file' ? 'T' : k));
+ try {
+ assert.throws(() => efActEnc('BOGUS'), /^Error: U: BOGUS$/);
+ assert.throws(() => efEncPlmnList([
+ { mcc: '262', mnc: '01', access_tech: 'GSM' },
+ { mcc: '228', mnc: '06', access_tech: 'GSM' },
+ ], '62F2100084'), /^Error: T \(1 max\)$/);
+ } finally {
+ delete globalThis.t;
+ }
+});
+
+test('unchanged content re-encodes byte-identically (all fixtures)', () => {
+ const decode = (name, hex) => {
+ const bytes = efBytes(hex);
+ if (name === 'EF.SPN') return efDecSpn(bytes);
+ if (name === 'EF.PLMNwAcT') return efDecPlmnWact(bytes).plmns;
+ if (name === 'EF.BDN') return efDecAdn(bytes, 15);
+ if (name === 'EF.ADN') return efDecAdn(bytes);
+ if (name === 'EF.PNN') return efDecItemsFromHex(efFindEncoder('EF.PNN', null), hex);
+ throw new Error('no decoder for ' + name);
+ };
+ const cases = [
+ { name: 'EF.SPN', fid: '6f46', hex: SPN_ORIG },
+ { name: 'EF.SPN', fid: '6f46', hex: 'FF'.repeat(17) },
+ { name: 'EF.PLMNwAcT', fid: '6f60', hex: PLMN_ORIG },
+ { name: 'EF.PLMNwAcT', fid: '6f60', hex: PLMN_ORIG + '22F8608000' },
+ { name: 'EF.PLMNwAcT', fid: '6f60', hex: '62F2108C00' },
+ { name: 'EF.PLMNwAcT', fid: '6f60', hex: '62F210008C' },
+ { name: 'EF.PLMNwAcT', fid: '6f60', hex: '62F2107000' },
+ { name: 'EF.ADN', fid: '6f3a', hex: ADN_1 },
+ { name: 'EF.ADN', fid: '6f3a', hex: ADN_2 },
+ { name: 'EF.ADN', fid: '6f3a', hex: SSC_ADN },
+ { name: 'EF.ADN', fid: '6f3a', hex: 'FF'.repeat(64) },
+ { name: 'EF.BDN', fid: '6f4d', hex: BDN },
+ { name: 'EF.BDN', fid: '6f4d', hex: 'FF'.repeat(38) },
+ { name: 'EF.PNN', fid: '6fc5', hex: PNN_1 },
+ { name: 'EF.PNN', fid: '6fc5', hex: PNN_2 },
+ { name: 'EF.PNN', fid: '6fc5', hex: '800400410042' + 'FF'.repeat(16) },
+ { name: 'EF.PNN', fid: '6fc5', hex: '430a82d432bbbc7eb75de4325a02aabb' + 'ff'.repeat(14) },
+ ];
+ for (const c of cases) {
+ const entry = efFindEncoder(c.name, c.fid);
+ assert.strictEqual(entry.enc(decode(c.name, c.hex), c.hex), c.hex.toUpperCase(),
+ c.name + ' ' + c.hex);
+ }
+});
+
+test('F2: low-byte packed, AddCI and UCS-2-padded names round-trip unchanged', () => {
+ const entry = efFindEncoder('EF.PNN', null);
+ // 0x82 spare 2, packed 41 21 = 'AB' (the old all-low heuristic read 'A!')
+ assert.strictEqual(efPnnText(efBytes('824121')), 'AB');
+ const records = [
+ '4303824121' + 'FF'.repeat(28), // low-byte packed 'AB'
+ '43038A4121' + 'FF'.repeat(28), // the same with the AddCI bit
+ '43079000410042FFFF' + 'FF'.repeat(20), // UCS-2 'AB' with FF padding
+ ];
+ for (const hex of records) {
+ const items = efDecItemsFromHex(entry, hex);
+ assert.strictEqual(efEncPnn(items, hex), hex, hex);
+ }
+});
+
+test('F2: editing one PNN name leaves the other TLV byte-identical', () => {
+ const entry = efFindEncoder('EF.PNN', null);
+ // 43 carries the AddCI-bit packing; 45 is a plain packed name
+ const hex = '43038A4121' + '4503824222' + 'FF'.repeat(24);
+ const items = efDecItemsFromHex(entry, hex);
+ assert.deepStrictEqual(items.map(i => i.text), ['AB', 'BD']);
+ const edited = items.map(i => i.tag === '43' ? { tag: i.tag, text: 'Z', orig: i.orig } : i);
+ const out = efEncPnn(edited, hex);
+ assert.ok(out.includes('4503824222'), 'the 45 TLV is unchanged');
+ assert.strictEqual(efDecItemsFromHex(entry, out)[1].text, 'BD');
+});
+
+test('F8: the ADN-family entries name their extension EF and use it in refusals', () => {
+ const names = ['EF.ADN', 'EF.FDN', 'EF.SDN', 'EF.MSISDN', 'EF.LND', 'EF.BDN'];
+ assert.deepStrictEqual(names.map(n => efFindEncoder(n, null).ext),
+ ['EXT1', 'EXT2', 'EXT3', 'EXT5', 'EXT1', 'EXT4']);
+ // a non-FF extension slot refuses a changed number with its own EF name
+ const fdn = ADN_1.slice(0, -2) + '03'; // EXT2 set
+ assert.throws(() => efFindEncoder('EF.FDN', null).enc(
+ { name: 'B HA 1 Sic', number: '6082658002' }, fdn), /EF\.EXT2/);
+ const sdn = ADN_1.slice(0, -2) + '03';
+ assert.throws(() => efFindEncoder('EF.SDN', null).enc(
+ { name: 'B HA 1 Sic', number: '6082658002' }, sdn), /EF\.EXT3/);
+ const msisdn = ADN_1.slice(0, -2) + '03';
+ assert.throws(() => efFindEncoder('EF.MSISDN', null).enc(
+ { name: 'B HA 1 Sic', number: '6082658002' }, msisdn), /EF\.EXT5/);
+ // BDN: the slot before the CMI pointer is EXT4
+ const bdnExt4 = '54455354' + '06' + '91' + '2143658709' + 'FF'.repeat(6) + '04' + '03';
+ assert.throws(() => efEncAdn({ name: 'TEST', number: '+79001234567' }, bdnExt4, 15, 'EXT4'), /EF\.EXT4/);
+ // the unchanged number still copies the extension slots verbatim
+ assert.strictEqual(efEncAdn(efDecAdn(efBytes(fdn)), fdn, 14, 'EXT2'), fdn.toUpperCase());
+});
diff --git a/frontend/tests/fs_dec_edit.test.js b/frontend/tests/fs_dec_edit.test.js
new file mode 100644
index 0000000..015eb8c
--- /dev/null
+++ b/frontend/tests/fs_dec_edit.test.js
@@ -0,0 +1,655 @@
+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);
+}
+
+function extractConst(src, name) {
+ const m = new RegExp('const ' + name + ' = ').exec(src);
+ if (!m) throw new Error('const ' + name + ' not found');
+ const begin = m.index + m[0].length;
+ const opener = src[begin];
+ const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
+ if (!closer) throw new Error('unsupported const ' + name);
+ let depth = 0, i = begin;
+ for (; i < src.length; i++) {
+ if (src[i] === opener) depth++;
+ else if (src[i] === closer) {
+ depth--;
+ if (depth === 0) { i++; break; }
+ }
+ }
+ return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
+}
+
+const FNS = [
+ 'efBytes', 'efHex', 'efAllFf', 'efRstripFf', 'efUcs2Be', 'efGsm7Octets',
+ 'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct', 'efDecSpn', 'efDecPlmnWact',
+ 'efDecAdn', 'efFindDecoder', 'efDecodeBytes', 'efIsRawOnly', 'efTlv',
+ 'efPnnText', 'efDecItemsFromHex',
+ 'efGsm7PlainText', 'efEncTextBytes', 'efPlmnEnc', 'efActEnc', 'efEncSpn',
+ 'efEncPlmnList', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
+ 'efFindEncoder', 'efHasEncoder', 'efEncError',
+ 'efDecModelValues', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
+ 'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
+ 'efDecRecordsHtml', 'efDecEditHtml',
+ 'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
+ 'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
+ 'pysimFsDecRemoveItem', 'pysimFsFindNode',
+ 'pysimFsHasDecoder', 'pysimFsHasEncoder', 'pysimFsDecSave', 'pysimFsDecEdit',
+ 'pysimFsPillsEnabled', 'pysimFsSyncContentVisibility', 'gsm7Decode',
+ 'gsm7TextToSeptets', 'gsm7Encode',
+];
+
+let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n'
+ + 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n';
+for (const f of FNS) code += extractFunc(html, f) + '\n';
+for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
+ 'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_ENCODERS']) {
+ code += extractConst(html, c);
+}
+eval(code);
+
+// The decoder entries these files resolve to (the full EF_DECODERS registry is
+// pinned by ef_decode.test.js; only the model's lookup path is exercised here).
+var EF_DECODERS = [
+ { name: 'EF.SPN', fid: '6f46', fn: efDecSpn },
+ { name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
+ { name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact },
+ { name: 'EF.HPLMNwAcT', fid: '6f62', fn: efDecPlmnWact },
+ { name: 'EF.ADN', fid: '6f3a', fn: efDecAdn },
+ { name: 'EF.FDN', fid: '6f3b', fn: efDecAdn },
+ { name: 'EF.SDN', fid: '6f49', fn: efDecAdn },
+ { name: 'EF.MSISDN', fid: '6f40', fn: efDecAdn },
+ { name: 'EF.LND', fid: '6f44', fn: efDecAdn },
+ { name: 'EF.BDN', fid: '6f4d', fn: b => efDecAdn(b, 15) },
+];
+
+globalThis.t = k => k;
+globalThis.esc = s => String(s);
+
+const SPN_ORIG = '0147534d2d52204348ffffffffffffffff';
+const ADN_1 = '42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF';
+const ADN_FF = 'FF'.repeat(64);
+const PLMN_ORIG = '62F210008422F8608000';
+
+test('efFindEncoder / efHasEncoder resolve by name and FID, like the decoders', () => {
+ assert.strictEqual(efFindEncoder('EF.SPN', null).fid, '6f46');
+ assert.strictEqual(efFindEncoder('ef.plmnwact', null).name, 'EF.PLMNwAcT');
+ assert.strictEqual(efFindEncoder('MF/7F10/6F3A/EF.ADN', null).name, 'EF.ADN');
+ assert.strictEqual(efFindEncoder('EF.ALIAS', '6f40').name, 'EF.MSISDN');
+ assert.strictEqual(efFindEncoder('EF.IMSI', '6f07'), null);
+ assert.strictEqual(efFindEncoder(null, null), null);
+ assert.strictEqual(efHasEncoder('EF.SDN', null), true);
+ assert.strictEqual(efHasEncoder('EF.IMSI', null), false);
+});
+
+test('the model is a TR value set for SPN / PLMNwAcT and record rows for ADN', () => {
+ const spn = efDecEditModel('EF.SPN', null, { kind: 'transparent', data: SPN_ORIG });
+ assert.strictEqual(spn.mode, 'tr');
+ assert.strictEqual(spn.entry.name, 'EF.SPN');
+ assert.deepStrictEqual(spn.values, { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
+ // an all-FF SPN exposes its FF display bits (both set, no name), so an
+ // untouched save keeps the file byte-identical
+ const emptySpn = efDecEditModel('EF.SPN', '6f46', { kind: 'transparent', data: 'FF'.repeat(17) });
+ assert.deepStrictEqual(emptySpn.values, { show_in_hplmn: true, hide_in_oplmn: true });
+ const plmn = efDecEditModel('EF.PLMNwAcT', null, { kind: 'transparent', data: PLMN_ORIG });
+ assert.strictEqual(plmn.values.plmns.length, 2);
+ assert.strictEqual(plmn.values.plmns[0].mcc, '262');
+ const adn = efDecEditModel('EF.ADN', null, { kind: 'record', records: [
+ { num: 1, data: ADN_1 },
+ { num: 2, data: ADN_FF },
+ ] });
+ assert.strictEqual(adn.mode, 'records');
+ assert.strictEqual(adn.records.length, 2);
+ assert.strictEqual(adn.records[0].num, 1);
+ assert.strictEqual(adn.records[0].orig, ADN_1);
+ assert.strictEqual(adn.records[0].values.name, 'B HA 1 Sic');
+ assert.strictEqual(adn.records[0].values.number, '6082658001');
+ assert.deepStrictEqual(adn.records[1].values, {});
+ assert.strictEqual(adn.records[1].orig, ADN_FF);
+ // no encoder -> no model, even when a decoder exists
+ assert.strictEqual(efDecEditModel('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' }), null);
+ assert.strictEqual(efDecEditModel('EF.NOPE', 'abcd', { kind: 'transparent', data: '00' }), null);
+});
+
+test('the TR form is a field grid carrying data-key inputs and the original hex', () => {
+ const model = efDecEditModel('EF.SPN', null, { kind: 'transparent', data: SPN_ORIG });
+ const out = efDecEditHtml(model, SPN_ORIG);
+ assert.match(out, /class="pysim-dec-edit"/);
+ assert.match(out, /data-mode="tr"/);
+ assert.ok(out.includes('data-orig="' + SPN_ORIG + '"'));
+ assert.match(out, /data-key="show_in_hplmn" checked/);
+ assert.match(out, /data-key="hide_in_oplmn"/);
+ assert.match(out, /data-key="name" value="GSM-R CH"/);
+ assert.strictEqual((out.match(/class="pysim-dec-item /g) || []).length, 3);
+ assert.doesNotMatch(out, /pysim-fs-record-cb/);
+ // the form's booleans/labels go through t() and the labels are the spec
+ // bit semantics (TS 31.102 4.2.12)
+ assert.match(out, /Display the network name in HPLMN \/ an SPDI PLMN/);
+ assert.match(out, /Hide the service provider name outside HPLMN \/ the SPDI PLMNs/);
+});
+
+test('the TR list form is a row editor with per-row remove and an Add button', () => {
+ const model = efDecEditModel('EF.PLMNwAcT', null, { kind: 'transparent', data: PLMN_ORIG });
+ const out = efDecEditHtml(model, PLMN_ORIG);
+ assert.match(out, /class="pysim-dec-edit"/);
+ assert.strictEqual((out.match(/class="pysim-dec-row /g) || []).length, 2);
+ assert.strictEqual((out.match(/class="pysim-dec-remove/g) || []).length, 2);
+ assert.ok(out.includes('✕'));
+ assert.match(out, /onclick="pysimFsDecAddRow\(\)"/);
+ assert.match(out, />Add network);
+ assert.match(out, /data-key="mcc" value="262"/);
+ assert.match(out, /data-key="mnc" value="01"/);
+ assert.strictEqual((out.match(/data-tech="GSM" checked/g) || []).length, 1);
+ assert.strictEqual((out.match(/data-tech="UTRAN" checked/g) || []).length, 1);
+ assert.match(out, /data-tech="NG-RAN"/);
+});
+
+test('the record form is one block per record with a hidden selection checkbox', () => {
+ const model = efDecEditModel('EF.ADN', null, { kind: 'record', records: [
+ { num: 1, data: ADN_1 },
+ { num: 2, data: ADN_FF },
+ ] });
+ const out = efDecEditHtml(model);
+ assert.match(out, /data-mode="records"/);
+ assert.strictEqual((out.match(/class="pysim-dec-rec /g) || []).length, 2);
+ assert.match(out, /data-rec="1" data-orig="4220/);
+ assert.match(out, /data-rec="2" data-orig="FFFF/);
+ assert.strictEqual((out.match(/class="pysim-fs-record-cb shrink-0 hidden"/g) || []).length, 2);
+ assert.match(out, /Record 1/);
+ assert.match(out, /Record 2 — empty/);
+ assert.match(out, /data-key="name" value="B HA 1 Sic"/);
+ assert.match(out, /data-key="number" value="6082658001"/);
+ // every input auto-checks its record block
+ assert.match(out, /oninput="pysimFsDecRowCheck\(this\)"/);
+});
+
+function fakeRoot(inputs, techs) {
+ return {
+ querySelector: sel => {
+ const m = /^\[data-key="(.+)"\]$/.exec(sel);
+ return (m && inputs[m[1]]) || null;
+ },
+ querySelectorAll: sel => sel === '[data-tech]' ? (techs || []) : [],
+ };
+}
+
+test('efDecCollectValues reads the DOM inputs back into the decoder shape', () => {
+ const spn = efDecCollectValues(fakeRoot({
+ show_in_hplmn: { checked: true },
+ hide_in_oplmn: { checked: false },
+ name: { value: 'GSM-R CH' },
+ }), EF_SPN_FIELDS);
+ assert.deepStrictEqual(spn, { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
+ // a missing boolean reads false, a missing text field ''
+ assert.deepStrictEqual(efDecCollectValues(fakeRoot({}), EF_ADN_FIELDS), { name: '', number: '' });
+ const row = efDecCollectValues(fakeRoot({
+ mcc: { value: '262' },
+ mnc: { value: '01' },
+ }, [
+ { checked: true, getAttribute: () => 'GSM' },
+ { checked: true, getAttribute: () => 'UTRAN' },
+ { checked: false, getAttribute: () => 'NG-RAN' },
+ ]), EF_PLMN_LIST.itemFields);
+ assert.deepStrictEqual(row, { mcc: '262', mnc: '01', access_tech: 'GSM, UTRAN' });
+ assert.deepStrictEqual(efDecCollectValues(null, EF_ADN_FIELDS), {});
+});
+
+test('efDecCollectRows reads the PLMN row editor back into the decoder shape', () => {
+ const rows = [
+ fakeRoot({ mcc: { value: '262' }, mnc: { value: '01' } }, [{ checked: true, getAttribute: () => 'GSM' }]),
+ fakeRoot({ mcc: { value: '228' }, mnc: { value: '06' } }, [{ checked: true, getAttribute: () => 'UTRAN' }]),
+ ];
+ const out = efDecCollectRows({ querySelectorAll: sel => sel === '.pysim-dec-row' ? rows : [] }, EF_PLMN_LIST.itemFields);
+ assert.deepStrictEqual(out, [
+ { mcc: '262', mnc: '01', access_tech: 'GSM' },
+ { mcc: '228', mnc: '06', access_tech: 'UTRAN' },
+ ]);
+ assert.deepStrictEqual(efDecCollectRows(null, EF_PLMN_LIST.itemFields), []);
+});
+
+test('changing a record input checks its block', () => {
+ const cb = { checked: false };
+ const rec = { querySelector: sel => sel === '.pysim-fs-record-cb' ? cb : null };
+ const el = { closest: sel => sel === '.pysim-dec-rec' ? rec : null };
+ pysimFsDecRowCheck(el);
+ assert.strictEqual(cb.checked, true);
+ pysimFsDecRowCheck(null);
+ assert.strictEqual(cb.checked, true);
+});
+
+test('pysimFsHasEncoder resolves the selected file by name or FID', () => {
+ pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SPN', fid: '6f46' }, { name: 'EF.ALIAS', fid: '6f3a' }] };
+ pysimFsSelected = 'EF.SPN';
+ assert.strictEqual(pysimFsHasEncoder(), true);
+ pysimFsSelected = 'EF.ALIAS';
+ assert.strictEqual(pysimFsHasEncoder(), true);
+ pysimFsSelected = '6F46';
+ assert.strictEqual(pysimFsHasEncoder(), true);
+ pysimFsSelected = 'EF.IMSI';
+ assert.strictEqual(pysimFsHasEncoder(), false);
+ pysimFsSelected = null;
+ assert.strictEqual(pysimFsHasEncoder(), false);
+});
+
+test('the Edit decoded button is encoder-gated next to Edit raw', () => {
+ const btn = /