From 87bf4acbbc1be47e9f9cf7a9b238d73a4952b02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 23 Oct 2026 16:54:12 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 22 ++ README.md | 2 +- README_RUS.md | 2 +- docs/decoded-editing.md | 27 +- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 440 +++++++++++++++++++++++++++-- frontend/sw.js | 2 +- frontend/tests/ef_decode.test.js | 30 +- frontend/tests/ef_encode.test.js | 245 +++++++++++++++- frontend/tests/fs_dec_edit.test.js | 207 +++++++++++++- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 13 files changed, 944 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b47d3..1e08ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ every change carries a descriptive commit message. ## [Unreleased] +## [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. + +### 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. + ## [3.34.0] — 2026-10-23 ### Added diff --git a/README.md b/README.md index e5ab468..213fe3c 100644 --- a/README.md +++ b/README.md @@ -595,7 +595,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**, **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), and the cell-broadcast lists **CBMI / CBMID / CBMIR**. 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 diff --git a/README_RUS.md b/README_RUS.md index 293b66c..9b487c3 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -570,7 +570,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**, **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**. Для 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 требует ключа разблокировки карты diff --git a/docs/decoded-editing.md b/docs/decoded-editing.md index 78101f8..3468701 100644 --- a/docs/decoded-editing.md +++ b/docs/decoded-editing.md @@ -1,7 +1,8 @@ # Decoded editing — file-format survey and design -Status: design note for the **Edit decoded** feature. Waves 1 and 2 shipped in -v3.33.0 and v3.34.0; decoded editing for EF.DIR/EF.ARR/EF.SMS/EF.PBR is +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 @@ -108,7 +109,7 @@ editing candidates. | 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 | on request only | +| 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)** | @@ -169,7 +170,15 @@ beyond S9/S10. 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 3.35 language work). + 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 @@ -183,7 +192,13 @@ beyond S9/S10. 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** — LI/LP/PL (the SIM files need the 1-byte DCS language coding +- **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, @@ -191,8 +206,6 @@ beyond S9/S10. - **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. -- **On request only** — EF.SMSP (a parameter form; no TPDU/parameter builder - exists in the PWA today). - **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. diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 4f054dd..8adf50d 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -409,7 +409,7 @@ diff --git a/frontend/help.html b/frontend/help.html index a5092dc..14bb516 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -408,7 +408,7 @@ diff --git a/frontend/index.html b/frontend/index.html index ea9edb0..b295d73 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1944,7 +1944,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.34.0'; +const SIMPLE_VERSION = '3.35.0'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -16329,9 +16329,10 @@ async function pysimFsDecSave(node, rawName, statusEl, out, arrTouched) { try { if (model.mode === 'tr') { const orig = root ? (root.getAttribute('data-orig') || '') : ''; - const values = entry.list - ? efDecCollectRows(root, entry.list.itemFields) - : efDecCollectValues(root, entry.fields); + const values = entry.custom + ? entry.custom.collect(root) + : (entry.list ? efDecCollectRows(root, entry.list.itemFields) + : efDecCollectValues(root, entry.fields)); const body = pysimFsSelectBody(node || { name: rawName, fid: null }); body.data = entry.enc(values, orig); // identity writes ask once, and only when something actually changes @@ -16358,9 +16359,9 @@ async function pysimFsDecSave(node, rawName, statusEl, out, arrTouched) { const num = block.getAttribute('data-rec'); if (!num) continue; const body = pysimFsSelectBody(node || { name: rawName, fid: null }); - const collected = entry.items - ? efDecCollectItems(block) - : efDecCollectValues(block, entry.fields); + const collected = entry.custom + ? entry.custom.collect(block) + : (entry.items ? efDecCollectItems(block) : efDecCollectValues(block, entry.fields)); try { body.data = entry.enc(collected, block.getAttribute('data-orig') || ''); } catch (e) { @@ -22753,7 +22754,7 @@ const EF_FIELD_LABELS = { op_mode: 'Operation mode', test_mode: 'Test mode', additional_info: 'Additional info', mnc_length: 'MNC length', services: 'Services', plmns: 'PLMNs', applications: 'Applications', aid: 'AID', label: 'Label', eap: 'EAP', message_ids: 'Message IDs', ranges: 'Ranges', - esc: 'Emergency category', param_indicators: 'Parameter indicators', tp_da: 'TP-DA', + esc: 'Emergency category', indicators: 'Parameter indicators', tp_da: 'TP-DA', sca: 'SCA', tp_pid: 'TP-PID', tp_dcs: 'TP-DCS', tp_vp: 'TP-VP', alpha: 'Alpha', direction: 'Direction', status: 'Status', tpdu: 'TPDU', display_condition: 'Display condition', rfu: 'RFU', ccp: 'CCP', ext: 'Extension', tag: 'Tag', hex: 'Hex', lac_range: 'LAC range', @@ -23248,6 +23249,17 @@ function efDecAcl(b) { return apns.length ? { apns: apns } : { raw: efHex(b) }; } +// TS 23.040 §9.1.2.5: the TP-DA length is the DIGIT count, so the BCD octets +// are ceil(len/2) (the SCA length counts the TON/NPI + BCD octets instead). +function efSmspDaNumber(slot) { + const n = slot.length ? slot[0] : 0xFF; + if (n === 0xFF || n === 0) return ''; + const ton = (slot[1] >> 4) & 0x07; + const octets = Math.min(10, Math.ceil(n / 2)); + const digits = efSwapNibbles(efHex(slot.subarray(2, 2 + octets))).replace(/[fF]+$/, ''); + return digits ? (ton === 1 ? '+' + digits : digits) : ''; +} + function efDecSmsp(b) { if (efAllFf(b)) return { empty: true }; if (b.length < 28) return { raw: efHex(b) }; @@ -23255,14 +23267,15 @@ function efDecSmsp(b) { const out = {}; const alpha = efAnnexA(efRstripFf(b.subarray(0, y))); if (alpha) out.alpha = alpha; + // TS 31.102 4.2.27: bits 1..5 = DA, SCA, PID, DCS, VP (1 = absent), bits + // 6..8 reserved (1). const pi = b[y]; - const flags = []; - out.param_indicators = '0x' + pi.toString(16).toUpperCase().padStart(2, '0'); - if (!(pi & 0x01)) out.tp_da = efBcdAddressLen(b.subarray(y + 1, y + 13)); - if (!(pi & 0x10)) out.sca = efBcdAddressLen(b.subarray(y + 13, y + 25)); - if (!(pi & 0x02)) out.tp_pid = '0x' + b[y + 25].toString(16).toUpperCase().padStart(2, '0'); - if (!(pi & 0x04)) out.tp_dcs = '0x' + b[y + 26].toString(16).toUpperCase().padStart(2, '0'); - if (!(pi & 0x08)) out.tp_vp = '0x' + b[y + 27].toString(16).toUpperCase().padStart(2, '0'); + out.indicators = '0x' + pi.toString(16).toUpperCase().padStart(2, '0'); + if (!(pi & 0x01)) out.tp_da = efSmspDaNumber(b.subarray(y + 1, y + 13)); + if (!(pi & 0x02)) out.sca = efBcdAddressLen(b.subarray(y + 13, y + 25)); + if (!(pi & 0x04)) out.tp_pid = '0x' + b[y + 25].toString(16).toUpperCase().padStart(2, '0'); + if (!(pi & 0x08)) out.tp_dcs = '0x' + b[y + 26].toString(16).toUpperCase().padStart(2, '0'); + if (!(pi & 0x10)) out.tp_vp = '0x' + b[y + 27].toString(16).toUpperCase().padStart(2, '0'); return out; } @@ -24290,6 +24303,343 @@ function efEncCbmir(values, origHex) { return efHex(out); } +// ===== EF.SMSP (TS 31.102 4.2.27) ===== +// A linear-fixed record: alpha (Y = len-28 octets), the parameter indicator +// byte (bits 1..5 = DA, SCA, PID, DCS, VP; 1 = absent; bits 6..8 = 1), the +// 12-octet DA slot, the 12-octet SCA slot, then the PID, DCS and VP bytes. +const EF_SMSP_PARAMS = [ + { key: 'da', label: 'TP-DA', kind: 'address', bit: 0x01, off: 1 }, + { key: 'sca', label: 'TS-SCA', kind: 'address', bit: 0x02, off: 13 }, + { key: 'pid', label: 'TP-PID', kind: 'hex', bit: 0x04, off: 25 }, + { key: 'dcs', label: 'TP-DCS', kind: 'hex', bit: 0x08, off: 26 }, + { key: 'vp', label: 'TP-VP', kind: 'hex', bit: 0x10, off: 27 }, +]; + +// Hint text goes through t() when the runtime has it (the pure test harness +// extracts these without the dictionary and gets the English text). +function smspT(s) { + return typeof t === 'function' ? t(s) : s; +} + +// TS 23.038 §4 coding-group hint. +function smspDcsHint(hex) { + const s = String(hex === undefined || hex === null ? '' : hex).replace(/^0x/i, '').trim(); + if (!/^[0-9a-fA-F]{2}$/.test(s)) return ''; + const d = parseInt(s, 16); + const top = d >> 4; + const classes = ['class 0', 'class 1', 'class 2', 'class 3']; + if (top <= 7) { + const parts = [ + smspT(top >= 4 ? 'automatic deletion' : 'general data coding'), + smspT(['GSM 7-bit', '8-bit data', 'UCS2', 'reserved'][(d >> 2) & 3]), + smspT((d & 0x20) ? 'compressed' : 'uncompressed'), + ]; + if (d & 0x10) parts.push(smspT(classes[d & 3])); + return parts.join(', '); + } + if (top <= 11) return smspT('reserved coding group'); + if (top <= 14) { + return [ + smspT(top === 12 ? 'MWI discard' : 'MWI store'), + smspT(top === 14 ? 'UCS2' : 'GSM 7-bit'), + smspT((d & 0x08) ? 'active' : 'inactive'), + smspT(['voicemail', 'fax', 'email', 'other'][d & 3]), + ].join(', '); + } + return [ + smspT('data coding'), + smspT((d & 0x04) ? '8-bit data' : 'GSM 7-bit'), + smspT(classes[d & 3]), + ].join(', '); +} + +function smspDurationText(secs) { + const h = Math.floor(secs / 3600); + const m = Math.round((secs % 3600) / 60); + if (!h) return m + ' ' + smspT('min'); + return h + ' ' + smspT('h') + (m ? ' ' + m + ' ' + smspT('min') : ''); +} + +// Russian needs three plural forms (1 / 2-4 / 5+, and the 11-14 exception); +// English needs the singular/plural pair. `few` is only used by the RU form. +function smspPluralIndex(n) { + n = Math.abs(n) % 100; + if (n >= 11 && n <= 14) return 2; + const d = n % 10; + if (d === 1) return 0; + if (d >= 2 && d <= 4) return 1; + return 2; +} + +function smspCount(n, one, few, many) { + const ru = typeof currentLang !== 'undefined' && currentLang === 'ru'; + if (ru) { + const idx = smspPluralIndex(n); + return n + ' ' + smspT(idx === 0 ? one : (idx === 1 ? few : many)); + } + return n + ' ' + smspT(n === 1 ? one : many); +} + +// TS 23.040 §9.2.3.12.1 relative validity-period hint: minutes for 0-143, +// 30-minute steps for 144-167, DAYS for 168-196 and WEEKS for 197-255. +function smspVpHint(hex) { + const s = String(hex === undefined || hex === null ? '' : hex).replace(/^0x/i, '').trim(); + if (!/^[0-9a-fA-F]{2}$/.test(s)) return ''; + const v = parseInt(s, 16); + if (v <= 143) return smspDurationText((v + 1) * 300); + if (v <= 167) return smspDurationText(43200 + (v - 143) * 1800); + if (v <= 196) return smspCount(v - 166, 'day', 'days-few', 'days'); + return smspCount(v - 192, 'week', 'weeks-few', 'weeks'); +} + +// A DA/SCA slot decoded for the form: the length byte is the digit count for +// DA and the TON/NPI + BCD octet count for SCA (0 = the default SMSC). A +// TON 5 (alphanumeric) address is not BCD text - it is shown raw and kept +// verbatim by the encoder. +function efSmspAddressModel(slot, present, isSca) { + const orig = efHex(slot); + const n = slot.length ? slot[0] : 0xFF; + let ton = 1, number = ''; + if (n !== 0xFF && n !== 0) { + ton = (slot[1] >> 4) & 0x07; + const octets = Math.max(0, Math.min(10, isSca ? n - 1 : Math.ceil(n / 2))); + const digits = ton === 5 ? '' + : efSwapNibbles(efHex(slot.subarray(2, 2 + octets))).replace(/[fF]+$/, ''); + number = digits ? (ton === 1 ? '+' + digits : digits) : ''; + } + return { present: present, orig: orig, number: number, ton: ton, alnum: ton === 5 }; +} + +// The per-record form model: alpha + each parameter's presence, original slot +// bytes and editable value. +function efSmspModel(hex) { + const b = efBytes(hex || ''); + const y = Math.max(0, b.length - 28); + const pi = b.length >= 28 ? b[y] : 0xFF; + const alpha = efAnnexA(efRstripFf(b.subarray(0, Math.min(y, b.length)))); + const slot = (at, len) => b.subarray(at, Math.min(b.length, at + len)); + const byte = (at, bit) => { + const v = b.length > at ? b[at] : 0xFF; + return { present: !(pi & bit), orig: efHex([v]), + value: '0x' + v.toString(16).toUpperCase().padStart(2, '0') }; + }; + return { + alpha: alpha, + params: { + da: efSmspAddressModel(slot(y + 1, 12), !(pi & 0x01), false), + sca: efSmspAddressModel(slot(y + 13, 12), !(pi & 0x02), true), + pid: byte(y + 25, 0x04), + dcs: byte(y + 26, 0x08), + vp: byte(y + 27, 0x10), + }, + }; +} + +function efSmspTonOptions(ton) { + const opts = [{ v: 0, l: 'Unknown' }, { v: 1, l: 'International' }, { v: 2, l: 'National' }]; + if (!opts.some(o => o.v === ton)) opts.push({ v: ton, l: 'Current', current: true }); + return opts.map(o => '').join(''); +} + +function efSmspParamRow(key, label, slot) { + const absent = !slot.present; + const dis = absent ? ' disabled' : ''; + const raw = slot.alnum && slot.present; + let body; + if ((key === 'da' || key === 'sca') && raw) { + // TS 23.040 9.1.2.5 TON 5: GSM 7-bit alphanumeric contents, not BCD - + // keep the slot verbatim, only its presence is editable here. + body = '' + esc(t(label)) + '' + + '' + esc(slot.orig || '') + '' + + '' + + esc(t('alphanumeric address — not editable here; it is preserved as is')) + ''; + } else if (key === 'da' || key === 'sca') { + body = '' + esc(t(label)) + '' + + '' + + ''; + if (key === 'sca') { + body += '' + esc(t('empty = default SMSC')) + ''; + } + } else { + const hint = absent ? t('absent — the ME default applies') + : (key === 'dcs' ? smspDcsHint(slot.value) : (key === 'vp' ? smspVpHint(slot.value) : '')); + body = '' + esc(t(label)) + '' + + ''; + body += key === 'pid' + ? '' + esc(t('TS 23.040 TP-PID (raw); 0x00 = short message type 0')) + '' + : '' + esc(hint) + ''; + } + return '
' + + '' + + body + '
'; +} + +function efSmspHtml(values) { + const v = values || {}; + const p = v.params || {}; + let html = '
' + + '
' + + '' + esc(t('Alpha')) + '' + + '' + + '
'; + for (const spec of EF_SMSP_PARAMS) html += efSmspParamRow(spec.key, spec.label, p[spec.key] || {}); + return html + '
'; +} + +function efSmspCollect(root) { + const pick = key => root.querySelector('[data-smsp="' + key + '"]'); + const out = { alpha: '', params: {} }; + const alpha = pick('alpha'); + if (alpha) out.alpha = alpha.value; + for (const spec of EF_SMSP_PARAMS) { + const row = root.querySelector('[data-smsp-orig="' + spec.key + '"]'); + const absent = row ? row.querySelector('[data-smsp-absent="' + spec.key + '"]') : null; + const present = !(absent && absent.checked); + if (spec.kind === 'address') { + const num = pick(spec.key + '.number'); + const ton = pick(spec.key + '.ton'); + out.params[spec.key] = { present: present, + number: num ? num.value : '', ton: ton ? parseInt(ton.value, 10) : 1 }; + } else { + const val = pick(spec.key + '.value'); + out.params[spec.key] = { present: present, value: val ? val.value : '' }; + } + } + return out; +} + +function pysimFsSmspToggle(cb) { + const row = cb && cb.closest ? cb.closest('[data-smsp-orig]') : null; + if (!row) return; + const absent = !!cb.checked; + row.querySelectorAll('[data-smsp]').forEach(el => { el.disabled = absent; }); + const key = row.getAttribute('data-smsp-orig'); + const hint = row.querySelector('[data-smsp-hint]'); + if (hint) { + const input = row.querySelector('[data-smsp="' + key + '.value"]'); + hint.textContent = absent ? t('absent — the ME default applies') + : (key === 'dcs' ? smspDcsHint(input && input.value) : smspVpHint(input && input.value)); + } + pysimFsDecRowCheck(cb); +} + +function pysimFsSmspHexInput(input) { + const row = input && input.closest ? input.closest('[data-smsp-orig]') : null; + if (row) { + const key = row.getAttribute('data-smsp-orig'); + const cb = row.querySelector('[data-smsp-absent]'); + const hint = row.querySelector('[data-smsp-hint]'); + if (hint) { + hint.textContent = (cb && cb.checked) ? t('absent — the ME default applies') + : (key === 'dcs' ? smspDcsHint(input.value) : smspVpHint(input.value)); + } + } + pysimFsDecRowCheck(input); +} + +function efSmspHexByte(value) { + const s = String(value === undefined || value === null ? '' : value).replace(/^0x/i, '').trim(); + if (!/^[0-9a-fA-F]{2}$/.test(s)) throw efEncError('The parameter value must be 2 hex digits'); + return parseInt(s, 16); +} + +// A DA/SCA slot: [length][TON/NPI][10 BCD bytes], FF beyond the digits. DA's +// length is the digit count, SCA's the TON/NPI+BCD octet count (0 = default). +// A leading '+' forces TON 1 (international) whatever the select says. +function efSmspAddressBytes(key, val) { + const num = String(val && val.number !== undefined && val.number !== null ? val.number : '').replace(/\s+/g, ''); + const plus = num.charAt(0) === '+'; + const digits = num.replace(/^\+/, ''); + if (digits !== '' && !/^\d+$/.test(digits)) throw efEncError('The number must be digits with an optional leading +'); + if (digits.length > 20) throw efEncError('The number is too long for SMSP (max 20 digits)'); + if (key === 'da' && digits.length === 0) throw efEncError('The TP-DA number is required when present'); + const ton = plus ? 1 : Number(val && val.ton); + const out = new Uint8Array(12); + out.fill(0xFF); + if (key === 'da') out[0] = digits.length; + else out[0] = digits.length ? 1 + Math.ceil(digits.length / 2) : 0; + if (!digits.length) return out; + out[1] = 0x80 | ((Number.isInteger(ton) ? ton : 1) << 4) | 0x01; // extension + TON + NPI(ISDN) + for (let i = 0; i < 10; i++) { + const lo = i * 2 < digits.length ? digits[i * 2] : 'F'; + const hi = i * 2 + 1 < digits.length ? digits[i * 2 + 1] : 'F'; + out[2 + i] = parseInt(hi + lo, 16); + } + return out; +} + +function efSmspAddrUnchanged(slot, key, val) { + const model = efSmspAddressModel(slot, true, key === 'sca'); + const num = String(val && val.number !== undefined && val.number !== null ? val.number : '').replace(/\s+/g, ''); + const ton = val && val.ton !== undefined && val.ton !== null ? Number(val.ton) : model.ton; + return num === model.number && ton === model.ton; +} + +function efSmspByteUnchanged(origByte, value) { + const s = String(value === undefined || value === null ? '' : value).replace(/^0x/i, '').trim(); + if (origByte === undefined || !/^[0-9a-fA-F]{2}$/.test(s)) return false; + return parseInt(s, 16) === origByte; +} + +// EF.SMSP: the alpha, the five parameters' slots and the indicator byte. An +// unchanged slot is copied verbatim (the fixture's absent DCS 00 and VP FF +// included); the indicator byte is kept when all five presence flags match. +function efEncSmsp(values, origHex) { + const orig = efBytes(origHex || ''); + if (orig.length < 28) throw efEncError('The record is too short for the SMSP layout'); + const y = orig.length - 28; + const origPi = orig[y]; + const out = new Uint8Array(orig.length); + out.fill(0xFF); + const alpha = String(values && values.alpha !== undefined && values.alpha !== null ? values.alpha : ''); + const origAlpha = efAnnexA(efRstripFf(orig.subarray(0, y))); + if (alpha === origAlpha) out.set(orig.subarray(0, y), 0); + else out.set(efEncTextBytes(alpha, y, 'The alpha identifier is too long for this record'), 0); + const params = (values && values.params) || {}; + const presence = {}; + for (const spec of EF_SMSP_PARAMS) { + const v = params[spec.key] || {}; + const origPresent = !(origPi & spec.bit); + const present = v.present === undefined ? origPresent : !!v.present; + presence[spec.key] = present; + const at = y + spec.off; + const samePresence = present === origPresent; + if (spec.kind === 'address') { + const slot = orig.subarray(at, at + 12); + const origTon = slot.length > 1 ? (slot[1] >> 4) & 0x07 : 0; + // a present alphanumeric (TON 5) address is not editable here: it + // stays verbatim, and only its presence can change + if (origPresent && origTon === 5) { + if (present) out.set(slot, at); + else for (let i = 0; i < 12; i++) out[at + i] = 0xFF; + } else if (samePresence && efSmspAddrUnchanged(slot, spec.key, v)) out.set(slot, at); + else if (!present) for (let i = 0; i < 12; i++) out[at + i] = 0xFF; + else out.set(efSmspAddressBytes(spec.key, v), at); + } else { + const origByte = orig.length > at ? orig[at] : undefined; + if (samePresence && efSmspByteUnchanged(origByte, v.value)) out[at] = origByte; + else if (!present) out[at] = 0xFF; + else out[at] = efSmspHexByte(v.value); + } + } + const allSame = EF_SMSP_PARAMS.every(spec => presence[spec.key] === !(origPi & spec.bit)); + if (allSame) out[y] = origPi; + else { + let pi = 0xE0; + for (const spec of EF_SMSP_PARAMS) if (!presence[spec.key]) pi |= spec.bit; + out[y] = pi; + } + return efHex(out); +} + +const EF_SMSP_CUSTOM = { model: efSmspModel, html: efSmspHtml, collect: efSmspCollect }; + // The decoded editor's field schemas: one per file family, the keys matching // the decoder output. `kind` is text, digits, bool, select or techs (the // access technology checkboxes, the value being the decoder's ', '-joined @@ -24423,6 +24773,7 @@ const EF_ENCODERS = [ { name: 'EF.CBMI', fid: '6f45', enc: efEncCbmi, model: efCbmiModel, list: EF_CBMI_LIST }, { name: 'EF.CBMID', fid: '6f48', enc: efEncCbmi, model: efCbmiModel, list: EF_CBMI_LIST }, { name: 'EF.CBMIR', fid: '6f50', enc: efEncCbmir, model: efCbmirModel, list: EF_CBMIR_LIST }, + { name: 'EF.SMSP', fid: '6f42', enc: efEncSmsp, custom: EF_SMSP_CUSTOM }, { name: 'EF.ADN', fid: '6f3a', enc: efEncAdnExt(14, 'EXT1'), ext: 'EXT1', fields: EF_ADN_FIELDS }, { name: 'EF.FDN', fid: '6f3b', enc: efEncAdnExt(14, 'EXT2'), ext: 'EXT2', fields: EF_ADN_FIELDS }, { name: 'EF.SDN', fid: '6f49', enc: efEncAdnExt(14, 'EXT3'), ext: 'EXT3', fields: EF_ADN_FIELDS }, @@ -24652,16 +25003,19 @@ function efDecEditModel(name, fid, content) { const entry = efFindEncoder(name, fid); if (!entry) return null; const dec = efFindDecoder(name, fid); + const modelValues = hex => entry.custom + ? entry.custom.model(hex) + : efDecModelFor(entry, dec, hex); if (content && content.kind === 'record') { return { entry: entry, mode: 'records', records: (content.records || []).map(r => entry.items ? { num: r.num, orig: r.data || '', items: efDecItemsFromHex(entry, r.data || '') } - : { num: r.num, orig: r.data || '', values: efDecModelFor(entry, dec, r.data || '') }), + : { num: r.num, orig: r.data || '', values: modelValues(r.data || '') }), }; } - return { entry: entry, mode: 'tr', values: efDecModelFor(entry, dec, (content && content.data) || '') }; + return { entry: entry, mode: 'tr', values: modelValues((content && content.data) || '') }; } // The value of a techs field (the decoder's ', '-joined string) as a list. @@ -24793,7 +25147,8 @@ function efDecRecordsHtml(entry, records) { '' + - '
' + (entry.items ? efDecItemsHtml(entry, r) : efDecGridHtml(entry.fields, r.values)) + '
'; + '
' + (entry.items ? efDecItemsHtml(entry, r) + : (entry.custom ? entry.custom.html(r.values) : efDecGridHtml(entry.fields, r.values))) + '
'; } return html; } @@ -24816,7 +25171,7 @@ function efDecEditHtml(model, origHex) { return '
' + title + html + '
'; } return '
' + title + - efDecGridHtml(entry.fields, model.values) + '
'; + (entry.custom ? entry.custom.html(model.values) : efDecGridHtml(entry.fields, model.values)) + ''; } // Collect the form values of one scope (the whole TR form or one record @@ -25671,6 +26026,53 @@ const LANG_RU = { 'Write cancelled': 'Запись отменена', 'Writing the ICCID changes the card identity and may break network registration. Continue?': 'Запись ICCID меняет идентичность карты и может нарушить регистрацию в сети. Продолжить?', 'Writing the IMSI changes the card identity and may break network registration. Continue?': 'Запись IMSI меняет идентичность карты и может нарушить регистрацию в сети. Продолжить?', + 'Unknown': 'Неизвестно', + 'International': 'Международный', + 'National': 'Национальный', + 'Absent (the ME default applies)': 'Отсутствует (действует значение ME по умолчанию)', + 'absent — the ME default applies': 'отсутствует — действует значение ME по умолчанию', + 'alphanumeric address — not editable here; it is preserved as is': 'алфавитно-цифровой адрес — здесь не редактируется; сохраняется как есть', + 'day': 'день', + 'days-few': 'дня', + 'weeks-few': 'недели', + 'TP-DA': 'TP-DA', + 'TS-SCA': 'TS-SCA', + 'TP-PID': 'TP-PID', + 'TP-DCS': 'TP-DCS', + 'TP-VP': 'TP-VP', + 'empty = default SMSC': 'пусто = SMSC по умолчанию', + 'TS 23.040 TP-PID (raw); 0x00 = short message type 0': 'TS 23.040 TP-PID (raw); 0x00 — короткое сообщение типа 0', + 'general data coding': 'общая индикация кодирования', + 'automatic deletion': 'автоматическое удаление', + 'GSM 7-bit': 'GSM 7-бит', + '8-bit data': '8-битные данные', + 'UCS2': 'UCS2', + 'reserved': 'резерв', + 'compressed': 'сжатый', + 'uncompressed': 'несжатый', + 'class 0': 'класс 0', + 'class 1': 'класс 1', + 'class 2': 'класс 2', + 'class 3': 'класс 3', + 'reserved coding group': 'резервная группа кодирования', + 'MWI discard': 'MWI: удалить', + 'MWI store': 'MWI: сохранить', + 'inactive': 'неактивно', + 'active': 'активно', + 'voicemail': 'голосовая почта', + 'fax': 'факс', + 'email': 'эл. почта', + 'other': 'другое', + 'data coding': 'кодирование данных', + 'h': 'ч', + 'week': 'неделя', + 'weeks': 'недель', + 'days': 'дней', + 'The record is too short for the SMSP layout': 'Запись слишком короткая для формата SMSP', + 'The alpha identifier is too long for this record': 'Альфа-идентификатор слишком длинный для этой записи', + 'The number is too long for SMSP (max 20 digits)': 'Номер слишком длинный для SMSP (максимум 20 цифр)', + 'The TP-DA number is required when present': 'Номер TP-DA обязателен, если параметр присутствует', + 'The parameter value must be 2 hex digits': 'Значение параметра должно состоять из 2 hex-цифр', 'Refresh': 'Обновить', 'Last refresh': 'Последнее обновление', 'Save': 'Сохранить', diff --git a/frontend/sw.js b/frontend/sw.js index cf395a2..5080986 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v467'; +const CACHE = 'simple-v468'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js index 45a95f6..fd67d64 100644 --- a/frontend/tests/ef_decode.test.js +++ b/frontend/tests/ef_decode.test.js @@ -27,7 +27,7 @@ const FNS = [ 'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecSst', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact', 'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc', 'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl', - 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn', + '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', @@ -278,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'); @@ -285,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' }]); diff --git a/frontend/tests/ef_encode.test.js b/frontend/tests/ef_encode.test.js index f521e1b..abd6978 100644 --- a/frontend/tests/ef_encode.test.js +++ b/frontend/tests/ef_encode.test.js @@ -44,20 +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', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn', 'efFindEncoder', 'efHasEncoder', 'gsm7Decode', 'gsm7TextToSeptets', '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', + '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_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_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); @@ -456,6 +456,7 @@ test('unchanged content re-encodes byte-identically (all fixtures)', () => { 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 = {}; @@ -510,6 +511,8 @@ test('unchanged content re-encodes byte-identically (all fixtures)', () => { { 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) { @@ -768,3 +771,237 @@ test('CBMI / CBMIR: message ids and ranges round-trip with FF padding', () => { { 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'); +}); diff --git a/frontend/tests/fs_dec_edit.test.js b/frontend/tests/fs_dec_edit.test.js index d1b759f..df79bd4 100644 --- a/frontend/tests/fs_dec_edit.test.js +++ b/frontend/tests/fs_dec_edit.test.js @@ -40,7 +40,7 @@ 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', @@ -56,14 +56,14 @@ const FNS = [ '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', + '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_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_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); @@ -905,3 +905,204 @@ test('F5: an untouched all-FF AD saves FF through the collected form value', asy 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, /