10 Commits

Author SHA1 Message Date
catarrh 851ff65af6 changelog: note the README Telegram channel link
release / release (push) Waiting to run
2026-10-23 21:46:10 +03:00
catarrh 68a22e854b readme: link the project news Telegram channel
Add @youeyeseesee (releases and updates for my GitHub projects) near the
top of both READMEs.
2026-10-23 21:44:45 +03:00
catarrh 39d384636e batch waiting cue: move the contact pad to the bottom edge
The card's contact pad sat mid-card; it now sits near the bottom edge,
away from the top-right cut corner (the cut side is the card's top).
No other geometry or animation changes.

1024 Python + 1063 frontend; simple-v473.
2026-10-23 21:41:30 +03:00
catarrh 14d15b1fe8 batch waiting cue: use the app's SIM silhouette for the card
Redraw the inline card-into-reader SVG after sim.svg: a rounded card
with the top-right cut corner and the 3x2 contact pad, sliding behind
the reader bar.  The viewBox grew to 72x48 and the resting position is
0, so the card is no longer clipped at the top; the insert slide is
20px.  The currentColor/Tailwind theming and the reduced-motion
fallback are unchanged.

1024 Python + 1063 frontend; simple-v472.
2026-10-23 21:37:15 +03:00
catarrh 110dc1f1ad batch delete: report an already-absent object as such
A delete run whose DELETE answered 6A88/6A82 is still a success (the
object is gone, the goal is met) but it is no longer reported as a plain
"OK": the attempt result is 'already_absent', the counts gain that
bucket ("Deleted: N - Already absent: M"), the verdict reads "Already
absent" and the notice says the card was already absent.  A reinstall's
tolerated pre-delete keeps the install outcome (the install is the
point).

1024 Python + 1063 frontend; simple-v471.
2026-10-23 21:23:55 +03:00
catarrh fb65644328 v3.36.0: a deliberate "insert the next card" cue for batch runs
The waiting phase is the only moment the operator must act, so it now:
- shows a card-into-reader SVG animation in the status modal (inline
  SVG, CSS keyframes in src/style.css; reduced-motion users get the
  static card-above-the-slot picture);
- plays a short Web Audio chime once per card, edge-triggered in the
  2 s poll so it never repeats (the AudioContext is unlocked by the
  Start click);
- sets the tab title to "Insert next card" while the window is hidden;
- offers a speaker toggle next to the status (persisted as
  simple_ram_batch_sound, on by default).

The prebuilt style.css was rebuilt: it had gone stale and lacked
utilities the UI already used (bg-blue-50 on the batch notice,
inline-flex, mx-2, max-h-48, w-44, min-w-24).

1022 Python + 1062 frontend green; simple-v470.
2026-10-23 21:14:32 +03:00
catarrh 194b88e7d1 changelog: fold the unreleased 3.34.0 entry into 3.35.0
release / release (push) Waiting to run
v3.34.0 was committed but never tagged; its changes ship in the v3.35.0
release, so the changelog names only released versions (the same rule
applied to 3.32.3 -> 3.32.4 and 3.32.6/3.32.7 -> 3.33.0).
2026-10-23 20:11:59 +03:00
catarrh d5e67360fe file manager: an enabled inactive read pill is outlined, not grey
The Read decoded pill used the same grey fill as a disabled control, so
an enabled pill looked unavailable (reported live).  The inactive-but-
enabled state is now an outlined button (blue on white/slate) and the
grey fill plus dimming are reserved for the actually disabled state.
The active pill keeps a border of the same width so the row does not
shift.

Pinned in fs_edit.test.js.  1022 Python + 1058 frontend; simple-v469.
2026-10-23 17:06:50 +03:00
catarrh 87bf4acbbc v3.35.0: EF.SMSP decoded editor (the full SMS-parameter form)
TS 31.102 4.2.27 scope: alpha; TP-DA and TS-SCA with their own length
conventions (DA = digits, SCA = octets, 0 = default SMSC) and a TON
select; TP-PID; TP-DCS with TS 23.038 hints; the relative TP-VP with the
TS 23.040 ladder; a per-parameter "absent (the ME default applies)"
state.  Unchanged slots are copied verbatim (the live fixture's
DCS-absent 00 and present VP FF survive), alphanumeric TON-5 addresses
stay read-only, and every checked record is validated before any write.

Decoder fixes: the parameter-indicator bits were shifted (SCA on 5,
PID on 2, DCS on 3, VP on 4; the spec is DA/SCA/PID/DCS/VP on bits 1-5
with 6-8 reserved, 1 = absent), and the TP-DA length was read as octets
instead of digits (TS 23.040 9.1.2.5).

Live fixture: ffffffffffffffffffffffffffe9ffffffffffffffffffffffff07919733824009f0ffffffff0000ff
(SCA +79332804900, PID 00, DCS absent/00, VP FF) round-trips
byte-identically.

1022 Python + 1058 frontend green; simple-v468.
2026-10-23 16:54:12 +03:00
catarrh 180dd50c06 v3.34.0: Edit decoded wave 2 — service tables, scalars, PLMN lists, BCD, CB lists
New client-side editors: the service tables EF.UST/IST/EST (a filterable
bit grid; unmodelled bits and the file length preserved), EF.ACC,
EF.PHASE, EF.HPPLMN, EF.AD (op-mode enum, additional info, MNC length
incl. 0 and the 3-byte SIM form), the PLMN-only lists
EF.PLMNsel/FPLMN/EHPLMN, EF.ICCID/EF.IMSI behind a confirmation, and the
cell-broadcast lists EF.CBMI/CBMID/CBMIR.

Framework: a bits form kind, a per-entry model(dec) hook, a per-entry
confirm prompt and hex fields.

Decoder fixes: EF.ACC class numbering (byte1 b8..b1 = 15..8, byte2 =
7..0), EF.AD operation mode (whole byte) and its MNC length rules,
EF.SST decoded with the SIM 2-bit allocated/activated layout (editing
stays disabled until that UI exists), and the EF.IMSI parity nibble
(9 odd / 1 even).  A no-edit save of the new list files no longer
writes all-FF (the collected rows and the encoders disagreed on the
value shape).

Decoded editing for EF.DIR/EF.ARR/EF.SMS/EF.PBR is postponed by
decision (read-only viewing is enough); the survey records it.

1022 Python + 1033 frontend green; simple-v467.
2026-10-23 14:21:00 +03:00
18 changed files with 2636 additions and 144 deletions
+76
View File
@@ -9,6 +9,82 @@ every change carries a descriptive commit message.
## [Unreleased]
## [3.36.0] — 2026-10-23
### Added
- The batch **"waiting for the next card"** state is now a deliberate cue: the status
modal shows a card-into-reader animation next to the prompt, a short chime sounds
**once per card** when the run enters the waiting phase (Web Audio — no audio asset;
edge-triggered in the 2 s poll, so it does not repeat), and while the tab is hidden the
title hints "Insert next card". The speaker button next to the status mutes the chime
and the choice is remembered (`simple_ram_batch_sound`); the chime is unlocked by the
Start click (browser autoplay policy); reduced-motion users get the static card-above-
the-slot picture. The card follows the app's SIM silhouette (`sim.svg`): a cut corner
at the top and the contact pad near the bottom edge, sliding behind the reader bar.
### Changed
- A **batch delete that finds the object already gone** is still a success — the goal is met
and the run continues — but it is no longer reported as a plain "OK": the attempt's verdict
reads **Already absent**, the delete run's counts separate real deletions from no-ops
(`Deleted: N · Already absent: M`), and the notice says the card was already absent. The
counts gain an `already_absent` bucket (the attempt result is `already_absent`); a
reinstall's tolerated pre-delete is *not* affected — its outcome is still the install.
- The READMEs link the project's news channel: [Telegram — @youeyeseesee](https://t.me/youeyeseesee).
### Fixed
- The prebuilt `style.css` had gone stale: a rebuild restored utility classes the UI
already used but the output lacked (e.g. `bg-blue-50` on the batch notice, `inline-flex`,
`mx-2`, `max-h-48`, `w-44`, `min-w-24`).
## [3.35.0] — 2026-10-23
### Added
- **EF.SMSP decoded editor** — the full SMS-parameter form (TS 31.102 §4.2.27): alpha,
TP-Destination Address and TS-Service Centre Address (each with its own length
convention and a TON select; an empty SCA means the default SMSC), TP-PID, TP-DCS and
the relative TP-VP — every parameter with an *absent (the ME default applies)* toggle.
Live hints decode the DCS (TS 23.038 §4: coding group, alphabet, class, compression,
message-waiting) and the relative validity period (TS 23.040 §9.2.3.12.1). Unchanged
parameters keep their exact bytes (a card quirk such as a DCS marked absent with byte
`00` survives), alphanumeric TON addresses are preserved read-only, and all checked
records are validated before anything is written.
- **Edit decoded, wave 2** — new editors for the service tables **EF.UST / EF.IST /
EF.EST** (a filterable bit grid preserving services the table does not model), the
scalars **EF.ACC**, **EF.PHASE**, **EF.HPPLMN** and **EF.AD**, the PLMN lists
**EF.PLMNsel / EF.FPLMN / EF.EHPLMN**, the identities **EF.ICCID / EF.IMSI** (behind a
confirmation — writing them changes the card identity) and the cell-broadcast lists
**EF.CBMI / EF.CBMID / EF.CBMIR**. **EF.SST** is deliberately left read-only: its
SIM layout is 2 bits per service (allocated + activated), which needs its own editor.
- The file-format survey (`docs/decoded-editing.md`) now records the shipped waves and
marks **EF.DIR / EF.ARR / EF.SMS / EF.PBR** as postponed by decision (read-only).
### Fixed
- `efDecSmsp` mapped the parameter indicators shifted (SCA on bit 5, PID on bit 2, DCS
on bit 3, VP on bit 4); the spec order is DA/SCA/PID/DCS/VP on bits 1–5 with bits 6–8
reserved — a record with a mixed indicator (e.g. `E9`) showed DCS and hid VP.
- The read-only EF.SMSP view decoded the TP-DA length as octets, although it is the digit
count (TS 23.040 §9.1.2.5); a slot padded with `00` gained phantom digits.
- The **Read decoded** pill no longer looks disabled when it is enabled: an inactive-but-
enabled pill is now an outlined button (blue on white/slate), and the grey fill and
dimming are left to the actually disabled state.
- **EF.ACC** class numbering (TS 31.102 4.2.15): byte 1 b8…b1 codes classes 15…8 and
byte 2 classes 7…0 — the decoder counted class 0 at the MSB (an all-FF ACC read as
class 0 instead of classes 0–15).
- **EF.AD** (TS 31.102 4.2.18): the operation mode is a whole-byte enum
(00/80/01/81/02/04), not bits 0-1 plus a test bit; the MNC length may be 0 (service
n°130 available) or absent (3-byte SIM form) — the editor previously refused both.
- **EF.SST** (TS 51.011 10.3.7): the SIM service table is decoded with its 2-bits-per-
service layout (allocated + activated) instead of the UST 1-bit codec.
- **EF.IMSI**: the parity/type nibble is `9` for an odd digit count and `1` for an even
one (TS 24.008 10.5.1.4); the encoder always wrote `9`.
- Saving a PLMN/CB list with no edits no longer rewrites it as all-FF (the collected rows
and the list encoders disagreed on the value shape).
## [3.33.0] — 2026-10-23
### Added
+3 -1
View File
@@ -2,6 +2,8 @@
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
📢 Project news and updates: [Telegram channel](https://t.me/youeyeseesee)
SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the PWA). It covers the full lab cycle:
- **APDU workbench** — SIM/USIM RFM and Expanded Script builders (TS 102 226), GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
@@ -595,7 +597,7 @@ 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. **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`
- **Edit decoded** — edit through the file's own decoded form (client-side encoders; written via the normal update path): **EF.SPN**, **EF.PNN**, the PLMN lists **PLMNsel / FPLMN / EHPLMN** and **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, the dialling-number files **ADN / FDN / SDN / MSISDN / LND / BDN**, the service tables **UST / IST / EST** (**SST** stays read-only until the SIM allocated/activated editor exists), **ACC**, **PHASE**, **HPPLMN**, **AD**, **ICCID / IMSI** (behind a confirmation), the cell-broadcast lists **CBMI / CBMID / CBMIR**, and **EF.SMSP** (the full SMS-parameter form: alpha, TP-DA/TS-SCA with their addressing rules and TON, TP-PID, TP-DCS and relative TP-VP, each with an absent/default toggle and live DCS/validity hints). 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
+3 -1
View File
@@ -2,6 +2,8 @@
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
📢 Новости и обновления проекта: [Telegram-канал](https://t.me/youeyeseesee)
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS PWA и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт PWA). Закрывает весь цикл лабораторной работы:
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
@@ -570,7 +572,7 @@ 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 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`
- **Edit decoded** — редактирование в собственной декодированной форме файла (клиентские кодировщики; запись обычным путём): **EF.SPN**, **EF.PNN**, списки PLMN **PLMNsel / FPLMN / EHPLMN** и **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, файлы номеров **ADN / FDN / SDN / MSISDN / LND / BDN**, таблицы сервисов **UST / IST / EST** (**SST** только для чтения до появления редактора allocated/activated), **ACC**, **PHASE**, **HPPLMN**, **AD**, **ICCID / IMSI** (с подтверждением) списки cell broadcast **CBMI / CBMID / CBMIR** и **EF.SMSP** (полная форма SMS-параметров: alpha, TP-DA/TS-SCA с их правилами адресации и TON, TP-PID, TP-DCS и относительный TP-VP; у каждого — флажок «отсутствует/по умолчанию» и живые подсказки по DCS и сроку действия). Для 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 требует ключа разблокировки карты
+75 -36
View File
@@ -1,6 +1,9 @@
# Decoded editing — file-format survey and design
Status: design note for the **Edit decoded** feature (v3.33.0). It classifies
Status: design note for the **Edit decoded** feature. Waves 1–3 shipped in
v3.33.0 (SPN/PLMN/ADN/PNN), v3.34.0 (service tables/scalars/PLMN lists/BCD/CB)
and v3.35.0 (EF.SMSP); decoded editing for EF.DIR/EF.ARR/EF.SMS/EF.PBR is
**postponed by decision** (their decoded view is enough). 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/`):
@@ -57,32 +60,32 @@ editing candidates.
| 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.ICCID | 2FE2 | TS 51.011 10.3.1 | 10 B | BCD (nibble-swapped), F padding; UPDATE is NEV per TS 102 221 | S3 | **3.34** |
| EF.IMSI | 6F07 | TS 31.102 4.2.2 | 9 B | length byte + parity/type nibble ('9' odd / '1' even) + 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 | **2 bits/service** (allocated + activated) | S6 (pending) | decoder fixed; editor later |
| EF.EST | 6F56 | TS 31.102 4.2.47 | X ≥ 1 | enabled-service bits (3 services) | S6 | **3.34** |
| EF.LI | 6F05 | TS 31.102 4.2.1 | 2n | ISO 639 letters (2 B each) | S6 | 3.35 |
| EF.LP / EF.PL | 6F05 / 2F05 | TS 51.011 10.3.1 / 10.1.2 | 1–n | **1 byte per language** (DCS language coding) | S6 | 3.35 |
| 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.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 (FFFFFF holes anywhere) | 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.SPDI | 6FCD | TS 31.102 4.2.66 | x | TLV: A3 > 80 > PLMN list | S4 | 3.35 |
| EF.GID1 / GID2 | 6F3E / 6F3F | TS 31.102 4.2.10/4.2.11 | n | identifier bytes (coding not stated in the clause) | S2 | 3.35 (verify TS 31.101) |
| 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.ACC | 6F78 | TS 31.102 4.2.15 | 2 B | class bits (b8…b1 = 15…8 / 7…0; ACC 10 written 0) | S6 | **3.34** |
| EF.PHASE | 6FAE | TS 51.011 10.3.19 | 1 B | phase id (00/02/03) | S1 | **3.34** |
| EF.HPPLMN | 6F31 | TS 31.102 4.2.6 | 1 B | search-period multiplier | S1 | **3.34** |
| EF.AD | 6FAD | TS 31.102 4.2.18 | 4+X | op-mode byte enum, info, MNC length (may be 0 or absent) | 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** | — |
@@ -103,14 +106,14 @@ editing candidates.
| 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 (SIM) | 6FB7 | TS 51.011 10.3.27 | 3n | emergency codes (TR) | S6 | 3.35 |
| 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.SMSP | 6F42 | TS 31.102 4.2.27 | 28+Y | alpha + parameter indicators + TP-DA/SCA/PID/DCS/VP | S10 | **3.35** |
| EF.SMS | 6F3C | TS 31.102 4.2.25 | 176 | status byte + TPDU | S10 | **postponed (read-only)** |
| EF.ARR | 2F06/6F06 | TS 31.102 4.2.55 | X | access-rule TLVs (ISO 7816-4) | S8/S9 | **postponed (read-only)** |
| EF.DIR | 2F00 | TS 51.011 10.3.3 | X | application template TLVs (61 > 4F/50/73…) | S8/S9 | **postponed (read-only)** |
| EF.PBR | 4F30/4FXX | TS 31.102 4.4.2.1 | X | BER-TLV: A8/A9/AA containers, C0–CC links | S9 | **postponed (read-only)** |
| 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) | — |
@@ -152,21 +155,57 @@ beyond S9/S10.
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.
8. **EF.SST is a 2-bit table** (TS 51.011 10.3.7): 4 services per byte, first
bit of the pair = allocated, second = activated. The shared 1-bit
UST/EST codec was used for it, so SIM service tables were both misread and
miswritten; the decoder is fixed in v3.34.0, editing waits for an
allocated/activated UI.
9. **EF.ACC class numbering** (TS 31.102 4.2.15): byte 1 b8…b1 = classes 15…8
and byte 2 = 7…0 (class 10 is written as 0); the old decoder counted from
class 0 at the MSB. Fixed in v3.34.0.
10. **EF.AD's operation mode is a whole byte** (00/80/01/81/02/04), not bits
0-1 plus a test bit; the MNC-length byte may be 0 (service n°130 available)
or absent (3-byte SIM form). The decoder and encoder follow this now.
11. **The IMSI parity/type nibble** (TS 24.008 10.5.1.4) is 9 for an odd digit
count and 1 for an even one; the first encoder always wrote 9.
12. **SIM EF.LP/EF.PL use the 1-byte DCS language coding**, unlike the USIM
EF.LI's 2-byte ISO 639 letters — the shared decoder is wrong for the SIM
files (queued with the language work).
13. **EF.SMSP's parameter indicators are a five-bit presence map with inverted
meaning** (TS 31.102 §4.2.27: bit 1 DA, 2 SCA, 3 PID, 4 DCS, 5 VP; bit = 1
means *absent*; bits 6–8 reserved = 1). The decoder had the bits shifted
(SCA on 5, PID on 2, DCS on 3, VP on 4), so mixed indicators were misread.
14. **The two address slots use different length conventions**: TP-DA's length
is the digit count (TS 23.040 §9.1.2.5), TS-SCA's is the octet count of
TON/NPI + digits with 0 = default SMSC (TS 24.011 §8.2.5.2); the decoder
read the DA slot as octets. Both are handled explicitly now.
## 4. Release waves
- **v3.33.0 (current)** — framework: encoder registry, field/list/items
- **v3.33.0 (shipped)** — 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).
- **v3.34.0 (shipped)** — the S1/S3/S4/S6 wave: UST/IST/EST service tables
(filterable bit grid), ACC, PHASE, HPPLMN, AD, the PLMN-only lists
(PLMNsel/FPLMN/EHPLMN), ICCID/IMSI (behind a confirmation) and
CBMI/CBMID/CBMIR. Decoder fixes: ACC class numbering, the AD operation
mode / MNC-length rules, the SST 2-bit layout (read-only for now) and the
IMSI parity nibble.
- **v3.35.0 (shipped)** — EF.SMSP as the first S10 dedicated builder: the full
parameter form (alpha; TP-DA and TS-SCA with their own length conventions
and TON; TP-PID; TP-DCS with TS 23.038 hints; the relative TP-VP with the
TS 23.040 ladder), a per-parameter *absent (the ME default applies)* state,
verbatim preservation of unchanged slots (including alphanumeric TON
addresses) and the parameter-indicator and TP-DA-length decoder fixes.
- **v3.36.0** — LI/LP/PL (the SIM files need the 1-byte DCS language coding
and a name-dependent decoder), GID1/GID2 (the clause states no coding —
verify TS 31.101 first), SPDI (the A3 > 80 wrapper), OPL (PNN record
select), ECC (SIM and USIM — needs a structure-aware decode), SUUME,
IMPI/DOMAIN/IMPU, ACL; EXT1–EXT5 as a read-only structured view.
- **Postponed by decision (read-only; do not implement without confirmation)**
— EF.DIR, EF.ARR, EF.SMS, EF.PBR: they rarely need manual changes and the
decoded view is enough.
- **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.
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -324,13 +324,13 @@
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) &mdash; grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click &mdash; the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 &mdash; card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) &mdash; a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card &mdash; the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word &mdash; the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports &ldquo;already processed&rdquo; &mdash; restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package &mdash; no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as such and counted OK; any other failure is reported with the card's status word.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as <strong>Already absent</strong> and counted separately from real deletions &mdash; it is still a success (the goal is met) and the run continues; any other failure is reported with the card's status word.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component &mdash; the point where the JCRE verifies the import list &mdash; then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at &ge; that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 &mdash; if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block &mdash; on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' &mdash; a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr>
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
</tbody>
</table>
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or &ldquo;Insert next card. Waiting for a card...&rdquo;) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends &mdash; the run itself is server-side and survives a reload. After a finished run the panel&rsquo;s <strong>Show status</strong> button reopens the report.</p>
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or &ldquo;Insert next card. Waiting for a card...&rdquo;) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends &mdash; the run itself is server-side and survives a reload. After a finished run the panel&rsquo;s <strong>Show status</strong> button reopens the report. While the run waits for a card the modal shows a card-into-reader animation and plays a short chime once per card (the speaker button next to the status mutes it and the choice is remembered); when the window is in the background the tab title asks for the next card.</p>
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
<p class="text-sm mb-2">After &ldquo;Explore Card&rdquo; runs, the explorer displays:</p>
@@ -408,7 +408,7 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read raw / Read decoded</strong> — 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. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
<li><strong>Edit decoded</strong> — edit the file through its own decoded form, encoded in the browser and written with the normal update path. The encoders cover <strong>EF.SPN</strong>, the PLMN lists with access technology (<strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), the dialling-number files (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>) and <strong>EF.PNN</strong>. 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. <strong>Save</strong> 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.</li>
<li><strong>Edit decoded</strong> — edit the file through its own decoded form, encoded in the browser and written with the normal update path. The encoders cover <strong>EF.SPN</strong>, <strong>EF.PNN</strong>, the PLMN lists (<strong>PLMNsel / FPLMN / EHPLMN</strong> and the access-technology lists <strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), the dialling-number files (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>), the service tables (<strong>UST / IST / EST</strong>; <strong>SST</strong> stays read-only until the SIM allocated/activated editor exists), <strong>ACC</strong>, <strong>PHASE</strong>, <strong>HPPLMN</strong>, <strong>AD</strong>, <strong>ICCID / IMSI</strong> (behind a confirmation — writing them changes the card identity) the cell-broadcast lists (<strong>CBMI / CBMID / CBMIR</strong>) and <strong>EF.SMSP</strong> (the full SMS-parameter form: alpha, TP-DA and TS-SCA with their addressing rules and TON, TP-PID, TP-DCS and the relative TP-VP, each with an <em>absent (the ME default applies)</em> toggle and live DCS/validity hints). 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. <strong>Save</strong> 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.</li>
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
<li><strong>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) 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&rsquo;s unblock key.</li>
</ul>
+1125 -51
View File
File diff suppressed because one or more lines are too long
+24
View File
@@ -5,3 +5,27 @@
.dark .text-heading {
color: #f1f5f9;
}
/* RAM batch: the "insert the next card" cue (v3.36.0).
The card slides into the reader slot and back; the slot glows while the
run waits. The static transform is the reduced-motion fallback: the card
rests above the slot. */
.ram-insert-card {
/* the resting position is the card just above the slot (0); the insert
slide takes it 20px down, behind the reader front */
animation: ram-insert 2.8s ease-in-out infinite;
}
@keyframes ram-insert {
0%, 12% { transform: translateY(0); }
45%, 55% { transform: translateY(20px); }
88%, 100% { transform: translateY(0); }
}
.ram-insert-glow {
animation: ram-insert-glow 1.4s ease-in-out infinite;
}
@keyframes ram-insert-glow {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.95; }
}
@media (prefers-reduced-motion: reduce) {
.ram-insert-card, .ram-insert-glow { animation: none; }
}
+97 -10
View File
@@ -616,6 +616,10 @@ video {
pointer-events: none;
}
.\!visible {
visibility: visible !important;
}
.visible {
visibility: visible;
}
@@ -684,6 +688,11 @@ video {
grid-column: span 4 / span 4;
}
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
@@ -807,6 +816,10 @@ video {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.table {
display: table;
}
@@ -843,6 +856,10 @@ video {
max-height: 8rem;
}
.max-h-48 {
max-height: 12rem;
}
.max-h-56 {
max-height: 14rem;
}
@@ -935,6 +952,10 @@ video {
width: 10rem;
}
.w-44 {
width: 11rem;
}
.w-48 {
width: 12rem;
}
@@ -980,6 +1001,10 @@ video {
min-width: 0px;
}
.min-w-24 {
min-width: 6rem;
}
.min-w-\[12rem\] {
min-width: 12rem;
}
@@ -1395,6 +1420,11 @@ video {
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
}
.bg-blue-50 {
--tw-bg-opacity: 1;
background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
}
.bg-blue-50\/50 {
background-color: rgb(239 246 255 / 0.5);
}
@@ -1796,6 +1826,11 @@ video {
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
}
.text-gray-900 {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
}
.text-green-600 {
--tw-text-opacity: 1;
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
@@ -1893,10 +1928,61 @@ video {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.dark .text-heading {
color: #f1f5f9;
}
/* RAM batch: the "insert the next card" cue (v3.36.0).
The card slides into the reader slot and back; the slot glows while the
run waits. The static transform is the reduced-motion fallback: the card
rests above the slot. */
.ram-insert-card {
/* the resting position is the card just above the slot (0); the insert
slide takes it 20px down, behind the reader front */
animation: ram-insert 2.8s ease-in-out infinite;
}
@keyframes ram-insert {
0%, 12% {
transform: translateY(0);
}
45%, 55% {
transform: translateY(20px);
}
88%, 100% {
transform: translateY(0);
}
}
.ram-insert-glow {
animation: ram-insert-glow 1.4s ease-in-out infinite;
}
@keyframes ram-insert-glow {
0%, 100% {
opacity: 0.35;
}
50% {
opacity: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.ram-insert-card, .ram-insert-glow {
animation: none;
}
}
.hover\:bg-amber-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1));
@@ -1956,11 +2042,6 @@ video {
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
}
.hover\:bg-red-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
}
.hover\:bg-red-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1));
@@ -2093,6 +2174,10 @@ video {
background-color: rgb(30 58 138 / 0.1);
}
.dark\:bg-blue-900\/20:is(.dark *) {
background-color: rgb(30 58 138 / 0.2);
}
.dark\:bg-blue-900\/40:is(.dark *) {
background-color: rgb(30 58 138 / 0.4);
}
@@ -2258,10 +2343,6 @@ video {
background-color: rgb(6 78 59 / 0.4);
}
.dark\:hover\:bg-red-900\/40:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.4);
}
.dark\:hover\:bg-red-900\/50:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.5);
}
@@ -2289,6 +2370,11 @@ video {
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
.dark\:hover\:text-slate-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
}
.dark\:hover\:text-slate-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
@@ -2328,7 +2414,8 @@ video {
.lg\:flex-row {
flex-direction: row;
}
}/* ===== Theme contrast adjustments ===== */
}
/* ===== Theme contrast adjustments ===== */
/* Light theme: darken softer (muted) text one step for higher contrast */
.text-gray-400 {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v466';
const CACHE = 'simple-v473';
const URLS = [
'index.html',
'help.html',
+59 -6
View File
@@ -24,10 +24,10 @@ function extractFunc(src, name) {
const FNS = [
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact',
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecSst', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact',
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
'efSmspDaNumber', 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo',
'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
@@ -42,7 +42,7 @@ for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) {
code += html.match(new RegExp('const ' + c + ' = [\\s\\S]*?\\n\\];'))[0].replace('const ', 'var ') + '\n';
}
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_PHASE_NAMES', 'EF_PBR_KINDS']) {
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_PBR_KINDS']) {
code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
}
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
@@ -103,7 +103,9 @@ test('EF decoders match the pinned spec / pySim test vectors', () => {
assert.deepStrictEqual(efDecLi(efBytes('FFFF')), { languages: [] });
assert.deepStrictEqual(efDecSpn(efBytes('0147534d2d52204348ffffffffffffffff')), { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
assert.deepStrictEqual(efDecAcc(efBytes('0000')), { access_classes: [] });
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [0] });
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [15] });
assert.deepStrictEqual(efDecAcc(efBytes('0001')), { access_classes: [0] });
assert.deepStrictEqual(efDecAcc(efBytes('8001')), { access_classes: [15, 0] });
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
@@ -115,7 +117,7 @@ test('EF decoders match the pinned spec / pySim test vectors', () => {
assert.strictEqual(efFindDecoder('EF.FPLMN', null).fn, efDecFplmn);
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' });
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and PROFILE DOWNLOAD required' });
assert.deepStrictEqual(efDecNai(efBytes('8021696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')),
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
@@ -276,6 +278,9 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
assert.strictEqual(smsp.sca, '+4915790109999');
assert.strictEqual(smsp.tp_pid, '0x00');
assert.strictEqual(smsp.tp_dcs, '0x00');
assert.strictEqual(smsp.tp_vp, '0xA9');
assert.strictEqual(smsp.indicators, '0xE1');
assert.strictEqual(smsp.tp_da, undefined);
// EF.SMS status byte + skip SMSC + TPDU
const sms = efDecSms(efBytes('030891945197109099f90000'));
assert.strictEqual(sms.direction, 'MT');
@@ -283,6 +288,31 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
assert.strictEqual(sms.tpdu, '0000');
});
test('EF.SMSP decodes the indicator bits (TS 31.102 4.2.27)', () => {
// live card record: DA absent (0x01), SCA present, PID present, DCS absent
// (0x08), VP present; bits 6..8 reserved = 1 -> 0xE9
const live = efDecSmsp(efBytes('ffffffffffffffffffffffffffe9ffffffffffffffffffffffff07919733824009f0ffffffff0000ff'));
assert.deepStrictEqual(live, {
indicators: '0xE9',
sca: '+79332804900',
tp_pid: '0x00',
tp_vp: '0xFF',
});
assert.strictEqual(live.tp_da, undefined);
assert.strictEqual(live.tp_dcs, undefined); // DCS absent, its 00 slot kept raw for the editor
});
test('EF.SMSP reads the TP-DA length as a digit count (00 padding is not ABS)', () => {
// alpha FF x13 | indicator FE (DA present, rest absent) |
// DA 04 81 21 43 00 00 FF... ("1234" padded with 00, not FF)
const hex = 'FF'.repeat(13) + 'FE' + '048121430000FFFFFFFFFFFF'
+ 'FF'.repeat(12) + 'FF' + 'FF' + 'FF';
assert.deepStrictEqual(efDecSmsp(efBytes(hex)), {
indicators: '0xFE',
tp_da: '1234',
});
});
test('service tables: UST service bits and IST fallback for 6F07', () => {
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
@@ -290,6 +320,29 @@ test('service tables: UST service bits and IST fallback for 6F07', () => {
assert.deepStrictEqual(ist.services, [{ n: 1, name: 'P-CSCF address' }]);
});
test('EF.SST is the SIM 2-bit service table (allocated/activated)', () => {
// TS 51.011 10.3.7: service 2 ADN -> byte 0, pair start bit 2 (0x04
// allocated, 0x08 activated)
assert.deepStrictEqual(efDecSst(efBytes('0C')), { services: [
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: true, activated: true },
] });
assert.deepStrictEqual(efDecSst(efBytes('04')), { services: [
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: true, activated: false },
] });
assert.deepStrictEqual(efDecSst(efBytes('08')), { services: [
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: false, activated: true },
] });
// byte 1 pair 3 (bits 6/7) = service 4 SMS; byte 0 pair 1 = service 2
assert.deepStrictEqual(efDecSst(efBytes('C0')), { services: [
{ n: 4, name: 'Short Message Storage (SMS)', allocated: true, activated: true },
] });
assert.deepStrictEqual(efDecSst(efBytes('00')), { services: [] });
assert.deepStrictEqual(efFindDecoder('EF.SST', null).fn(efBytes('0C')).services[0].activated, true);
// the read-only view shows both bits
assert.ok(efFlatten(efDecSst(efBytes('0C'))).some(r =>
r[0] === 'Service 2' && r[1].includes('allocated') && r[1].includes('activated')));
});
test('decoder registry resolves by name first, then FID; unknown returns null', () => {
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
+506 -12
View File
@@ -44,19 +44,20 @@ function extractConst(src, name) {
const FNS = [
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efUcs2Be',
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct',
'efDecSpn', 'efDecPlmnWact', 'efDecAdn', 'efDecPnn', 'efFindDecoder',
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct',
'efDecSpn', 'efDecPlmnWact', 'efDecAdn', 'efDecPnn', 'efDecSmsp', 'efFindDecoder',
'efDecodeBytes', 'efTlv', 'efPnnText', 'efDecItemsFromHex',
'efGsm7PlainText', 'efEncTextBytes', 'efPlmnEnc', 'efActEnc', 'efEncSpn',
'efEncPlmnList', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
'efFindEncoder', 'efHasEncoder', 'gsm7Decode', 'gsm7TextToSeptets',
'gsm7Encode', 'efEncError',
'gsm7Encode', 'efEncError', 'efDecModelValues',
'efDecServiceTable', 'efDecSst', 'efDecAcc', 'efDecPhase', 'efDecHpplmn', 'efDecAd', 'efDecCbmi', 'efDecCbmir', 'efDecIccid', 'efDecImsi', 'efDecPlmnList', 'efDecFplmn', 'efEncServiceTable', 'efEncAcc', 'efPhaseValue', 'efPhaseModel', 'efEncPhase', 'efEncHpplmn', 'efAdOpValue', 'efAdModel', 'efEncAd', 'efEncPlmnOnly', 'efEncIccid', 'efEncImsi', 'efCbmiModel', 'efEncCbmi', 'efCbmirModel', 'efEncCbmir', 'efSmspAddressModel', 'efSmspModel', 'efSmspTonOptions', 'efSmspParamRow', 'efSmspHtml', 'efSmspCollect', 'pysimFsSmspToggle', 'pysimFsSmspHexInput', 'efSmspHexByte', 'efSmspAddressBytes', 'efSmspAddrUnchanged', 'efSmspByteUnchanged', 'efEncSmsp', 'smspT', 'smspDcsHint', 'smspVpHint', 'smspDurationText', 'smspPluralIndex', 'smspCount', 'efSmspDaNumber',
];
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']) {
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS']) {
code += extractConst(html, c);
}
eval(code);
@@ -211,7 +212,7 @@ test('EF.SPN and the ADN family share the encoder registry with the PLMN list',
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);
assert.strictEqual(efHasEncoder('EF.LOCI', 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);
@@ -429,14 +430,37 @@ test('F14: the encoder messages go through t() (unknown technology, too many row
});
test('unchanged content re-encodes byte-identically (all fixtures)', () => {
const tables = { 'EF.UST': EF_UST_SERVICES, 'EF.IST': EF_IST_SERVICES,
'EF.EST': EF_EST_SERVICES };
const decode = (name, hex) => {
const entry = efFindEncoder(name, null);
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);
let dec;
if (name === 'EF.SPN') dec = efDecSpn(bytes);
else if (tables[name]) {
// the form renders only the table's services; other set bits stay
// in the original bytes
dec = { services: efDecServiceTable(bytes, tables[name]).services
.filter(s => Object.prototype.hasOwnProperty.call(tables[name], s.n)) };
} else if (name === 'EF.ACC') dec = efDecAcc(bytes);
else if (name === 'EF.PHASE') dec = efDecPhase(bytes);
else if (name === 'EF.HPPLMN') dec = efDecHpplmn(bytes);
else if (name === 'EF.AD') dec = efDecAd(bytes);
else if (name === 'EF.PLMNsel' || name === 'EF.EHPLMN') dec = efDecPlmnList(bytes);
else if (name === 'EF.FPLMN') dec = efDecFplmn(bytes);
else if (name === 'EF.ICCID') dec = efDecIccid(bytes);
else if (name === 'EF.IMSI') dec = efDecImsi(bytes);
else if (name === 'EF.CBMI' || name === 'EF.CBMID') dec = efDecCbmi(bytes);
else if (name === 'EF.CBMIR') dec = efDecCbmir(bytes);
else if (name === 'EF.PLMNwAcT') dec = efDecPlmnWact(bytes).plmns;
else if (name === 'EF.BDN') dec = efDecAdn(bytes, 15);
else if (name === 'EF.ADN') dec = efDecAdn(bytes);
else if (name === 'EF.PNN') dec = efDecItemsFromHex(efFindEncoder('EF.PNN', null), hex);
else if (name === 'EF.SMSP') dec = efSmspModel(hex);
else throw new Error('no decoder for ' + name);
// the model's empty/raw marker yields the form's empty value set
if (dec && (dec.empty === true || (dec.raw !== undefined && Object.keys(dec).length === 1))) dec = {};
return entry.model ? entry.model(dec) : dec;
};
const cases = [
{ name: 'EF.SPN', fid: '6f46', hex: SPN_ORIG },
@@ -456,6 +480,40 @@ test('unchanged content re-encodes byte-identically (all fixtures)', () => {
{ 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) },
// wave 2
{ name: 'EF.UST', fid: '6f38', hex: UST_ONE },
{ name: 'EF.UST', fid: '6f38', hex: UST_MULTI },
{ name: 'EF.UST', fid: '6f38', hex: UST_UNMODELLED },
{ name: 'EF.UST', fid: '6f38', hex: 'FF'.repeat(19) },
{ name: 'EF.IST', fid: '6f07', hex: '01' },
{ name: 'EF.EST', fid: '6f56', hex: '07' },
{ name: 'EF.EST', fid: '6f56', hex: EST_FF },
{ name: 'EF.ACC', fid: '6f78', hex: ACC_ONE },
{ name: 'EF.PHASE', fid: '6fae', hex: '00' },
{ name: 'EF.PHASE', fid: '6fae', hex: '02' },
{ name: 'EF.PHASE', fid: '6fae', hex: '03' },
{ name: 'EF.PHASE', fid: '6fae', hex: '0F' },
{ name: 'EF.HPPLMN', fid: '6f31', hex: '00' },
{ name: 'EF.HPPLMN', fid: '6f31', hex: '2A' },
{ name: 'EF.AD', fid: '6fad', hex: AD_HEX },
{ name: 'EF.AD', fid: '6fad', hex: '55' + '0000' + '03' + 'AABB' },
{ name: 'EF.AD', fid: '6fad', hex: 'FF'.repeat(6) },
{ name: 'EF.AD', fid: '6fad', hex: '00' + '0000' },
{ name: 'EF.AD', fid: '6fad', hex: '00' + '0000' + '00' + 'AABB' },
{ name: 'EF.PLMNsel', fid: '6f30', hex: PLMN_ONLY },
{ name: 'EF.FPLMN', fid: '6f7b', hex: FPLMN_HOLES },
{ name: 'EF.EHPLMN', fid: '6fd9', hex: PLMN_ONLY },
{ name: 'EF.ICCID', fid: '2fe2', hex: ICCID_HEX },
{ name: 'EF.ICCID', fid: '2fe2', hex: 'FF'.repeat(10) },
{ name: 'EF.IMSI', fid: '6f07', hex: IMSI_HEX },
{ name: 'EF.IMSI', fid: '6f07', hex: '0811325476981032F4' },
{ name: 'EF.IMSI', fid: '6f07', hex: 'FF'.repeat(9) },
{ name: 'EF.CBMI', fid: '6f45', hex: CBMI_HEX },
{ name: 'EF.CBMID', fid: '6f48', hex: CBMI_HEX },
{ name: 'EF.CBMIR', fid: '6f50', hex: CBMIR_HEX },
{ name: 'EF.SMSP', fid: '6f42', hex: SMSP_FIX },
{ name: 'EF.SMSP', fid: '6f42', hex: SMSP_VEC },
{ name: 'EF.CBMIR', fid: '6f50', hex: 'FF'.repeat(4) },
];
for (const c of cases) {
const entry = efFindEncoder(c.name, c.fid);
@@ -511,3 +569,439 @@ test('F8: the ADN-family entries name their extension EF and use it in refusals'
// the unchanged number still copies the extension slots verbatim
assert.strictEqual(efEncAdn(efDecAdn(efBytes(fdn)), fdn, 14, 'EXT2'), fdn.toUpperCase());
});
// Wave 2 fixtures.
const UST_ONE = '00000010'; // service 29
const UST_MULTI = '010204'; // services 1, 10, 19
const UST_UNMODELLED = '000000' + '10' + '00'.repeat(14) + 'FC'; // 29 + bits 147-152
const EST_FF = 'FFFF';
const ACC_ONE = '8001'; // classes 15 and 0
const AD_HEX = '00' + '0000' + '03' + 'AABB'; // op normal, info 0000, MNC 3, RFU
const PLMN_ONLY = '62F21022F860';
const FPLMN_HOLES = '22F860FFFFFF62F210';
const ICCID_HEX = '988812010000400310f0';
const IMSI_HEX = '082982608200002080';
const CBMI_HEX = '0010FFFF0020';
const CBMIR_HEX = '0000FFFE';
test('service tables: encode/decode round-trips and unmodelled bits', () => {
const label = n => EF_UST_SERVICES[n] || 'RFU';
assert.deepStrictEqual(efDecServiceTable(efBytes(UST_MULTI), EF_UST_SERVICES).services,
[{ n: 1, name: label(1) }, { n: 10, name: label(10) }, { n: 19, name: label(19) }]);
for (const [name, hex] of [['EF.UST', UST_ONE], ['EF.UST', UST_MULTI],
['EF.IST', '01'], ['EF.EST', '07']]) {
const entry = efFindEncoder(name, null);
const table = name === 'EF.UST' ? EF_UST_SERVICES
: (name === 'EF.IST' ? EF_IST_SERVICES : EF_EST_SERVICES);
const services = efDecServiceTable(efBytes(hex), table).services;
assert.strictEqual(entry.enc({ services: services }, hex), hex, name + ' ' + hex);
}
// EF.SST has no editor (its 2-bit SIM layout needs a dedicated UI)
assert.strictEqual(efFindEncoder('EF.SST', '6f38'), null);
assert.strictEqual(efHasEncoder('EF.SST', '6f38'), false);
assert.strictEqual(efFindEncoder('EF.UST', '6f38').name, 'EF.UST');
// EST defines services 1-3; all-FF keeps the other bits too
assert.deepStrictEqual(efDecServiceTable(efBytes('07'), EF_EST_SERVICES).services,
[{ n: 1, name: 'Fixed Dialling Numbers (FDN)' },
{ n: 2, name: 'Barred Dialling Numbers (BDN)' },
{ n: 3, name: 'APN Control List (ACL)' }]);
assert.strictEqual(efFindEncoder('EF.EST', null).enc(
{ services: efDecServiceTable(efBytes(EST_FF), EF_EST_SERVICES).services
.filter(s => Object.prototype.hasOwnProperty.call(EF_EST_SERVICES, s.n)) }, EST_FF), EST_FF);
// service bits outside the table are preserved from the original bytes
const entry = efFindEncoder('EF.UST', null);
const unmodelled = efDecServiceTable(efBytes(UST_UNMODELLED), EF_UST_SERVICES).services
.filter(s => Object.prototype.hasOwnProperty.call(EF_UST_SERVICES, s.n));
assert.strictEqual(entry.enc({ services: unmodelled }, UST_UNMODELLED), UST_UNMODELLED);
// unchecking everything clears the table bits but keeps the unmodelled byte
assert.strictEqual(entry.enc({ services: [] }, UST_UNMODELLED),
'000000' + '00' + '00'.repeat(14) + 'FC');
// length is kept
assert.strictEqual(entry.enc({ services: efDecServiceTable(efBytes('00000010' + '00'), EF_UST_SERVICES).services },
'0000001000'), '0000001000');
});
test('service tables: unknown services and a too-short table are refused', () => {
assert.throws(() => efEncServiceTable({ services: [{ n: 147, name: 'RFU' }] },
'FF'.repeat(19), EF_UST_SERVICES), /Unknown service/);
assert.throws(() => efEncServiceTable({ services: [{ n: 9, name: 'MSISDN' }] },
'00', EF_UST_SERVICES), /too short for service/);
assert.throws(() => efEncServiceTable({ services: [{ n: 0 }] }, '00', EF_UST_SERVICES), /Unknown service/);
});
test('F-ACC: the class numbering and the ACC10/encoder rules', () => {
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [15] });
assert.deepStrictEqual(efDecAcc(efBytes('0001')), { access_classes: [0] });
assert.deepStrictEqual(efDecAcc(efBytes('8001')), { access_classes: [15, 0] });
assert.strictEqual(efEncAcc({ access_classes: [{ n: 15 }] }, '0000'), '8000');
assert.strictEqual(efEncAcc({ access_classes: [{ n: 0 }] }, '0000'), '0001');
assert.strictEqual(efEncAcc({ access_classes: efDecAcc(efBytes(ACC_ONE)).access_classes }, ACC_ONE), ACC_ONE);
// ACC 10 is not editable but the encoder always forces it to 0
assert.strictEqual(efEncAcc({ access_classes: [{ n: 10 }] }, '0000'), '0000');
assert.strictEqual(efEncAcc({ access_classes: [{ n: 15 }, { n: 10 }] }, '0000'), '8000');
assert.throws(() => efEncAcc({ access_classes: [{ n: 16 }] }, '0000'), /Unknown access class/);
assert.throws(() => efEncAcc({ access_classes: [] }, '00'), /too short/);
});
test('PHASE: known and unknown values round-trip through the model', () => {
for (const hex of ['00', '02', '03', '0F']) {
const model = efPhaseModel(efDecPhase(efBytes(hex)));
assert.strictEqual(efEncPhase(model, hex), hex, hex);
}
assert.deepStrictEqual(efPhaseModel(efDecPhase(efBytes('03'))), { phase_hex: '03' });
assert.deepStrictEqual(efPhaseModel(efDecPhase(efBytes('0F'))), { phase_hex: '0F' });
assert.strictEqual(efDecPhase(efBytes('0F')).phase, '0x0F');
assert.throws(() => efEncPhase({ phase_hex: 'ZZ' }, '00'), /hex byte/);
});
test('HPPLMN: the raw interval byte round-trips', () => {
assert.deepStrictEqual(efDecHpplmn(efBytes('2A')), { interval: '0x2A' });
for (const hex of ['00', '2A', 'FF']) {
assert.strictEqual(efEncHpplmn(efDecHpplmn(efBytes(hex)), hex), hex, hex);
}
assert.strictEqual(efEncHpplmn({ interval: '2A' }, '2A'), '2A');
assert.throws(() => efEncHpplmn({ interval: 'XYZ' }, '00'), /hex byte/);
});
test('AD: the whole-byte operation mode, additional info, MNC length and RFU tail', () => {
const dec = efDecAd(efBytes(AD_HEX));
assert.deepStrictEqual(dec, { op_mode: 'normal operation', additional_info: '0000', mnc_length: 3 });
assert.deepStrictEqual(efAdModel(dec), { op_mode: '00', additional_info: '0000', mnc_length: '3' });
assert.strictEqual(efEncAd(efAdModel(dec), AD_HEX), AD_HEX);
for (const [op, name] of [['80', 'type approval'], ['01', 'normal operation + specific facilities'],
['81', 'type approval + specific facilities'], ['02', 'maintenance (off line)'],
['04', 'cell test operation']]) {
const hex = op + '0000' + '03';
assert.strictEqual(efDecAd(efBytes(hex)).op_mode, name, op);
assert.strictEqual(efEncAd(efAdModel(efDecAd(efBytes(hex))), hex), hex, op);
}
// an edited op mode / info / MNC keeps the RFU tail
assert.strictEqual(efEncAd({ op_mode: '80', additional_info: '1234', mnc_length: '2' }, AD_HEX),
'80123402AABB');
// the SIM 3-byte form (no MNC-length byte) and the valid MNC length 0
assert.strictEqual(efEncAd({ op_mode: '00', additional_info: '1234' }, '00' + '0000'), '001234');
assert.strictEqual(efEncAd({ op_mode: '00', additional_info: '1234', mnc_length: '' }, '00' + '0000'), '001234');
assert.throws(() => efEncAd({ op_mode: '00', additional_info: '1234', mnc_length: '2' }, '00' + '0000'),
/no MNC length byte/);
assert.strictEqual(efEncAd({ op_mode: '00', additional_info: '0000', mnc_length: '0' }, AD_HEX),
'000000' + '00' + 'AABB');
// an unknown op byte is shown and written back
const unk = '55' + '0000' + '03' + 'AABB';
assert.strictEqual(efDecAd(efBytes(unk)).op_mode, '0x55');
assert.strictEqual(efAdModel(efDecAd(efBytes(unk))).op_mode, '55');
assert.strictEqual(efEncAd(efAdModel(efDecAd(efBytes(unk))), unk), unk);
// an all-FF record stays all-FF (the model is empty)
assert.strictEqual(efEncAd(efAdModel({}), 'FF'.repeat(6)), 'FF'.repeat(6));
assert.throws(() => efEncAd({ op_mode: 'ZZ' }, AD_HEX), /hex byte/);
assert.throws(() => efEncAd({ op_mode: '00', additional_info: '123' }, AD_HEX), /4 hex digits/);
assert.throws(() => efEncAd({ op_mode: '00', mnc_length: '4' }, AD_HEX), /0, 2 or 3/);
assert.throws(() => efEncAd({ op_mode: '00' }, '00'), /too short/);
});
test('PLMN-only lists: round-trip, holes, add/remove and capacity', () => {
assert.deepStrictEqual(efDecPlmnList(efBytes(PLMN_ONLY)).plmns,
[{ mcc: '262', mnc: '01' }, { mcc: '228', mnc: '06' }]);
assert.strictEqual(efEncPlmnOnly({ plmns: efDecPlmnList(efBytes(PLMN_ONLY)).plmns }, PLMN_ONLY), PLMN_ONLY);
// FPLMN FFFFFF holes anywhere are kept for an unchanged list
assert.deepStrictEqual(efDecFplmn(efBytes(FPLMN_HOLES)).plmns,
[{ mcc: '228', mnc: '06' }, { mcc: '262', mnc: '01' }]);
assert.strictEqual(efEncPlmnOnly({ plmns: efDecFplmn(efBytes(FPLMN_HOLES)).plmns }, FPLMN_HOLES), FPLMN_HOLES);
// an edit compacts and FF-pads
assert.strictEqual(efEncPlmnOnly({ plmns: [{ mcc: '262', mnc: '01' }] }, FPLMN_HOLES),
'62F210' + 'FF'.repeat(6));
assert.strictEqual(efEncPlmnOnly({ plmns: [
{ mcc: '262', mnc: '01' }, { mcc: '228', mnc: '06' }, { mcc: '250', mnc: '99' },
] }, '62F21022F860' + 'FFFFFF'), '62F21022F86052F099');
assert.throws(() => efEncPlmnOnly({ plmns: [{ mcc: '262', mnc: '01' }, { mcc: '228', mnc: '06' }] },
'62F210'), /Too many entries/);
assert.throws(() => efEncPlmnOnly({ plmns: [{ mcc: '26', mnc: '01' }] }, '62F210'), /MCC/);
});
test('ICCID / IMSI: BCD round-trips, padding and refusals', () => {
assert.deepStrictEqual(efDecIccid(efBytes(ICCID_HEX)), { iccid: '8988211000000430010' });
assert.strictEqual(efEncIccid({ iccid: '8988211000000430010' }, ICCID_HEX), ICCID_HEX.toUpperCase());
// odd digit count -> trailing F nibble
assert.strictEqual(efEncIccid({ iccid: '12345' }, 'FF'.repeat(10)), '2143F5' + 'FF'.repeat(7));
assert.strictEqual(efEncIccid({}, 'FF'.repeat(10)), 'FF'.repeat(10));
assert.throws(() => efEncIccid({ iccid: '12A' }, 'FF'.repeat(10)), /digits/);
assert.throws(() => efEncIccid({ iccid: '1'.repeat(21) }, 'FF'.repeat(10)), /too long/);
assert.throws(() => efEncIccid({ iccid: '1'.repeat(20) }, 'FF'.repeat(9)), /does not fit/);
assert.deepStrictEqual(efDecImsi(efBytes(IMSI_HEX)), { imsi: '228062800000208' });
assert.strictEqual(efEncImsi({ imsi: '228062800000208' }, IMSI_HEX), IMSI_HEX);
// 14 digits (even): the type/parity nibble is 1 and the body is F-padded
assert.strictEqual(efEncImsi({ imsi: '12345678901234' }, 'FF'.repeat(9)), '0811325476981032F4');
assert.deepStrictEqual(efDecImsi(efBytes('0811325476981032F4')), { imsi: '12345678901234' });
assert.strictEqual(efEncImsi({ imsi: '123456789012345' }, 'FF'.repeat(9)), '081932547698103254');
assert.deepStrictEqual(efDecImsi(efBytes('081932547698103254')), { imsi: '123456789012345' });
// 6F07 is also EF.IST: a short file is refused, not overwritten
assert.throws(() => efEncImsi({ imsi: '228062800000208' }, '01'), /service table, not an IMSI/);
assert.throws(() => efEncImsi({}, '01'), /service table, not an IMSI/);
assert.strictEqual(efEncImsi({}, 'FF'.repeat(9)), 'FF'.repeat(9));
assert.throws(() => efEncImsi({ imsi: '12345' }, 'FF'.repeat(9)), /6-15/);
assert.throws(() => efEncImsi({ imsi: '1'.repeat(16) }, 'FF'.repeat(9)), /6-15/);
assert.throws(() => efEncImsi({ imsi: '228062800000208' }, 'FF'.repeat(8)), /does not fit/);
});
test('CBMI / CBMIR: message ids and ranges round-trip with FF padding', () => {
assert.deepStrictEqual(efDecCbmi(efBytes(CBMI_HEX)), { message_ids: [16, 32] });
const cbmi = efCbmiModel(efDecCbmi(efBytes(CBMI_HEX)));
assert.deepStrictEqual(cbmi, { message_ids: [{ id: '0010' }, { id: '0020' }] });
assert.strictEqual(efEncCbmi(cbmi, CBMI_HEX), CBMI_HEX); // the FFFF hole is kept
assert.strictEqual(efEncCbmi({ message_ids: [{ id: '0010' }, { id: '0020' }, { id: '0044' }] },
CBMI_HEX), '001000200044');
assert.strictEqual(efEncCbmi({ message_ids: [{ id: '0010' }] }, '0010FFFFFFFF'), '0010FFFFFFFF');
assert.strictEqual(efEncCbmi({ message_ids: [{ id: '0010' }, { id: '20' }] }, '0010FFFFFFFF'),
'00100020FFFF');
assert.throws(() => efEncCbmi({ message_ids: [{ id: 'XYZ' }] }, CBMI_HEX), /1-4 hex digits/);
assert.throws(() => efEncCbmi({ message_ids: [{ id: '0010' }, { id: '0020' }, { id: '0030' }, { id: '0040' }] },
'0010FFFF'), /Too many entries/);
assert.deepStrictEqual(efDecCbmir(efBytes(CBMIR_HEX)), { ranges: [['0000', 'FFFE']] });
const cbmir = efCbmirModel(efDecCbmir(efBytes(CBMIR_HEX)));
assert.deepStrictEqual(cbmir, { ranges: [{ low: '0000', high: 'FFFE' }] });
assert.strictEqual(efEncCbmir(cbmir, CBMIR_HEX), CBMIR_HEX);
assert.strictEqual(efEncCbmir({ ranges: [
{ low: '0000', high: 'FFFE' }, { low: '0100', high: '02FF' },
] }, CBMIR_HEX + 'FFFFFFFF'), '0000FFFE010002FF');
assert.strictEqual(efEncCbmir({ ranges: [{ low: '0000', high: 'FFFE' }] },
CBMIR_HEX + 'FFFFFFFF'), CBMIR_HEX + 'FFFFFFFF');
assert.throws(() => efEncCbmir({ ranges: [{ low: '0000', high: 'XYZ' }] }, CBMIR_HEX), /1-4 hex digits/);
assert.throws(() => efEncCbmir({ ranges: [
{ low: '0000', high: 'FFFE' }, { low: '0100', high: '02FF' },
] }, CBMIR_HEX), /Too many entries/);
});
// EF.SMSP (TS 31.102 4.2.27): alpha (Y = len-28), the indicator byte, the
// DA/SCA slots, PID, DCS and VP. The live fixture and the 44-byte vector.
const SMSP_FIX = 'ffffffffffffffffffffffffffe9ffffffffffffffffffffffff07919733824009f0ffffffff0000ff';
const SMSP_VEC = '534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9';
test('EF.SMSP: the live fixture model and the no-edit round-trip', () => {
const entry = efFindEncoder('EF.SMSP', null);
assert.strictEqual(entry.fid, '6f42');
assert.ok(entry.custom, 'the SMSP entry has a custom builder');
assert.deepStrictEqual(efSmspModel(SMSP_FIX), {
alpha: '',
params: {
da: { present: false, orig: 'FFFFFFFFFFFFFFFFFFFFFFFF', number: '', ton: 1, alnum: false },
sca: { present: true, orig: '07919733824009F0FFFFFFFF', number: '+79332804900', ton: 1, alnum: false },
pid: { present: true, orig: '00', value: '0x00' },
dcs: { present: false, orig: '00', value: '0x00' },
vp: { present: true, orig: 'FF', value: '0xFF' },
},
});
assert.strictEqual(efEncSmsp(efSmspModel(SMSP_FIX), SMSP_FIX), SMSP_FIX.toUpperCase());
assert.strictEqual(efEncSmsp(efSmspModel(SMSP_VEC), SMSP_VEC), SMSP_VEC.toUpperCase());
});
test('EF.SMSP: SCA/DA edits and the two length conventions', () => {
const base = efSmspModel(SMSP_FIX);
const clone = () => JSON.parse(JSON.stringify(base));
// SCA length = TON/NPI + BCD octets
let v = clone();
v.params.sca.number = '+4915790109999';
let out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(52, 56), '0891'); // len 8, TON/NPI 91
assert.deepStrictEqual(efSmspModel(out).params.sca.number, '+4915790109999');
// SCA empty = the default SMSC (length 0)
v = clone();
v.params.sca.number = '';
out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(52, 56), '00FF');
assert.strictEqual(efSmspModel(out).params.sca.number, '');
// DA length = the digit count; TON 0 -> TON/NPI 01
v = clone();
Object.assign(v.params.da, { present: true, number: '12345', ton: 0 });
out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(26, 28), 'E8'); // DA bit cleared
assert.strictEqual(out.slice(28, 52), '05812143F5FFFFFFFFFFFFFF');
assert.deepStrictEqual(efSmspModel(out).params.da.number, '12345');
// 20 digits fit, 21 are refused
v = clone();
Object.assign(v.params.da, { present: true, number: '1'.repeat(20), ton: 1 });
assert.strictEqual(efEncSmsp(v, SMSP_FIX).slice(28, 30), '14');
v.params.da.number = '1'.repeat(21);
assert.throws(() => efEncSmsp(v, SMSP_FIX), /max 20 digits/);
assert.throws(() => efEncSmsp({ ...clone(), params: { ...base.params,
da: { present: true, number: '', ton: 1, orig: '' } } }, SMSP_FIX), /TP-DA number is required/);
assert.throws(() => efEncSmsp({ ...clone(), params: { ...base.params,
sca: { present: true, number: '12A', ton: 1, orig: '' } } }, SMSP_FIX), /digits/);
});
test('EF.SMSP: presence toggles and the indicator byte', () => {
const base = efSmspModel(SMSP_FIX);
const clone = () => JSON.parse(JSON.stringify(base));
// DCS newly present: E9 -> E1 (bit 4 cleared), the value written
let v = clone();
v.params.dcs.present = true;
v.params.dcs.value = '0x08';
let out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(26, 28), 'E1');
assert.strictEqual(out.slice(78, 80), '08');
// SCA newly absent: the bit set and the slot FF-filled
v = clone();
v.params.sca.present = false;
out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(26, 28), 'EB');
assert.strictEqual(out.slice(28, 52), 'FF'.repeat(12));
// PID newly absent: E9 -> ED
v = clone();
v.params.pid.present = false;
out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(26, 28), 'ED');
assert.strictEqual(out.slice(76, 78), 'FF');
// an untouched file keeps the original indicator byte (reserved bits too)
assert.strictEqual(efEncSmsp(base, SMSP_FIX).slice(26, 28), 'E9');
});
test('EF.SMSP: alpha, PID/DCS/VP hex and the refusals', () => {
const base = efSmspModel(SMSP_FIX);
const clone = () => JSON.parse(JSON.stringify(base));
let v = clone();
v.alpha = 'Test';
let out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(0, 8), '54657374');
assert.strictEqual(efSmspModel(out).alpha, 'Test');
assert.throws(() => efEncSmsp({ ...clone(), alpha: 'A'.repeat(14) }, SMSP_FIX), /alpha identifier is too long/);
v = clone();
v.params.pid.value = '0x7F';
v.params.vp.value = 'A8';
v.params.dcs = { present: true, orig: '', value: 'F4' };
out = efEncSmsp(v, SMSP_FIX);
assert.deepStrictEqual([out.slice(76, 78), out.slice(78, 80), out.slice(80, 82)], ['7F', 'F4', 'A8']);
for (const key of ['pid', 'dcs', 'vp']) {
const bad = clone();
bad.params[key] = { ...bad.params[key], present: true, value: 'ZZ' };
assert.throws(() => efEncSmsp(bad, SMSP_FIX), /2 hex digits/, key);
}
assert.throws(() => efEncSmsp({ ...clone(), params: { ...base.params,
sca: { present: true, number: '12A', ton: 1, orig: '' } } }, SMSP_FIX), /digits/);
assert.throws(() => efEncSmsp(base, 'FF'.repeat(27)), /too short for the SMSP layout/);
});
test('EF.SMSP: an all-FF 28-byte record round-trips', () => {
const ff = 'FF'.repeat(28);
const model = efSmspModel(ff);
assert.strictEqual(model.alpha, '');
for (const key of ['da', 'sca', 'pid', 'dcs', 'vp']) assert.strictEqual(model.params[key].present, false, key);
assert.strictEqual(efEncSmsp(model, ff), ff);
});
test('EF.SMSP: the TS 23.038 DCS hint', () => {
assert.strictEqual(smspDcsHint('00'), 'general data coding, GSM 7-bit, uncompressed');
assert.strictEqual(smspDcsHint('10'), 'general data coding, GSM 7-bit, uncompressed, class 0');
assert.strictEqual(smspDcsHint('08'), 'general data coding, UCS2, uncompressed');
assert.strictEqual(smspDcsHint('F4'), 'data coding, 8-bit data, class 0');
assert.strictEqual(smspDcsHint('D0'), 'MWI store, GSM 7-bit, inactive, voicemail');
assert.strictEqual(smspDcsHint('C0'), 'MWI discard, GSM 7-bit, inactive, voicemail');
assert.strictEqual(smspDcsHint('88'), 'reserved coding group');
assert.strictEqual(smspDcsHint('ZZ'), '');
});
test('EF.SMSP: the TS 23.040 relative VP hint', () => {
const cases = { '00': '5 min', '8F': '12 h', '90': '12 h 30 min', 'A7': '24 h',
'A8': '2 days', 'C4': '30 days', 'C5': '5 weeks', 'FF': '63 weeks' };
for (const [hex, want] of Object.entries(cases)) assert.strictEqual(smspVpHint(hex), want, hex);
assert.strictEqual(smspVpHint('ZZ'), '');
});
// A present DA slot with TON 5 (alphanumeric, TS 23.040 9.1.2.5).
const SMSP_ALNUM = 'FF'.repeat(13) + 'FE' + '0750414243444546FFFFFFFF'
+ 'FF'.repeat(12) + 'FF' + 'FF' + 'FF';
// The live fixture with the SCA absent (indicator EB, FF slot).
const SMSP_SCA_ABSENT = SMSP_FIX.slice(0, 26) + 'eb' + SMSP_FIX.slice(28, 52)
+ 'FF'.repeat(12) + SMSP_FIX.slice(76);
// The live fixture with VP absent (indicator F9, FF slot).
const SMSP_VP_ABSENT = SMSP_FIX.slice(0, 26) + 'f9' + SMSP_FIX.slice(28, 80) + 'ff';
test('EF.SMSP: a TON 5 alphanumeric address is kept verbatim and only toggles', () => {
const model = efSmspModel(SMSP_ALNUM);
assert.strictEqual(model.params.da.present, true);
assert.strictEqual(model.params.da.alnum, true);
assert.strictEqual(model.params.da.ton, 5);
assert.strictEqual(model.params.da.number, '');
// an untouched round trip is byte-identical (no BCD re-encode)
assert.strictEqual(efEncSmsp(model, SMSP_ALNUM), SMSP_ALNUM.toUpperCase());
// editing another field leaves the alphanumeric slot verbatim
const edited = JSON.parse(JSON.stringify(model));
edited.alpha = 'X';
const out = efEncSmsp(edited, SMSP_ALNUM);
assert.strictEqual(out.slice(0, 4), '58FF');
assert.strictEqual(out.slice(28, 52), '0750414243444546FFFFFFFF');
// toggling it absent FF-fills the slot and sets its indicator bit
const absent = JSON.parse(JSON.stringify(model));
absent.params.da.present = false;
const off = efEncSmsp(absent, SMSP_ALNUM);
assert.strictEqual(off.slice(26, 28), 'FF');
assert.strictEqual(off.slice(28, 52), 'FF'.repeat(12));
});
test('EF.SMSP: the VP presence bit (0x10) on decode and encode', () => {
const dec = efDecSmsp(efBytes(SMSP_VP_ABSENT));
assert.strictEqual(dec.indicators, '0xF9');
assert.strictEqual(dec.tp_vp, undefined);
assert.strictEqual(dec.sca, '+79332804900');
assert.strictEqual(efEncSmsp(efSmspModel(SMSP_VP_ABSENT), SMSP_VP_ABSENT), SMSP_VP_ABSENT.toUpperCase());
const model = efSmspModel(SMSP_FIX);
model.params.vp.present = false;
const out = efEncSmsp(model, SMSP_FIX);
assert.strictEqual(out.slice(26, 28), 'F9');
assert.strictEqual(out.slice(80, 82), 'FF');
});
test('EF.SMSP: a newly present empty SCA writes the default-SMSC slot', () => {
const model = efSmspModel(SMSP_SCA_ABSENT);
assert.strictEqual(model.params.sca.present, false);
model.params.sca.present = true;
model.params.sca.number = '';
const out = efEncSmsp(model, SMSP_SCA_ABSENT);
assert.strictEqual(out.slice(26, 28), 'E9'); // the SCA bit cleared
assert.strictEqual(out.slice(52, 56), '00FF'); // length 0 + FF fill
});
test('EF.SMSP: a leading + forces TON 1 (international)', () => {
const base = efSmspModel(SMSP_FIX);
const v = JSON.parse(JSON.stringify(base));
v.params.sca = { present: true, number: '+4915790109999', ton: 0 };
const out = efEncSmsp(v, SMSP_FIX);
assert.strictEqual(out.slice(54, 56), '91');
const da = JSON.parse(JSON.stringify(base));
Object.assign(da.params.da, { present: true, number: '+12345', ton: 2 });
const out2 = efEncSmsp(da, SMSP_FIX);
assert.strictEqual(out2.slice(30, 32), '91'); // extension + TON 1 + NPI
});
test('EF.SMSP: the day/week VP ladder boundaries', () => {
assert.strictEqual(smspVpHint('A8'), '2 days');
assert.strictEqual(smspVpHint('AD'), '7 days');
assert.strictEqual(smspVpHint('C2'), '28 days');
assert.strictEqual(smspVpHint('C4'), '30 days');
assert.strictEqual(smspVpHint('C5'), '5 weeks');
assert.strictEqual(smspVpHint('FF'), '63 weeks');
});
test('EF.SMSP: the remaining DCS hint branches', () => {
assert.strictEqual(smspDcsHint('40'), 'automatic deletion, GSM 7-bit, uncompressed');
assert.strictEqual(smspDcsHint('0C'), 'general data coding, reserved, uncompressed');
});
test('EF.SMSP: the duration plural helper (Russian 1 / 2-4 / 5+)', () => {
assert.deepStrictEqual([1, 2, 4, 5, 11, 21, 22, 25].map(smspPluralIndex), [0, 1, 1, 2, 2, 0, 1, 2]);
globalThis.currentLang = 'ru';
globalThis.t = k => ({
day: 'день', 'days-few': 'дня', days: 'дней',
week: 'неделя', 'weeks-few': 'недели', weeks: 'недель',
})[k] || k;
try {
assert.strictEqual(smspCount(1, 'day', 'days-few', 'days'), '1 день');
assert.strictEqual(smspCount(2, 'day', 'days-few', 'days'), '2 дня');
assert.strictEqual(smspCount(5, 'day', 'days-few', 'days'), '5 дней');
assert.strictEqual(smspCount(2, 'week', 'weeks-few', 'weeks'), '2 недели');
assert.strictEqual(smspCount(5, 'week', 'weeks-few', 'weeks'), '5 недель');
} finally {
delete globalThis.currentLang;
delete globalThis.t;
}
assert.strictEqual(smspCount(2, 'day', 'days-few', 'days'), '2 days');
});
+462 -9
View File
@@ -40,29 +40,30 @@ function extractConst(src, name) {
}
const FNS = [
'efBytes', 'efHex', 'efAllFf', 'efRstripFf', 'efUcs2Be', 'efGsm7Octets',
'efBytes', 'efHex', 'efSwapNibbles', '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',
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
'efFindEncoder', 'efHasEncoder', 'efEncError',
'efDecModelValues', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
'efDecRecordsHtml', 'efDecEditHtml',
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
'efBitsSelected', 'pysimFsDecBitsFilter', 'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
'pysimFsDecRemoveItem', 'pysimFsFindNode',
'pysimFsHasDecoder', 'pysimFsHasEncoder', 'pysimFsDecSave', 'pysimFsDecEdit',
'pysimFsPillsEnabled', 'pysimFsSyncContentVisibility', 'gsm7Decode',
'gsm7TextToSeptets', 'gsm7Encode',
'efDecServiceTable', 'efDecSst', 'efDecAcc', 'efDecPhase', 'efDecHpplmn', 'efDecAd', 'efDecCbmi', 'efDecCbmir', 'efDecIccid', 'efDecImsi', 'efDecPlmnList', 'efDecFplmn', 'efEncServiceTable', 'efEncAcc', 'efPhaseValue', 'efPhaseModel', 'efEncPhase', 'efEncHpplmn', 'efAdOpValue', 'efAdModel', 'efEncAd', 'efEncPlmnOnly', 'efEncIccid', 'efEncImsi', 'efCbmiModel', 'efEncCbmi', 'efCbmirModel', 'efEncCbmir', 'efSmspAddressModel', 'efSmspModel', 'efSmspTonOptions', 'efSmspParamRow', 'efSmspHtml', 'efSmspCollect', 'pysimFsSmspToggle', 'pysimFsSmspHexInput', 'efSmspHexByte', 'efSmspAddressBytes', 'efSmspAddrUnchanged', 'efSmspByteUnchanged', 'efEncSmsp', 'smspT', 'smspDcsHint', 'smspVpHint', 'smspDurationText', 'smspPluralIndex', 'smspCount', 'efSmspDaNumber',
];
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']) {
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS']) {
code += extractConst(html, c);
}
eval(code);
@@ -70,6 +71,22 @@ 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.ICCID', fid: '2fe2', fn: efDecIccid },
{ name: 'EF.IMSI', fid: '6f07', fn: efDecImsi },
{ name: 'EF.IST', fid: '6f07', fn: b => efDecServiceTable(b, EF_IST_SERVICES) },
{ name: 'EF.UST', fid: '6f38', fn: b => efDecServiceTable(b, EF_UST_SERVICES) },
{ name: 'EF.SST', fid: '6f38', fn: efDecSst },
{ name: 'EF.EST', fid: '6f56', fn: b => efDecServiceTable(b, EF_EST_SERVICES) },
{ name: 'EF.HPPLMN', fid: '6f31', fn: efDecHpplmn },
{ name: 'EF.ACC', fid: '6f78', fn: efDecAcc },
{ name: 'EF.PHASE', fid: '6fae', fn: efDecPhase },
{ name: 'EF.AD', fid: '6fad', fn: efDecAd },
{ name: 'EF.PLMNsel', fid: '6f30', fn: efDecPlmnList },
{ name: 'EF.FPLMN', fid: '6f7b', fn: efDecFplmn },
{ name: 'EF.EHPLMN', fid: '6fd9', fn: efDecPlmnList },
{ name: 'EF.CBMI', fid: '6f45', fn: efDecCbmi },
{ name: 'EF.CBMID', fid: '6f48', fn: efDecCbmi },
{ name: 'EF.CBMIR', fid: '6f50', fn: efDecCbmir },
{ name: 'EF.SPN', fid: '6f46', fn: efDecSpn },
{ name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
{ name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact },
@@ -95,10 +112,10 @@ test('efFindEncoder / efHasEncoder resolve by name and FID, like the decoders',
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('EF.LOCI', '6f7e'), null);
assert.strictEqual(efFindEncoder(null, null), null);
assert.strictEqual(efHasEncoder('EF.SDN', null), true);
assert.strictEqual(efHasEncoder('EF.IMSI', null), false);
assert.strictEqual(efHasEncoder('EF.LOCI', null), false);
});
test('the model is a TR value set for SPN / PLMNwAcT and record rows for ADN', () => {
@@ -126,7 +143,7 @@ test('the model is a TR value set for SPN / PLMNwAcT and record rows for ADN', (
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.LOCI', null, { kind: 'transparent', data: '082982608200002080' }), null);
assert.strictEqual(efDecEditModel('EF.NOPE', 'abcd', { kind: 'transparent', data: '00' }), null);
});
@@ -244,7 +261,11 @@ test('pysimFsHasEncoder resolves the selected file by name or FID', () => {
assert.strictEqual(pysimFsHasEncoder(), true);
pysimFsSelected = '6F46';
assert.strictEqual(pysimFsHasEncoder(), true);
pysimFsSelected = 'EF.IMSI';
pysimFsSelected = 'EF.LOCI';
assert.strictEqual(pysimFsHasEncoder(), false);
// the SIM service table shares 6F38 with UST but has no editor
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SST', fid: '6f38' }] };
pysimFsSelected = 'EF.SST';
assert.strictEqual(pysimFsHasEncoder(), false);
pysimFsSelected = null;
assert.strictEqual(pysimFsHasEncoder(), false);
@@ -653,3 +674,435 @@ test('F12: a selection change during the read abandons the decoded edit', async
assert.strictEqual(resets, 1);
assert.strictEqual(content.innerHTML, '', 'the old file must not render over the new selection');
});
// Wave 2 fixtures.
const UST_ONE = '00000010'; // service 29
const ACC_ONE = '8001'; // classes 15 and 0
const CBMI_HEX = '0010FFFF0020';
const CBMIR_HEX = '0000FFFE';
const ICCID_HEX = '988812010000400310f0';
test('the bits field renders a filtered checkbox grid and collects {n,name}', () => {
const model = efDecEditModel('EF.UST', '6f38', { kind: 'transparent', data: UST_ONE });
assert.deepStrictEqual(model.values.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
const out = efDecEditHtml(model, UST_ONE);
assert.match(out, /class="pysim-dec-bits /);
assert.match(out, /pysim-dec-bits-filter/);
assert.match(out, /data-bit="29" data-bit-name="Data download via SMS-CB" checked/);
// the whole UST table is rendered (146 services)
assert.strictEqual((out.match(/data-bit="/g) || []).length, 146);
// ACC: 15 editable classes (10 is always written 0), 15 and 0 checked
const acc = efDecEditHtml(efDecEditModel('EF.ACC', '6f78', { kind: 'transparent', data: ACC_ONE }), ACC_ONE);
assert.strictEqual((acc.match(/data-bit="/g) || []).length, 15);
assert.match(acc, /data-bit="15" data-bit-name="Class 15" checked/);
assert.match(acc, /data-bit="0" data-bit-name="Class 0" checked/);
assert.doesNotMatch(acc, /data-bit="10"/);
assert.match(acc, /data-bit="11" data-bit-name="Class 11"/);
// the collection yields the checked {n,name} list
const root = {
querySelectorAll: sel => sel === '[data-bit]' ? [
{ checked: true, getAttribute: k => k === 'data-bit' ? '1' : 'Fixed Dialling Numbers (FDN)' },
{ checked: false, getAttribute: () => '2' },
{ checked: true, getAttribute: k => k === 'data-bit' ? '3' : 'APN Control List (ACL)' },
] : [],
};
assert.deepStrictEqual(efDecCollectValues(root, EF_EST_FIELDS), { services: [
{ n: 1, name: 'Fixed Dialling Numbers (FDN)' },
{ n: 3, name: 'APN Control List (ACL)' },
] });
});
test('the bits filter hides the non-matching rows', () => {
const rows = [
{ textContent: '1 Local Phone Book', style: {} },
{ textContent: '2 Fixed Dialling Numbers', style: {} },
];
const box = { querySelectorAll: sel => sel === '.pysim-dec-bit-row' ? rows : [] };
const input = { value: 'fixed', closest: sel => sel === '.pysim-dec-bits' ? box : null };
pysimFsDecBitsFilter(input);
assert.strictEqual(rows[0].style.display, 'none');
assert.strictEqual(rows[1].style.display, '');
});
test('the select keeps an unknown current value as an option', () => {
const model = efDecEditModel('EF.PHASE', '6fae', { kind: 'transparent', data: '0F' });
assert.deepStrictEqual(model.values, { phase_hex: '0F' });
const out = efDecEditHtml(model, '0F');
assert.match(out, /<option value="0F" selected>Current 0F<\/option>/);
const known = efDecEditHtml(efDecEditModel('EF.PHASE', '6fae', { kind: 'transparent', data: '03' }), '03');
assert.match(known, /<option value="03" selected>phase 2 and PROFILE DOWNLOAD required<\/option>/);
// AD unknown op mode
const ad = efDecEditHtml(efDecEditModel('EF.AD', '6fad',
{ kind: 'transparent', data: '55' + '0000' + '03' + 'AABB' }), '55' + '0000' + '03' + 'AABB');
assert.match(ad, /<option value="55" selected>Current 55<\/option>/);
assert.match(ad, /data-key="additional_info" data-hex="1" value="0000"/);
assert.match(ad, /data-key="mnc_length" value="3"/);
});
test('the hint fields render their help text', () => {
const out = efDecEditHtml(efDecEditModel('EF.HPPLMN', '6f31', { kind: 'transparent', data: '2A' }), '2A');
assert.match(out, /data-key="interval" data-hex="1" value="0x2A" maxlength="4"/);
assert.match(out, /00 = no higher-priority PLMN search/);
assert.match(out, /NB-IoT\/EC-GSM-IoT\/eMTC-M1 read the same byte in hours/);
});
test('the wave-2 model hooks shape PHASE/AD/CBMI/CBMIR for the form', () => {
assert.deepStrictEqual(efDecEditModel('EF.PHASE', '6fae', { kind: 'transparent', data: '02' }).values,
{ phase_hex: '02' });
assert.deepStrictEqual(efDecEditModel('EF.AD', '6fad', { kind: 'transparent', data: '80' + '1234' + '02' }).values,
{ op_mode: '80', additional_info: '1234', mnc_length: '2' });
const cbmi = efDecEditModel('EF.CBMI', '6f45', { kind: 'transparent', data: CBMI_HEX });
assert.deepStrictEqual(cbmi.values, { message_ids: [{ id: '0010' }, { id: '0020' }] });
const cbmir = efDecEditModel('EF.CBMIR', '6f50', { kind: 'transparent', data: CBMIR_HEX });
assert.deepStrictEqual(cbmir.values, { ranges: [{ low: '0000', high: 'FFFE' }] });
});
test('the CB list forms use the row editor with hex ids/bounds', () => {
const cbmi = efDecEditModel('EF.CBMI', '6f45', { kind: 'transparent', data: CBMI_HEX });
const out = efDecEditHtml(cbmi, CBMI_HEX);
assert.strictEqual((out.match(/class="pysim-dec-row /g) || []).length, 2);
assert.match(out, /data-key="id" data-hex="1" value="0010"/);
assert.match(out, /data-hex="1"/);
assert.match(out, />Add message ID</);
const cbmir = efDecEditModel('EF.CBMIR', '6f50', { kind: 'transparent', data: CBMIR_HEX });
const out2 = efDecEditHtml(cbmir, CBMIR_HEX);
assert.strictEqual((out2.match(/class="pysim-dec-row /g) || []).length, 1);
assert.match(out2, /data-key="low" data-hex="1" value="0000"/);
assert.match(out2, /data-key="high" data-hex="1" value="FFFE"/);
assert.match(out2, />Add range</);
});
test('the confirm entries ask once and only for a changed identity', async () => {
const decSave = extractFunc(html, 'pysimFsDecSave');
assert.ok(decSave.includes('entry.confirm'));
assert.ok(decSave.includes('pysimConfirm(t(entry.confirm))'));
const entry = efFindEncoder('EF.ICCID', null);
pysimFsDecModel = { entry: entry, mode: 'tr' };
const makeOut = value => {
const root = {
getAttribute: k => k === 'data-orig' ? ICCID_HEX : null,
querySelector: sel => sel === '[data-key="iccid"]' ? { value: value } : null,
};
return { querySelector: sel => sel === '.pysim-dec-edit' ? root : null, querySelectorAll: () => [] };
};
// unchanged -> no dialog
let asked = 0;
globalThis.pysimConfirm = async () => { asked++; return true; };
let stubs = saveStubs({ success: true, sw: '9000' });
let status = { textContent: '' };
await pysimFsDecSave(null, 'EF.ICCID', status, makeOut('8988211000000430010'), false);
assert.strictEqual(asked, 0);
assert.strictEqual(stubs.bodies.length, 1);
// changed + cancel -> nothing written
globalThis.pysimConfirm = async () => { asked++; return false; };
stubs = saveStubs({ success: true, sw: '9000' });
status = { textContent: '' };
await pysimFsDecSave(null, 'EF.ICCID', status, makeOut('8988211000000430011'), false);
assert.strictEqual(asked, 1);
assert.strictEqual(stubs.bodies.length, 0);
assert.match(status.textContent, /Write cancelled/);
// changed + accept -> written
globalThis.pysimConfirm = async () => { asked++; return true; };
stubs = saveStubs({ success: true, sw: '9000' });
status = { textContent: '' };
await pysimFsDecSave(null, 'EF.ICCID', status, makeOut('8988211000000430011'), false);
assert.strictEqual(asked, 2);
assert.strictEqual(stubs.bodies.length, 1);
assert.strictEqual(stubs.bodies[0].body.data, '988812010000400310F1');
});
// Finding 1: the list save passes the collected row ARRAY; the encoders must
// not wipe the file when nothing was edited.
const PLMN_ONLY = '62F21022F860';
const FPLMN_HOLES = '22F860FFFFFF62F210';
function listSaveRoot(orig, rows) {
const rowEls = rows.map(vals => ({
querySelector: sel => {
const m = /^\[data-key="(.+)"\]$/.exec(sel);
return m && vals[m[1]] !== undefined ? { value: vals[m[1]] } : null;
},
querySelectorAll: () => [],
}));
return {
getAttribute: k => k === 'data-orig' ? orig : null,
querySelectorAll: sel => sel === '.pysim-dec-row' ? rowEls : [],
};
}
test('F1: a list save with no edits keeps the original bytes', async () => {
const cases = [
{ name: 'EF.PLMNsel', fid: '6f30', orig: PLMN_ONLY,
rows: [{ mcc: '262', mnc: '01' }, { mcc: '228', mnc: '06' }] },
{ name: 'EF.FPLMN', fid: '6f7b', orig: FPLMN_HOLES,
rows: [{ mcc: '228', mnc: '06' }, { mcc: '262', mnc: '01' }] },
{ name: 'EF.EHPLMN', fid: '6fd9', orig: PLMN_ONLY,
rows: [{ mcc: '262', mnc: '01' }, { mcc: '228', mnc: '06' }] },
{ name: 'EF.CBMI', fid: '6f45', orig: CBMI_HEX, rows: [{ id: '0010' }, { id: '0020' }] },
{ name: 'EF.CBMID', fid: '6f48', orig: CBMI_HEX, rows: [{ id: '0010' }, { id: '0020' }] },
{ name: 'EF.CBMIR', fid: '6f50', orig: CBMIR_HEX, rows: [{ low: '0000', high: 'FFFE' }] },
];
for (const c of cases) {
pysimFsDecModel = { entry: efFindEncoder(c.name, c.fid), mode: 'tr' };
const out = {
querySelector: sel => sel === '.pysim-dec-edit' ? listSaveRoot(c.orig, c.rows) : null,
querySelectorAll: () => [],
};
const stubs = saveStubs({ success: true, sw: '9000' });
const status = { textContent: '' };
await pysimFsDecSave(null, c.name, status, out, false);
assert.strictEqual(stubs.bodies.length, 1, c.name);
assert.strictEqual(stubs.bodies[0].body.data, c.orig,
c.name + ': a no-edit list save must not wipe the file');
}
});
test('F1: an edited list row is written through the same save path', async () => {
const cases = [
{ name: 'EF.PLMNsel', fid: '6f30', orig: PLMN_ONLY,
rows: [{ mcc: '262', mnc: '01' }, { mcc: '228', mnc: '07' }] },
{ name: 'EF.CBMI', fid: '6f45', orig: CBMI_HEX,
rows: [{ id: '0010' }, { id: '0030' }] },
{ name: 'EF.CBMIR', fid: '6f50', orig: CBMIR_HEX,
rows: [{ low: '0000', high: 'FF00' }] },
];
for (const c of cases) {
const entry = efFindEncoder(c.name, c.fid);
pysimFsDecModel = { entry: entry, mode: 'tr' };
const out = {
querySelector: sel => sel === '.pysim-dec-edit' ? listSaveRoot(c.orig, c.rows) : null,
querySelectorAll: () => [],
};
const stubs = saveStubs({ success: true, sw: '9000' });
const status = { textContent: '' };
await pysimFsDecSave(null, c.name, status, out, false);
const expected = entry.enc(c.rows, c.orig);
assert.notStrictEqual(expected, c.orig, c.name + ': the edit must change the bytes');
assert.strictEqual(stubs.bodies.length, 1, c.name);
assert.strictEqual(stubs.bodies[0].body.data, expected, c.name);
}
});
test('F5: an untouched all-FF AD saves FF through the collected form value', async () => {
const entry = efFindEncoder('EF.AD', null);
const model = efDecEditModel('EF.AD', null, { kind: 'transparent', data: 'FF'.repeat(6) });
assert.deepStrictEqual(model.values, { op_mode: 'FF', additional_info: '', mnc_length: '' });
const form = efDecEditHtml(model, 'FF'.repeat(6));
assert.match(form, /<option value="FF" selected>Current FF<\/option>/);
pysimFsDecModel = model;
const vals = { op_mode: 'FF', additional_info: '', mnc_length: '' };
const root = {
getAttribute: k => k === 'data-orig' ? 'FF'.repeat(6) : null,
querySelector: sel => {
const m = /^\[data-key="(.+)"\]$/.exec(sel);
return m ? { value: vals[m[1]] } : null;
},
};
const out = { querySelector: sel => sel === '.pysim-dec-edit' ? root : null, querySelectorAll: () => [] };
const stubs = saveStubs({ success: true, sw: '9000' });
const status = { textContent: '' };
await pysimFsDecSave(null, 'EF.AD', status, out, false);
assert.strictEqual(stubs.bodies.length, 1);
assert.strictEqual(stubs.bodies[0].body.data, 'FF'.repeat(6));
});
// EF.SMSP: the custom (dedicated builder) entry kind.
const SMSP_FIX = 'ffffffffffffffffffffffffffe9ffffffffffffffffffffffff07919733824009f0ffffffff0000ff';
function smspSaveBlock(orig, num, values) {
const inputs = { alpha: { value: values.alpha } };
for (const key of ['da', 'sca', 'pid', 'dcs', 'vp']) {
const p = values.params[key];
if (key === 'da' || key === 'sca') {
inputs[key + '.number'] = { value: p.number };
inputs[key + '.ton'] = { value: String(p.ton) };
} else {
inputs[key + '.value'] = { value: p.value };
}
}
const rows = {};
for (const key of ['da', 'sca', 'pid', 'dcs', 'vp']) {
rows[key] = {
querySelector: sel => sel === '[data-smsp-absent="' + key + '"]'
? { checked: !values.params[key].present } : null,
};
}
return {
querySelector: sel => {
if (sel === '.pysim-fs-record-cb') return { checked: true };
let m = /^\[data-smsp="(.+)"\]$/.exec(sel);
if (m) return inputs[m[1]] || null;
m = /^\[data-smsp-orig="(.+)"\]$/.exec(sel);
return m ? (rows[m[1]] || null) : null;
},
querySelectorAll: () => [],
getAttribute: k => k === 'data-rec' ? String(num) : (k === 'data-orig' ? orig : null),
};
}
test('the SMSP custom form renders the parameter rows, presence and hints', () => {
const model = efDecEditModel('EF.SMSP', '6f42', { kind: 'record', records: [{ num: 1, data: SMSP_FIX }] });
assert.strictEqual(model.mode, 'records');
assert.strictEqual(model.records[0].values.params.sca.number, '+79332804900');
const out = efDecEditHtml(model);
assert.match(out, /class="pysim-dec-items/);
assert.match(out, /data-smsp="alpha" value=""/);
assert.match(out, /data-smsp-absent="da" checked/);
assert.doesNotMatch(out, /data-smsp-absent="sca" checked/);
assert.match(out, /data-smsp="sca.number" value="\+79332804900"/);
assert.match(out, /data-smsp="pid.value" value="0x00"/);
assert.match(out, /data-smsp="dcs.value" value="0x00"/);
assert.match(out, /data-smsp="vp.value" value="0xFF"/);
// an absent DCS shows the neutral ME-default note (not a decoded hint);
// the present VP still shows its relative-VP hint
assert.match(out, /data-smsp-hint>absent — the ME default applies/);
assert.doesNotMatch(out, /general data coding/);
assert.match(out, /63 weeks/);
assert.match(out, /TS 23.040 TP-PID \(raw\)/);
// a present DCS renders its decoded TS 23.038 hint (the 44-byte vector)
const vec = efDecEditModel('EF.SMSP', '6f42', { kind: 'record', records: [{ num: 1,
data: '534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9' }] });
assert.match(efDecEditHtml(vec), /general data coding, GSM 7-bit, uncompressed/);
});
test('the SMSP custom collect reads the rows back with their original slots', () => {
const model = efDecEditModel('EF.SMSP', '6f42', { kind: 'record', records: [{ num: 1, data: SMSP_FIX }] });
const block = smspSaveBlock(SMSP_FIX, 1, model.records[0].values);
assert.deepStrictEqual(efSmspCollect(block), {
alpha: '',
params: {
da: { present: false, number: '', ton: 1 },
sca: { present: true, number: '+79332804900', ton: 1 },
pid: { present: true, value: '0x00' },
dcs: { present: false, value: '0x00' },
vp: { present: true, value: '0xFF' },
},
});
});
test('an untouched SMSP record saves byte-identically; an edit re-encodes', async () => {
const entry = efFindEncoder('EF.SMSP', null);
const model = efDecEditModel('EF.SMSP', '6f42', { kind: 'record', records: [{ num: 1, data: SMSP_FIX }] });
pysimFsDecModel = model;
const out = { querySelector: () => null,
querySelectorAll: sel => sel === '.pysim-dec-rec'
? [smspSaveBlock(SMSP_FIX, 1, model.records[0].values)] : [] };
let stubs = saveStubs({ success: true, sw: '9000' });
let status = { textContent: '' };
await pysimFsDecSave(null, 'EF.SMSP', status, out, false);
assert.strictEqual(stubs.bodies.length, 1);
assert.strictEqual(stubs.bodies[0].body.record_nr, 1);
assert.strictEqual(stubs.bodies[0].body.data, SMSP_FIX.toUpperCase());
// edit the SCA number
const edited = JSON.parse(JSON.stringify(model.records[0].values));
edited.params.sca.number = '+4915790109999';
stubs = saveStubs({ success: true, sw: '9000' });
status = { textContent: '' };
const out2 = { querySelector: () => null,
querySelectorAll: sel => sel === '.pysim-dec-rec' ? [smspSaveBlock(SMSP_FIX, 1, edited)] : [] };
await pysimFsDecSave(null, 'EF.SMSP', status, out2, false);
assert.strictEqual(stubs.bodies[0].body.data, efEncSmsp(edited, SMSP_FIX));
});
test('the SMSP absent toggle disables the row inputs and the hex hint is live', () => {
const inputs = [{ disabled: false }, { disabled: false }];
const hint = { textContent: '' };
const row = {
getAttribute: () => 'dcs',
querySelector: sel => sel === '[data-smsp-hint]' ? hint : null,
querySelectorAll: sel => sel === '[data-smsp]' ? inputs : [],
};
const cb = { checked: true, closest: sel => sel === '[data-smsp-orig]' ? row : null };
pysimFsSmspToggle(cb);
assert.ok(inputs.every(i => i.disabled));
pysimFsSmspHexInput({ value: 'F4', closest: sel => sel === '[data-smsp-orig]' ? row : null });
assert.strictEqual(hint.textContent, 'data coding, 8-bit data, class 0');
const vpHint = { textContent: '' };
const vpRow = {
getAttribute: () => 'vp',
querySelector: sel => sel === '[data-smsp-hint]' ? vpHint : null,
};
pysimFsSmspHexInput({ value: '90', closest: () => vpRow });
assert.strictEqual(vpHint.textContent, '12 h 30 min');
});
test('the SMSP entry is wired into the encoder registry', () => {
const entry = efFindEncoder('EF.SMSP', null);
assert.strictEqual(entry.fid, '6f42');
assert.strictEqual(typeof entry.custom.html, 'function');
assert.strictEqual(typeof entry.custom.collect, 'function');
assert.strictEqual(typeof entry.custom.model, 'function');
assert.strictEqual(efHasEncoder('EF.SMSP', '6f42'), true);
});
const SMSP_ALNUM = 'FF'.repeat(13) + 'FE' + '0750414243444546FFFFFFFF'
+ 'FF'.repeat(12) + 'FF' + 'FF' + 'FF';
const SMSP_VEC = '534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9';
test('the SMSP TON 5 alphanumeric slot is read-only in the form', () => {
const model = efDecEditModel('EF.SMSP', '6f42', { kind: 'record', records: [{ num: 1, data: SMSP_ALNUM }] });
assert.strictEqual(model.records[0].values.params.da.alnum, true);
const out = efDecEditHtml(model);
assert.doesNotMatch(out, /data-smsp="da.number"/);
assert.doesNotMatch(out, /data-smsp="da.ton"/);
assert.match(out, /alphanumeric address — not editable here; it is preserved as is/);
assert.match(out, /0750414243444546FFFFFFFF/);
// still togglable absent, and the encoder keeps it verbatim while present
const block = smspSaveBlock(SMSP_ALNUM, 1, model.records[0].values);
const vals = efSmspCollect(block);
assert.strictEqual(vals.params.da.present, true);
assert.strictEqual(efEncSmsp(vals, SMSP_ALNUM), SMSP_ALNUM.toUpperCase());
});
test('the SMSP TON select keeps an unknown current value', () => {
const model = efSmspModel(SMSP_FIX);
model.params.sca.ton = 3;
const html = efSmspHtml(model);
assert.match(html, /<option value="3" selected>Current 3<\/option>/);
});
test('a multi-record SMSP save scopes each block to its own record', async () => {
const entry = efFindEncoder('EF.SMSP', null);
// record 1 untouched, record 2 (the 44-byte vector) with an edited alpha
const model = efDecEditModel('EF.SMSP', '6f42', { kind: 'record', records: [
{ num: 1, data: SMSP_FIX }, { num: 2, data: SMSP_VEC },
] });
const edited = JSON.parse(JSON.stringify(model.records[1].values));
edited.alpha = 'Two';
pysimFsDecModel = model;
const blocks = [smspSaveBlock(SMSP_FIX, 1, model.records[0].values),
smspSaveBlock(SMSP_VEC, 2, edited)];
const out = { querySelector: () => null,
querySelectorAll: sel => sel === '.pysim-dec-rec' ? blocks : [] };
const stubs = saveStubs({ success: true, sw: '9000' });
const status = { textContent: '' };
await pysimFsDecSave(null, 'EF.SMSP', status, out, false);
assert.strictEqual(stubs.bodies.length, 2);
assert.deepStrictEqual(stubs.bodies.map(b => b.body.record_nr), [1, 2]);
assert.strictEqual(stubs.bodies[0].body.data, SMSP_FIX.toUpperCase());
assert.strictEqual(stubs.bodies[1].body.data, efEncSmsp(edited, SMSP_VEC));
assert.notStrictEqual(stubs.bodies[1].body.data, SMSP_VEC.toUpperCase());
});
test('the SMSP DCS hint follows the absent toggle', () => {
const hint = { textContent: 'general data coding, GSM 7-bit, uncompressed' };
const input = { value: '00' };
const cb = { checked: false };
const row = {
getAttribute: () => 'dcs',
querySelector: sel => {
if (sel === '[data-smsp-hint]') return hint;
if (sel === '[data-smsp-absent]') return cb;
if (sel === '[data-smsp="dcs.value"]') return input;
return null;
},
querySelectorAll: () => [],
};
cb.checked = true;
cb.closest = sel => sel === '[data-smsp-orig]' ? row : null;
pysimFsSmspToggle(cb);
assert.strictEqual(hint.textContent, 'absent — the ME default applies');
cb.checked = false;
pysimFsSmspToggle(cb);
assert.strictEqual(hint.textContent, 'general data coding, GSM 7-bit, uncompressed');
});
+10
View File
@@ -117,12 +117,19 @@ test('Read raw / Read decoded pills switch the view and read the file', () => {
assert.strictEqual(h.reads(), 0, 'skipRead updates the pills only');
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
// an enabled inactive pill is outlined, not grey: the grey fill reads as
// "disabled" although the pill is clickable (reported live)
assert.ok(h.pill('dec').classList.contains('bg-white'));
assert.ok(h.pill('dec').classList.contains('text-blue-600'));
assert.ok(!h.pill('dec').classList.contains('bg-gray-200'));
pysimFsSetMode('dec');
assert.strictEqual(pysimFsDecodedMode, true);
assert.strictEqual(h.reads(), 1, 'a pill click reads the selected file');
assert.ok(h.pill('dec').classList.contains('bg-blue-600'));
assert.ok(!h.pill('raw').classList.contains('bg-blue-600'));
assert.ok(h.pill('raw').classList.contains('bg-white'));
assert.ok(!h.pill('raw').classList.contains('bg-gray-200'));
});
test('pill clicks are ignored while editing', () => {
@@ -221,4 +228,7 @@ test('the file manager row exposes the read pills and Edit raw', () => {
assert.ok(html.includes('id="pysim-fs-read-raw-btn"') && html.includes('data-needs="card"'));
const dec = /<button[^>]*id="pysim-fs-read-dec-btn"[^>]*>/.exec(html);
assert.ok(dec && dec[0].includes('data-needs-decoder'), 'the decoded pill is decoder-gated');
// the enabled-inactive look is an outline; the grey fill is for disabled
assert.ok(dec[0].includes('bg-white') && dec[0].includes('text-blue-600'));
assert.ok(!dec[0].includes('bg-gray-200'));
});
+126 -4
View File
@@ -28,7 +28,10 @@ for (const fn of ['ramIsBatchOp', 'ramBatchPanelVisible', 'ramBatchMergedState',
'ramBatchStepsText',
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'ramOpProgressText',
'ramOpModalClose', 'esc']) {
'ramOpModalClose', 'esc',
'ramBatchSoundEnabled', 'ramBatchUpdateSoundButton', 'ramBatchToggleSound',
'ramBatchAudioUnlock', 'ramBatchChime', 'ramBatchTitleHint',
'ramBatchWaitingCue']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
@@ -39,7 +42,9 @@ const els = {};
function setEl(id, value) {
els[id] = {
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
disabled: false, style: {},
disabled: false, style: {}, title: '', attrs: {},
setAttribute(k, v) { els[id].attrs[k] = String(v); },
getAttribute(k) { return els[id].attrs[k]; },
classes: {},
classList: {
add(c) { els[id].classes[c] = true; },
@@ -64,7 +69,8 @@ function clearEls() {
function setModalEls() {
for (const id of ['ram-op-modal', 'ram-op-title', 'ram-op-status', 'ram-op-bar',
'ram-op-steps', 'ram-op-spin', 'ram-op-warn', 'ram-op-close',
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear']) setEl(id, '');
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear',
'ram-op-insert', 'ram-op-sound']) setEl(id, '');
}
function setPanelEls() {
for (const id of ['ram-op', 'ram-batch-panel', 'ram-batch-start', 'ram-batch-stop',
@@ -75,7 +81,17 @@ function setPanelEls() {
const app = new Function('els', `
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
var document = { getElementById: id => els[id] || null };
var document = {
getElementById: id => els[id] || null,
hidden: false, title: 'SIMple',
addEventListener: () => {},
};
var _ls = {};
var localStorage = {
getItem: k => (k in _ls ? _ls[k] : null),
setItem: (k, v) => { _ls[k] = String(v); },
removeItem: k => { delete _ls[k]; },
};
var t = k => k;
var isViewVisible = () => sandbox.visibility;
var alert = m => sandbox.alerts.push(String(m));
@@ -109,6 +125,9 @@ const app = new Function('els', `
var _ramBatchFetchAt = 0;
var _ramBatchSummary = null;
var _ramBatchProgress = null;
var _ramBatchWaiting = false;
var _ramBatchAudio = null;
var _ramBatchTitleBase = '';
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
${code}
sandbox.getLast = () => _ramBatchLast;
@@ -134,6 +153,16 @@ const app = new Function('els', `
sandbox.ramBatchStart = ramBatchStart;
sandbox.ramBatchStop = ramBatchStop;
sandbox.ramBatchClear = ramBatchClear;
sandbox.ramBatchWaitingCue = ramBatchWaitingCue;
sandbox.ramBatchTitleHint = ramBatchTitleHint;
sandbox.ramBatchSoundEnabled = ramBatchSoundEnabled;
sandbox.ramBatchUpdateSoundButton = ramBatchUpdateSoundButton;
sandbox.ramBatchToggleSound = ramBatchToggleSound;
sandbox.setHidden = v => { document.hidden = !!v; };
sandbox.getTitle = () => document.title;
sandbox.resetWaiting = () => { _ramBatchWaiting = false; };
sandbox.storage = _ls;
sandbox.resetStorage = () => { for (const k of Object.keys(_ls)) delete _ls[k]; };
return sandbox;
`)(els);
@@ -639,3 +668,96 @@ test('the stop and clear calls hit their endpoints', async () => {
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
});
test('the waiting cue fires once per card and drives the insert animation (v3.36.0)', () => {
clearEls();
setPanelEls();
setModalEls();
app.resetWaiting();
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'entering waiting cues');
assert.strictEqual(app.ramBatchWaitingCue(true), false, 'still waiting: no repeat');
assert.strictEqual(app.ramBatchWaitingCue(false), false, 'installing resets');
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'the next card cues again');
assert.ok(els['ram-op-insert'].classes['flex'] === true);
assert.ok(!els['ram-op-insert'].classes['hidden']);
app.ramBatchWaitingCue(false);
assert.ok(els['ram-op-insert'].classes['hidden']);
// the render drives it from the phase
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
app.ramBatchRender();
assert.ok(!els['ram-op-insert'].classes['hidden']);
app.setLast({ active: true, phase: 'installing', current: { iccid: '8970119000004600098' },
attempts: [], counts: {}, attempts_total: 1 });
app.ramBatchRender();
assert.ok(els['ram-op-insert'].classes['hidden']);
});
test('the sound toggle persists and updates the button (v3.36.0)', () => {
clearEls();
setModalEls();
app.resetStorage();
assert.strictEqual(app.ramBatchSoundEnabled(), true, 'sound is on by default');
app.ramBatchUpdateSoundButton();
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F50A}');
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'true');
app.ramBatchToggleSound();
assert.strictEqual(app.ramBatchSoundEnabled(), false);
assert.strictEqual(app.storage['simple_ram_batch_sound'], '0');
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F507}');
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'false');
app.ramBatchToggleSound();
assert.strictEqual(app.ramBatchSoundEnabled(), true);
});
test('the tab title hints while the run waits and the tab is hidden (v3.36.0)', () => {
app.setHidden(false);
app.ramBatchTitleHint(true);
assert.strictEqual(app.getTitle(), 'SIMple');
app.setHidden(true);
app.ramBatchTitleHint(true);
assert.strictEqual(app.getTitle(), 'Insert next card \u2014 SIMple');
app.setHidden(false);
app.ramBatchTitleHint(true);
assert.strictEqual(app.getTitle(), 'SIMple');
});
test('the waiting cue is wired into the poll render, the Start gesture and the CSS (v3.36.0)', () => {
const render = extractFunc(html, 'ramBatchRender');
assert.match(render, /const waiting = active && phase !== 'installing' && phase !== 'stopping';/);
assert.match(render, /if \(ramBatchWaitingCue\(waiting\)\) ramBatchChime\(\);/);
assert.match(extractFunc(html, 'ramBatchStart'), /ramBatchAudioUnlock\(\)/);
assert.match(html, /id="ram-op-insert"/);
assert.match(html, /id="ram-op-sound"/);
const css = fs.readFileSync(path.join(__dirname, '..', 'style.css'), 'utf8');
assert.match(css, /\.ram-insert-card/);
assert.match(css, /@keyframes ram-insert/);
assert.match(css, /\.ram-insert-glow/);
assert.match(css, /prefers-reduced-motion: reduce/);
});
test('an already-absent delete is reported as such, not as a deletion (v3.36.0)', () => {
clearEls();
setPanelEls();
setModalEls();
app.resetBatchModal();
app.setLast({ active: false, phase: 'finished', kind: 'delete',
attempts: [{
iccid: '8970119000000115026', preset: 'Alfa', result: 'already_absent',
steps: [{ name: 'DELETE (application)', sw: '6A88', ok: true, not_found: true }],
steps_total: 1, duration_ms: 1200,
}],
counts: { installed: 2, already_absent: 1, failed: 0, skipped: 0 },
attempts_total: 3, notice: 'Card 8970119000000115026 was already absent.' });
app.ramBatchRender();
// the counts separate a real deletion from a no-op
assert.match(els['ram-batch-counts'].textContent, /Deleted: 2/);
assert.match(els['ram-batch-counts'].textContent, /Already absent: 1/);
// the attempt's verdict says it - not a plain OK - and keeps the step line
assert.match(els['ram-batch-attempts'].innerHTML, /Already absent/);
assert.match(els['ram-batch-attempts'].innerHTML, /6A88 \(not found\)/);
// the modal carries the same counts
app.ramBatchModalOpen();
assert.match(els['ram-op-batch-info'].textContent, /Deleted: 2/);
assert.match(els['ram-op-batch-info'].textContent, /Already absent: 1/);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.33.0"
version = "3.36.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9"
+16 -4
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.33.0'
VERSION = '3.36.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -2914,7 +2914,7 @@ def _ram_batch_new_state(plan):
return {'active': True, 'phase': 'waiting_card', 'stop': False,
'started': time.time(), 'stopped': None, 'plan': plan,
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
'current': None, 'notice': ''}
@@ -2939,6 +2939,8 @@ def _ram_batch_record(state, rec):
verb = {'delete': 'deleted',
'reinstall-app': 'reinstalled'}.get(kind, 'installed')
state['notice'] = 'Card %s %s.' % (who, verb)
elif result == 'already_absent':
state['notice'] = 'Card %s was already absent.' % who
elif result == 'failed':
step = rec.get('failed_step') or ''
sw = rec.get('por_sw') or rec.get('sw') or ''
@@ -2947,8 +2949,9 @@ def _ram_batch_record(state, rec):
else:
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
detail = rec.get('reason') or rec.get('failed_step') or ''
label = {'already_absent': 'already absent'}.get(result, result)
sys.stderr.write('RAM-BATCH: %s%s%s\n' % (
result, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
label, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
def _ram_batch_failure_fields(result):
@@ -3044,6 +3047,15 @@ def _ram_batch_handle_card(state, server):
'module_aid': result.get('module_aid'),
'nv_delta': result.get('nv_delta')}
rec.update(_ram_batch_steps_fields(result))
# A delete run whose DELETE answered 'not found' is a success (the
# object is gone, the goal is met) but not a deletion: report it as
# "already absent" and count it separately (v3.36.0). Only the delete
# kind: a reinstall's tolerated pre-delete is not the outcome.
if (state.get('plan') or {}).get('kind') == 'delete' and any(
s.get('delete_not_found') for s in (result.get('steps') or [])):
outcome = 'already_absent'
rec['result'] = outcome
rec['not_found'] = True
elif result is not None:
outcome = 'failed'
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
@@ -3178,7 +3190,7 @@ def _ram_batch_status():
if _RAM_BATCH is None:
return {'active': False, 'phase': 'idle', 'attempts': [],
'attempts_total': 0, 'current': None, 'notice': '',
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
'started': None, 'stopped': None, 'progress': None,
'kind': None}
return {'active': bool(_RAM_BATCH.get('active')),
+45 -1
View File
@@ -88,7 +88,8 @@ class BatchStateTests(unittest.TestCase):
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
'reason': 'no preset matches this ICCID'})
self.assertEqual(st['counts'], {'installed': 1, 'failed': 1, 'skipped': 1})
self.assertEqual(st['counts'], {'installed': 1, 'already_absent': 0,
'failed': 1, 'skipped': 1})
self.assertEqual(st['attempts_total'], 3)
# the notice names the last attempt's outcome
self.assertIn('skipped: no preset matches this ICCID', st['notice'])
@@ -230,6 +231,49 @@ class BatchCardTests(unittest.TestCase):
self.assertEqual(seen['current']['iccid'], '8970119000004600098')
self.assertIn('installed', st['notice']) # ...and set for the new outcome
def test_a_delete_that_finds_nothing_is_reported_already_absent(self):
"""A delete run whose DELETE answered 6A88 is a success (the object is
gone, the goal is met) but not a deletion: it is counted in its own
bucket and the notice says so (v3.36.0)."""
st = srv._ram_batch_new_state(_plan('delete'))
preset = _preset('8970119000004600098', 'Alfa')
def fake_run(server, body, plan, scc):
return {'success': True, 'final_cntr': '0000000002',
'steps': [{'name': 'DELETE (application)', 'sw': '9000',
'por_status': 'por_ok', 'por_sw': '6A88',
'delete_not_found': True}]}
with mock.patch.object(srv, '_ram_plan_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
rec = st['attempts'][0]
self.assertEqual(rec['result'], 'already_absent')
self.assertTrue(rec['not_found'])
self.assertEqual(st['counts']['already_absent'], 1)
self.assertEqual(st['counts']['installed'], 0)
self.assertIn('already absent', st['notice'])
self.assertEqual(st['seen']['8970119000004600098'], 'already_absent')
def test_a_reinstall_with_a_tolerated_pre_delete_is_still_an_install(self):
"""The reinstall's pre-delete may tolerate 'not found', but the
outcome is the INSTALL, not 'already absent'."""
st = srv._ram_batch_new_state(_plan('reinstall-app'))
preset = _preset('8970119000004600098', 'Alfa')
def fake_run(server, body, plan, scc):
return {'success': True,
'steps': [{'name': 'DELETE (application)', 'por_sw': '6A88',
'delete_not_found': True},
{'name': 'INSTALL [for install]', 'por_sw': '9000'}]}
with mock.patch.object(srv, '_ram_plan_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
self.assertEqual(st['attempts'][0]['result'], 'installed')
self.assertEqual(st['counts']['installed'], 1)
self.assertEqual(st['counts']['already_absent'], 0)
def test_a_card_already_processed_is_reported_not_reinstalled(self):
st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098', 'Alfa')