Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 105efe62da | |||
| 4835bc8585 | |||
| b173e73437 | |||
| a1892094f4 | |||
| 12ff552df8 | |||
| e10228c37c | |||
| fc27feba8e | |||
| 5ebcd0773d | |||
| 8e0a97f025 | |||
| 6d762b9d9c | |||
| 1d5a049ea1 | |||
| 504bf1d66e | |||
| 62c5b4e1a9 | |||
| 393b1098ce | |||
| 295f5553fc | |||
| 2ecf7bb45c | |||
| a269f8bd2e | |||
| 0231df905c | |||
| 80cbd8e935 | |||
| 8ebcfd8af4 | |||
| c6a6bf0bc6 | |||
| cd0db3f9d3 | |||
| 513f16d9d2 | |||
| 643c6609f3 | |||
| 2181ef6bbf | |||
| 851ff65af6 | |||
| 68a22e854b | |||
| 39d384636e | |||
| 14d15b1fe8 | |||
| 110dc1f1ad | |||
| fb65644328 | |||
| 194b88e7d1 | |||
| d5e67360fe | |||
| 87bf4acbbc | |||
| 180dd50c06 |
+248
@@ -9,6 +9,254 @@ every change carries a descriptive commit message.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.39.0] — 2026-10-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **EF.SST tri-state editor.** The SIM service table (TS 51.011 10.3.7, two
|
||||||
|
bits per service — allocated + activated) now edits per service as
|
||||||
|
*deleted / allocated-off / on*, preserving unmodelled pairs and the file
|
||||||
|
length; it stops being read-only.
|
||||||
|
- **Cross-file Services editor** (a new Card-reader pill): SST / UST / EST /
|
||||||
|
IST shown side by side from a curated 161-row feature cross-reference, a
|
||||||
|
column rendered only for a file present on the card, per-file Save buttons,
|
||||||
|
and a **Check filesystem** action (with a throbber) that verifies the
|
||||||
|
declared services against the card's actual files.
|
||||||
|
- **Path-exact filesystem check.** Each feature's files are resolved by
|
||||||
|
(symbolic name, root) → canonical path through `uicc_files.json`, probed at
|
||||||
|
that exact path, and reported in both directions (declared-but-missing,
|
||||||
|
present-but-undeclared) plus an "unchecked" bucket for files not yet in the
|
||||||
|
registry.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **EF resolution is DF-context aware.** `efDfFromPath` derives the parent DF
|
||||||
|
and `efFindDecoder`/`efFindEncoder` resolve DF → name → FID, so same-name/
|
||||||
|
same-FID files in different DFs are distinct; EF.ECC gained a USIM
|
||||||
|
linear-fixed decoder beside the SIM transparent one, and EF.AD is registered
|
||||||
|
per DF.
|
||||||
|
- **The file manager selects files by canonical path** (not node name), so two
|
||||||
|
same-named files under different DFs (EF.ECC/EF.AD, the DF.PHONEBOOK copies)
|
||||||
|
are distinguishable, and snapshot/profile paths root ADFs by AID are
|
||||||
|
normalized (`A0000000871002`→USIM, `…1004`→ISIM, `…100B`→USIM-non-IMSI).
|
||||||
|
- **The USIM service table is complete**: UST 147–150 (5MBS UE pre-configuration,
|
||||||
|
Operator Controlled Signal Threshold per Access Technology, A2X, IMS Data
|
||||||
|
Channel Indication) were missing.
|
||||||
|
- **`uicc_files.json`** gained the 13 optional files pySim does not model
|
||||||
|
(DF.MExE, DF.SoLSA, DF.BCAST, DF.ACDC, DF.TV, DF.A2X and their children),
|
||||||
|
emitted with distinct (name, path) so same-FID files in other DFs stay
|
||||||
|
separate.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **The start scripts name the address they were asked to bind.** The opening
|
||||||
|
`Starting pysim-simple-server on http://127.0.0.1:8080` line was hardcoded
|
||||||
|
while `--http-host`/`--http-port` were passed through to the server:
|
||||||
|
`./start.sh --http-host 10.0.28.161` bound correctly (the server's own
|
||||||
|
`listening on` banner named the address), but the first line still said
|
||||||
|
127.0.0.1. `start.sh` and `start.bat` now parse both flags (space and `=`
|
||||||
|
forms, the last value wins like argparse) and echo the effective address;
|
||||||
|
the defaults are unchanged.
|
||||||
|
- eCall Data's `EF.FDN` reference was a typo (`6F3A` is EF.ADN); corrected to
|
||||||
|
EF.SDN 6F49 / EF.FDN 6F3B.
|
||||||
|
|
||||||
|
## [3.38.1] — 2026-10-25
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Equip/initialization reads no longer select the file twice.** pySim's
|
||||||
|
`scc.read_binary`/`scc.read_record` call `select_path(ef)` first, but the
|
||||||
|
ICCID, network-state, EF.ARR and EF.PBR readers had already made a
|
||||||
|
parent-scoped selection and held its FCP. A new `_read_selected_ef()` reads
|
||||||
|
the selected file directly — READ BINARY chunked at `max_cmd_len`, READ
|
||||||
|
RECORD at the FCP record length — removing one SELECT per file read and one
|
||||||
|
per record (~106 files + 94 records on the bench card: ~0.7 s of the ~3.4 s
|
||||||
|
traced startup init). The pySim helpers stay as the fallback when the FCP
|
||||||
|
lacks the size/record length.
|
||||||
|
- The startup TERMINAL PROFILE drain uses the chain's final SW, so an idle
|
||||||
|
chain no longer costs one extra STATUS poll (~128 ms).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A PC/SC access denial is reported instead of a silent serial fallback.**
|
||||||
|
When no reader was named and `readers()` failed (e.g. `SCARD_E_NO_ACCESS` —
|
||||||
|
the user may not use `pcscd`), the error was swallowed and pySim fell back to
|
||||||
|
the serial transport, failing on `/dev/ttyUSB0` while the server still printed
|
||||||
|
"listening". The startup now probes PC/SC and reports **`PC/SC access
|
||||||
|
denied`** (naming the pcsc-lite PolicyKit actions and the
|
||||||
|
`99-pcscd-allow.rules` rule — a world-writable `pcscd.comm` does not bypass
|
||||||
|
polkit's active-session policy — plus the `pcsc_scan` cross-check),
|
||||||
|
**`pcscd is not running`** or **`no PC/SC reader found`**, then exits 2;
|
||||||
|
`start.sh` runs the same access pre-flight before launching. An
|
||||||
|
explicit `-d <device>` keeps the serial path, and a reader present but no card
|
||||||
|
is still a normal cardless start.
|
||||||
|
- **`setup.sh`/`setup.bat` recover from an incomplete `.venv`.** A failed venv
|
||||||
|
creation (e.g. the Debian/Ubuntu `python3-venv` package missing) left the
|
||||||
|
directory behind; the next run saw `.venv` and skipped creation, then tried to
|
||||||
|
activate a venv without `activate`/`pip`. The scripts now validate
|
||||||
|
`bin/activate`/`bin/python`/`bin/pip` (`Scripts\…` on Windows), remove an
|
||||||
|
incomplete directory and create it again; a creation failure removes the
|
||||||
|
partial directory and points at the venv package. The READMEs note it.
|
||||||
|
|
||||||
|
## [3.38.0] — 2026-10-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Known-file picker in the test-script file actions.** The `UPDATE file` /
|
||||||
|
`READ file` step editor's path field gains the selector the APDU builder's
|
||||||
|
SELECT rows use: the file-manager tree, the custom files and the standard
|
||||||
|
file list (`uicc_files.json`), grouped by root with a filter, filling the
|
||||||
|
path. Only EFs are offered (the action reads/writes a file; a DF/ADF is not
|
||||||
|
a target); the text field stays authoritative, so a name path or a file the
|
||||||
|
lists do not carry can still be typed, and the picker preselects the current
|
||||||
|
path when it is known.
|
||||||
|
- The **live load-test stats** now include the timing: while a script or suite
|
||||||
|
run repeats, the **run banner** (visible on every Test-script view), the
|
||||||
|
script editor's result head and the suite summary show the **elapsed time**,
|
||||||
|
the **average per completed iteration** and the **estimated time left**,
|
||||||
|
recomputed on every status poll (`testLoadTiming`/`testRunBannerText`; the
|
||||||
|
final report keeps the server's `elapsed_ms`/`avg_ms`). No completed
|
||||||
|
iteration yet means no bogus average/estimate - only the elapsed time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The load-test **iteration cap is raised to 1,000,000,000** (from 1,000,000) in
|
||||||
|
both layers: the PWA's `TEST_ITERATIONS_MAX` and the two field `max`
|
||||||
|
attributes, and the server's `_TEST_LOAD_MAX`. The value is well inside the
|
||||||
|
JavaScript safe-integer range (2^53-1), so the counters and the progress
|
||||||
|
display stay exact; the practical ceiling is the run duration (at 50 ms per
|
||||||
|
iteration the cap is ~1.6 years).
|
||||||
|
|
||||||
|
## [3.37.0] — 2026-10-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Load testing** for test scripts and suites. `POST /api/test/run` takes an optional
|
||||||
|
`iterations` count (the PWA's **Iterations** field next to *Start* / *Run suite*): the
|
||||||
|
run repeats N times, successful iterations are discarded (only the iteration counter and
|
||||||
|
the timing are kept; the per-step log is not rendered or retained), and the first
|
||||||
|
iteration that ends in an **error** stops the loop and keeps that iteration's full
|
||||||
|
report. A manual stop ends it after the current iteration, and a card session change
|
||||||
|
between iterations is an error (a swapped card is never run against another card's
|
||||||
|
script). The SCP80 counter state is shared across iterations, so packets continue from
|
||||||
|
what the previous run consumed. The run status gains a `load` block (`iteration`,
|
||||||
|
`done`, `failed_iteration`, `elapsed_ms`, `avg_ms`); the count is a run parameter, not
|
||||||
|
part of the stored script or suite.
|
||||||
|
|
||||||
|
- The **stuck-card watchdog** for test runs. A card that stops answering (no status
|
||||||
|
word, no driver error) used to leave the runner inside a blocking `SCardTransmit`
|
||||||
|
holding the card lock — *Stop* could not take effect, the presence monitor waited for
|
||||||
|
the lock and a load test hung in that iteration without explanation. After 30 s
|
||||||
|
without runner progress (a bounded STATUS/drain loop beats once per attempt) the
|
||||||
|
watchdog marks the run `stuck`, records `the card stopped responding (step N, no
|
||||||
|
answer for N s)`, sets the stop flag and **tries** to cancel the blocked transmit by
|
||||||
|
disconnecting the PC/SC link on a background thread. Where PC/SC can cancel it, the
|
||||||
|
step fails through the ordinary `on_fail` path and the run finalises as an **error**
|
||||||
|
(never `stopped`); where it cannot (pcsc-lite serializes the disconnect behind the
|
||||||
|
pending transmit), the status already names the step until the driver's own card
|
||||||
|
timeout returns. The transport is marked stale once the disconnect returns, so the
|
||||||
|
next *Equip* rebuilds it. The status gains `stuck`; the help EN/RU,
|
||||||
|
`docs/test-scripts.md` and `docs/api.md` describe the behaviour and the
|
||||||
|
background-disconnect caveat.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The **PLI location answer** now uses the 7-byte PLI form (TS 31.111 8.19.1):
|
||||||
|
PLMN 001/01, LAC `0001`, Cell ID `0001`. The former default was the 5-byte
|
||||||
|
*Network Rejection event* form (no Cell ID) with LAC `0000`; a card applet
|
||||||
|
that re-reads its location after a `*LOCI` status change (the Alfa MultiIMSI
|
||||||
|
applet) treated it as empty and re-requested PLI on every STATUS tick — the
|
||||||
|
PLI storm that slowed RAM Explore. The TR data is now
|
||||||
|
`13 07 00F110 0001 0001`.
|
||||||
|
- The **proactive chain is bounded on every call site**: `_handle_proactive_chain`'s
|
||||||
|
`max_rounds` defaults to `_POLL_CHAIN_MAX` (the background poll always passed it;
|
||||||
|
the manual STATUS poll and the ENVELOPE/menu/TP chains did not). A card that
|
||||||
|
re-queues a command (the PLI storm) could keep the manual poll looping forever
|
||||||
|
while it held the card lock — `/api/status-poll` never returned and every card
|
||||||
|
request waited. The manual poll also passes the bound explicitly.
|
||||||
|
- The load-test loop closes properly on a raised runner error (it used to leave the run
|
||||||
|
`running` forever - the PWA polled a dead run and *Clear report* stayed refused), on a
|
||||||
|
card session change inside a suite iteration, and on a member that was stopped; a
|
||||||
|
warning in any iteration is reflected in the final status, `avg_ms` covers the attempted
|
||||||
|
iterations (the failing one included), and each suite iteration's `counters_before` is
|
||||||
|
read fresh from the preset store.
|
||||||
|
|
||||||
|
## [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
|
## [3.33.0] — 2026-10-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
|
> 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:
|
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);
|
- **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`)
|
- 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)
|
- **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 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)`
|
- **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
|
- **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
|
||||||
|
|
||||||
@@ -746,6 +748,7 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **Python 3.9+** with `pip`, and **Git** — the app runs on 3.9+, but the **SCP81 TLS-PSK listener needs Python 3.13+** (`ssl`'s PSK callbacks).
|
- **Python 3.9+** with `pip`, and **Git** — the app runs on 3.9+, but the **SCP81 TLS-PSK listener needs Python 3.13+** (`ssl`'s PSK callbacks).
|
||||||
|
- **Python venv support** — `setup.sh` creates `.venv`; if that fails with `ensurepip is not available` (Debian/Ubuntu ship the venv module separately), install it (`sudo apt install python3-venv`) and run `./setup.sh` again. An incomplete `.venv` left by the failed run is removed automatically.
|
||||||
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux). `pyscard` has no Linux wheels, so it builds from source: install the PC/SC header package too (`libpcsclite-dev` on Debian/Ubuntu, `pcsc-lite-devel` on Fedora; on Arch `pcsclite` includes them).
|
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux). `pyscard` has no Linux wheels, so it builds from source: install the PC/SC header package too (`libpcsclite-dev` on Debian/Ubuntu, `pcsc-lite-devel` on Fedora; on Arch `pcsclite` includes them).
|
||||||
- **Windows** — use **Python 3.13 (strongly recommended)**: `pyscard` has precompiled wheels up to 3.13 and the SCP81 TLS-PSK listener needs 3.13+. On 3.14 `pyscard` and some pysim dependency wheels are not available and build from source (needs MSVC C++ Build Tools). 3.9–3.12 run the app but not the SCP81 PSK listener. The SMPP bridge (`smpp.twisted3`) is intentionally skipped on Windows.
|
- **Windows** — use **Python 3.13 (strongly recommended)**: `pyscard` has precompiled wheels up to 3.13 and the SCP81 TLS-PSK listener needs 3.13+. On 3.14 `pyscard` and some pysim dependency wheels are not available and build from source (needs MSVC C++ Build Tools). 3.9–3.12 run the app but not the SCP81 PSK listener. The SMPP bridge (`smpp.twisted3`) is intentionally skipped on Windows.
|
||||||
|
|
||||||
@@ -753,11 +756,11 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
|
|||||||
|
|
||||||
| Script | What it does |
|
| Script | What it does |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning or after unzipping the release package. |
|
| `setup.sh` / `setup.bat` | Creates `.venv/` (or repairs an incomplete one), installs pysim and the server. Run once after cloning or after unzipping the release package. |
|
||||||
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
|
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
|
||||||
| `update.sh` / `update.bat` | Updates a packaged install in place: fetches the latest release package, replaces the app files and re-installs the app into the existing `.venv` (pysim is kept). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
| `update.sh` / `update.bat` | Updates a packaged install in place: fetches the latest release package, replaces the app files and re-installs the app into the existing `.venv` (pysim is kept). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||||
|
|
||||||
`start.sh` asks for the first PC/SC reader when `pcscd` is running; otherwise the server still auto-detects a PC/SC reader itself, and with none present pysim falls back to its serial default `/dev/ttyUSB0` (pass `-d <device>` for a serial/FTDI reader). `start.bat` always passes `-p 0` (PC/SC is built into Windows). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button.
|
`start.sh` checks PC/SC access before starting: if the user may not use `pcscd` (or the service is not running) it prints the cause with the group/polkit guidance and exits — the server **no longer falls back to a serial reader silently**. With access, the server auto-detects the first PC/SC reader; `start.bat` always passes `-p 0` (PC/SC is built into Windows). An explicit serial/FTDI reader is still possible with `-d <device>` (the pre-flight check is skipped). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no PC/SC reader is found the server reports it and exits — connect the reader and restart (a reader present but no card is still a normal cardless start).
|
||||||
|
|
||||||
### Manual installation (Linux/macOS)
|
### Manual installation (Linux/macOS)
|
||||||
|
|
||||||
@@ -797,8 +800,21 @@ its MSVC build chain) is not pulled in.
|
|||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
- **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
- **"Failed to establish context: Access denied"** — the user may not use `pcscd`. On a headless/SSH box the socket is often world-writable (`srw-rw-rw- root root`) yet access is still denied: pcscd checks **PolicyKit**, whose default policy allows only *active local* sessions (`allow_active=yes`, `allow_inactive=no`, `allow_any=no`). Allow the user with `/etc/polkit-1/rules.d/99-pcscd-allow.rules` (replace `YOUR_USER`; any `*.rules` file name works):
|
||||||
- **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader.
|
|
||||||
|
```js
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||||
|
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||||
|
subject.user == "YOUR_USER") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
then `sudo systemctl restart polkit` (or log out/in). Start the service if needed (`sudo systemctl enable --now pcscd`); `start.sh` and the server report this and exit instead of falling back to a serial reader, and `pcsc_scan` shows the same error until the rule is active. If your `pcscd` was built without PolicyKit and the socket is group-owned, adding the user to that group works instead.
|
||||||
|
- **"device file /dev/ttyUSB0 does not exist"** — only with an explicit `-d <device>`: the device is not connected (or the path is wrong). Without `-d` the server no longer uses the serial transport.
|
||||||
|
- **"no PC/SC reader found"** — connect a reader (`pcsc_scan` lists them) and restart; the server exits instead of starting without a reader.
|
||||||
- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed.
|
- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed.
|
||||||
- **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
- **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
||||||
|
|
||||||
|
|||||||
+21
-5
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
> Прежнее название — 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). Закрывает весь цикл лабораторной работы:
|
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);
|
- **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`)
|
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
||||||
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. таблица декодируется в браузере из тех же сырых байт (без серверного декодирования); **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. таблица декодируется в браузере из тех же сырых байт (без серверного декодирования); **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
||||||
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. **Edit raw** сначала переключается на hex и при необходимости читает файл
|
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. **Edit raw** сначала переключается на hex и при необходимости читает файл
|
||||||
- **Edit decoded** — редактирование в собственной декодированной форме файла (клиентские кодировщики; запись обычным путём): **EF.SPN**, **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, файлы номеров **ADN / FDN / SDN / MSISDN / LND / BDN** и **EF.PNN**. Для transparent-файла — одна форма (строки PLMN и элементы PNN с добавлением/удалением); для record-файла — форма на запись с флажком, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. **Save** проверяет все выбранные записи до записи; указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется. Обзор форматов и план охвата — в `docs/decoded-editing.md`
|
- **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 — `(пусто)`
|
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||||
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
||||||
|
|
||||||
@@ -730,6 +732,7 @@ PWA проверяет версию сервера при подключении
|
|||||||
### Требования
|
### Требования
|
||||||
|
|
||||||
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
|
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
|
||||||
|
- **Поддержка venv в Python** — `setup.sh` создаёт `.venv`; если создание завершается с `ensurepip is not available` (в Debian/Ubuntu модуль venv поставляется отдельно), установите его (`sudo apt install python3-venv`) и запустите `./setup.sh` снова. Неполный `.venv`, оставшийся от неудачного запуска, удаляется автоматически.
|
||||||
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux). У `pyscard` нет Linux-wheel, поэтому он собирается из исходников: поставьте и пакет с заголовками PC/SC (`libpcsclite-dev` в Debian/Ubuntu, `pcsc-lite-devel` в Fedora; в Arch они входят в `pcsclite`).
|
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux). У `pyscard` нет Linux-wheel, поэтому он собирается из исходников: поставьте и пакет с заголовками PC/SC (`libpcsclite-dev` в Debian/Ubuntu, `pcsc-lite-devel` в Fedora; в Arch они входят в `pcsclite`).
|
||||||
- **Windows** — используйте **Python 3.13 (настоятельно рекомендуется)**: у `pyscard` есть готовые wheel до 3.13, и SCP81 TLS-PSK-слушателю нужен 3.13+. На 3.14 wheel для `pyscard` и части зависимостей pysim отсутствуют, они собираются из исходников (нужны MSVC C++ Build Tools). 3.9–3.12 запускают приложение, но не SCP81 PSK-слушатель. SMPP-мост (`smpp.twisted3`) в Windows сознательно не ставится.
|
- **Windows** — используйте **Python 3.13 (настоятельно рекомендуется)**: у `pyscard` есть готовые wheel до 3.13, и SCP81 TLS-PSK-слушателю нужен 3.13+. На 3.14 wheel для `pyscard` и части зависимостей pysim отсутствуют, они собираются из исходников (нужны MSVC C++ Build Tools). 3.9–3.12 запускают приложение, но не SCP81 PSK-слушатель. SMPP-мост (`smpp.twisted3`) в Windows сознательно не ставится.
|
||||||
|
|
||||||
@@ -737,11 +740,11 @@ PWA проверяет версию сервера при подключении
|
|||||||
|
|
||||||
| Скрипт | Назначение |
|
| Скрипт | Назначение |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
| `setup.sh` / `setup.bat` | Создаёт `.venv/` (или исправляет неполный), ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
||||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
||||||
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||||
|
|
||||||
`start.sh` при работающем `pcscd` запрашивает первый PC/SC-ридер; иначе сервер сам автоопределяет PC/SC-ридер, а если его нет — pysim откатывается на последовательное устройство по умолчанию `/dev/ttyUSB0` (для serial/FTDI-ридера укажите `-d <устройство>`). `start.bat` всегда передаёт `-p 0`. Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**.
|
`start.sh` перед запуском проверяет доступ к PC/SC: если пользователь не может использовать `pcscd` (или служба не запущена), он печатает причину и рекомендации (группа/polkit) и завершается — сервер **больше не откатывается на последовательный ридер молча**. При наличии доступа сервер сам находит первый PC/SC-ридер; `start.bat` всегда передаёт `-p 0` (в Windows PC/SC встроен). Явный serial/FTDI-ридер по-прежнему возможен через `-d <устройство>` (проверка пропускается). Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Если PC/SC-ридер не найден, сервер сообщает об этом и завершается — подключите ридер и перезапустите (ридер без карты — по-прежнему нормальный старт без карты).
|
||||||
|
|
||||||
### Ручная установка (Linux/macOS)
|
### Ручная установка (Linux/macOS)
|
||||||
|
|
||||||
@@ -781,8 +784,21 @@ pysim-simple-server --http-port 8080
|
|||||||
|
|
||||||
### Устранение неполадок
|
### Устранение неполадок
|
||||||
|
|
||||||
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
- **"Failed to establish context: Access denied"** — у пользователя нет прав на `pcscd`. На headless/SSH-системе сокет часто доступен всем (`srw-rw-rw- root root`), но доступ всё равно запрещён: pcscd проверяет **PolicyKit**, политика по умолчанию разрешает только *активные локальные* сессии (`allow_active=yes`, `allow_inactive=no`, `allow_any=no`). Разрешите пользователя в `/etc/polkit-1/rules.d/99-pcscd-allow.rules` (замените `YOUR_USER`; имя файла `*.rules` может быть любым):
|
||||||
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
|
|
||||||
|
```js
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||||
|
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||||
|
subject.user == "YOUR_USER") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
затем `sudo systemctl restart polkit` (или выйдите и войдите). При необходимости запустите службу (`sudo systemctl enable --now pcscd`); `start.sh` и сервер сообщают об этом и завершаются вместо отката на serial-ридер, а `pcsc_scan` показывает ту же ошибку, пока правило не начнёт действовать. Если `pcscd` собран без PolicyKit и сокет принадлежит отдельной группе, вместо этого можно добавить пользователя в неё.
|
||||||
|
- **"device file /dev/ttyUSB0 does not exist"** — только при явном `-d <устройство>`: устройство не подключено (или путь неверен). Без `-d` сервер больше не использует последовательный транспорт.
|
||||||
|
- **"no PC/SC reader found"** — подключите ридер (`pcsc_scan` покажет список) и перезапустите; сервер завершается, а не стартует без ридера.
|
||||||
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
||||||
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -1006,11 +1006,22 @@ on every stop** as long as the card is in the reader; a card reset (session
|
|||||||
change) stops the suite, skips the remaining members and is flagged in the
|
change) stops the suite, skips the remaining members and is flagged in the
|
||||||
report.
|
report.
|
||||||
|
|
||||||
|
`iterations` (optional, 1-1,000,000,000, default 1) repeats the script or the
|
||||||
|
whole suite: successful iterations are discarded (only the counter and the
|
||||||
|
timing are kept; the per-step log is not retained), the first iteration that
|
||||||
|
ends in an **error** stops the loop and keeps that iteration's report, a
|
||||||
|
manual stop ends it after the current iteration, and a card session change
|
||||||
|
between iterations is an error. The status gains `load`:
|
||||||
|
`{enabled, iterations, iteration, done, failed_iteration, started, finished,
|
||||||
|
elapsed_ms, avg_ms}`.
|
||||||
|
|
||||||
The suite run's snapshot adds `suite`: the member records (`status`, `steps`,
|
The suite run's snapshot adds `suite`: the member records (`status`, `steps`,
|
||||||
`note`, `log` - the semantic run-log excerpt), the `summary` (ok/warning/
|
`note`, `log` - the semantic run-log excerpt), the `summary` (ok/warning/
|
||||||
error/skipped counts, `wall_ms`, `stopped`, `session_changed`, the SCP80
|
error/skipped counts, `wall_ms`, `stopped`, `session_changed`, the SCP80
|
||||||
`counters_before`/`counters_after` per keyset), the `start_reset` flag and
|
`counters_before`/`counters_after` per keyset), the `start_reset` flag and
|
||||||
the `reset` result (ICCID + ms) and the run's `adm` state.
|
the `reset` result (ICCID + ms) and the run's `adm` state. A successful load
|
||||||
|
test leaves `suite` `null` (no iteration is tracked); a failing one keeps the
|
||||||
|
failing iteration's suite state.
|
||||||
The report is exported from the PWA as JSON or Markdown; the run state lives
|
The report is exported from the PWA as JSON or Markdown; the run state lives
|
||||||
in the server until `POST /api/test/clear` (no history).
|
in the server until `POST /api/test/clear` (no history).
|
||||||
|
|
||||||
@@ -1028,7 +1039,15 @@ The current (or last) run:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`status` becomes `ok`/`warning`/`error`/`stopped` when the run finishes;
|
`status` becomes `ok`/`warning`/`error`/`stopped` when the run finishes;
|
||||||
expected/actual pairs are reported per check.
|
expected/actual pairs are reported per check. A card that stops answering is
|
||||||
|
not waited on silently: after 30 s without runner progress the watchdog sets
|
||||||
|
`stuck: true`, `stop` and `error` (`the card stopped responding (step N, no
|
||||||
|
answer for N s)`) and tries to cancel the blocked transmit by disconnecting
|
||||||
|
the PC/SC link on a background thread (pcsc-lite serializes the disconnect
|
||||||
|
behind the pending transmit, so the call returns only when the driver's card
|
||||||
|
timeout fires). Once the step returns, the run finalises as `error` - a stuck
|
||||||
|
card is a failure, not a user stop - and the next equip rebuilds the link
|
||||||
|
(the transport is marked stale).
|
||||||
|
|
||||||
### `POST /api/test/stop`
|
### `POST /api/test/stop`
|
||||||
|
|
||||||
@@ -1750,13 +1769,16 @@ Lists the PROVIDE LOCAL INFORMATION qualifier codes with their names.
|
|||||||
|
|
||||||
Returns the effective PLI data dictionary as a qualifier-code map: the
|
Returns the effective PLI data dictionary as a qualifier-code map: the
|
||||||
operator's override, else the built-in default (the qualifiers whose objects
|
operator's override, else the built-in default (the qualifiers whose objects
|
||||||
the pinned ETSI specs define - location as the test network 001/01, the test
|
the pinned ETSI specs define - location as the test network 001/01 with test
|
||||||
IMEI/IMEISV, language, access technology, search mode, battery; v3.23.1).
|
LAC 0001 and Cell ID 0001 (the 7-byte PLI form per TS 31.111 8.19.1; the
|
||||||
|
5-byte form without the Cell ID belongs to the Network Rejection event
|
||||||
|
download), the test IMEI/IMEISV, language, access technology, search mode,
|
||||||
|
battery; v3.23.1, the PLI location form fixed in v3.37.0).
|
||||||
The date/time qualifier stays empty - the host clock answers it while no
|
The date/time qualifier stays empty - the host clock answers it while no
|
||||||
override is set.
|
override is set.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"00": "930500F1100000", "0A": "630104", "03": ""}
|
{"00": "130700F11000010001", "0A": "630104", "03": ""}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `POST /api/pli-dict`
|
### `POST /api/pli-dict`
|
||||||
|
|||||||
+197
-41
@@ -1,6 +1,9 @@
|
|||||||
# Decoded editing — file-format survey and design
|
# 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
|
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
|
own structure and content coding, assigns each an editing *shape*, and sets the
|
||||||
release waves. The reference is the pinned specs (`projects/docs/uicc/`):
|
release waves. The reference is the pinned specs (`projects/docs/uicc/`):
|
||||||
@@ -57,32 +60,34 @@ editing candidates.
|
|||||||
|
|
||||||
| EF | FID | Spec | Size | Content | Shape | Wave |
|
| 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.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 + '9'-prefixed BCD | 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.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.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.SST | 6F38 | TS 51.011 10.3.7 | X ≥ 1 | **2 bits/service** (allocated + activated) | S6 | **shipped** — tri-state editor (deleted / allocated-off / on) |
|
||||||
| EF.EST | 6F56 | TS 31.102 4.2.47 | X ≥ 1 | enabled-service bits | S6 | 3.34 |
|
| 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 language codes | 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.3.2 | 1–n | language codes | S6 | 3.34 |
|
| EF.LP | 6F05 | TS 51.011 10.3.1 | n | **1 byte per language** (SMS 7-bit) | S6 | 3.35 |
|
||||||
|
| EF.PL | 2F05 | TS 102 221 13.3 | 2n | ISO 639 letters (2 B each, like LI) | S6 | 3.35 |
|
||||||
| EF.SPN | 6F46 | TS 31.102 4.2.12 | 17 B | condition byte + Annex A text | S2 | **3.33** |
|
| 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.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.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.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.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.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.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.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 | text (Annex A) | S2 | 3.34 |
|
| 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.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.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.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 | S1 | 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 | 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, info, MNC length | S1 | 3.34 |
|
| EF.AD (SIM) | 6FAD | TS 51.011 10.3.18 | 3+X | op-mode byte + additional info (no MNC length) | S1 | **3.34** |
|
||||||
| EF.CBMI | 6F45 | TS 31.102 4.2.14 | 2n | CB message ids | S6 | 3.34 |
|
| EF.AD (USIM) | 6FAD | TS 31.102 4.2.18 | 4+X | op-mode byte + info + MNC length (may be 0) | S1 | **3.34** |
|
||||||
| EF.CBMID | 6F48 | TS 31.102 4.2.20 | 2n | CB message ids | S6 | 3.34 |
|
| EF.CBMI | 6F45 | TS 31.102 4.2.14 | 2n | CB message ids | S6 | **3.34** |
|
||||||
| EF.CBMIR | 6F50 | TS 31.102 4.2.22 | 4n | CB id ranges | 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.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.LOCI | 6F7E | TS 31.102 4.2.17 | 11 B | TMSI/LAI/status | **S0** | — |
|
||||||
| EF.PSLOCI | 6F73 | TS 31.102 4.2.23 | 14 B | P-TMSI/RAI/status | **S0** | — |
|
| EF.PSLOCI | 6F73 | TS 31.102 4.2.23 | 14 B | P-TMSI/RAI/status | **S0** | — |
|
||||||
@@ -103,14 +108,14 @@ editing candidates.
|
|||||||
| EF.BDN | 6F4D | TS 31.102 4.2.44 | **X+15** | ADN layout + **Comparison Method Pointer** | S7 | **3.33** (fix) |
|
| 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.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.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.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.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.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 | 3.36 |
|
| 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 | 3.36 |
|
| 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 | 3.36 |
|
| 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 | later |
|
| 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.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) | — |
|
| 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) | — |
|
||||||
|
|
||||||
@@ -123,6 +128,28 @@ must not be edited from a workbench without a dedicated flow (Keys, DCK,
|
|||||||
network parameters, START-HFN). Nothing in the survey forces a new shape
|
network parameters, START-HFN). Nothing in the survey forces a new shape
|
||||||
beyond S9/S10.
|
beyond S9/S10.
|
||||||
|
|
||||||
|
### 2.4 SIM ↔ USIM format divergence
|
||||||
|
|
||||||
|
Some features are represented by a different file (or the same FID with a
|
||||||
|
different structure) in the SIM (DF.GSM, TS 51.011) and USIM (ADF.USIM,
|
||||||
|
TS 31.102) applications. Three groups:
|
||||||
|
|
||||||
|
| group | SIM | USIM | purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A — same FID + name, different format | EF.ECC 6FB7 transparent 3n (code+ESC) | EF.ECC 6FB7 linear X+4 (code+alpha+ESC) | emergency codes |
|
||||||
|
| A | EF.AD 6FAD 3+X (no MNC length) | EF.AD 6FAD 4+X (+ MNC length) | administrative data |
|
||||||
|
| B — same FID, different name + format | EF.SST 6F38 (2-bit) | EF.UST 6F38 (1-bit) | service table |
|
||||||
|
| B | EF.LP 6F05 (1 B/lang) | EF.LI 6F05 (2 B/lang) | language preference |
|
||||||
|
| C — different FID + name + format | EF.PLMNsel 6F30 (3n, no AcT) | EF.PLMNwAcT 6F60 (5n, +AcT) | user PLMN selector |
|
||||||
|
| C | EF.Kc/KcGPRS 6F20/6F52 (9 B) | EF.Keys/KeysPS 6F08/6F09 (33 B) | ciphering keys |
|
||||||
|
| C | EF.CCP 6F3D (14 B) | EF.CCP2 6F4F (X≥15) | bearer capability |
|
||||||
|
|
||||||
|
Group A is the only one where the *name and FID both* collide, so those two
|
||||||
|
(EF.ECC, EF.AD) are what the DF-context resolver (finding 6) is for; groups B
|
||||||
|
and C are already disambiguated by name. EF.HPLMNwAcT (a list) vs the SIM
|
||||||
|
EF.HPPLMN (a 1-byte search period) is a related-but-different pair, and
|
||||||
|
OPLMNwAcT/OPL/PNN/EHPLMN/FPLMN/SPDI/IMG are USIM-only (no SIM counterpart).
|
||||||
|
|
||||||
## 3. Findings (defects and constraints the survey exposed)
|
## 3. Findings (defects and constraints the survey exposed)
|
||||||
|
|
||||||
1. **EF.BDN is X+15, not X+14.** The shared ADN codec assumes the last two
|
1. **EF.BDN is X+15, not X+14.** The shared ADN codec assumes the last two
|
||||||
@@ -130,10 +157,16 @@ beyond S9/S10.
|
|||||||
(`X+15`, TS 31.102 4.2.44). The read-only decoded view currently
|
(`X+15`, TS 31.102 4.2.44). The read-only decoded view currently
|
||||||
mis-parses BDN records, and the v3.33.0 encoder would corrupt them. The
|
mis-parses BDN records, and the v3.33.0 encoder would corrupt them. The
|
||||||
codec must take the layout (ADN vs BDN) and preserve the CMI pointer.
|
codec must take the layout (ADN vs BDN) and preserve the CMI pointer.
|
||||||
2. **EF.ECC has two layouts.** SIM: transparent 3n (TS 51.011 10.3.27);
|
2. **EF.ECC is two different files, not one file with two layouts.** They
|
||||||
USIM: linear fixed X+4 (TS 31.102 4.2.21). The decoder implements the SIM
|
share the FID `6FB7` **and the name `EF.ECC`**, but live in different DFs
|
||||||
layout only; on a USIM the alpha bytes are read as more codes. A
|
under different specs: DF.GSM — transparent 3n, code(2 BCD) + category
|
||||||
structure-aware decode is needed before an ECC editor.
|
(TS 51.011 10.3.27); ADF.USIM — linear fixed X+4, code(3 BCD) + alpha
|
||||||
|
(Annex A) + category (TS 31.102 4.2.21). The decoder implements the SIM
|
||||||
|
file only; on a USIM the alpha bytes are read as more codes. They also
|
||||||
|
need **different editors** (a plain code list vs a record form with a text
|
||||||
|
alpha), so they are two registry entries, resolved by the DF context —
|
||||||
|
not a name/FID lookup, which is ambiguous here (the name *and* the FID
|
||||||
|
collide).
|
||||||
3. **S8 is not one shape.** PNN (flat tag+length+value TLVs) is the simple
|
3. **S8 is not one shape.** PNN (flat tag+length+value TLVs) is the simple
|
||||||
case. DIR/ARR/PBR nest TLVs and cross-reference other files; they need
|
case. DIR/ARR/PBR nest TLVs and cross-reference other files; they need
|
||||||
either a tree editor or a structured read-only view first.
|
either a tree editor or a structured read-only view first.
|
||||||
@@ -147,26 +180,72 @@ beyond S9/S10.
|
|||||||
6. **FID collisions across DFs** (e.g. 6F02 = EF.IMPI in ISIM and EF.OCST in
|
6. **FID collisions across DFs** (e.g. 6F02 = EF.IMPI in ISIM and EF.OCST in
|
||||||
USIM) make the fid-only decoder fallback unsafe; the name-based lookup is
|
USIM) make the fid-only decoder fallback unsafe; the name-based lookup is
|
||||||
the primary one and encoders should not be fid-only where a collision is
|
the primary one and encoders should not be fid-only where a collision is
|
||||||
possible.
|
possible. **Name collisions across DFs** also exist — EF.ECC and EF.AD
|
||||||
|
(DF.GSM vs ADF.USIM) — where both the name *and* the FID collide. The
|
||||||
|
specs key files by **FID within their DF**, not by symbolic name (names are
|
||||||
|
a derived display label), so the resolver must be **(DF, FID)-first**: the
|
||||||
|
parent DF + FID is the authoritative key, and the name is only a label.
|
||||||
|
The FS check in the Services editor follows the same rule — it probes a
|
||||||
|
(DF, FID), never a name.
|
||||||
7. **Text coding is per file.** Annex A (TS 102 221) for SPN/ADN/SUUME;
|
7. **Text coding is per file.** Annex A (TS 102 221) for SPN/ADN/SUUME;
|
||||||
the Network Name IE (TS 24.008 10.5.3.5a) for PNN, where the first byte is
|
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
|
`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.
|
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, and the tri-state editor
|
||||||
|
(deleted / allocated-off / on) shipped with the DF-context work.
|
||||||
|
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. **The SIM/USIM language files differ in width.** EF.LP (SIM, DF.GSM,
|
||||||
|
6F05) is 1 byte per language (SMS 7-bit); EF.LI (USIM, ADF.USIM, 6F05) is
|
||||||
|
the 2-byte ISO 639 pair; EF.PL (MF, 2F05) is 2 bytes, like LI — not 1
|
||||||
|
byte. The shared decoder is wrong for EF.LP only (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
|
## 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,
|
schemas, record checkboxes + auto-check, save/validation; shapes S2, S5,
|
||||||
S7, S8 for SPN, PLMNwAcT/OPLMNwAcT/HPLMNwAcT, ADN/FDN/SDN/MSISDN/LND,
|
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.
|
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,
|
- **v3.34.0 (shipped)** — the S1/S3/S4/S6 wave: UST/IST/EST service tables
|
||||||
ACC, PHASE, HPPLMN, AD, CBMI/CBMID/CBMIR, ECC (SIM), ICCID, IMSI,
|
(filterable bit grid), ACC, PHASE, HPPLMN, AD, the PLMN-only lists
|
||||||
PLMNsel/FPLMN/EHPLMN, SPDI, GID1/GID2.
|
(PLMNsel/FPLMN/EHPLMN), ICCID/IMSI (behind a confirmation) and
|
||||||
- **v3.35.0** — remaining S7 records: OPL (PNN record select), ECC (USIM),
|
CBMI/CBMID/CBMIR. Decoder fixes: ACC class numbering, the AD operation
|
||||||
SUUME, IMPI/DOMAIN/IMPU; EXT1–5 as a read-only structured view.
|
mode / MNC-length rules, the SST 2-bit layout and the
|
||||||
- **v3.36.0+** — S10/complex: SMSP (parameter form), SMS (TPDU editor,
|
IMSI parity nibble.
|
||||||
reusing the existing PDU builders), ARR (rule editor — the decoder already
|
- **v3.35.0 (shipped)** — EF.SMSP as the first S10 dedicated builder: the full
|
||||||
renders rule lines), DIR (application templates), PBR (nested tree).
|
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 (**two files, two editors** — DF.GSM transparent code list vs
|
||||||
|
ADF.USIM record form with an alpha; needs the DF-context resolver from
|
||||||
|
finding 6), 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/
|
- **Read-only forever (S0)** unless a dedicated flow appears: LOCI/PSLOCI/
|
||||||
EPSLOCI/EPSNSC/LOCIGPRS, Kc/KcGPRS, Keys/KeysPS, ACM, START-HFN/THRESHOLD,
|
EPSLOCI/EPSNSC/LOCIGPRS, Kc/KcGPRS, Keys/KeysPS, ACM, START-HFN/THRESHOLD,
|
||||||
call logs (ME-written), DCK/NETPAR.
|
call logs (ME-written), DCK/NETPAR.
|
||||||
@@ -183,3 +262,80 @@ beyond S9/S10.
|
|||||||
- **No capability by accident**: a file without an encoder must never show
|
- **No capability by accident**: a file without an encoder must never show
|
||||||
the editor (gating test), and a schema/label drift guard keeps the editors
|
the editor (gating test), and a schema/label drift guard keeps the editors
|
||||||
and decoders aligned.
|
and decoders aligned.
|
||||||
|
|
||||||
|
## 6. Cross-file "Services" editor (proposal)
|
||||||
|
|
||||||
|
A file-agnostic service editor for the Card reader tab, in addition to the
|
||||||
|
per-file decoded editors (which stay independent). It presents the four
|
||||||
|
service tables side by side and writes them back consistently. EF.SST also
|
||||||
|
has its own per-file tri-state editor (deleted / allocated-off / on); the
|
||||||
|
Services editor's SIM column only toggles on / allocated-off and never
|
||||||
|
de-allocates.
|
||||||
|
|
||||||
|
- **Scope**: EF.SST (DF.GSM, 2 bits/service: allocated + activated), EF.UST
|
||||||
|
(ADF.USIM, 1 bit), EF.EST (ADF.USIM, a 3-bit *enabled* subset of UST:
|
||||||
|
FDN/BDN/ACL = UST 2/6/35) and EF.IST (ADF.ISIM, 1 bit, IMS services).
|
||||||
|
- **The numbering does not align across files**: SST and UST (and EST/IST)
|
||||||
|
use different service-number -> name maps, so the editor is driven by a
|
||||||
|
curated **feature cross-reference** (by name), not by service number. Each
|
||||||
|
feature row belongs to *SIM-only*, *USIM-only*, *ISIM-only* or *shared*
|
||||||
|
(e.g. FDN = SST 3 / UST 2 / EST 1; SMS = SST 4 / UST 10). The table is
|
||||||
|
spec-derived and pinned by a test, like the event/TR vectors.
|
||||||
|
- **Card-aware and probed**: on open, the editor resolves the four files on
|
||||||
|
the current card (the file-manager presence state, or a targeted probe) and
|
||||||
|
renders a column per file that exists; an absent file (a card without ISIM
|
||||||
|
has no EF.IST) hides that column and its feature rows entirely. Presence is
|
||||||
|
cached per `card_session`, like the EF.ARR copies.
|
||||||
|
- **Semantics**: "enabled" = UST bit set = SST *allocated* **and** *activated*.
|
||||||
|
SST's intermediate "allocated but not activated" state has no UST
|
||||||
|
equivalent, so the SIM column is a per-feature toggle between *on*
|
||||||
|
(allocated + activated) and *off* (allocated but not activated) — the
|
||||||
|
Services editor never de-allocates, so a card-wide change keeps the SIM
|
||||||
|
able to re-enable the service. Explicit *deletion* (clearing the allocation
|
||||||
|
bit) is the per-file EF.SST editor's job: its grid is a tri-state per
|
||||||
|
service (deleted / allocated-off / on).
|
||||||
|
- **Write, per file**: the merged form carries the intended bytes for every
|
||||||
|
visible file, but writing is explicit and separate — one button per file
|
||||||
|
("Save to EF.SST", "Save to EF.UST", "Save to EF.EST", "Save to EF.IST").
|
||||||
|
Each button writes only that file via `/api/write`; an unchanged file is
|
||||||
|
skipped and the panel reports each write's result (there is no atomic
|
||||||
|
cross-file write).
|
||||||
|
- **Filesystem check** (implemented, step 4b): many services declare a DF or EF
|
||||||
|
(ADN -> EF.ADN 6F3A, SPN -> EF.SPN 6F46, GSM Access -> DF.GSM-ACCESS 5F3B,
|
||||||
|
GPRS -> EF.KcGPRS, …); a second curated table (`SERVICES_FS_MAP`, keyed by
|
||||||
|
feature name) maps a feature to the **symbolic files** its `Filesystem` cell
|
||||||
|
declares, resolved through the standard file list's `(name, root) -> path`
|
||||||
|
index — path-exact, so the same FID under another DF (EF.PBR/EF.SAI both
|
||||||
|
4F30, EF.SST/EF.UST both 6F38) no longer explains a service. A **Check
|
||||||
|
filesystem** action probes the file-manager tree once
|
||||||
|
(`pysimFsProbeAll`) and flags mismatches — a declared service whose file is
|
||||||
|
absent, or a present file whose service is not declared in any checked tree;
|
||||||
|
a declared feature the standard list cannot resolve is reported separately as
|
||||||
|
*unchecked*. Services that map to a capability rather than a file (CHV1
|
||||||
|
disable, Proactive SIM, …) carry no file check. The declarations are read
|
||||||
|
per application (SST -> MF, UST/EST -> ADF.USIM, IST -> ADF.ISIM) and a
|
||||||
|
USIM/ISIM role may resolve into the common DF.TELECOM subtree (the MF tree),
|
||||||
|
except the GSM application's mapped copies (EF.ADN/EF.EXT1/EF.ECCP, TS 31.102
|
||||||
|
4.5). The follow-ups are the `ADF.USIM-NON-IMSI` root alias and the files
|
||||||
|
missing from the standard list (`DF.GRAPHICS`/`EF.IMG`, the DF.TELECOM
|
||||||
|
DF.SoLSA/DF.MExE, EF.3GPPPSDATAOFFservicelist) — `docs/services-editor.md`
|
||||||
|
§B.0. The last report is kept in memory (it carries its resolved paths) and
|
||||||
|
dropped with the cached reads on a card-session change.
|
||||||
|
|
||||||
|
### 6.1 Candidates for the same cross-application treatment
|
||||||
|
|
||||||
|
The cross-file pattern of the Services editor generalises to the other
|
||||||
|
SIM/USIM feature pairs (group C in §2.4), where one operator action should
|
||||||
|
update both applications consistently:
|
||||||
|
|
||||||
|
- **PLMN selectors** — EF.PLMNsel (SIM, no AcT) ↔ EF.PLMNwAcT (USIM, +AcT):
|
||||||
|
a shared PLMN list, the SIM side dropping the AcT and the USIM side
|
||||||
|
re-applying it (AcT is not expressible on the SIM side).
|
||||||
|
- **Language preferences** — EF.LP (SIM, 1 B/lang) ↔ EF.LI (USIM, 2 B/lang)
|
||||||
|
↔ EF.PL (MF, 2 B/lang): a shared ordered language list re-encoded per file.
|
||||||
|
- **Bearer capabilities** — EF.CCP (SIM) ↔ EF.CCP2 (USIM): a shared
|
||||||
|
bearer-capability record re-encoded per file's layout.
|
||||||
|
|
||||||
|
None of these are committed; they are recorded here so the DF-context resolver
|
||||||
|
and the per-file encoders are shaped with them in mind (group A/C files must
|
||||||
|
not be fid-only).
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
# Services editor — data tables (draft)
|
||||||
|
|
||||||
|
> **Status: DRAFT for human review — data only.** No code or UI has been written
|
||||||
|
> against these tables yet. They are a semantic merge of the four service lists
|
||||||
|
> from `frontend/index.html` (`EF_SST_SERVICES`, `EF_UST_SERVICES`,
|
||||||
|
> `EF_IST_SERVICES`, `EF_EST_SERVICES`) cross-referenced with the filesystem
|
||||||
|
> registry `docs/uicc/UICC_FILES.md` and the standard file tree
|
||||||
|
> `frontend/uicc_files.json`.
|
||||||
|
>
|
||||||
|
> Step 4a (the cross-file Services editor: §A's cross-reference, read, render,
|
||||||
|
> per-file save) is implemented against §A; the counts below were verified
|
||||||
|
> against the PWA's constants by `frontend/tests/services.test.js`. Step 4b
|
||||||
|
> (§B, the filesystem check) is implemented as well — see §B.0 for the
|
||||||
|
> representation and the path-exact `(name, root)` rule it uses.
|
||||||
|
|
||||||
|
**Counts:** SST **59** / UST **150** / IST **22** / EST **3** services →
|
||||||
|
**164 feature rows** (10 SIM-only, 85 USIM-only, 5 ISIM-only, 61 shared,
|
||||||
|
3 RFU/reserved). Reconciliation is exact; see Notes §4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Feature cross-reference
|
||||||
|
|
||||||
|
Columns: **SST #** (SIM, TS 51.011 §10.3.7, 2 bits/service: allocated + activated),
|
||||||
|
**UST #** (USIM, TS 31.102 §4.2.8, 1 bit), **IST #** (ISIM, TS 31.103 §4.2.7, 1 bit),
|
||||||
|
**EST #** (USIM Enabled Services Table, TS 31.102 §4.2.47). Class ∈
|
||||||
|
`SIM-only · USIM-only · ISIM-only · shared · RFU`. The **Filesystem** column is
|
||||||
|
deliverable B merged in (see §B for the legend and the full `capability`/`?` list).
|
||||||
|
|
||||||
|
Names are canonicalised; wording aliases are listed in Notes §1.
|
||||||
|
|
||||||
|
### A.1 Features present in the SIM list (SST), and their USIM/ISIM counterparts
|
||||||
|
|
||||||
|
| # | Feature | SST # | UST # | IST # | EST # | Class | Filesystem |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 1 | CHV1 disable function | 1 | | | | SIM-only | capability |
|
||||||
|
| 2 | Local Phone Book (ADN) | 2 | 1 | | | shared | EF.ADN 6F3A (USIM: DF.PHONEBOOK 5F3A → EF.PBR 4F30) |
|
||||||
|
| 3 | Fixed Dialling Numbers (FDN) | 3 | 2 | | 1 | shared | EF.FDN 6F3B (+ EF.EXT2 6F4B) |
|
||||||
|
| 4 | Short Message Storage (SMS) | 4 | 10 | 6 | | shared | EF.SMS 6F3C |
|
||||||
|
| 5 | Advice of Charge (AoC) | 5 | 13 | | | shared | EF.ACM 6F39 · EF.ACMmax 6F37 · EF.PUCT 6F41 |
|
||||||
|
| 6 | Capability Configuration Parameters (CCP) | 6 | | | | SIM-only | EF.CCP 6F3D |
|
||||||
|
| 7 | Capability Configuration Parameters 2 (CCP2) | | 14 | | | USIM-only | EF.CCP2 6F4F |
|
||||||
|
| 8 | PLMN selector | 7 | | | | SIM-only | EF.PLMNsel 6F30 |
|
||||||
|
| 9 | MSISDN | 9 | 21 | | | shared | EF.MSISDN 6F40 |
|
||||||
|
| 10 | Extension 1 | 10 | | | | SIM-only | EF.EXT1 6F4A |
|
||||||
|
| 11 | Extension 2 | 11 | 3 | | | shared | EF.EXT2 6F4B |
|
||||||
|
| 12 | SMS Parameters (SMSP) | 12 | 12 | | | shared | EF.SMSP 6F42 |
|
||||||
|
| 13 | Last Number Dialled (LND) | 13 | | | | SIM-only | EF.LND 6F44 |
|
||||||
|
| 14 | Cell Broadcast Message Identifier (CBMI) | 14 | 15 | | | shared | EF.CBMI 6F45 |
|
||||||
|
| 15 | Group Identifier Level 1 (GID1) | 15 | 17 | | | shared | EF.GID1 6F3E |
|
||||||
|
| 16 | Group Identifier Level 2 (GID2) | 16 | 18 | | | shared | EF.GID2 6F3F |
|
||||||
|
| 17 | Service Provider Name (SPN) | 17 | 19 | | | shared | EF.SPN 6F46 |
|
||||||
|
| 18 | Service Dialling Numbers (SDN) | 18 | 4 | | | shared | EF.SDN 6F49 (+ EF.EXT3 6F4C) |
|
||||||
|
| 19 | Extension 3 | 19 | 5 | | | shared | EF.EXT3 6F4C |
|
||||||
|
| 20 | VGCS Group Identifier List | 21 | 57 | | | shared | EF.VGCS 6FB1 · EF.VGCSS 6FB2 |
|
||||||
|
| 21 | VBS Group Identifier List | 22 | 58 | | | shared | EF.VBS 6FB3 · EF.VBSS 6FB4 |
|
||||||
|
| 22 | enhanced Multi-Level Precedence and Pre-emption (eMLPP) | 23 | 24 | | | shared | EF.eMLPP 6FB5 |
|
||||||
|
| 23 | Automatic Answer for eMLPP (AAeM) | 24 | 25 | | | shared | EF.AAeM 6FB6 |
|
||||||
|
| 24 | Data download via SMS-CB | 25 | 29 | | | shared | capability (gate: EF.CBMID 6F48) |
|
||||||
|
| 25 | Data download via SMS-PP | 26 | 28 | | | shared | capability |
|
||||||
|
| 26 | Menu selection | 27 | | | | SIM-only | capability (menu title: EF.SUME 6F54) |
|
||||||
|
| 27 | Call control (by SIM/USIM) | 28 | 30 | | | shared | capability |
|
||||||
|
| 28 | Proactive SIM | 29 | | | | SIM-only | capability |
|
||||||
|
| 29 | Cell Broadcast Message Identifier Ranges (CBMIR) | 30 | 16 | | | shared | EF.CBMIR 6F50 |
|
||||||
|
| 30 | Barred Dialling Numbers (BDN) | 31 | 6 | | 2 | shared | EF.BDN 6F4D (+ EF.EXT4 6F4E/6F55) |
|
||||||
|
| 31 | Extension 4 | 32 | 7 | | | shared | EF.EXT4 6F4E (SIM) / 6F55 (USIM) |
|
||||||
|
| 32 | De-personalisation Control Keys (DCK) | 33 | 36 | | | shared | EF.DCK 6F2C |
|
||||||
|
| 33 | Co-operative Network List (CNL) | 34 | 37 | | | shared | EF.CNL 6F32 |
|
||||||
|
| 34 | Short Message Status Reports (SMSR) | 35 | 11 | 7 | | shared | EF.SMSR 6F47 |
|
||||||
|
| 35 | Network's indication of alerting in the MS (NIA) | 36 | 56 | | | shared | EF.NIA 6F51 (SIM) / 6FD3 (USIM) |
|
||||||
|
| 36 | MO-SMS control (by SIM/USIM) | 37 | 31 | | | shared | capability |
|
||||||
|
| 37 | GPRS | 38 | | | | SIM-only | EF.KcGPRS 6F52 (+ DF.GSM 7F20) |
|
||||||
|
| 38 | Image (IMG) | 39 | 22 | | | shared | DF.GRAPHICS 5F50 → EF.IMG 4F20 |
|
||||||
|
| 39 | SoLSA | 40 | 23 | | | shared | DF.SoLSA 5F70 → EF.SAI 4F30 · EF.SLL 4F31 |
|
||||||
|
| 40 | USSD string data object supported in Call Control | 41 | | | | SIM-only | capability |
|
||||||
|
| 41 | RUN AT COMMAND command | 42 | 32 | | | shared | capability |
|
||||||
|
| 42 | User controlled PLMN selector with Access Technology | 43 | 20 | | | shared | EF.PLMNwAcT 6F60 |
|
||||||
|
| 43 | Operator controlled PLMN selector with Access Technology | 44 | 42 | | | shared | EF.OPLMNwAcT 6F61 |
|
||||||
|
| 44 | HPLMN selector with Access Technology | 45 | 43 | | | shared | EF.HPLMNwAcT 6F62 |
|
||||||
|
| 45 | CPBCCH Information | 46 | 39 | | | shared | EF.CPBCCH 6F63 (SIM) / 4F63 (USIM) |
|
||||||
|
| 46 | Investigation Scan | 47 | 40 | | | shared | EF.InvScan 6F64 (SIM) / 4F64 (USIM) |
|
||||||
|
| 47 | Extended Capability Configuration Parameters (ECCP) | 48 | | | | SIM-only | EF.ECCP 6F4F |
|
||||||
|
| 48 | MExE | 49 | 41 | | | shared | DF.MExE 5F3C → EF.MExE-ST 4F40 |
|
||||||
|
| 49 | PLMN Network Name (PNN) | 51 | 45 | | | shared | EF.PNN 6FC5 |
|
||||||
|
| 50 | Operator PLMN List (OPL) | 52 | 46 | | | shared | EF.OPL 6FC6 |
|
||||||
|
| 51 | Mailbox Dialling Numbers (MBDN) | 53 | 47 | | | shared | EF.MBDN 6FC7 (+ EF.EXT6 6FC8) |
|
||||||
|
| 52 | Message Waiting Indication Status (MWIS) | 54 | 48 | | | shared | EF.MWIS 6FCA (+ EF.MBI 6FC9) |
|
||||||
|
| 53 | Call Forwarding Indication Status (CFIS) | 55 | 49 | | | shared | EF.CFIS 6FCB (+ EF.EXT7 6FCC) |
|
||||||
|
| 54 | Service Provider Display Information (SPDI) | 56 | 51 | | | shared | EF.SPDI 6FCD |
|
||||||
|
| 55 | Multimedia Messaging Service (MMS) | 57 | 52 | | | shared | EF.MMSN 6FCE (+ MMS set: EXT8/MMSICP/MMSUP/MMSUCP) |
|
||||||
|
| 56 | Extension 8 | 58 | 53 | | | shared | EF.EXT8 6FCF |
|
||||||
|
| 57 | MMS User Connectivity Parameters | 59 | 55 | | | shared | EF.MMSICP 6FD0 · EF.MMSUCP 6FD2 · EF.MMSUP 6FD1 |
|
||||||
|
|
||||||
|
### A.2 USIM-only, and USIM+ISIM shared features (no SIM entry)
|
||||||
|
|
||||||
|
| # | Feature | SST # | UST # | IST # | EST # | Class | Filesystem |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 58 | Outgoing Call Information (OCI and OCT) | | 8 | | | USIM-only | EF.OCI 6F81 · EF.OCT 6F83 |
|
||||||
|
| 59 | Incoming Call Information (ICI and ICT) | | 9 | | | USIM-only | EF.ICI 6F80 · EF.ICT 6F82 |
|
||||||
|
| 60 | GSM Access | | 27 | | | USIM-only | DF.GSM-ACCESS 5F3B |
|
||||||
|
| 61 | Enabled Services Table | | 34 | | | USIM-only | EF.EST 6F56 |
|
||||||
|
| 62 | APN Control List (ACL) | | 35 | | 3 | USIM-only | EF.ACL 6F57 |
|
||||||
|
| 63 | GSM security context | | 38 | | | USIM-only | EF.Kc 4F20 · EF.KcGPRS 4F52 (DF.GSM-ACCESS 5F3B) |
|
||||||
|
| 64 | Extension 5 | | 44 | | | USIM-only | EF.EXT5 6F4E (USIM) |
|
||||||
|
| 65 | Call control on GPRS by USIM | | 54 | | | USIM-only | capability |
|
||||||
|
| 66 | Pseudonym | | 59 | | | USIM-only | EF.Pseudo 4F41 (DF.WLAN 5F40) |
|
||||||
|
| 67 | User Controlled PLMN selector for I-WLAN access | | 60 | | | USIM-only | EF.UPLMNWLAN 4F42 |
|
||||||
|
| 68 | Operator Controlled PLMN selector for I-WLAN access | | 61 | | | USIM-only | EF.OPLMNWLAN 4F43 |
|
||||||
|
| 69 | User controlled WSID list | | 62 | | | USIM-only | EF.UWSIDL 4F44 |
|
||||||
|
| 70 | Operator controlled WSID list | | 63 | | | USIM-only | EF.OWSIDL 4F45 |
|
||||||
|
| 71 | VGCS security | | 64 | | | USIM-only | EF.VGCSCA 6FD4 |
|
||||||
|
| 72 | VBS security | | 65 | | | USIM-only | EF.VBSCA 6FD5 |
|
||||||
|
| 73 | WLAN Reauthentication Identity | | 66 | | | USIM-only | EF.WRI 4F46 |
|
||||||
|
| 74 | Multimedia Messages Storage | | 67 | | | USIM-only | EF.MML 4F47 · EF.MMDF 4F48 (DF.MULTIMEDIA 5F3B) |
|
||||||
|
| 75 | Generic Bootstrapping Architecture (GBA) | | 68 | 2 | | shared | EF.GBABP 6FD6 (USIM) / 6FD5 (ISIM) · EF.GBANL 6FDA/6FD7 · EF.NAFKCA 6FDD |
|
||||||
|
| 76 | MBMS security | | 69 | | | USIM-only | EF.MSK 6FD7 · EF.MUK 6FD8 |
|
||||||
|
| 77 | Data download via USSD and USSD application mode | | 70 | | | USIM-only | capability |
|
||||||
|
| 78 | Equivalent HPLMN | | 71 | | | USIM-only | EF.EHPLMN 6FD9 |
|
||||||
|
| 79 | Additional TERMINAL PROFILE after UICC activation | | 72 | | | USIM-only | capability |
|
||||||
|
| 80 | Equivalent HPLMN Presentation Indication | | 73 | | | USIM-only | EF.EHPLMNPI 6FDB |
|
||||||
|
| 81 | Last RPLMN Selection Indication | | 74 | | | USIM-only | EF.LRPLMNSI 6FDC |
|
||||||
|
| 82 | OMA BCAST Smart Card Profile | | 75 | | | USIM-only | DF.BCAST 5F80 |
|
||||||
|
| 83 | GBA-based Local Key Establishment Mechanism | | 76 | 4 | | shared | capability |
|
||||||
|
| 84 | Terminal Applications | | 77 | | | USIM-only | capability |
|
||||||
|
| 85 | Service Provider Name Icon | | 78 | | | USIM-only | EF.SPNI 6FDE |
|
||||||
|
| 86 | PLMN Network Name Icon | | 79 | | | USIM-only | EF.PNNI 6FDF |
|
||||||
|
| 87 | Connectivity Parameters for USIM IP connections | | 80 | | | USIM-only | EF.NCP-IP 6FE2 |
|
||||||
|
| 88 | Home I-WLAN Specific Identifier List | | 81 | | | USIM-only | EF.HWSIDL 4F47 |
|
||||||
|
| 89 | I-WLAN Equivalent HPLMN Presentation Indication | | 82 | | | USIM-only | EF.WEHPLMNPI 4F48 |
|
||||||
|
| 90 | I-WLAN HPLMN Priority Indication | | 83 | | | USIM-only | EF.WHPI 4F49 |
|
||||||
|
| 91 | I-WLAN Last Registered PLMN | | 84 | | | USIM-only | EF.WLRPLMN 4F4A |
|
||||||
|
| 92 | EPS Mobility Management Information | | 85 | | | USIM-only | EF.EPSLOCI 6FE3 · EF.EPSNSC 6FE4 |
|
||||||
|
| 93 | Allowed CSG Lists and corresponding indications | | 86 | | | USIM-only | EF.ACSGL 4F81 · EF.CSGT 4F82 · EF.HNBN 4F83 |
|
||||||
|
| 94 | Call control on EPS PDN connection by USIM | | 87 | | | USIM-only | capability |
|
||||||
|
| 95 | HPLMN Direct Access | | 88 | | | USIM-only | EF.HPLMNDAI 4F4B |
|
||||||
|
| 96 | eCall Data | | 89 | | | USIM-only | EF.SDN 6F49 (+ EF.FDN 6F3B) |
|
||||||
|
| 97 | Operator CSG Lists and corresponding indications | | 90 | | | USIM-only | EF.OCSGL 4F84 · EF.OCSGT 4F85 · EF.OHNBN 4F86 |
|
||||||
|
| 98 | Support for SM-over-IP | | 91 | 8 | | shared | capability (ISIM-related: EF.PSISMSC 6FE5) |
|
||||||
|
| 99 | Support of CSG Display Control | | 92 | | | USIM-only | capability |
|
||||||
|
| 100 | Communication Control for IMS | | 93 | 9 | | shared | capability |
|
||||||
|
| 101 | Extended Terminal Applications | | 94 | | | USIM-only | capability |
|
||||||
|
| 102 | Support of UICC access to IMS | | 95 | 10 | | shared | capability |
|
||||||
|
| 103 | Non-Access Stratum configuration by USIM | | 96 | | | USIM-only | EF.NASCONFIG 6FE8 |
|
||||||
|
| 104 | PWS configuration by USIM | | 97 | | | USIM-only | EF.PWS 6FEC |
|
||||||
|
| 105 | URI support by UICC | | 99 | 11 | | shared | capability (URI files: EF.FDNURI 6FED · EF.BDNURI 6FEE · EF.SDNURI 6FEF) |
|
||||||
|
| 106 | Extended EARFCN support | | 100 | | | USIM-only | capability |
|
||||||
|
| 107 | ProSe | | 101 | | | USIM-only | DF.ProSe 5F90 → EF.PST 4F10 |
|
||||||
|
| 108 | USAT Application Pairing | | 102 | | | USIM-only | EF.IPS 6FF1 (+ EF.IAL 6FF0 · EF.IPD 6FF2) |
|
||||||
|
| 109 | Media Type support | | 103 | 12 | | shared | capability |
|
||||||
|
| 110 | IMS call disconnection cause | | 104 | 13 | | shared | capability |
|
||||||
|
| 111 | URI support for MO SHORT MESSAGE CONTROL | | 105 | 14 | | shared | capability |
|
||||||
|
| 112 | ePDG configuration Information support | | 106 | | | USIM-only | EF.ePDGSelection 6FF4 |
|
||||||
|
| 113 | ePDG configuration Information configured | | 107 | | | USIM-only | EF.ePDGId 6FF3 |
|
||||||
|
| 114 | ACDC support | | 108 | | | USIM-only | DF.ACDC 5FA0 → EF.ACDC_LIST 4F01 |
|
||||||
|
| 115 | MCPTT | | 109 | 15 | | shared | DF.MCS 5F3D → EF.MST 4F01 (USIM) |
|
||||||
|
| 116 | ePDG configuration Information for Emergency Service support | | 110 | | | USIM-only | EF.ePDGSelectionEm 6FF6 |
|
||||||
|
| 117 | ePDG configuration Information for Emergency Service configured | | 111 | | | USIM-only | EF.ePDGIdEm 6FF5 |
|
||||||
|
| 118 | eCall Data over IMS | | 112 | | | USIM-only | EF.SDNURI 6FEF (+ EF.FDNURI 6FED) |
|
||||||
|
| 119 | URI support for SMS-PP DOWNLOAD | | 113 | 16 | | shared | capability |
|
||||||
|
| 120 | From Preferred | | 114 | 17 | | shared | EF.FromPreferred 6FF7 |
|
||||||
|
| 121 | IMS configuration data | | 115 | 18 | | shared | EF.IMSConfigData 6FF8 |
|
||||||
|
| 122 | TV configuration | | 116 | | | USIM-only | EF.TVCONFIG 6FFB (+ DF.TV 5FB0 → EF.TVUSD) |
|
||||||
|
| 123 | 3GPP PS Data Off | | 117 | | | USIM-only | EF.3GPPPSDATAOFF 6FF9 |
|
||||||
|
| 124 | 3GPP PS Data Off Service List | | 118 | | | USIM-only | EF.3GPPPSDATAOFFservicelist 6FFA |
|
||||||
|
| 125 | V2X | | 119 | | | USIM-only | DF.V2X 5F3E → EF.VST 4F01 · EF.V2X_CONFIG 4F02 |
|
||||||
|
| 126 | XCAP Configuration Data | | 120 | 19 | | shared | EF.XCAPConfigData 6FFC |
|
||||||
|
| 127 | EARFCN list for MTC/NB-IOT UEs | | 121 | | | USIM-only | EF.EARFCNList 6FFD |
|
||||||
|
| 128 | 5GS Mobility Management Information | | 122 | | | USIM-only | DF.5GS 5FC0 → EF.5GS3GPPLOCI 4F01 · EF.5GSN3GPPLOCI 4F02 |
|
||||||
|
| 129 | 5G Security Parameters | | 123 | | | USIM-only | EF.5GAUTHKEYS 4F05 (+ EF.5GS3GPPNSC 4F03 · EF.5GSN3GPPNSC 4F04) |
|
||||||
|
| 130 | Subscription identifier privacy support | | 124 | | | USIM-only | capability (SUCI data: EF.SUCI_Calc_Info 4F07, gated by 125) |
|
||||||
|
| 131 | SUCI calculation by the USIM | | 125 | | | USIM-only | EF.SUCI_Calc_Info 4F07 |
|
||||||
|
| 132 | UAC Access Identities support | | 126 | | | USIM-only | EF.UAC_AIC 4F06 |
|
||||||
|
| 133 | Expect control plane-based Steering of Roaming information during initial registration in VPLMN | | 127 | | | USIM-only | capability (SOR data: EF.SOR-CMCI 4F0E, gated by 138) |
|
||||||
|
| 134 | Call control on PDU Session by USIM | | 128 | | | USIM-only | capability |
|
||||||
|
| 135 | 5GS Operator PLMN List | | 129 | | | USIM-only | EF.OPL5G 4F08 |
|
||||||
|
| 136 | Support for SUPI of type NSI or GLI or GCI | | 130 | | | USIM-only | EF.SUPI_NAI 4F09 |
|
||||||
|
| 137 | 3GPP PS Data Off separate Home and Roaming lists | | 131 | | | USIM-only | capability (extends EF.3GPPPSDATAOFFservicelist 6FFA) |
|
||||||
|
| 138 | Support for URSP by USIM | | 132 | | | USIM-only | EF.URSP 4F0B |
|
||||||
|
| 139 | 5G Security Parameters extended | | 133 | | | USIM-only | capability (extends EF.5GAUTHKEYS 4F05) |
|
||||||
|
| 140 | MuD and MiD configuration data | | 134 | 21 | | shared | EF.MuDMiDConfigData 6FFE |
|
||||||
|
| 141 | Support for Trusted non-3GPP access networks by USIM | | 135 | | | USIM-only | capability |
|
||||||
|
| 142 | Support for multiple records of NAS security context storage for multiple registration | | 136 | | | USIM-only | EF.5GS3GPPNSC 4F03 · EF.5GSN3GPPNSC 4F04 (2nd record) |
|
||||||
|
| 143 | Pre-configured CAG information list | | 137 | | | USIM-only | EF.CAG 4F0D |
|
||||||
|
| 144 | SOR-CMCI storage in USIM | | 138 | | | USIM-only | EF.SOR-CMCI 4F0E |
|
||||||
|
| 145 | 5G ProSe | | 139 | | | USIM-only | DF.5G_ProSe 5FF0 → EF.5G_PROSE_ST 4F01 |
|
||||||
|
| 146 | Storage of disaster roaming information in USIM | | 140 | | | USIM-only | EF.DRI 4F0F |
|
||||||
|
| 147 | Pre-configured eDRX parameters | | 141 | | | USIM-only | EF.5GSEDRX 4F10 |
|
||||||
|
| 148 | 5G NSWO support | | 142 | | | USIM-only | EF.5GNSWO_CONF 4F11 |
|
||||||
|
| 149 | PWS configuration for SNPN in USIM | | 143 | | | USIM-only | EF.PWS_SNPN 4F01 (DF.SNPN 5FE0) |
|
||||||
|
| 150 | Multiplier Coefficient for Higher Priority PLMN search via NG-RAN satellite access | | 144 | | | USIM-only | EF.MCHPPLMN 4F15 |
|
||||||
|
| 151 | K_AUSF derivation configuration | | 145 | | | USIM-only | EF.KAUSF_DERIVATION 4F16 |
|
||||||
|
| 152 | Network Identifier for SNPN (NID) | | 146 | | | USIM-only | EF.NID 4F02 (DF.SNPN 5FE0) |
|
||||||
|
| 153 | 5MBS UE pre-configuration | | 147 | | | USIM-only | DF.5MBSUECONFIG 5FF1 → EF.5MBSUECONFIG 4F01 |
|
||||||
|
| 154 | Operator Controlled Signal Threshold per Access Technology | | 148 | | | USIM-only | EF.OCST 6F02 |
|
||||||
|
| 155 | A2X | | 149 | | | USIM-only | DF.A2X 5F3F → EF.AST 4F01 |
|
||||||
|
| 156 | IMS Data Channel Indication | | 150 | | | USIM-only | EF.IMSDCI 6F0B |
|
||||||
|
|
||||||
|
### A.3 ISIM-only features
|
||||||
|
|
||||||
|
| # | Feature | SST # | UST # | IST # | EST # | Class | Filesystem |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 153 | P-CSCF address | | | 1 | | ISIM-only | EF.P-CSCF 6F09 |
|
||||||
|
| 154 | HTTP Digest | | | 3 | | ISIM-only | capability |
|
||||||
|
| 155 | Support of P-CSCF discovery for IMS Local Break Out | | | 5 | | ISIM-only | capability (address store: EF.P-CSCF 6F09) |
|
||||||
|
| 156 | WebRTC URI | | | 20 | | ISIM-only | EF.WebRTCURI 6FFA |
|
||||||
|
| 157 | IMS Data Channel indication | | | 22 | | ISIM-only | EF.IMSDCI 6F0B |
|
||||||
|
|
||||||
|
### A.4 RFU / reserved (non-editable)
|
||||||
|
|
||||||
|
| # | Feature | SST # | UST # | IST # | EST # | Class | Filesystem |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 158 | RFU | 8, 20 | 26, 98 | | | RFU | — |
|
||||||
|
| 159 | Reserved and shall be ignored | 50 | 50 | | | RFU | — |
|
||||||
|
| 160 | shall be set to 1 | | 33 | | | RFU | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Feature → filesystem (FS check)
|
||||||
|
|
||||||
|
The FS column in §A is deliverable B, merged for readability. Legend:
|
||||||
|
|
||||||
|
- `EF.<name> <FID>` — the file the service declares (FID from `uicc_files.json`
|
||||||
|
or `UICC_FILES.md`; both agree unless noted).
|
||||||
|
- `DF.<name> <FID> → EF.<name> <FID>` — the service gates a whole DF; the
|
||||||
|
principal EF is shown. Companion files after `(+ …)` or `·`.
|
||||||
|
- `capability` — a procedural / terminal-side feature with no single file.
|
||||||
|
- `?` — no clear file found in either source; needs a human decision.
|
||||||
|
- `—` — RFU/reserved.
|
||||||
|
|
||||||
|
### B.0 Implemented check (`SERVICES_FS_MAP` + the Check filesystem action)
|
||||||
|
|
||||||
|
`frontend/index.html` carries the column as `SERVICES_FS_MAP`, a map keyed by
|
||||||
|
the §A feature name (the cross-reference's row identity) whose value is the
|
||||||
|
list of **symbolic file names the cell mentions**, in the cell's order:
|
||||||
|
|
||||||
|
- `EF.FDN 6F3B (+ EF.EXT2 6F4B)` → `['EF.FDN', 'EF.EXT2']`
|
||||||
|
- `DF.GRAPHICS 5F50 → EF.IMG 4F20` → `['DF.GRAPHICS', 'EF.IMG']` (a DF's own
|
||||||
|
name counts: the DF is the file the service gates)
|
||||||
|
- `EF.NIA 6F51 (SIM) / 6FD3 (USIM)` → `['EF.NIA']` — one name, a different
|
||||||
|
path per tree
|
||||||
|
- `capability` → `[]`; the parenthetical references a capability cell carries
|
||||||
|
(`gate: EF.CBMID 6F48`, `menu title: EF.SUME 6F54`, the URI set, `SUCI data`)
|
||||||
|
are informational — the service does not declare a file of its own. The
|
||||||
|
§A.4 RFU/reserved rows are not features and therefore have no entry.
|
||||||
|
|
||||||
|
The names are the standard file list's (`frontend/uicc_files.json`, built from
|
||||||
|
pySim by `pysim_simple_server/uicc_files.py`) `name` values, so the check can
|
||||||
|
resolve them **path-exactly**: a FID alone is ambiguous across applications
|
||||||
|
(`4F30` is EF.PBR *and* EF.SAI, `6F38` EF.SST *and* EF.UST, `6F4E` EF.EXT4
|
||||||
|
under DF.TELECOM *and* EF.EXT5 under ADF.USIM). `servicesPathIndexBuild()`
|
||||||
|
turns the list into a `NAME|ROOT → path` index (uppercased both sides; the one
|
||||||
|
duplicate — pySim models `EF.SUCI_Calc_Info` twice under ADF.USIM, in DF.5GS
|
||||||
|
`4F07` and in the eSIM profile package's DF.SAIP `4F01` — takes the list's
|
||||||
|
first entry, the DF.5GS one). `servicesPathIndex()` loads it once per page
|
||||||
|
through `uiccStdFilesLoad()`.
|
||||||
|
|
||||||
|
The **Check filesystem** button (Services pill, needs a card) reads the four
|
||||||
|
service tables, probes the file manager's tree once (`pysimFsProbeAll`; the
|
||||||
|
`/api/tree` model lists files without testing them, so a loaded-but-unprobed
|
||||||
|
tree proves nothing), collects the tree's **present paths** (every node with
|
||||||
|
`exists === true`, DFs included, as canonical `pysimFsNodePath` paths) and
|
||||||
|
compares the two sides:
|
||||||
|
|
||||||
|
- **declared per tree** — `servicesFsDeclaredTrees()`: EF.SST declares in the
|
||||||
|
**MF** tree, EF.UST/EF.EST in **ADF.USIM**, EF.IST in **ADF.ISIM** (the state
|
||||||
|
the editor shows: EF.SST only at *allocated + activated*). A tree whose
|
||||||
|
service table is not on the card declares nothing there (its declarations
|
||||||
|
are unknown, so it is not checked at all).
|
||||||
|
- `missing` — a declared feature whose resolved paths are absent, in §A order
|
||||||
|
then MF / ADF.USIM / ADF.ISIM; each entry carries the declaring `tree` and
|
||||||
|
the absent `paths` (in the cell's order).
|
||||||
|
- `extra` — a present path whose mapped features are declared in **none** of
|
||||||
|
the checked trees, sorted by path, with the path's own `tree`, its `role`
|
||||||
|
name and every mapping feature. A path **any** declared feature maps is
|
||||||
|
explained: several features share one file (DF.GSM-ACCESS across "GSM
|
||||||
|
Access"/"GSM security context", EXT2 across FDN/Extension 2, the 5G DFs'
|
||||||
|
`4F01`/`4F02`) and a declaration in one application may gate a file of
|
||||||
|
another (a UST service gating a DF.TELECOM DF — see below).
|
||||||
|
- `unchecked` — a declared feature with **no** path at all: the standard list
|
||||||
|
is missing the file, so its presence cannot be verified. Today:
|
||||||
|
`Image (IMG)` (`DF.GRAPHICS`/`EF.IMG` — pySim does not model DF.GRAPHICS),
|
||||||
|
`SoLSA` and `MExE` in the MF tree (the DF.TELECOM copies of DF.SoLSA/DF.MExE
|
||||||
|
are not in the list) and `3GPP PS Data Off Service List`
|
||||||
|
(EF.3GPPPSDATAOFFservicelist). Reported instead of guessed — the follow-up
|
||||||
|
is to add those files to `SUPPLEMENT` in `pysim_simple_server/uicc_files.py`.
|
||||||
|
|
||||||
|
**Resolution per declaring tree.** A role resolves in the declaring tree's
|
||||||
|
root first, then — for a USIM/ISIM declaration — in the **MF** tree, because
|
||||||
|
several UST-gated DFs are children of the common DF.TELECOM (TS 31.102 4.6.0:
|
||||||
|
DF.MULTIMEDIA `5F3B`, DF.MCS `5F3D`, DF.V2X `5F3E`, DF.A2X `5F3F`,
|
||||||
|
DF.GRAPHICS `5F50`), i.e. `MF/7F10/…` paths. The exceptions
|
||||||
|
(`SERVICES_FS_GSM_MAPPED`) are the files TS 31.102 4.5 lists as *mappings* of a
|
||||||
|
DF.PHONEBOOK file into DF.TELECOM "in case of a present GSM application" —
|
||||||
|
EF.ADN `6F3A`, EF.EXT1 `6F4A`, EF.ECCP `6F4F`: a USIM declaration's phonebook
|
||||||
|
is DF.PHONEBOOK + EF.PBR (ADR's `EF.ADN` is the SIM's file), so those are
|
||||||
|
never expected in a USIM/ISIM tree. A role the list knows for another root
|
||||||
|
only is skipped, not a failure — a feature's files are the union of its
|
||||||
|
applications' files (ADN = EF.ADN for the SIM, DF.PHONEBOOK + EF.PBR for the
|
||||||
|
USIM).
|
||||||
|
|
||||||
|
**Not covered (follow-ups):** `ADF.USIM-NON-IMSI` — the list mirrors the
|
||||||
|
ADF.USIM files under that root, but a card's tree names the application
|
||||||
|
`ADF.USIM-non-IMSI`, so a non-IMSI USIM card's UST/EST paths are not matched
|
||||||
|
yet (resolve it as an alias of ADF.USIM); and the registry gaps listed above.
|
||||||
|
|
||||||
|
The pure pieces (`servicesFsDeclaredTrees`, `servicesFsPresentPaths`,
|
||||||
|
`servicesPathIndexBuild`, `servicesFsRolePath`, `servicesFsReport`,
|
||||||
|
`servicesFsReportHtml`) are DOM/fetch-free and unit-tested; the last report is
|
||||||
|
kept in memory (it carries its resolved paths) and redrawn on a language
|
||||||
|
switch, and it is dropped with the cached reads on a card-session change.
|
||||||
|
|
||||||
|
### B.1 `capability` entries (no single file)
|
||||||
|
|
||||||
|
CHV1 disable · Data download via SMS-PP · Data download via SMS-CB (gate
|
||||||
|
EF.CBMID 6F48) · Menu selection (title EF.SUME 6F54) · Call control · Proactive
|
||||||
|
SIM · MO-SMS control · USSD string in Call Control · RUN AT COMMAND · Call
|
||||||
|
control on GPRS · Data download via USSD · Additional TERMINAL PROFILE ·
|
||||||
|
GBA-based Local Key Establishment · Terminal Applications · SM-over-IP (related
|
||||||
|
EF.PSISMSC 6FE5, ISIM) · CSG Display Control · Communication Control for IMS ·
|
||||||
|
Extended Terminal Applications · UICC access to IMS · URI support by UICC (URI
|
||||||
|
files EF.FDNURI/BDNURI/SDNURI) · Media Type support · IMS call disconnection
|
||||||
|
cause · URI for MO SHORT MESSAGE CONTROL · URI for SMS-PP DOWNLOAD · Call
|
||||||
|
control on EPS PDN · Call control on PDU Session · Subscription identifier
|
||||||
|
privacy (SUCI data in EF.SUCI_Calc_Info 4F07) · SOR expectation (data in
|
||||||
|
EF.SOR-CMCI 4F0E) · PS Data Off separate Home/Roaming (EF.3GPPPSDATAOFFservicelist
|
||||||
|
6FFA) · 5G Security Parameters extended (EF.5GAUTHKEYS 4F05) · Trusted non-3GPP
|
||||||
|
access · HTTP Digest · P-CSCF discovery Local Break Out (EF.P-CSCF 6F09).
|
||||||
|
|
||||||
|
### B.2 Resolved against TS 31.102 v18.10.0
|
||||||
|
|
||||||
|
The three initially-unresolved mappings were settled from the spec text
|
||||||
|
(`docs/uicc/ts_131102*.txt`):
|
||||||
|
|
||||||
|
- **eCall Data (89)** — no dedicated EF: the eCall test/reconfiguration
|
||||||
|
numbers live in **EF.SDN 6F49** ("If service n° 4 and/or n° 89 is available
|
||||||
|
this file shall be present … eCall test and reconfiguration numbers") and
|
||||||
|
EF.FDN 6F3B (the eCall procedures).
|
||||||
|
- **eCall Data over IMS (112)** — the eCall test/reconfiguration **URIs** live
|
||||||
|
in **EF.SDNURI 6FEF** (and EF.FDNURI 6FED).
|
||||||
|
- **EARFCN list for MTC/NB-IOT UEs (121)** — **EF.EARFCNList 6FFD** (§4.2.112,
|
||||||
|
whose clause title is literally "EARFCN list for MTC/NB-IOT UEs").
|
||||||
|
"Extended EARFCN support" (100) is a **capability** with no file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes / open questions
|
||||||
|
|
||||||
|
### 1. Aliases used (case-insensitive exact name did not join these; merged manually)
|
||||||
|
|
||||||
|
| Canonical | SST wording | UST wording | IST wording |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Local Phone Book (ADN) | "Abbreviated Dialling Numbers (ADN)" (2) | "Local Phone Book" (1) | |
|
||||||
|
| SMS Parameters (SMSP) | "SMS Parameters" (12) | "Short Message Service Parameters (SMSP)" (12) | |
|
||||||
|
| Short Message Status Reports (SMSR) | "Short Message Status Reports" (35) | "…Status Reports (SMSR)" (11) | "…Status Reports (SMSR)" (7) |
|
||||||
|
| Network's indication of alerting in the MS (NIA) | "Network's indication of alerting in the MS" (36) | "…in the MS (NIA)" (56) | |
|
||||||
|
| Call control | "Call control" (28) | "Call Control by USIM" (30) | |
|
||||||
|
| MO-SMS control | "Mobile Originated Short Message control by SIM" (37) | "MO-SMS Control by USIM" (31) | |
|
||||||
|
| De-personalisation Control Keys (DCK) | "De-personalization Control Keys" (33) | "Depersonalisation Control Keys" (36) | |
|
||||||
|
| SoLSA | "SoLSA (Support of Local Service Area)" (40) | "Support of Localised Service Areas (SoLSA)" (23) | |
|
||||||
|
| eMLPP / MExE | "enhanced Multi-Level…" (23), "MExE" (49) | "Enhanced Multi-Level…" (24), "MexE" (41) | |
|
||||||
|
| PLMN selector w/ Access Technology (×3) | "…PLMN Selector with Access Technology" (43–45) | "…PLMN selector with Access Technology" (20/42/43) | |
|
||||||
|
| Extension 2 | "Extension2" (11) | "Extension 2" (3) | |
|
||||||
|
| SM-over-IP | | "Support for SM-over-IP" (91) | "Support for SM-over-IP including data download via SMS-PP as defined in TS 31.111 [31]" (8) |
|
||||||
|
| Communication Control for IMS | | "…by USIM" (93) | "…by ISIM" (9) |
|
||||||
|
| URI support for SMS-PP DOWNLOAD | | "…TS 31.111 [12]" (113) | "…TS 31.111 [31]" (16) |
|
||||||
|
|
||||||
|
The other shared rows joined on a case-insensitive exact name (e.g. "Service
|
||||||
|
Provider Name" SST 17 / UST 19, "MSISDN" SST 9 / UST 21, "Short Message Storage
|
||||||
|
(SMS)" SST 4 / UST 10 / IST 6).
|
||||||
|
|
||||||
|
### 2. Kept separate, flagged for review
|
||||||
|
|
||||||
|
- **CCP / CCP2 / ECCP.** SST carries two bearer-capability services — "Capability
|
||||||
|
Configuration Parameters (CCP)" (6, file EF.CCP 6F3D) and "Extended Capability
|
||||||
|
Configuration Parameters (ECCP)" (48, EF.ECCP 6F4F); UST carries one —
|
||||||
|
"Capability Configuration Parameters 2 (CCP2)" (14, EF.CCP2 6F4F). These were
|
||||||
|
kept as three rows. Note the structural mismatch: the USIM's CCP2 shares FID
|
||||||
|
**6F4F** with the SIM's **ECCP**, not with the SIM's CCP (6F3D), so a
|
||||||
|
CCP↔CCP2 merge is *not* obviously correct — decide whether CCP2 ≙ CCP or
|
||||||
|
CCP2 ≙ ECCP (or neither) before writing code.
|
||||||
|
|
||||||
|
### 3. FID / tree collisions to respect in the editor
|
||||||
|
|
||||||
|
- `6F4E` = EF.EXT4 in the GSM tree, EF.EXT5 in the USIM tree (UICC_FILES.md §2.3).
|
||||||
|
- `6F4F` = EF.ECCP (GSM) vs EF.CCP2 (USIM) — see §2.
|
||||||
|
- `6F07` = EF.IMSI (DF.GSM / ADF.USIM) vs EF.IST (ADF.ISIM).
|
||||||
|
- `6F38` = EF.SST (DF.GSM) vs EF.UST (ADF.USIM).
|
||||||
|
- `6FD7` = EF.MSK (USIM) vs EF.GBANL (ISIM).
|
||||||
|
- `EF.NIA` has different FIDs per tree: 6F51 (SIM) vs 6FD3 (USIM).
|
||||||
|
- `DF.GSM-ACCESS 5F3B` (ADF.USIM) vs `DF.MULTIMEDIA 5F3B` (DF.TELECOM) — same
|
||||||
|
FID, different parents; the child-DF summary (UICC_FILES.md §4.13) is the
|
||||||
|
authority for which DF a service gates.
|
||||||
|
|
||||||
|
The filesystem column already resolves these per-tree (e.g. EF.EXT4 shows both
|
||||||
|
FIDs); the editor's DF-context selection must do the same.
|
||||||
|
|
||||||
|
### 4. Reconciliation (completeness invariant)
|
||||||
|
|
||||||
|
- **SST 59:** 56 non-RFU service numbers (SST 1–59 minus {8,20,50}) spread over
|
||||||
|
the 57 rows of §A.1 (one of those rows, CCP2, carries no SST number) + 3
|
||||||
|
RFU/reserved slots (8, 20, 50) = 59. ✓
|
||||||
|
- **UST 150:** 46 shared-with-SIM + 15 shared-with-ISIM + 85 USIM-only (1 in
|
||||||
|
§A.1 = CCP2, 84 in §A.2) + 4 RFU/reserved (26, 33, 50, 98) = 150. ✓
|
||||||
|
- **IST 22:** 2 shared with SIM (SMS 6, SMSR 7) + 15 shared with USIM
|
||||||
|
(2,4,8,9,10,11,12,13,14,15,16,17,18,19,21) + 5 ISIM-only (1,3,5,20,22) = 22. ✓
|
||||||
|
- **EST 3:** FDN (1), BDN (2), ACL (3) — all present. ✓
|
||||||
|
|
||||||
|
The PWA's `EF_UST_SERVICES` constant previously stopped at **146**; it now
|
||||||
|
carries **147–150** (5MBS UE pre-configuration / Operator Controlled Signal
|
||||||
|
Threshold per Access Technology / A2X / IMS Data Channel Indication), matching
|
||||||
|
`UICC_FILES.md` §6.1 and TS 31.102 §4.2.8, so the tables above are complete.
|
||||||
|
|
||||||
|
### 5. Source-coverage gaps (the standard file list)
|
||||||
|
|
||||||
|
The Services editor is **card-only**: it probes the equipped card's service
|
||||||
|
tables by path and writes via `/api/write`. The **FS check** additionally
|
||||||
|
needs the *cardless* standard file list (`frontend/uicc_files.json`) to resolve
|
||||||
|
a feature's symbolic files to paths (`(name, root) -> path`, §B.0) — the card's
|
||||||
|
own tree supplies presence, the list supplies the paths. Part 1 of step 4b
|
||||||
|
added the service-gated files pySim's model omits to `SUPPLEMENT`
|
||||||
|
(`pysim_simple_server/uicc_files.py`): EF.LRPLMNSI, EF.IAL, EF.IPD,
|
||||||
|
EF.TVCONFIG, EF.3GPPPSDATAOFF, DF.MExE + its files, DF.SoLSA + EF.SAI/EF.SLL,
|
||||||
|
DF.BCAST, DF.ACDC + EF.ACDC_LIST, DF.TV, DF.A2X + EF.AST/EF.A2X_CONFIG.
|
||||||
|
|
||||||
|
Still missing from the list, and therefore reported as *unchecked* by the
|
||||||
|
check (a declared feature whose files have no path) rather than guessed:
|
||||||
|
|
||||||
|
- **DF.GRAPHICS 5F50 → EF.IMG 4F20** — the DF.TELECOM child (TS 31.102 4.6.0);
|
||||||
|
pySim's SIM model does not carry DF.GRAPHICS at all.
|
||||||
|
- **DF.SoLSA 5F70 / DF.MExE 5F3C under DF.TELECOM** — the list carries them
|
||||||
|
under ADF.USIM only, so an *SST* declaration of SoLSA (service 40) or MExE
|
||||||
|
(49) cannot be checked in the MF tree.
|
||||||
|
- **EF.3GPPPSDATAOFFservicelist 6FFA** — a pySim TODO.
|
||||||
|
|
||||||
|
A non-IMSI USIM (`ADF.USIM-NON-IMSI`) is mirrored in the list, but a card's
|
||||||
|
tree names the application `ADF.USIM-non-IMSI`; resolving the two as one root
|
||||||
|
is the follow-up (§B.0).
|
||||||
|
|
||||||
|
### 6. Deliberately unmapped
|
||||||
|
|
||||||
|
- "GSM Access" (UST 27) maps to **DF.GSM-ACCESS 5F3B** (UICC_FILES.md §4.13c
|
||||||
|
"DF_GSM-ACCESS — UST n°27"), not to DF.GSM 7F20 — DF.GSM is the classic SIM
|
||||||
|
tree and is not UST-gated. This corrects the DF.GSM hint in the drafting brief.
|
||||||
|
- EF.RPLMNAcT 6F65 (legacy, "deleted in Rel-6") has no current UST/SST service
|
||||||
|
and is intentionally absent.
|
||||||
|
- EF.ACC 6F78 (access-control class) and EF.Phase 6FAE are not service-gated in
|
||||||
|
either list, so they do not appear.
|
||||||
+47
-1
@@ -229,6 +229,47 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
|
|||||||
(`APDU-TRACE(…): <cmd> → SW: … RESP: …`) for every APDU, including the
|
(`APDU-TRACE(…): <cmd> → SW: … RESP: …`) for every APDU, including the
|
||||||
file-manager reads and pySim's auto-handler traffic.
|
file-manager reads and pySim's auto-handler traffic.
|
||||||
|
|
||||||
|
## Load testing
|
||||||
|
|
||||||
|
A run may be repeated: `POST /api/test/run {..., "iterations": N}` (the
|
||||||
|
PWA's **Iterations** field next to Start / Run suite) executes the script or
|
||||||
|
the whole suite N times. Successful iterations are discarded - only the
|
||||||
|
iteration counter and the timing are kept, so memory use stays flat. The
|
||||||
|
first iteration that ends in an error stops the loop and keeps its full
|
||||||
|
report (steps, failed checks, log excerpt; for a suite, the member report);
|
||||||
|
a manual stop ends it after the current iteration, and a card session change
|
||||||
|
between iterations is an error. The SCP80 counter state is shared across
|
||||||
|
iterations, so packets continue from what the previous iteration consumed.
|
||||||
|
The iteration count is a run parameter - the stored script/suite is unchanged.
|
||||||
|
The accepted range is **1 to 1,000,000,000** (the PWA clamps, the server
|
||||||
|
answers 400 above it). While the run repeats, the run banner (visible on
|
||||||
|
every Test-script view) and the result panels show the elapsed time, the
|
||||||
|
average per completed iteration and the estimated time left, recomputed on
|
||||||
|
every poll; the final report keeps the server's `elapsed_ms`/`avg_ms`.
|
||||||
|
|
||||||
|
## A card that stops answering
|
||||||
|
|
||||||
|
pyscard's `SCardTransmit` is blocking and has no timeout: a card that stops
|
||||||
|
answering (no status word, no driver error) would leave the runner inside the
|
||||||
|
step while it holds the card lock - Stop could not take effect, the presence
|
||||||
|
monitor would wait for the lock, and a load test would hang in that iteration.
|
||||||
|
The runner's **watchdog** watches a progress heartbeat: after 30 s without one
|
||||||
|
(a bounded STATUS/drain loop beats once per attempt, so a long poll is not
|
||||||
|
mistaken for a stuck card) it marks the run `stuck`, sets the error
|
||||||
|
`the card stopped responding (step N, no answer for N s)` and the stop flag,
|
||||||
|
and **tries** to cancel the blocked transmit by disconnecting the PC/SC link
|
||||||
|
on a background thread. Where PC/SC can cancel it, the blocking call raises,
|
||||||
|
the step fails through the ordinary path (`on_fail` applies) and the run
|
||||||
|
finalises as an **error** - never as `stopped`, even though the stop flag
|
||||||
|
ended the loop. The disconnect runs in the background because pcsc-lite
|
||||||
|
serializes it behind the pending transmit (both take the per-context lock), so
|
||||||
|
on that stack the call returns only when the driver's own card timeout fires
|
||||||
|
(libccid derives it from the card's ATR); the run is already flagged and the
|
||||||
|
status names the step meanwhile. The transport is marked stale once the
|
||||||
|
disconnect returns, so the next **Equip** rebuilds the link. A physical card
|
||||||
|
removal is unaffected: the driver aborts the transmit and the presence monitor
|
||||||
|
clears the card session.
|
||||||
|
|
||||||
## Document shape
|
## Document shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -293,7 +334,12 @@ consecutive runs continue correctly.
|
|||||||
| `proactive-drain` | `respond`, `first`, `attempts` 1–1000 (default 3), `interval_ms` (default 200), `require` | drain the card's announced proactive commands and confirm it is idle (see below) |
|
| `proactive-drain` | `respond`, `first`, `attempts` 1–1000 (default 3), `interval_ms` (default 200), `require` | drain the card's announced proactive commands and confirm it is idle (see below) |
|
||||||
|
|
||||||
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
|
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
|
||||||
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
|
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`. The step editor's
|
||||||
|
**pick a known file** selector (the same source merge as the APDU builder's
|
||||||
|
SELECT picker: the loaded file tree, custom files and the standard
|
||||||
|
`uicc_files.json` list, grouped by root, with a filter) fills the path;
|
||||||
|
only EFs are listed, and the text field stays authoritative (typing a name
|
||||||
|
path or an unlisted file is always possible).
|
||||||
|
|
||||||
`file-write` sends a direct terminal-side UPDATE BINARY/RECORD - it does **not**
|
`file-write` sends a direct terminal-side UPDATE BINARY/RECORD - it does **not**
|
||||||
fire the card's EVENT_REMOTE_FILE_UPDATE (that event reports an update done by
|
fire the card's EVENT_REMOTE_FILE_UPDATE (that event reports an update done by
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+7
-6
@@ -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] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1–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) — 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 → 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 — 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 — 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>) — 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><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] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1–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) — 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 → 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 — 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 — 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>) — 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 — 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 — 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 “already processed” — 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 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 — 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 — 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 “already processed” — 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 — 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 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 — 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 — 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 — the point where the JCRE verifies the import list — 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 ≥ that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 — 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 — 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">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 — the point where the JCRE verifies the import list — 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 ≥ that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 — 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 — 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' — 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 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' — 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>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or “Insert next card. Waiting for a card...”) 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 — the run itself is server-side and survives a reload. After a finished run the panel’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 “Insert next card. Waiting for a card...”) 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 — the run itself is server-side and survives a reload. After a finished run the panel’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>
|
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
|
||||||
<p class="text-sm mb-2">After “Explore Card” runs, the explorer displays:</p>
|
<p class="text-sm mb-2">After “Explore Card” runs, the explorer displays:</p>
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<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>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 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> and <strong>SST</strong> — the SIM table, edited per service as deleted / allocated-off / on), <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>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’s unblock key.</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’s unblock key.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -539,7 +539,7 @@
|
|||||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="text-sm mb-3">Values persist server-side until restart. Sensible defaults are pre-settled for the qualifiers the ETSI specs define (location as the test network 001/01, the test IMEI 490154203237518 and its IMEISV, language ‘en’, E-UTRAN access technology, automatic search mode, a full battery): a value you set overrides its default, and <strong>clearing the field restores it</strong>; the date/time qualifier is answered from the host clock while empty, and the 3GPP/3GPP2-specific qualifiers (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, …) stay empty - the ETSI specs define no contents for them. A field the server cannot use (a value that is not hex, an odd number of digits, a code outside the qualifier list) is named under the Save button instead of being silently dropped. When the card issues PLI, the server injects the values into the TERMINAL RESPONSE - also into a test-script expectation's answer.</p>
|
<p class="text-sm mb-3">Values persist server-side until restart. Sensible defaults are pre-settled for the qualifiers the ETSI specs define (location as the test network 001/01 with test LAC 0001 and Cell ID 0001 — the 7-byte PLI form, not the shorter Network Rejection one, the test IMEI 490154203237518 and its IMEISV, language ‘en’, E-UTRAN access technology, automatic search mode, a full battery): a value you set overrides its default, and <strong>clearing the field restores it</strong>; the date/time qualifier is answered from the host clock while empty, and the 3GPP/3GPP2-specific qualifiers (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, …) stay empty - the ETSI specs define no contents for them. A field the server cannot use (a value that is not hex, an odd number of digits, a code outside the qualifier list) is named under the Save button instead of being silently dropped. When the card issues PLI, the server injects the values into the TERMINAL RESPONSE - also into a test-script expectation's answer.</p>
|
||||||
|
|
||||||
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Network simulation</h3>
|
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Network simulation</h3>
|
||||||
<p class="text-sm mb-2">Replays the write patterns a real phone performs when the network condition changes (trace study in <code class="font-mono text-sm">projects/UICC_NAA.md</code>): EPS attach, service loss / limited service, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration and AUTHENTICATE. One button per scenario; the card must be equipped.</p>
|
<p class="text-sm mb-2">Replays the write patterns a real phone performs when the network condition changes (trace study in <code class="font-mono text-sm">projects/UICC_NAA.md</code>): EPS attach, service loss / limited service, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration and AUTHENTICATE. One button per scenario; the card must be equipped.</p>
|
||||||
@@ -570,10 +570,11 @@
|
|||||||
|
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
|
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
|
||||||
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409), and background STATUS polling and the automatic proactive handling are suspended — a command the card announces stays pending for the script, and both resume when the run ends. The card’s own BIP commands (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) are an exception: the terminal answers them for real — its channels keep working during the run and the BIP events flow for the channels the card itself opened (BIP <em>control</em> stays blocked) — unless the step’s <code class="font-mono text-sm">respond</code> overrides the answer with <code class="font-mono text-sm">raw</code> TLVs or a non-ok result; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button — a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
|
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409), and background STATUS polling and the automatic proactive handling are suspended — a command the card announces stays pending for the script, and both resume when the run ends. The card’s own BIP commands (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) are an exception: the terminal answers them for real — its channels keep working during the run and the BIP events flow for the channels the card itself opened (BIP <em>control</em> stays blocked) — unless the step’s <code class="font-mono text-sm">respond</code> overrides the answer with <code class="font-mono text-sm">raw</code> TLVs or a non-ok result; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button — a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is). A card that stops answering does not hang the run silently: after 30 s without progress the watchdog marks the run as failed, naming the step, sets the stop flag and tries to cancel the blocked exchange by disconnecting the PC/SC link on a background thread (the link is marked stale, so the next <strong>Equip</strong> rebuilds it). A driver with its own card timeout fails the step through the normal path instead.</p>
|
||||||
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order — the optional <em>setup</em> first and the <em>teardown</em> last, marked with role badges; per script <em>Run</em>/<em>Edit</em>/<em>Move</em>/<em>Copy</em>/<em>Delete</em>, the per-member <em>on failure</em> policy <em>stop</em> (default) / <em>continue</em>, and <em>ADM required</em>), the <strong>script editor</strong> (the step list; its <em>Start</em> runs that single script and shows the result under the steps — not preserved) and the <strong>suite report</strong> (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before → after per keyset, the preset and a card-session-changed warning; export as <em>JSON</em> or <em>Markdown</em>). A <strong>suite run</strong> executes its scripts in order in <strong>one card session, without resets and without resume</strong>; <em>on failure</em> decides whether the suite stops at that script or runs the next one, the <strong>teardown always runs</strong> on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. <strong>Start with card reset</strong> resets the card and runs the full re-initialization before the suite starts (the scripts then run against a freshly initialized card); the preset must still match the card's ICCID afterwards and a required ADM is verified again for the new card session. The initialization consumes the card's initial proactive commands, so a script cannot catch the first command after the reset (they stay in the proactive log). <strong>ADM required</strong> (on the suite or a script) verifies the matched preset's ADM key <strong>once</strong> before the start — a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created “Imported scripts” suite).</p>
|
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order — the optional <em>setup</em> first and the <em>teardown</em> last, marked with role badges; per script <em>Run</em>/<em>Edit</em>/<em>Move</em>/<em>Copy</em>/<em>Delete</em>, the per-member <em>on failure</em> policy <em>stop</em> (default) / <em>continue</em>, and <em>ADM required</em>), the <strong>script editor</strong> (the step list; its <em>Start</em> runs that single script and shows the result under the steps — not preserved) and the <strong>suite report</strong> (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before → after per keyset, the preset and a card-session-changed warning; export as <em>JSON</em> or <em>Markdown</em>). A <strong>suite run</strong> executes its scripts in order in <strong>one card session, without resets and without resume</strong>; <em>on failure</em> decides whether the suite stops at that script or runs the next one, the <strong>teardown always runs</strong> on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. <strong>Start with card reset</strong> resets the card and runs the full re-initialization before the suite starts (the scripts then run against a freshly initialized card); the preset must still match the card's ICCID afterwards and a required ADM is verified again for the new card session. The initialization consumes the card's initial proactive commands, so a script cannot catch the first command after the reset (they stay in the proactive log). <strong>ADM required</strong> (on the suite or a script) verifies the matched preset's ADM key <strong>once</strong> before the start — a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created “Imported scripts” suite).</p>
|
||||||
|
<p class="text-sm mb-2">A run can be repeated: the <strong>Iterations</strong> field next to <em>Start</em> / <em>Run suite</em> runs the script or the whole suite that many times — a <strong>load test</strong>. Successful iterations are not tracked: the status shows only the counter (iteration k / N) and the timing, the per-step log stays empty, and memory use stays flat however high the count. The first iteration that ends in an error stops the loop and keeps its full report (steps, failed checks, run-log excerpt, suite member report); a manual <em>Stop</em> ends it after the current iteration, and a card change between iterations is reported as an error (the next card is never run against another card’s script). The count is a run option — the saved script/suite is unchanged — and the SCP80 counters continue from what the previous iteration consumed. The field accepts <strong>1 to 1,000,000,000</strong> iterations (the practical limit is the run duration). While it runs, the run banner (on every Test-script view) and the result panels also show the <strong>elapsed time</strong>, the <strong>average per iteration</strong> and the <strong>estimated time left</strong>.</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data, with an optional source override — 82 terminal / 83 network) or <em>Event</em> (the semantic forms: the <strong>same form the Phone tab's send dialog uses</strong>, for every modelled event — the four events the server builds (Location status, Access technology change, Network rejection, Data connection status change) travel as fields it validates and builds at run time, every other event carries the form's built event-data hex (shown live under the form, with the one-ENVELOPE check); the device-identities source comes from the event (e.g. MT call → network) or its source field), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains match, case-insensitive by default — the step editor's <em>Match case</em> choice switches to an exact-case match; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format — compact, or the TS 102 226 §5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE 80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — the server persists the consumed counter into it as the run proceeds), <em>STATUS</em> (with <em>attempts</em> > 1 it polls until the card announces a command) and <em>Proactive drain</em> (poll STATUS and consume every announced command — the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step — use it at the start, in the middle or at the end of a script to make sure the card has stopped sending; it also consumes a command a previous step left pending (a step that answered <code class="font-mono text-sm">91XX</code>). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards — <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR — status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
|
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data, with an optional source override — 82 terminal / 83 network) or <em>Event</em> (the semantic forms: the <strong>same form the Phone tab's send dialog uses</strong>, for every modelled event — the four events the server builds (Location status, Access technology change, Network rejection, Data connection status change) travel as fields it validates and builds at run time, every other event carries the form's built event-data hex (shown live under the form, with the one-ENVELOPE check); the device-identities source comes from the event (e.g. MT call → network) or its source field), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains match, case-insensitive by default — the step editor's <em>Match case</em> choice switches to an exact-case match; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; the path can be typed or picked from the known-file list — the file tree, custom files and the standard list, grouped by root with a filter; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format — compact, or the TS 102 226 §5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE 80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — the server persists the consumed counter into it as the run proceeds), <em>STATUS</em> (with <em>attempts</em> > 1 it polls until the card announces a command) and <em>Proactive drain</em> (poll STATUS and consume every announced command — the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step — use it at the start, in the middle or at the end of a script to make sure the card has stopped sending; it also consumes a command a previous step left pending (a step that answered <code class="font-mono text-sm">91XX</code>). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards — <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR — status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
|
||||||
<li><strong>Expectations</strong> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command”: the step fails at its fail level — Error stops the script, Warning continues (the tolerant “consume a command if there is one” form) — and a STATUS action with <em>attempts</em> > 1 polls when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> — distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data — for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first — the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all — leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> is part of the instruction: the <strong>result</strong> offers the full TS 102 223 §8.12.0 list (each entry labelled <code class="font-mono text-sm"><hex> - <alias or name></code>, plus a manual hex entry; with a command selected the list is grouped by the spec’s applicability table (the command-relevant values first, the rest after — nothing is hidden); the aliases follow the spec — <code class="font-mono text-sm">ok</code> 00, <code class="font-mono text-sm">modified</code> 07, <code class="font-mono text-sm">cancel</code> 10, <code class="font-mono text-sm">timeout</code> 12, <code class="font-mono text-sm">refused</code> 22, <code class="font-mono text-sm">not_understood</code> 32, …), the <strong>additional information</strong> byte of the general result (default <code class="font-mono text-sm">00</code>; the §8.12.2 terminal-problem values are suggested), the item identifier, the text (+DCS) and extra TLVs.</li>
|
<li><strong>Expectations</strong> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command”: the step fails at its fail level — Error stops the script, Warning continues (the tolerant “consume a command if there is one” form) — and a STATUS action with <em>attempts</em> > 1 polls when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> — distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data — for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first — the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all — leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> is part of the instruction: the <strong>result</strong> offers the full TS 102 223 §8.12.0 list (each entry labelled <code class="font-mono text-sm"><hex> - <alias or name></code>, plus a manual hex entry; with a command selected the list is grouped by the spec’s applicability table (the command-relevant values first, the rest after — nothing is hidden); the aliases follow the spec — <code class="font-mono text-sm">ok</code> 00, <code class="font-mono text-sm">modified</code> 07, <code class="font-mono text-sm">cancel</code> 10, <code class="font-mono text-sm">timeout</code> 12, <code class="font-mono text-sm">refused</code> 22, <code class="font-mono text-sm">not_understood</code> 32, …), the <strong>additional information</strong> byte of the general result (default <code class="font-mono text-sm">00</code>; the §8.12.2 terminal-problem values are suggested), the item identifier, the text (+DCS) and extra TLVs.</li>
|
||||||
<li><strong>Fail levels</strong> — a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); every step failure honours it, including an expectation that finds no pending command and a card error raised during the step (an absent optional file's <code class="font-mono text-sm">6A82</code>); each check is reported with expected vs actual values.</li>
|
<li><strong>Fail levels</strong> — a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); every step failure honours it, including an expectation that finds no pending command and a card error raised during the step (an absent optional file's <code class="font-mono text-sm">6A82</code>); each check is reported with expected vs actual values.</li>
|
||||||
<li><strong>Templates</strong> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action renders the Simulator's event form), and a suite exports/imports as a JSON bundle (the suite plus its scripts).</li>
|
<li><strong>Templates</strong> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action renders the Simulator's event form), and a suite exports/imports as a JSON bundle (the suite plus its scripts).</li>
|
||||||
|
|||||||
+2682
-145
File diff suppressed because one or more lines are too long
@@ -5,3 +5,27 @@
|
|||||||
.dark .text-heading {
|
.dark .text-heading {
|
||||||
color: #f1f5f9;
|
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
@@ -616,6 +616,10 @@ video {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.\!visible {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
.visible {
|
.visible {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
@@ -684,6 +688,11 @@ video {
|
|||||||
grid-column: span 4 / span 4;
|
grid-column: span 4 / span 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mx-3 {
|
.mx-3 {
|
||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
@@ -807,6 +816,10 @@ video {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
@@ -843,6 +856,10 @@ video {
|
|||||||
max-height: 8rem;
|
max-height: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-h-48 {
|
||||||
|
max-height: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
.max-h-56 {
|
.max-h-56 {
|
||||||
max-height: 14rem;
|
max-height: 14rem;
|
||||||
}
|
}
|
||||||
@@ -935,6 +952,10 @@ video {
|
|||||||
width: 10rem;
|
width: 10rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-44 {
|
||||||
|
width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-48 {
|
.w-48 {
|
||||||
width: 12rem;
|
width: 12rem;
|
||||||
}
|
}
|
||||||
@@ -980,6 +1001,10 @@ video {
|
|||||||
min-width: 0px;
|
min-width: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-w-24 {
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.min-w-\[12rem\] {
|
.min-w-\[12rem\] {
|
||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1420,11 @@ video {
|
|||||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
|
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 {
|
.bg-blue-50\/50 {
|
||||||
background-color: rgb(239 246 255 / 0.5);
|
background-color: rgb(239 246 255 / 0.5);
|
||||||
}
|
}
|
||||||
@@ -1796,6 +1826,11 @@ video {
|
|||||||
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
|
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 {
|
.text-green-600 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(22 163 74 / var(--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);
|
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 {
|
.dark .text-heading {
|
||||||
color: #f1f5f9;
|
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 {
|
.hover\:bg-amber-100:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(254 243 199 / var(--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));
|
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 {
|
.hover\:bg-red-200:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(254 202 202 / var(--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);
|
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 *) {
|
.dark\:bg-blue-900\/40:is(.dark *) {
|
||||||
background-color: rgb(30 58 138 / 0.4);
|
background-color: rgb(30 58 138 / 0.4);
|
||||||
}
|
}
|
||||||
@@ -2258,10 +2343,6 @@ video {
|
|||||||
background-color: rgb(6 78 59 / 0.4);
|
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 *) {
|
.dark\:hover\:bg-red-900\/50:hover:is(.dark *) {
|
||||||
background-color: rgb(127 29 29 / 0.5);
|
background-color: rgb(127 29 29 / 0.5);
|
||||||
}
|
}
|
||||||
@@ -2289,6 +2370,11 @@ video {
|
|||||||
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
|
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 *) {
|
.dark\:hover\:text-slate-300:hover:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||||
@@ -2328,7 +2414,8 @@ video {
|
|||||||
.lg\:flex-row {
|
.lg\:flex-row {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}/* ===== Theme contrast adjustments ===== */
|
}
|
||||||
|
/* ===== Theme contrast adjustments ===== */
|
||||||
|
|
||||||
/* Light theme: darken softer (muted) text one step for higher contrast */
|
/* Light theme: darken softer (muted) text one step for higher contrast */
|
||||||
.text-gray-400 {
|
.text-gray-400 {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v466';
|
const CACHE = 'simple-v484';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ function extractFunc(src, name) {
|
|||||||
const FNS = [
|
const FNS = [
|
||||||
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
|
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
|
||||||
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
|
'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',
|
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
|
||||||
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
|
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecEccUsim', 'efEccCategory', 'efDecOpl', 'efDecAd', 'efDecAcl',
|
||||||
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
|
'efSmspDaNumber', 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
|
||||||
'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo',
|
'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo',
|
||||||
'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
|
'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDfFromPath', 'efDecodeBytes',
|
||||||
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
|
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
|
||||||
'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml',
|
'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml',
|
||||||
'pysimFsDecodedHtml', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects',
|
'pysimFsDecodedHtml', 'pysimFsNodePath', 'pysimFsFindNodeByPath', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects',
|
||||||
'profilerAspectSummary', 'profilerMatchedRecordsText', 'profilerRawDataCheck', 'profilerCustomNameForPath',
|
'profilerAspectSummary', 'profilerMatchedRecordsText', 'profilerRawDataCheck', 'profilerCustomNameForPath',
|
||||||
'pysimCustomNormPath',
|
'pysimCustomNormPath',
|
||||||
];
|
];
|
||||||
@@ -42,12 +42,14 @@ for (const f of FNS) code += extractFunc(html, f) + '\n';
|
|||||||
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) {
|
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';
|
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(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
|
||||||
}
|
}
|
||||||
|
code += html.match(/const EF_ECC_CATEGORIES = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||||
|
code += html.match(/const EF_ADF_RIDS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||||
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
||||||
code += html.match(/const EF_DECODERS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
code += html.match(/const EF_DECODERS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||||
code += 'var pysimCustomFiles = [];\nvar pysimFsSelected = \'EF.IMSI\';\n';
|
code += 'var pysimCustomFiles = [];\nvar pysimFsSelected = \'EF.IMSI\';\nvar pysimFsTreeRoot = null;\n';
|
||||||
code += 'function esc(s){return String(s);}\nfunction escHtml(s){return String(s);}\nfunction t(k){return k;}\n';
|
code += 'function esc(s){return String(s);}\nfunction escHtml(s){return String(s);}\nfunction t(k){return k;}\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
|
|
||||||
@@ -103,8 +105,10 @@ test('EF decoders match the pinned spec / pySim test vectors', () => {
|
|||||||
assert.deepStrictEqual(efDecLi(efBytes('FFFF')), { languages: [] });
|
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(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('0000')), { access_classes: [] });
|
||||||
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [0] });
|
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [15] });
|
||||||
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
|
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: 'police' });
|
||||||
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
|
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
|
||||||
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
|
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
|
||||||
// EF.FPLMN: a FFFFFF gap in any position is skipped, not a terminator
|
// EF.FPLMN: a FFFFFF gap in any position is skipped, not a terminator
|
||||||
@@ -115,7 +119,7 @@ test('EF decoders match the pinned spec / pySim test vectors', () => {
|
|||||||
assert.strictEqual(efFindDecoder('EF.FPLMN', null).fn, efDecFplmn);
|
assert.strictEqual(efFindDecoder('EF.FPLMN', null).fn, efDecFplmn);
|
||||||
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
|
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
|
||||||
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
|
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')),
|
assert.deepStrictEqual(efDecNai(efBytes('8021696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')),
|
||||||
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
|
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
|
||||||
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
|
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
|
||||||
@@ -276,6 +280,9 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
|
|||||||
assert.strictEqual(smsp.sca, '+4915790109999');
|
assert.strictEqual(smsp.sca, '+4915790109999');
|
||||||
assert.strictEqual(smsp.tp_pid, '0x00');
|
assert.strictEqual(smsp.tp_pid, '0x00');
|
||||||
assert.strictEqual(smsp.tp_dcs, '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
|
// EF.SMS status byte + skip SMSC + TPDU
|
||||||
const sms = efDecSms(efBytes('030891945197109099f90000'));
|
const sms = efDecSms(efBytes('030891945197109099f90000'));
|
||||||
assert.strictEqual(sms.direction, 'MT');
|
assert.strictEqual(sms.direction, 'MT');
|
||||||
@@ -283,6 +290,31 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
|
|||||||
assert.strictEqual(sms.tpdu, '0000');
|
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', () => {
|
test('service tables: UST service bits and IST fallback for 6F07', () => {
|
||||||
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
|
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
|
||||||
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
|
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
|
||||||
@@ -290,6 +322,29 @@ test('service tables: UST service bits and IST fallback for 6F07', () => {
|
|||||||
assert.deepStrictEqual(ist.services, [{ n: 1, name: 'P-CSCF address' }]);
|
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', () => {
|
test('decoder registry resolves by name first, then FID; unknown returns null', () => {
|
||||||
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
|
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
|
||||||
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
|
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
|
||||||
@@ -304,6 +359,106 @@ test('decoder registry resolves by name first, then FID; unknown returns null',
|
|||||||
assert.strictEqual(efFidFromPath('ADF.USIM'), null);
|
assert.strictEqual(efFidFromPath('ADF.USIM'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('EF.ECC (DF.GSM) decodes the Emergency Service Category like the USIM file', () => {
|
||||||
|
// TS 51.011 10.3.27: the category byte is the TS 24.008 10.5.135d coding,
|
||||||
|
// the same table the ADF.USIM file uses (bit n = service n)
|
||||||
|
assert.deepStrictEqual(efDecEcc(efBytes('19F104')), { number: '911', esc: 'fire brigade' });
|
||||||
|
assert.deepStrictEqual(efDecEcc(efBytes('19F102')), { number: '911', esc: 'ambulance' });
|
||||||
|
assert.deepStrictEqual(efDecEcc(efBytes('19F109')), { number: '911', esc: 'police, marine guard' });
|
||||||
|
// a byte without a known bit keeps its raw hex value
|
||||||
|
assert.deepStrictEqual(efDecEcc(efBytes('19F180')), { number: '911', esc: '0x80' });
|
||||||
|
// a record whose code is FF is still decoded (only an all-FF record is empty)
|
||||||
|
assert.deepStrictEqual(efDecEcc(efBytes('FFFF00')), { number: null, esc: '0x00' });
|
||||||
|
// several 3-byte records
|
||||||
|
assert.deepStrictEqual(efDecEcc(efBytes('19F10411F220')), {
|
||||||
|
numbers: [{ number: '911', esc: 'fire brigade' }, { number: '112', esc: 'manual eCall' }],
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(efDecEcc(efBytes('FFFFFFFF')), { empty: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('efDfFromPath reads the parent DF of a canonical path', () => {
|
||||||
|
assert.strictEqual(efDfFromPath('MF/7F20/6FAD'), '7F20');
|
||||||
|
assert.strictEqual(efDfFromPath('ADF.USIM/6FAD'), 'ADF.USIM');
|
||||||
|
assert.strictEqual(efDfFromPath('MF/ADF.USIM/6FAD'), 'ADF.USIM');
|
||||||
|
assert.strictEqual(efDfFromPath('mf/7f20/6fad'), '7F20');
|
||||||
|
assert.strictEqual(efDfFromPath('MF/7F10/5F3A/4F30'), '5F3A');
|
||||||
|
// a bare FID (and a bare root) carries no DF context
|
||||||
|
assert.strictEqual(efDfFromPath('6FAD'), '');
|
||||||
|
assert.strictEqual(efDfFromPath('MF'), '');
|
||||||
|
assert.strictEqual(efDfFromPath('ADF.USIM'), '');
|
||||||
|
assert.strictEqual(efDfFromPath(''), '');
|
||||||
|
assert.strictEqual(efDfFromPath(null), '');
|
||||||
|
// the two halves of the spec identity come from the same path
|
||||||
|
assert.strictEqual(efFidFromPath('MF/7F20/6FAD'), '6FAD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('efDfFromPath folds an ADF AID root onto its symbolic ADF name', () => {
|
||||||
|
// a snapshot/profile path roots an ADF by the DF name (the AID) the card
|
||||||
|
// reports, not by "ADF.USIM" - the RID decides, whatever the PIX is
|
||||||
|
assert.strictEqual(efDfFromPath('A0000000871002/6F07'), 'ADF.USIM');
|
||||||
|
assert.strictEqual(efDfFromPath('A0000000871002FF49FF0589/6F07'), 'ADF.USIM');
|
||||||
|
assert.strictEqual(efDfFromPath('a0000000871002ff49ff0589/6fad'), 'ADF.USIM');
|
||||||
|
assert.strictEqual(efDfFromPath('A0000000871004/6F07'), 'ADF.ISIM');
|
||||||
|
assert.strictEqual(efDfFromPath('A0000000871004FF49FF0589/6F07'), 'ADF.ISIM');
|
||||||
|
// the non-IMSI USIM (TS 31.102 Annex N) repeats the ADF.USIM file set
|
||||||
|
assert.strictEqual(efDfFromPath('A000000087100B/6F38'), 'ADF.USIM-NON-IMSI');
|
||||||
|
assert.strictEqual(efDfFromPath('a000000087100bff49ff0589/6f38'), 'ADF.USIM-NON-IMSI');
|
||||||
|
// an unrelated AID is not an ADF name: it passes through as itself
|
||||||
|
assert.strictEqual(efDfFromPath('A0000000090003FFFF/6F07'), 'A0000000090003FFFF');
|
||||||
|
assert.strictEqual(efDfFromPath('F0414C46416001/6F07'), 'F0414C46416001');
|
||||||
|
// the AID root reads the same as the symbolic name and the plain FID do
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', '6FB7', efDfFromPath('A0000000871002/6FB7')).fn, efDecEccUsim);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', '6FB7', efDfFromPath('MF/7F20/6FB7')).fn, efDecEcc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the decoder resolver is DF-first: EF.ECC is the SIM or the USIM file', () => {
|
||||||
|
// EF.ECC collides in name AND FID: only the parent DF separates the
|
||||||
|
// TS 51.011 transparent form from the TS 31.102 linear-fixed one
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', '7F20').fn, efDecEcc);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', 'ADF.USIM').fn, efDecEccUsim);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', 'adf.usim').fn, efDecEccUsim);
|
||||||
|
assert.strictEqual(efFindDecoder(null, '6FB7', 'ADF.USIM').fn, efDecEccUsim);
|
||||||
|
// without a DF the historical name-then-FID lookup answers (the first
|
||||||
|
// entry, the DF.GSM form, is unchanged behaviour)
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', '6fb7').fn, efDecEcc);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', null).fn, efDecEcc);
|
||||||
|
// a DF carrying no entry of its own falls back to the generic lookup
|
||||||
|
assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', 'ADF.ISIM').fn, efDecEcc);
|
||||||
|
// the FID fallback stays inside the DF
|
||||||
|
assert.strictEqual(efFindDecoder('EF.NOPE', '6FB7', 'ADF.USIM').fn, efDecEccUsim);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.NOPE', 'abcd', 'ADF.USIM'), null);
|
||||||
|
// every EF.AD form has its own DF-keyed entry over one decoder
|
||||||
|
assert.strictEqual(efFindDecoder('EF.AD', '6fad', '7F20').fn, efDecAd);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.AD', '6fad', 'ADF.USIM').fn, efDecAd);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.AD', '6fad', 'ADF.ISIM').fn, efDecAd);
|
||||||
|
assert.strictEqual(efFindDecoder('EF.AD', '6fad', 'ADF.ISIM').df, 'ADF.ISIM');
|
||||||
|
// files without a collision are unaffected by the DF context
|
||||||
|
assert.strictEqual(efFindDecoder('EF.IMSI', '6f07', 'ADF.USIM').name, 'EF.IMSI');
|
||||||
|
assert.strictEqual(efFindDecoder(null, '2fe2', 'MF').name, 'EF.ICCID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.ECC (USIM) decodes a X+4 record: code, alpha and service category', () => {
|
||||||
|
// pySim's EF_ECC vectors (TS 31.102 4.2.21): 3 BCD code bytes, the
|
||||||
|
// optional annex A alpha, then the category byte
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF01')), { code: '911', category: 'police' });
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('19F3FF02')), { code: '913', category: 'ambulance' });
|
||||||
|
// code 112 = 11 F2 FF (odd digit count, F padded)
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('11F2FF40')), { code: '112', category: 'automatic eCall' });
|
||||||
|
// with the optional alpha identifier (X = 3, GSM 7-bit octets)
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF424F4204')),
|
||||||
|
{ code: '911', category: 'fire brigade', alpha: 'BOB' });
|
||||||
|
// a UCS-2 (0x80-prefixed) alpha, and two category bits at once
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF800042004F004205')),
|
||||||
|
{ code: '911', category: 'police, fire brigade', alpha: 'BOB' });
|
||||||
|
// an unmodelled category byte stays visible as its hex value
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF80')), { code: '911', category: '0x80' });
|
||||||
|
// unused records: all FF, or the mandatory code all FF
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('FFFFFFFFFFFF')), { empty: true });
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('FFFFFF02')), { empty: true });
|
||||||
|
// shorter than the mandatory fields: raw fallback, never a throw
|
||||||
|
assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF')), { raw: '19F1FF' });
|
||||||
|
});
|
||||||
|
|
||||||
test('efDecodeFile handles transparent and record content', () => {
|
test('efDecodeFile handles transparent and record content', () => {
|
||||||
const t = efDecodeFile('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' });
|
const t = efDecodeFile('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' });
|
||||||
assert.strictEqual(typeof t.fn, 'function');
|
assert.strictEqual(typeof t.fn, 'function');
|
||||||
@@ -345,6 +500,58 @@ test('efFlatten skips the all-FF empty marker', () => {
|
|||||||
assert.deepStrictEqual(efFlatten({ empty: true }), []);
|
assert.deepStrictEqual(efFlatten({ empty: true }), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the file manager resolves two same-named files by their parent DF', () => {
|
||||||
|
// EF.ECC exists in DF.GSM and ADF.USIM with the same name and FID; the
|
||||||
|
// selection key is the canonical path, so each resolves to its own node
|
||||||
|
const root = { name: 'MF', fid: '3F00', children: [], parent: null };
|
||||||
|
const gsm = { name: 'DF.GSM', fid: '7F20', children: [], parent: root };
|
||||||
|
const usim = { name: 'ADF.USIM', fid: '7FFF', children: [], parent: root };
|
||||||
|
const gsmEcc = { name: 'EF.ECC', fid: '6FB7', children: null, parent: gsm };
|
||||||
|
const usimEcc = { name: 'EF.ECC', fid: '6FB7', children: null, parent: usim };
|
||||||
|
root.children = [gsm, usim];
|
||||||
|
gsm.children = [gsmEcc];
|
||||||
|
usim.children = [usimEcc];
|
||||||
|
pysimFsTreeRoot = root;
|
||||||
|
assert.strictEqual(pysimFsFindNodeByPath('MF/7F20/6FB7'), gsmEcc);
|
||||||
|
assert.strictEqual(pysimFsFindNodeByPath('ADF.USIM/6FB7'), usimEcc);
|
||||||
|
// a path that is not loaded resolves to nothing (the callers fall back to
|
||||||
|
// the path's own last segment / FID)
|
||||||
|
assert.strictEqual(pysimFsFindNodeByPath('MF/7F10/6FB7'), null);
|
||||||
|
// the decoded view's DF follows the resolved node: the USIM file is the
|
||||||
|
// linear-fixed X+4 form, the DF.GSM copy the SIM transparent one
|
||||||
|
pysimFsSelected = 'ADF.USIM/6FB7';
|
||||||
|
const usimHtml = pysimFsDecodedHtml(pysimFsFindNodeByPath(pysimFsSelected),
|
||||||
|
{ success: true, records: [{ num: 1, data: '19F1FF04' }] });
|
||||||
|
assert.ok(usimHtml.includes('Emergency call code'));
|
||||||
|
assert.ok(usimHtml.includes('fire brigade'));
|
||||||
|
pysimFsSelected = 'MF/7F20/6FB7';
|
||||||
|
const gsmHtml = pysimFsDecodedHtml(pysimFsFindNodeByPath(pysimFsSelected),
|
||||||
|
{ success: true, data: '19F104' });
|
||||||
|
assert.ok(gsmHtml.includes('Emergency category'));
|
||||||
|
assert.ok(gsmHtml.includes('fire brigade'));
|
||||||
|
assert.ok(!gsmHtml.includes('Emergency call code'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimFsDecodedHtml decodes EF.ECC by the selected node\'s parent DF', () => {
|
||||||
|
// ADF.USIM's EF.ECC is the linear-fixed X+4 form (code/alpha/category)
|
||||||
|
const usim = { name: 'ADF.USIM', children: [] };
|
||||||
|
const usimEcc = { name: 'EF.ECC', fid: '6FB7', parent: usim };
|
||||||
|
usim.children.push(usimEcc);
|
||||||
|
const usimHtml = pysimFsDecodedHtml(usimEcc, { success: true, records: [{ num: 1, data: '19F1FF01' }] });
|
||||||
|
assert.ok(usimHtml.includes('Emergency call code'));
|
||||||
|
assert.ok(usimHtml.includes('911'));
|
||||||
|
assert.ok(usimHtml.includes('police'));
|
||||||
|
// DF.GSM's EF.ECC keeps the transparent code + category decode
|
||||||
|
const gsm = { name: 'DF.GSM', fid: '7F20', children: [] };
|
||||||
|
const gsmEcc = { name: 'EF.ECC', fid: '6FB7', parent: gsm };
|
||||||
|
gsm.children.push(gsmEcc);
|
||||||
|
const gsmHtml = pysimFsDecodedHtml(gsmEcc, { success: true, data: '19F101' });
|
||||||
|
assert.ok(gsmHtml.includes('Number'));
|
||||||
|
assert.ok(gsmHtml.includes('911'));
|
||||||
|
assert.ok(gsmHtml.includes('Emergency category'));
|
||||||
|
assert.ok(!gsmHtml.includes('Emergency call code'));
|
||||||
|
});
|
||||||
|
|
||||||
test('efDiffData names an all-FF side Empty (all FF)', () => {
|
test('efDiffData names an all-FF side Empty (all FF)', () => {
|
||||||
assert.deepStrictEqual(efDiffData({ empty: true }, { full: 'Miran' }),
|
assert.deepStrictEqual(efDiffData({ empty: true }, { full: 'Miran' }),
|
||||||
[['Full name', 'Empty (all FF)', 'Miran']]);
|
[['Full name', 'Empty (all FF)', 'Miran']]);
|
||||||
|
|||||||
@@ -44,19 +44,20 @@ function extractConst(src, name) {
|
|||||||
|
|
||||||
const FNS = [
|
const FNS = [
|
||||||
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efUcs2Be',
|
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efUcs2Be',
|
||||||
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct',
|
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct',
|
||||||
'efDecSpn', 'efDecPlmnWact', 'efDecAdn', 'efDecPnn', 'efFindDecoder',
|
'efDecSpn', 'efDecPlmnWact', 'efDecAdn', 'efDecPnn', 'efDecSmsp', 'efFindDecoder',
|
||||||
'efDecodeBytes', 'efTlv', 'efPnnText', 'efDecItemsFromHex',
|
'efDecodeBytes', 'efTlv', 'efPnnText', 'efDecItemsFromHex',
|
||||||
'efGsm7PlainText', 'efEncTextBytes', 'efPlmnEnc', 'efActEnc', 'efEncSpn',
|
'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',
|
'efFindEncoder', 'efHasEncoder', 'gsm7Decode', 'gsm7TextToSeptets',
|
||||||
'gsm7Encode', 'efEncError',
|
'gsm7Encode', 'efEncError', 'efDecModelValues',
|
||||||
|
'efDecServiceTable', 'efDecSst', 'efDecAcc', 'efDecPhase', 'efDecHpplmn', 'efDecAd', 'efDecCbmi', 'efDecCbmir', 'efDecIccid', 'efDecImsi', 'efDecPlmnList', 'efDecFplmn', 'efEncServiceTable', 'efEncSst', '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 = '';
|
let code = '';
|
||||||
for (const f of FNS) code += extractFunc(html, f) + '\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',
|
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_SST_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);
|
code += extractConst(html, c);
|
||||||
}
|
}
|
||||||
eval(code);
|
eval(code);
|
||||||
@@ -211,12 +212,49 @@ 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.ALIAS', '6f60').enc, efEncPlmnList);
|
||||||
assert.strictEqual(efFindEncoder('EF.NOPE', 'abcd'), null);
|
assert.strictEqual(efFindEncoder('EF.NOPE', 'abcd'), null);
|
||||||
assert.strictEqual(efHasEncoder('EF.ADN', null), true);
|
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
|
// 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.ADN', null).fields, efFindEncoder('EF.BDN', null).fields);
|
||||||
assert.strictEqual(efFindEncoder('EF.PLMNwAcT', null).list, efFindEncoder('EF.HPLMNwAcT', null).list);
|
assert.strictEqual(efFindEncoder('EF.PLMNwAcT', null).list, efFindEncoder('EF.HPLMNwAcT', null).list);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the encoder resolver is DF-first: every EF.AD form shares one encoder', () => {
|
||||||
|
// EF.AD collides in name AND FID: one entry per DF (DF.GSM 3+X,
|
||||||
|
// ADF.USIM 4+X, ADF.ISIM 3+X), all over the same length-preserving codec
|
||||||
|
const sim = efFindEncoder('EF.AD', '6fad', '7F20');
|
||||||
|
const usim = efFindEncoder('EF.AD', '6fad', 'ADF.USIM');
|
||||||
|
const isim = efFindEncoder('EF.AD', '6fad', 'ADF.ISIM');
|
||||||
|
for (const e of [sim, usim, isim]) {
|
||||||
|
assert.strictEqual(e.enc, efEncAd);
|
||||||
|
assert.strictEqual(e.model, efAdModel);
|
||||||
|
assert.strictEqual(e.fields, EF_AD_FIELDS);
|
||||||
|
}
|
||||||
|
assert.notStrictEqual(sim, isim);
|
||||||
|
// without a DF the historical name-first lookup answers (DF.GSM's entry)
|
||||||
|
assert.strictEqual(efFindEncoder('EF.AD', '6fad'), sim);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.AD', '6fad', 'ADF.NOPE'), sim);
|
||||||
|
// EF.ECC stays read-only in either DF; the shared 6F38 FID resolves to
|
||||||
|
// EF.SST under DF.GSM and to EF.UST under ADF.USIM (the FID fallback
|
||||||
|
// without a DF keeps the historical UST answer - SST's entry is DF-scoped)
|
||||||
|
assert.strictEqual(efHasEncoder('EF.ECC', '6fb7', '7F20'), false);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.ECC', '6fb7', 'ADF.USIM'), false);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.SST', '6f38', '7F20'), true);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.UST', '6f38', 'ADF.USIM'), true);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.SST', '6f38', '7F20').enc, efEncSst);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.UST', '6f38', 'ADF.USIM').enc, efFindEncoder('EF.UST', '6f38').enc);
|
||||||
|
assert.strictEqual(efFindEncoder(null, '6f38', '7F20').name, 'EF.SST');
|
||||||
|
assert.strictEqual(efFindEncoder(null, '6f38', 'ADF.USIM').name, 'EF.UST');
|
||||||
|
assert.strictEqual(efFindEncoder(null, '6f38').name, 'EF.UST');
|
||||||
|
assert.strictEqual(efFindEncoder(null, '6f38', 'ADF.USIM').enc !== efEncSst, true);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.NOPE', 'abcd', 'ADF.USIM'), null);
|
||||||
|
// the SIM 3+X and USIM 4+X forms keep their length on an untouched save
|
||||||
|
for (const hex of ['00' + '0000', '00' + '0000' + '02' + 'AABB', AD_HEX, 'FF'.repeat(6)]) {
|
||||||
|
for (const e of [sim, usim]) {
|
||||||
|
assert.strictEqual(e.enc(e.model(efDecAd(efBytes(hex))), hex), hex.toUpperCase(), hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('encoder validation errors go through t() when the runtime provides it', () => {
|
test('encoder validation errors go through t() when the runtime provides it', () => {
|
||||||
globalThis.t = k => (k === 'Select at least one access technology' ? 'Выберите технологию' : k);
|
globalThis.t = k => (k === 'Select at least one access technology' ? 'Выберите технологию' : k);
|
||||||
try {
|
try {
|
||||||
@@ -429,14 +467,37 @@ test('F14: the encoder messages go through t() (unknown technology, too many row
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('unchanged content re-encodes byte-identically (all fixtures)', () => {
|
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 decode = (name, hex) => {
|
||||||
|
const entry = efFindEncoder(name, null);
|
||||||
const bytes = efBytes(hex);
|
const bytes = efBytes(hex);
|
||||||
if (name === 'EF.SPN') return efDecSpn(bytes);
|
let dec;
|
||||||
if (name === 'EF.PLMNwAcT') return efDecPlmnWact(bytes).plmns;
|
if (name === 'EF.SPN') dec = efDecSpn(bytes);
|
||||||
if (name === 'EF.BDN') return efDecAdn(bytes, 15);
|
else if (tables[name]) {
|
||||||
if (name === 'EF.ADN') return efDecAdn(bytes);
|
// the form renders only the table's services; other set bits stay
|
||||||
if (name === 'EF.PNN') return efDecItemsFromHex(efFindEncoder('EF.PNN', null), hex);
|
// in the original bytes
|
||||||
throw new Error('no decoder for ' + name);
|
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 = [
|
const cases = [
|
||||||
{ name: 'EF.SPN', fid: '6f46', hex: SPN_ORIG },
|
{ name: 'EF.SPN', fid: '6f46', hex: SPN_ORIG },
|
||||||
@@ -456,6 +517,40 @@ test('unchanged content re-encodes byte-identically (all fixtures)', () => {
|
|||||||
{ name: 'EF.PNN', fid: '6fc5', hex: PNN_2 },
|
{ name: 'EF.PNN', fid: '6fc5', hex: PNN_2 },
|
||||||
{ name: 'EF.PNN', fid: '6fc5', hex: '800400410042' + 'FF'.repeat(16) },
|
{ name: 'EF.PNN', fid: '6fc5', hex: '800400410042' + 'FF'.repeat(16) },
|
||||||
{ name: 'EF.PNN', fid: '6fc5', hex: '430a82d432bbbc7eb75de4325a02aabb' + 'ff'.repeat(14) },
|
{ 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) {
|
for (const c of cases) {
|
||||||
const entry = efFindEncoder(c.name, c.fid);
|
const entry = efFindEncoder(c.name, c.fid);
|
||||||
@@ -511,3 +606,500 @@ test('F8: the ADN-family entries name their extension EF and use it in refusals'
|
|||||||
// the unchanged number still copies the extension slots verbatim
|
// the unchanged number still copies the extension slots verbatim
|
||||||
assert.strictEqual(efEncAdn(efDecAdn(efBytes(fdn)), fdn, 14, 'EXT2'), fdn.toUpperCase());
|
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) + 'C0'; // 29 + bits 151-152 (above the 150-service table)
|
||||||
|
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 its own 2-bit editor on the shared 6F38 FID (the DF-scoped
|
||||||
|
// entry keeps the UST answer for a bare FID / ADF context)
|
||||||
|
assert.strictEqual(efFindEncoder('EF.SST', '6f38').enc, efEncSst);
|
||||||
|
assert.strictEqual(efHasEncoder('EF.SST', '6f38'), true);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.UST', '6f38').name, 'EF.UST');
|
||||||
|
assert.strictEqual(efFindEncoder(null, '6f38').name, 'EF.UST');
|
||||||
|
assert.strictEqual(efFindEncoder(null, '6f38', '7F20').name, 'EF.SST');
|
||||||
|
// 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) + 'C0');
|
||||||
|
// 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: 151, 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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// EF.SST (TS 51.011 10.3.7): two bits per service, four per byte. 15 bytes =
|
||||||
|
// services 1-60; C7/1C/C1 carry on (11), off (10) and deleted (00) pairs and
|
||||||
|
// the last byte's bits 7-8 are service 60, above the 59-entry table.
|
||||||
|
const SST_MIX = 'C71CC1' + '00'.repeat(11) + 'C0';
|
||||||
|
|
||||||
|
test('EF.SST: the 2-bit encoder round-trips the decoded file', () => {
|
||||||
|
const dec = efDecSst(efBytes(SST_MIX));
|
||||||
|
assert.deepStrictEqual(dec.services.map(s => [s.n, s.allocated, s.activated]), [
|
||||||
|
[1, true, true], [2, true, false], [4, true, true], [6, true, true],
|
||||||
|
[7, true, false], [9, true, false], [12, true, true], [60, true, true],
|
||||||
|
]);
|
||||||
|
// a deleted (00) pair is omitted by the decoder, so decode->encode is exact
|
||||||
|
assert.strictEqual(efEncSst(dec, SST_MIX), SST_MIX);
|
||||||
|
// every state on: all-FF, unmodelled service 60 included
|
||||||
|
assert.strictEqual(efEncSst(efDecSst(efBytes('FF'.repeat(15))), 'FF'.repeat(15)), 'FF'.repeat(15));
|
||||||
|
// no service allocated at all
|
||||||
|
assert.strictEqual(efEncSst(efDecSst(efBytes('00'.repeat(15))), '00'.repeat(15)), '00'.repeat(15));
|
||||||
|
// clearing every modelled service keeps service 60's pair (byte 14 C0)
|
||||||
|
assert.strictEqual(efEncSst({ services: [] }, SST_MIX), '000000' + '00'.repeat(11) + 'C0');
|
||||||
|
// the file length is kept
|
||||||
|
assert.strictEqual(efEncSst(efDecSst(efBytes('0C')), '0C'), '0C');
|
||||||
|
assert.strictEqual(efEncSst(efDecSst(efBytes('0C00')), '0C00'), '0C00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.SST: deleted clears the pair, off sets 10 and on sets 11', () => {
|
||||||
|
const on = n => ({ n: n, allocated: true, activated: true });
|
||||||
|
const off = n => ({ n: n, allocated: true, activated: false });
|
||||||
|
const del = n => ({ n: n, allocated: false, activated: false });
|
||||||
|
// service 2 (byte 0, pair 2)
|
||||||
|
assert.strictEqual(efEncSst({ services: [on(2)] }, '00'), '0C');
|
||||||
|
assert.strictEqual(efEncSst({ services: [off(2)] }, '00'), '04');
|
||||||
|
assert.strictEqual(efEncSst({ services: [del(2)] }, '0C'), '00');
|
||||||
|
// every modelled pair is rebuilt from the list, the rest of the file stays
|
||||||
|
assert.strictEqual(efEncSst({ services: [on(2)] }, 'FF'.repeat(15)),
|
||||||
|
'0C' + '00'.repeat(13) + 'C0');
|
||||||
|
assert.strictEqual(efEncSst({ services: [on(2), off(4)] }, SST_MIX).slice(0, 2), '4C');
|
||||||
|
// a pair above the table is written like any other (a longer file)
|
||||||
|
const long = '00'.repeat(16);
|
||||||
|
assert.strictEqual(efEncSst({ services: [on(62)] }, long), '00'.repeat(15) + '0C');
|
||||||
|
// the decoder's illegal '08' pair (activated, not allocated) is refused
|
||||||
|
assert.deepStrictEqual(efDecSst(efBytes('08')), { services: [
|
||||||
|
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: false, activated: true },
|
||||||
|
] });
|
||||||
|
assert.throws(() => efEncSst(efDecSst(efBytes('08')), '08'), /must be deleted, off or on/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EF.SST: unknown services, an invalid state and a too-short file are refused', () => {
|
||||||
|
assert.throws(() => efEncSst({ services: [{ n: 5, allocated: true, activated: true }] }, '00'),
|
||||||
|
/too short for service/);
|
||||||
|
assert.throws(() => efEncSst({ services: [{ n: 0, allocated: true, activated: true }] }, '00'),
|
||||||
|
/Unknown service/);
|
||||||
|
assert.throws(() => efEncSst({ services: [{ n: 'x' }] }, '00'), /Unknown service/);
|
||||||
|
assert.throws(() => efEncSst({ services: [{ n: 2, allocated: false, activated: true }] }, '00'),
|
||||||
|
/must be deleted, off or on/);
|
||||||
|
// a bare service number carries no state
|
||||||
|
assert.throws(() => efEncSst({ services: [2] }, '00'), /must be deleted, off or on/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,29 +40,30 @@ function extractConst(src, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FNS = [
|
const FNS = [
|
||||||
'efBytes', 'efHex', 'efAllFf', 'efRstripFf', 'efUcs2Be', 'efGsm7Octets',
|
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efUcs2Be', 'efGsm7Octets',
|
||||||
'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct', 'efDecSpn', 'efDecPlmnWact',
|
'efAnnexA', 'efBcdAddress', 'efPlmn', 'efPlmnAct', 'efDecSpn', 'efDecPlmnWact',
|
||||||
'efDecAdn', 'efFindDecoder', 'efDecodeBytes', 'efIsRawOnly', 'efTlv',
|
'efDecAdn', 'efFindDecoder', 'efDecodeBytes', 'efIsRawOnly', 'efTlv',
|
||||||
'efPnnText', 'efDecItemsFromHex',
|
'efPnnText', 'efDecItemsFromHex',
|
||||||
'efGsm7PlainText', 'efEncTextBytes', 'efPlmnEnc', 'efActEnc', 'efEncSpn',
|
'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',
|
'efFindEncoder', 'efHasEncoder', 'efEncError',
|
||||||
'efDecModelValues', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
|
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
|
||||||
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
|
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
|
||||||
'efDecRecordsHtml', 'efDecEditHtml',
|
'efDecRecordsHtml', 'efDecEditHtml',
|
||||||
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
|
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
|
||||||
'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
|
'efBitsSelected', 'pysimFsDecBitsFilter', 'pysimFsDecBits2Filter', 'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
|
||||||
'pysimFsDecRemoveItem', 'pysimFsFindNode',
|
'pysimFsDecRemoveItem', 'pysimFsFindNodeByPath', 'pysimCustomNormPath', 'pysimFsNodePath', 'pysimFsFallbackNode', 'efFidFromPath', 'efDfFromPath',
|
||||||
'pysimFsHasDecoder', 'pysimFsHasEncoder', 'pysimFsDecSave', 'pysimFsDecEdit',
|
'pysimFsHasDecoder', 'pysimFsHasEncoder', 'pysimFsDecSave', 'pysimFsDecEdit',
|
||||||
'pysimFsPillsEnabled', 'pysimFsSyncContentVisibility', 'gsm7Decode',
|
'pysimFsPillsEnabled', 'pysimFsSyncContentVisibility', 'gsm7Decode',
|
||||||
'gsm7TextToSeptets', 'gsm7Encode',
|
'gsm7TextToSeptets', 'gsm7Encode',
|
||||||
|
'efDecServiceTable', 'efDecSst', 'efDecAcc', 'efDecPhase', 'efDecHpplmn', 'efDecAd', 'efDecCbmi', 'efDecCbmir', 'efDecIccid', 'efDecImsi', 'efDecPlmnList', 'efDecFplmn', 'efEncServiceTable', 'efEncSst', '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'
|
let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n'
|
||||||
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n';
|
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n';
|
||||||
for (const f of FNS) code += extractFunc(html, f) + '\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',
|
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_SST_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', 'EF_ADF_RIDS']) {
|
||||||
code += extractConst(html, c);
|
code += extractConst(html, c);
|
||||||
}
|
}
|
||||||
eval(code);
|
eval(code);
|
||||||
@@ -70,6 +71,22 @@ eval(code);
|
|||||||
// The decoder entries these files resolve to (the full EF_DECODERS registry is
|
// 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).
|
// pinned by ef_decode.test.js; only the model's lookup path is exercised here).
|
||||||
var EF_DECODERS = [
|
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.SPN', fid: '6f46', fn: efDecSpn },
|
||||||
{ name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
|
{ name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
|
||||||
{ name: 'EF.OPLMNwAcT', fid: '6f61', 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('ef.plmnwact', null).name, 'EF.PLMNwAcT');
|
||||||
assert.strictEqual(efFindEncoder('MF/7F10/6F3A/EF.ADN', null).name, 'EF.ADN');
|
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.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(efFindEncoder(null, null), null);
|
||||||
assert.strictEqual(efHasEncoder('EF.SDN', null), true);
|
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', () => {
|
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.deepStrictEqual(adn.records[1].values, {});
|
||||||
assert.strictEqual(adn.records[1].orig, ADN_FF);
|
assert.strictEqual(adn.records[1].orig, ADN_FF);
|
||||||
// no encoder -> no model, even when a decoder exists
|
// 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);
|
assert.strictEqual(efDecEditModel('EF.NOPE', 'abcd', { kind: 'transparent', data: '00' }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,20 +253,50 @@ test('changing a record input checks its block', () => {
|
|||||||
assert.strictEqual(cb.checked, true);
|
assert.strictEqual(cb.checked, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pysimFsHasEncoder resolves the selected file by name or FID', () => {
|
test('pysimFsHasEncoder resolves the selected file by its path or FID', () => {
|
||||||
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SPN', fid: '6f46' }, { name: 'EF.ALIAS', fid: '6f3a' }] };
|
pysimFsTreeRoot = { name: 'MF', children: [] };
|
||||||
pysimFsSelected = 'EF.SPN';
|
const add = (name, fid) => {
|
||||||
|
const n = { name: name, fid: fid, parent: pysimFsTreeRoot };
|
||||||
|
pysimFsTreeRoot.children.push(n);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
add('EF.SPN', '6f46');
|
||||||
|
add('EF.ALIAS', '6f3a');
|
||||||
|
pysimFsSelected = 'MF/6F46';
|
||||||
assert.strictEqual(pysimFsHasEncoder(), true);
|
assert.strictEqual(pysimFsHasEncoder(), true);
|
||||||
pysimFsSelected = 'EF.ALIAS';
|
pysimFsSelected = 'MF/6F3A';
|
||||||
assert.strictEqual(pysimFsHasEncoder(), true);
|
assert.strictEqual(pysimFsHasEncoder(), true);
|
||||||
|
// a bare FID selection still resolves through the FID fallback
|
||||||
pysimFsSelected = '6F46';
|
pysimFsSelected = '6F46';
|
||||||
assert.strictEqual(pysimFsHasEncoder(), true);
|
assert.strictEqual(pysimFsHasEncoder(), true);
|
||||||
pysimFsSelected = 'EF.IMSI';
|
pysimFsSelected = 'MF/6F7E';
|
||||||
assert.strictEqual(pysimFsHasEncoder(), false);
|
assert.strictEqual(pysimFsHasEncoder(), false);
|
||||||
|
// the SIM service table shares 6F38 with UST but has its own 2-bit editor
|
||||||
|
pysimFsTreeRoot = { name: 'MF', children: [] };
|
||||||
|
const gsm = { name: 'DF.GSM', fid: '7F20', parent: pysimFsTreeRoot, children: [] };
|
||||||
|
pysimFsTreeRoot.children.push(gsm);
|
||||||
|
const sst = { name: 'EF.SST', fid: '6f38', parent: gsm };
|
||||||
|
gsm.children.push(sst);
|
||||||
|
pysimFsSelected = 'MF/7F20/6F38';
|
||||||
|
assert.strictEqual(pysimFsHasEncoder(), true);
|
||||||
pysimFsSelected = null;
|
pysimFsSelected = null;
|
||||||
assert.strictEqual(pysimFsHasEncoder(), false);
|
assert.strictEqual(pysimFsHasEncoder(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the file-manager resolver call sites pass the parent DF', () => {
|
||||||
|
// the DF context comes from the selected node's canonical path (or the
|
||||||
|
// selected path itself when the node is not loaded); these sites
|
||||||
|
// gate/read/edit a selected file and must resolve the same entry
|
||||||
|
assert.ok(html.includes('return !!efFindDecoder(node && node.name, fid, df);'));
|
||||||
|
assert.ok(html.includes('return !!efFindEncoder(node && node.name, fid, df);'));
|
||||||
|
assert.ok(html.includes("const df = node ? efDfFromPath(pysimFsNodePath(node)) : efDfFromPath(pysimFsSelected);"));
|
||||||
|
assert.ok(html.includes('const model = efDecEditModel(node && node.name, fid, content,'));
|
||||||
|
for (const fn of ['pysimFsDecSave', 'pysimFsDecAddRow', 'pysimFsDecAddItem']) {
|
||||||
|
assert.ok(/efFindEncoder\([^;]*efDfFromPath\(pysimFsNodePath\(node\)\)/s.test(extractFunc(html, fn)),
|
||||||
|
fn + ' resolves the entry with the node DF');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('the Edit decoded button is encoder-gated next to Edit raw', () => {
|
test('the Edit decoded button is encoder-gated next to Edit raw', () => {
|
||||||
const btn = /<button[^>]*id="pysim-fs-edit-dec-btn"[^>]*>/.exec(html);
|
const btn = /<button[^>]*id="pysim-fs-edit-dec-btn"[^>]*>/.exec(html);
|
||||||
assert.ok(btn, 'Edit decoded button');
|
assert.ok(btn, 'Edit decoded button');
|
||||||
@@ -613,8 +660,9 @@ test('F2: untouched PNN items keep their TLV bytes through the DOM and the save'
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('F12: a selection change during the read abandons the decoded edit', async () => {
|
test('F12: a selection change during the read abandons the decoded edit', async () => {
|
||||||
pysimFsSelected = 'EF.SPN';
|
pysimFsSelected = 'MF/6F46';
|
||||||
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SPN', fid: '6f46' }] };
|
pysimFsTreeRoot = { name: 'MF', children: [] };
|
||||||
|
pysimFsTreeRoot.children.push({ name: 'EF.SPN', fid: '6f46', parent: pysimFsTreeRoot });
|
||||||
const fakeEl = () => {
|
const fakeEl = () => {
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
return {
|
return {
|
||||||
@@ -647,9 +695,526 @@ test('F12: a selection change during the read abandons the decoded edit', async
|
|||||||
let resets = 0;
|
let resets = 0;
|
||||||
globalThis.pysimFsResetEdit = () => { resets++; };
|
globalThis.pysimFsResetEdit = () => { resets++; };
|
||||||
const pending = pysimFsDecEdit();
|
const pending = pysimFsDecEdit();
|
||||||
pysimFsSelected = 'EF.ADN';
|
pysimFsSelected = 'MF/6F3A';
|
||||||
release();
|
release();
|
||||||
await pending;
|
await pending;
|
||||||
assert.strictEqual(resets, 1);
|
assert.strictEqual(resets, 1);
|
||||||
assert.strictEqual(content.innerHTML, '', 'the old file must not render over the new selection');
|
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 (150 services)
|
||||||
|
assert.strictEqual((out.match(/data-bit="/g) || []).length, 150);
|
||||||
|
// 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 EF.SST grid renders one tri-state select per service', () => {
|
||||||
|
// 4C: service 2 on (11), service 4 off (10), the rest of the pairs deleted
|
||||||
|
const model = efDecEditModel('EF.SST', '6f38', { kind: 'transparent', data: '4C' }, '7F20');
|
||||||
|
assert.deepStrictEqual(model.values.services, [
|
||||||
|
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: true, activated: true },
|
||||||
|
{ n: 4, name: 'Short Message Storage (SMS)', allocated: true, activated: false },
|
||||||
|
]);
|
||||||
|
const out = efDecEditHtml(model, '4C');
|
||||||
|
assert.match(out, /class="pysim-dec-bits /);
|
||||||
|
assert.match(out, /pysim-dec-bits-filter/);
|
||||||
|
assert.match(out, /data-bit2="2" data-bit2-name="Abbreviated Dialling Numbers \(ADN\)"/);
|
||||||
|
// the whole SST table is rendered (59 services, RFU 8/20/50 included)
|
||||||
|
assert.strictEqual((out.match(/data-bit2="/g) || []).length, 59);
|
||||||
|
assert.match(out, /data-bit2="8" data-bit2-name="RFU"/);
|
||||||
|
assert.match(out, /data-bit2="20" data-bit2-name="RFU"/);
|
||||||
|
assert.match(out, /data-bit2="50" data-bit2-name="Reserved and shall be ignored"/);
|
||||||
|
// each select is preselected from the decoded state, an absent pair is deleted
|
||||||
|
const sel = n => {
|
||||||
|
const m = new RegExp('<select data-bit2="' + n + '"[^>]*>([\\s\\S]*?)</select>').exec(out);
|
||||||
|
assert.ok(m, 'select for service ' + n);
|
||||||
|
return m[1];
|
||||||
|
};
|
||||||
|
assert.match(sel(2), /<option value="on" selected>On<\/option>/);
|
||||||
|
assert.match(sel(4), /<option value="off" selected>Off<\/option>/);
|
||||||
|
assert.match(sel(1), /<option value="deleted" selected>Deleted<\/option>/);
|
||||||
|
assert.match(sel(59), /<option value="deleted" selected>Deleted<\/option>/);
|
||||||
|
for (const n of [2, 4, 1, 59]) {
|
||||||
|
assert.match(sel(n), />Deleted<\/option>/);
|
||||||
|
assert.match(sel(n), />Off<\/option>/);
|
||||||
|
assert.match(sel(n), />On<\/option>/);
|
||||||
|
}
|
||||||
|
// an all-FF file preselects every modelled service 'on'
|
||||||
|
const ff = efDecEditHtml(efDecEditModel('EF.SST', '6f38',
|
||||||
|
{ kind: 'transparent', data: 'FF'.repeat(15) }, '7F20'), 'FF'.repeat(15));
|
||||||
|
assert.strictEqual((ff.match(/<option value="on" selected>/g) || []).length, 59);
|
||||||
|
assert.ok(!/<option value="(deleted|off)" selected>/.test(ff));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the EF.SST collector yields {n, name, allocated, activated}', () => {
|
||||||
|
const row = (n, name, state) => ({
|
||||||
|
value: state,
|
||||||
|
getAttribute: k => k === 'data-bit2' ? String(n) : name,
|
||||||
|
});
|
||||||
|
const root = {
|
||||||
|
querySelectorAll: sel => sel === '[data-bit2]' ? [
|
||||||
|
row(2, 'Abbreviated Dialling Numbers (ADN)', 'on'),
|
||||||
|
row(1, 'CHV1 disable function', 'deleted'),
|
||||||
|
row(4, 'Short Message Storage (SMS)', 'off'),
|
||||||
|
] : [],
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(efDecCollectValues(root, EF_SST_FIELDS), { services: [
|
||||||
|
{ n: 2, name: 'Abbreviated Dialling Numbers (ADN)', allocated: true, activated: true },
|
||||||
|
{ n: 1, name: 'CHV1 disable function', allocated: false, activated: false },
|
||||||
|
{ n: 4, name: 'Short Message Storage (SMS)', allocated: true, activated: false },
|
||||||
|
] });
|
||||||
|
// an untouched form re-encodes to the original bytes
|
||||||
|
const dec = efDecSst(efBytes('4C'));
|
||||||
|
const values = efDecCollectValues({
|
||||||
|
querySelectorAll: () => [2, 4].map(n => row(n, EF_SST_SERVICES[n],
|
||||||
|
dec.services.find(s => s.n === n).activated ? 'on' : 'off')),
|
||||||
|
}, EF_SST_FIELDS);
|
||||||
|
assert.strictEqual(efFindEncoder('EF.SST', '6f38', '7F20').enc(values, '4C'), '4C');
|
||||||
|
// the EF.SST field is the tri-state kind, the 1-bit tables are untouched
|
||||||
|
assert.strictEqual(EF_SST_FIELDS[0].kind, 'bits2');
|
||||||
|
assert.strictEqual(EF_UST_FIELDS[0].kind, 'bits');
|
||||||
|
assert.strictEqual(EF_EST_FIELDS[0].kind, 'bits');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tri-state grid filter matches the row label, not the select options', () => {
|
||||||
|
const rows = [
|
||||||
|
{ getAttribute: k => k === 'data-label' ? '1 CHV1 disable function' : '', style: {} },
|
||||||
|
{ getAttribute: k => k === 'data-label' ? '4 Short Message Storage (SMS)' : '', style: {} },
|
||||||
|
];
|
||||||
|
const box = { querySelectorAll: sel => sel === '.pysim-dec-bit2-row' ? rows : [] };
|
||||||
|
const input = { value: 'sms', closest: sel => sel === '.pysim-dec-bits' ? box : null };
|
||||||
|
pysimFsDecBits2Filter(input);
|
||||||
|
assert.strictEqual(rows[0].style.display, 'none');
|
||||||
|
assert.strictEqual(rows[1].style.display, '');
|
||||||
|
// 'off' is a state option of every row, never part of a service name
|
||||||
|
pysimFsDecBits2Filter({ value: 'off', closest: () => box });
|
||||||
|
assert.strictEqual(rows[0].style.display, 'none');
|
||||||
|
assert.strictEqual(rows[1].style.display, 'none');
|
||||||
|
pysimFsDecBits2Filter(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ let code = 'var pysimFsDecodedMode = false;\n'
|
|||||||
+ 'var pysimFsSelected = null;\n'
|
+ 'var pysimFsSelected = null;\n'
|
||||||
+ 'var pysimFsTreeRoot = null;\n';
|
+ 'var pysimFsTreeRoot = null;\n';
|
||||||
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
||||||
'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNode', 'pysimFsHasDecoder',
|
'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNodeByPath', 'pysimCustomNormPath',
|
||||||
|
'pysimFsNodePath', 'efFidFromPath', 'efDfFromPath', 'pysimFsHasDecoder',
|
||||||
'pysimFsSyncContentVisibility']) {
|
'pysimFsSyncContentVisibility']) {
|
||||||
code += extractFunc(html, fn) + '\n';
|
code += extractFunc(html, fn) + '\n';
|
||||||
}
|
}
|
||||||
|
code += html.match(/const EF_ADF_RIDS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
|
|
||||||
let events = [];
|
let events = [];
|
||||||
@@ -83,8 +85,9 @@ function setup(opts) {
|
|||||||
pysimFsDecodedMode = !!opts.decoded;
|
pysimFsDecodedMode = !!opts.decoded;
|
||||||
pysimFsEditMode = false;
|
pysimFsEditMode = false;
|
||||||
pysimFsEditData = null;
|
pysimFsEditData = null;
|
||||||
pysimFsSelected = 'EF.IMSI';
|
pysimFsSelected = 'MF/6F07';
|
||||||
pysimFsTreeRoot = { name: 'MF', fid: '3f00', children: [{ name: 'EF.IMSI', fid: '6f07' }] };
|
pysimFsTreeRoot = { name: 'MF', fid: '3f00', children: [] };
|
||||||
|
pysimFsTreeRoot.children.push({ name: 'EF.IMSI', fid: '6f07', parent: pysimFsTreeRoot });
|
||||||
globalThis.efFindDecoder = (name, fid) => {
|
globalThis.efFindDecoder = (name, fid) => {
|
||||||
const n = (name || '').toUpperCase();
|
const n = (name || '').toUpperCase();
|
||||||
const f = (fid || '').toLowerCase();
|
const f = (fid || '').toLowerCase();
|
||||||
@@ -117,12 +120,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.strictEqual(h.reads(), 0, 'skipRead updates the pills only');
|
||||||
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
|
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
|
||||||
assert.ok(!h.pill('dec').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');
|
pysimFsSetMode('dec');
|
||||||
assert.strictEqual(pysimFsDecodedMode, true);
|
assert.strictEqual(pysimFsDecodedMode, true);
|
||||||
assert.strictEqual(h.reads(), 1, 'a pill click reads the selected file');
|
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('dec').classList.contains('bg-blue-600'));
|
||||||
assert.ok(!h.pill('raw').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', () => {
|
test('pill clicks are ignored while editing', () => {
|
||||||
@@ -198,12 +208,16 @@ test('Read decoded is ignored when the file has no decoder', () => {
|
|||||||
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
|
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pysimFsHasDecoder resolves the selected file by name or FID', () => {
|
test('pysimFsHasDecoder resolves the selected file by its path or FID', () => {
|
||||||
setup();
|
setup();
|
||||||
assert.strictEqual(pysimFsHasDecoder(), true, 'EF.IMSI is in the tree');
|
assert.strictEqual(pysimFsHasDecoder(), true, 'EF.IMSI is in the tree');
|
||||||
// alias name: the decoder is matched by the FID fallback
|
// alias name: the decoder is matched by the FID fallback
|
||||||
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.ALIAS', fid: '6f07' }] };
|
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.ALIAS', fid: '6f07' }] };
|
||||||
pysimFsSelected = 'EF.ALIAS';
|
pysimFsTreeRoot.children[0].parent = pysimFsTreeRoot;
|
||||||
|
pysimFsSelected = 'MF/6F07';
|
||||||
|
assert.strictEqual(pysimFsHasDecoder(), true);
|
||||||
|
// a bare FID selection (an API caller) resolves by the FID fallback
|
||||||
|
pysimFsSelected = '6F07';
|
||||||
assert.strictEqual(pysimFsHasDecoder(), true);
|
assert.strictEqual(pysimFsHasDecoder(), true);
|
||||||
// unknown file -> no decoder
|
// unknown file -> no decoder
|
||||||
globalThis.efFindDecoder = () => null;
|
globalThis.efFindDecoder = () => null;
|
||||||
@@ -221,4 +235,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"'));
|
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);
|
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');
|
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'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ function extractFunc(src, name) {
|
|||||||
let code = 'var pysimFsSelected = null;\n'
|
let code = 'var pysimFsSelected = null;\n'
|
||||||
+ 'var pysimFsDecodedMode = false;\n'
|
+ 'var pysimFsDecodedMode = false;\n'
|
||||||
+ 'var pysimFsTreeRoot = null;\n';
|
+ 'var pysimFsTreeRoot = null;\n';
|
||||||
|
code += extractFunc(html, 'efFidFromPath') + '\n';
|
||||||
|
code += extractFunc(html, 'efDfFromPath') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsFallbackNode') + '\n';
|
||||||
code += extractFunc(html, 'pysimFsSyncContentVisibility') + '\n';
|
code += extractFunc(html, 'pysimFsSyncContentVisibility') + '\n';
|
||||||
code += extractFunc(html, 'pysimFsRead') + '\n';
|
code += extractFunc(html, 'pysimFsRead') + '\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
@@ -53,7 +56,7 @@ function setup() {
|
|||||||
};
|
};
|
||||||
globalThis.t = s => s;
|
globalThis.t = s => s;
|
||||||
globalThis.esc = s => s;
|
globalThis.esc = s => s;
|
||||||
globalThis.pysimFsFindNode = () => ({ name: 'EF.IMSI', fid: '6f07' });
|
globalThis.pysimFsFindNodeByPath = () => ({ name: 'EF.IMSI', fid: '6f07' });
|
||||||
globalThis.pysimFsSelectBody = () => ({});
|
globalThis.pysimFsSelectBody = () => ({});
|
||||||
globalThis.pysimFsShowError = (el, sw, err) => {
|
globalThis.pysimFsShowError = (el, sw, err) => {
|
||||||
errorShown = { sw, err };
|
errorShown = { sw, err };
|
||||||
@@ -63,7 +66,7 @@ function setup() {
|
|||||||
if (fetchResult instanceof Error) throw fetchResult;
|
if (fetchResult instanceof Error) throw fetchResult;
|
||||||
return fetchResult;
|
return fetchResult;
|
||||||
};
|
};
|
||||||
pysimFsSelected = 'EF.IMSI';
|
pysimFsSelected = 'ADF.USIM/6F07';
|
||||||
pysimFsDecodedMode = false;
|
pysimFsDecodedMode = false;
|
||||||
pysimFsTreeRoot = { name: 'MF' };
|
pysimFsTreeRoot = { name: 'MF' };
|
||||||
fetchResult = { success: true, sw: '9000', data: 'AABB' };
|
fetchResult = { success: true, sw: '9000', data: 'AABB' };
|
||||||
@@ -92,6 +95,17 @@ test('a successful read fills and shows the content pane', async () => {
|
|||||||
assert.strictEqual(status.textContent, 'SW: 9000 OK');
|
assert.strictEqual(status.textContent, 'SW: 9000 OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a path whose node is not loaded selects by its last segment and FID', async () => {
|
||||||
|
setup();
|
||||||
|
globalThis.pysimFsFindNodeByPath = () => null;
|
||||||
|
let body = null;
|
||||||
|
globalThis.pysimFsSelectBody = node => { body = node; return {}; };
|
||||||
|
pysimFsSelected = 'MF/7F20/6FB7';
|
||||||
|
const ok = await pysimFsRead();
|
||||||
|
assert.strictEqual(ok, true);
|
||||||
|
assert.deepStrictEqual(body, { name: '6FB7', fid: '6FB7' });
|
||||||
|
});
|
||||||
|
|
||||||
test('a failed read keeps the content pane hidden', async () => {
|
test('a failed read keeps the content pane hidden', async () => {
|
||||||
setup();
|
setup();
|
||||||
fetchResult = { success: false, sw: '6982', error: 'Security status not satisfied' };
|
fetchResult = { success: false, sw: '6982', error: 'Security status not satisfied' };
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function extractFunc(src, name) {
|
|||||||
|
|
||||||
let code = 'var pysimFsSort = "fid";\n';
|
let code = 'var pysimFsSort = "fid";\n';
|
||||||
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsNodePath') + '\n';
|
||||||
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
@@ -65,3 +66,27 @@ test('non-existing directory hides previously loaded children', () => {
|
|||||||
assert.ok(!out.includes('(empty)'), out);
|
assert.ok(!out.includes('(empty)'), out);
|
||||||
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the row handlers carry the canonical path, so same-named files differ', () => {
|
||||||
|
// EF.ECC lives in DF.GSM and ADF.USIM with the same name AND FID: the
|
||||||
|
// onclick argument must be the path, never the ambiguous display name
|
||||||
|
const root = { name: 'MF', fid: '3F00', isDir: true, exists: true, children: [], expanded: true, parent: null };
|
||||||
|
const gsm = { name: 'DF.GSM', fid: '7F20', isDir: true, exists: true, children: [], expanded: true, parent: root };
|
||||||
|
const usim = { name: 'ADF.USIM', fid: '7FFF', isDir: true, exists: true, children: [], expanded: true, parent: root };
|
||||||
|
const gsmEcc = { name: 'EF.ECC', fid: '6FB7', isDir: false, exists: true, children: null, expanded: false, parent: gsm };
|
||||||
|
const usimEcc = { name: 'EF.ECC', fid: '6FB7', isDir: false, exists: true, children: null, expanded: false, parent: usim };
|
||||||
|
root.children = [gsm, usim];
|
||||||
|
gsm.children = [gsmEcc];
|
||||||
|
usim.children = [usimEcc];
|
||||||
|
assert.ok(pysimFsRenderNode(gsmEcc, 0).includes("pysimFsClickFile('MF/7F20/6FB7')"));
|
||||||
|
assert.ok(pysimFsRenderNode(usimEcc, 0).includes("pysimFsClickFile('ADF.USIM/6FB7')"));
|
||||||
|
assert.ok(pysimFsRenderNode(gsm, 0).includes("pysimFsToggleDir('MF/7F20')"));
|
||||||
|
assert.ok(pysimFsRenderNode(usim, 0).includes("pysimFsToggleDir('ADF.USIM')"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimFsRenderNode passes pysimFsNodePath(node) to the click/toggle handlers', () => {
|
||||||
|
const body = extractFunc(html, 'pysimFsRenderNode');
|
||||||
|
assert.ok(body.includes('const nodePath = pysimFsNodePath(node);'));
|
||||||
|
assert.match(body, /pysimFsToggleDir\(\\'' \+ esc\(nodePath\)/);
|
||||||
|
assert.match(body, /pysimFsClickFile\(\\'' \+ esc\(nodePath\)/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -446,7 +446,8 @@ test('file manager shows FCI info and keeps the selection in state, not the DOM'
|
|||||||
assert.ok(html.includes('pysimFsInfoHtml(sel, name, { arr: arrs, path: pysimFsNodePath(node) })'));
|
assert.ok(html.includes('pysimFsInfoHtml(sel, name, { arr: arrs, path: pysimFsNodePath(node) })'));
|
||||||
assert.ok(!html.includes('pysim-fs-filename'));
|
assert.ok(!html.includes('pysim-fs-filename'));
|
||||||
assert.ok(html.includes('let pysimFsSelected = null;'));
|
assert.ok(html.includes('let pysimFsSelected = null;'));
|
||||||
assert.ok(html.includes('pysimFsSelected = name;'));
|
assert.ok(html.includes('pysimFsSelected = node ? pysimFsNodePath(node) : path;'),
|
||||||
|
'the selection is keyed by the canonical path, not the display name');
|
||||||
assert.ok(!html.includes('pysimFsSelect()'));
|
assert.ok(!html.includes('pysimFsSelect()'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ for (const fn of ['ramIsBatchOp', 'ramBatchPanelVisible', 'ramBatchMergedState',
|
|||||||
'ramBatchStepsText',
|
'ramBatchStepsText',
|
||||||
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
|
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
|
||||||
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'ramOpProgressText',
|
'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';
|
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +42,9 @@ const els = {};
|
|||||||
function setEl(id, value) {
|
function setEl(id, value) {
|
||||||
els[id] = {
|
els[id] = {
|
||||||
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
|
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: {},
|
classes: {},
|
||||||
classList: {
|
classList: {
|
||||||
add(c) { els[id].classes[c] = true; },
|
add(c) { els[id].classes[c] = true; },
|
||||||
@@ -64,7 +69,8 @@ function clearEls() {
|
|||||||
function setModalEls() {
|
function setModalEls() {
|
||||||
for (const id of ['ram-op-modal', 'ram-op-title', 'ram-op-status', 'ram-op-bar',
|
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-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() {
|
function setPanelEls() {
|
||||||
for (const id of ['ram-op', 'ram-batch-panel', 'ram-batch-start', 'ram-batch-stop',
|
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', `
|
const app = new Function('els', `
|
||||||
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
|
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 t = k => k;
|
||||||
var isViewVisible = () => sandbox.visibility;
|
var isViewVisible = () => sandbox.visibility;
|
||||||
var alert = m => sandbox.alerts.push(String(m));
|
var alert = m => sandbox.alerts.push(String(m));
|
||||||
@@ -109,6 +125,9 @@ const app = new Function('els', `
|
|||||||
var _ramBatchFetchAt = 0;
|
var _ramBatchFetchAt = 0;
|
||||||
var _ramBatchSummary = null;
|
var _ramBatchSummary = null;
|
||||||
var _ramBatchProgress = null;
|
var _ramBatchProgress = null;
|
||||||
|
var _ramBatchWaiting = false;
|
||||||
|
var _ramBatchAudio = null;
|
||||||
|
var _ramBatchTitleBase = '';
|
||||||
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
|
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
|
||||||
${code}
|
${code}
|
||||||
sandbox.getLast = () => _ramBatchLast;
|
sandbox.getLast = () => _ramBatchLast;
|
||||||
@@ -134,6 +153,16 @@ const app = new Function('els', `
|
|||||||
sandbox.ramBatchStart = ramBatchStart;
|
sandbox.ramBatchStart = ramBatchStart;
|
||||||
sandbox.ramBatchStop = ramBatchStop;
|
sandbox.ramBatchStop = ramBatchStop;
|
||||||
sandbox.ramBatchClear = ramBatchClear;
|
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;
|
return sandbox;
|
||||||
`)(els);
|
`)(els);
|
||||||
|
|
||||||
@@ -639,3 +668,96 @@ test('the stop and clear calls hit their endpoints', async () => {
|
|||||||
await new Promise(r => setImmediate(r));
|
await new Promise(r => setImmediate(r));
|
||||||
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,702 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
function extractFunc(src, name) {
|
||||||
|
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
|
const m = re.exec(src);
|
||||||
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
|
let i = m.index + m[0].length - 1;
|
||||||
|
let depth = 0;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(m.index, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractConst(src, name) {
|
||||||
|
const m = new RegExp('const ' + name + ' = ').exec(src);
|
||||||
|
if (!m) throw new Error('const ' + name + ' not found');
|
||||||
|
const begin = m.index + m[0].length;
|
||||||
|
const opener = src[begin];
|
||||||
|
const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
|
||||||
|
if (!closer) throw new Error('unsupported const ' + name);
|
||||||
|
let depth = 0, i = begin;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === opener) depth++;
|
||||||
|
else if (src[i] === closer) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) { i++; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const FNS = [
|
||||||
|
'efBytes', 'efHex', 'efDecServiceTable', 'efDecSst', 'efEncServiceTable', 'efEncSst', 'efEncError',
|
||||||
|
'servicesDecode', 'servicesUnmodelled', 'servicesSstState', 'servicesBitState', 'servicesFeatureState',
|
||||||
|
'servicesSstAllocated', 'servicesSstPreserved', 'servicesBitsPreserved', 'servicesSstValues',
|
||||||
|
'servicesTableValues', 'servicesFileBytes', 'pysimServicesColumns', 'pysimServicesGridHtml',
|
||||||
|
'pysimServicesSaveRowHtml', 'pysimServicesCollect',
|
||||||
|
'servicesFsDeclaredTrees', 'servicesFsPresentPaths', 'servicesFsReport',
|
||||||
|
'servicesFsReportHtml', 'servicesFsPathKey', 'servicesFsNormPath', 'servicesFsRolePath',
|
||||||
|
'servicesPathIndexBuild', 'pysimFsNodePath',
|
||||||
|
];
|
||||||
|
|
||||||
|
let code = 'var _pysimServicesCache = null;\nvar _pysimServicesBusy = false;\n';
|
||||||
|
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||||
|
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES',
|
||||||
|
'SERVICES_CROSS_REF', 'SERVICES_FILES', 'SERVICES_FS_MAP', 'SERVICES_FS_TREES',
|
||||||
|
'SERVICES_FS_TREE_ORDER', 'SERVICES_FS_GSM_MAPPED', 'SERVICES_TABLES']) {
|
||||||
|
code += extractConst(html, c);
|
||||||
|
}
|
||||||
|
code += 'globalThis.esc = s => String(s);\nglobalThis.t = k => k;\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
// ---- data ----
|
||||||
|
|
||||||
|
test('SERVICES_CROSS_REF covers every non-RFU service of the four files exactly once', () => {
|
||||||
|
const tables = { sst: EF_SST_SERVICES, ust: EF_UST_SERVICES, est: EF_EST_SERVICES, ist: EF_IST_SERVICES };
|
||||||
|
// The doc's §A.4 RFU/reserved slots: SST 8/20/50, UST 26/33/50/98.
|
||||||
|
const rfu = { sst: [8, 20, 50], ust: [26, 33, 50, 98], est: [], ist: [] };
|
||||||
|
const seen = { sst: [], ust: [], est: [], ist: [] };
|
||||||
|
for (const f of SERVICES_CROSS_REF) {
|
||||||
|
for (const k of Object.keys(seen)) {
|
||||||
|
if (f[k] === null || f[k] === undefined) continue;
|
||||||
|
assert.ok(Number.isInteger(f[k]), 'non-integer ' + k + ' in ' + f.f);
|
||||||
|
seen[k].push(f[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(seen)) {
|
||||||
|
assert.strictEqual(new Set(seen[k]).size, seen[k].length, 'duplicate ' + k + ' numbers');
|
||||||
|
const expected = Object.keys(tables[k]).map(Number).filter(n => rfu[k].indexOf(n) < 0).sort((a, b) => a - b);
|
||||||
|
assert.deepStrictEqual(seen[k].slice().sort((a, b) => a - b), expected, k + ' coverage');
|
||||||
|
// the numbers a save copies from the file instead of editing
|
||||||
|
assert.deepStrictEqual(servicesUnmodelled(k), rfu[k], k + ' unmodelled slots');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SERVICES_CROSS_REF has the doc\'s totals and no empty row', () => {
|
||||||
|
const counts = { sim: 0, usim: 0, isim: 0, shared: 0 };
|
||||||
|
for (const f of SERVICES_CROSS_REF) {
|
||||||
|
assert.ok(typeof f.f === 'string' && f.f.length > 0, 'feature name');
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(counts, f.cls), 'class: ' + f.cls);
|
||||||
|
counts[f.cls]++;
|
||||||
|
const nums = ['sst', 'ust', 'est', 'ist'].filter(k => f[k] !== null && f[k] !== undefined);
|
||||||
|
assert.ok(nums.length > 0, 'row without a service number: ' + f.f);
|
||||||
|
// the class says which applications carry the feature
|
||||||
|
if (f.cls === 'sim') assert.deepStrictEqual(nums, ['sst'], f.f);
|
||||||
|
else if (f.cls === 'isim') assert.deepStrictEqual(nums, ['ist'], f.f);
|
||||||
|
else if (f.cls === 'usim') assert.ok(nums.indexOf('ust') >= 0 && nums.indexOf('sst') < 0 && nums.indexOf('ist') < 0, f.f);
|
||||||
|
else assert.ok(nums.length > 1, 'shared row with one file: ' + f.f);
|
||||||
|
}
|
||||||
|
// docs/services-editor.md: 164 table rows = 161 features + 3 RFU rows
|
||||||
|
// (10 SIM-only, 85 USIM-only, 5 ISIM-only, 61 shared).
|
||||||
|
assert.deepStrictEqual(counts, { sim: 10, usim: 85, isim: 5, shared: 61 });
|
||||||
|
assert.strictEqual(SERVICES_CROSS_REF.length, 161);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the four files are addressed by their canonical (DF, FID) path', () => {
|
||||||
|
assert.deepStrictEqual(SERVICES_FILES.map(f => f.key), ['sst', 'ust', 'est', 'ist']);
|
||||||
|
const paths = {};
|
||||||
|
for (const f of SERVICES_FILES) paths[f.key] = f.path;
|
||||||
|
assert.deepStrictEqual(paths, {
|
||||||
|
sst: 'DF.GSM/6F38', ust: 'ADF.USIM/6F38', est: 'ADF.USIM/6F56', ist: 'ADF.ISIM/6F07',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the edit-state helper ----
|
||||||
|
|
||||||
|
const dec = obj => obj;
|
||||||
|
function sstDec(pairs) {
|
||||||
|
// pairs: { <n>: [allocated, activated] } - a deleted service is absent
|
||||||
|
const services = [];
|
||||||
|
for (const k of Object.keys(pairs)) {
|
||||||
|
const n = Number(k);
|
||||||
|
if (!pairs[k][0] && !pairs[k][1]) continue;
|
||||||
|
services.push({ n, name: '', allocated: !!pairs[k][0], activated: !!pairs[k][1] });
|
||||||
|
}
|
||||||
|
return { services };
|
||||||
|
}
|
||||||
|
function bitDec(nums) {
|
||||||
|
return { services: nums.map(n => ({ n, name: '' })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('servicesFeatureState: shared feature on/off/deleted', () => {
|
||||||
|
const adn = SERVICES_CROSS_REF.find(f => f.f === 'Local Phone Book (ADN)');
|
||||||
|
assert.strictEqual(adn.sst, 2);
|
||||||
|
assert.strictEqual(adn.ust, 1);
|
||||||
|
// allocated + activated, UST bit set
|
||||||
|
let st = servicesFeatureState(adn, sstDec({ 2: [1, 1] }), bitDec([1, 3]), null, null);
|
||||||
|
assert.deepStrictEqual(st, { sst: 'on', ust: true, est: null, ist: null });
|
||||||
|
// allocated but not activated -> 'off' (the editor never de-allocates)
|
||||||
|
st = servicesFeatureState(adn, sstDec({ 2: [1, 0] }), bitDec([]), null, null);
|
||||||
|
assert.deepStrictEqual(st, { sst: 'off', ust: false, est: null, ist: null });
|
||||||
|
// deleted on the SIM (the decoder drops the service entirely) -> 'off'
|
||||||
|
st = servicesFeatureState(adn, sstDec({ 3: [1, 1] }), bitDec([]), null, null);
|
||||||
|
assert.strictEqual(st.sst, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFeatureState: sim-only, usim-only, isim-only and EST', () => {
|
||||||
|
const chv1 = SERVICES_CROSS_REF.find(f => f.f === 'CHV1 disable function');
|
||||||
|
assert.strictEqual(chv1.cls, 'sim');
|
||||||
|
assert.deepStrictEqual(servicesFeatureState(chv1, sstDec({ 1: [1, 1] }), null, null, null),
|
||||||
|
{ sst: 'on', ust: null, est: null, ist: null });
|
||||||
|
|
||||||
|
const oci = SERVICES_CROSS_REF.find(f => f.f === 'Outgoing Call Information (OCI and OCT)');
|
||||||
|
assert.deepStrictEqual(servicesFeatureState(oci, null, bitDec([8]), null, null),
|
||||||
|
{ sst: null, ust: true, est: null, ist: null });
|
||||||
|
|
||||||
|
const pcscf = SERVICES_CROSS_REF.find(f => f.f === 'P-CSCF address');
|
||||||
|
assert.deepStrictEqual(servicesFeatureState(pcscf, null, null, null, bitDec([1])),
|
||||||
|
{ sst: null, ust: null, est: null, ist: true });
|
||||||
|
|
||||||
|
const fdn = SERVICES_CROSS_REF.find(f => f.f === 'Fixed Dialling Numbers (FDN)');
|
||||||
|
assert.strictEqual(fdn.est, 1);
|
||||||
|
assert.deepStrictEqual(servicesFeatureState(fdn, sstDec({ 3: [1, 1] }), bitDec([2]), bitDec([]), null),
|
||||||
|
{ sst: 'on', ust: true, est: false, ist: null });
|
||||||
|
|
||||||
|
// a feature without a number in a file is null even when the file is there
|
||||||
|
const ccp2 = SERVICES_CROSS_REF.find(f => f.f === 'Capability Configuration Parameters 2 (CCP2)');
|
||||||
|
assert.deepStrictEqual(servicesFeatureState(ccp2, sstDec({ 1: [1, 1] }), bitDec([14]), null, null).sst, null);
|
||||||
|
// an absent file leaves its column null
|
||||||
|
const adn = SERVICES_CROSS_REF.find(f => f.f === 'Local Phone Book (ADN)');
|
||||||
|
assert.strictEqual(servicesFeatureState(adn, null, bitDec([1]), null, null).sst, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- render ----
|
||||||
|
|
||||||
|
test('the grid renders a column per present file and the right cell states', () => {
|
||||||
|
const adn = SERVICES_CROSS_REF.findIndex(f => f.f === 'Local Phone Book (ADN)');
|
||||||
|
const files = {
|
||||||
|
sst: { present: true, hex: '00300000', dec: sstDec({ 2: [1, 1] }) },
|
||||||
|
ust: { present: true, hex: '02000000', dec: bitDec([1]) },
|
||||||
|
est: { present: false },
|
||||||
|
ist: { present: false },
|
||||||
|
};
|
||||||
|
const out = pysimServicesGridHtml(files, {});
|
||||||
|
assert.ok(out.includes('>SST<'), out);
|
||||||
|
assert.ok(out.includes('>UST<'), out);
|
||||||
|
assert.ok(!out.includes('>EST<'), out);
|
||||||
|
assert.ok(!out.includes('>IST<'), out);
|
||||||
|
// the ADN row: SST select on 'on', UST checkbox checked
|
||||||
|
assert.ok(out.includes('data-svc="sst" data-row="' + adn + '"'));
|
||||||
|
assert.match(out, new RegExp('<option value="on" selected>[^<]*</option>'));
|
||||||
|
const adnRow = out.split('<tr').find(r => r.includes('Local Phone Book (ADN)'));
|
||||||
|
assert.ok(adnRow.includes('checked'), adnRow);
|
||||||
|
assert.ok(adnRow.includes('<select data-svc="sst"'), adnRow);
|
||||||
|
// an ISIM-only feature is hidden while EF.IST is absent
|
||||||
|
assert.ok(!out.includes('P-CSCF address'), out);
|
||||||
|
// and a UST-only feature is hidden while EF.UST is absent
|
||||||
|
const sstOnly = pysimServicesGridHtml({ sst: files.sst, ust: { present: false } }, {});
|
||||||
|
assert.ok(sstOnly.includes('>SST<'), sstOnly);
|
||||||
|
assert.ok(!sstOnly.includes('>UST<'), sstOnly);
|
||||||
|
assert.ok(!sstOnly.includes('Outgoing Call Information'), sstOnly);
|
||||||
|
assert.ok(sstOnly.includes('Local Phone Book (ADN)'), sstOnly);
|
||||||
|
// a feature without a number in a present file shows an empty cell, no input
|
||||||
|
const plmnRow = out.split('<tr').find(r => r.includes('>PLMN selector<'));
|
||||||
|
assert.ok(plmnRow, 'the PLMN selector row renders');
|
||||||
|
assert.ok(plmnRow.includes('data-svc="sst"'), plmnRow);
|
||||||
|
assert.ok(!plmnRow.includes('data-svc="ust"'), plmnRow);
|
||||||
|
assert.ok(plmnRow.includes('\u2014'), plmnRow);
|
||||||
|
// an EST column renders a checkbox per EST-numbered feature (FDN = 1)
|
||||||
|
const withEst = pysimServicesGridHtml({ ust: files.ust, est: { present: true, dec: bitDec([]) } }, {});
|
||||||
|
assert.ok(withEst.includes('>EST<'), withEst);
|
||||||
|
const fdnRow = withEst.split('<tr').find(r => r.includes('>Fixed Dialling Numbers (FDN)<'));
|
||||||
|
assert.ok(fdnRow.includes('data-svc="est"'), fdnRow);
|
||||||
|
assert.ok(!fdnRow.includes('data-svc="est" data-row="2" checked'), fdnRow);
|
||||||
|
// ... and is checked from the card's decode
|
||||||
|
const estOn = pysimServicesGridHtml({ ust: files.ust, est: { present: true, dec: bitDec([1]) } }, {});
|
||||||
|
const fdnOn = estOn.split('<tr').find(r => r.includes('>Fixed Dialling Numbers (FDN)<'));
|
||||||
|
assert.match(fdnOn, /data-svc="est" data-row="\d+" checked/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no present file reports why, and the save row lists only present files', () => {
|
||||||
|
const out = pysimServicesGridHtml({}, {});
|
||||||
|
assert.ok(out.includes('No service table found on this card'), out);
|
||||||
|
const row = pysimServicesSaveRowHtml({
|
||||||
|
sst: { present: true }, ust: { present: true }, est: { present: false }, ist: { present: false },
|
||||||
|
});
|
||||||
|
assert.ok(row.includes('pysimServicesSave(\'sst\')'), row);
|
||||||
|
assert.ok(row.includes('pysimServicesSave(\'ust\')'), row);
|
||||||
|
assert.ok(!row.includes('pysimServicesSave(\'est\')'), row);
|
||||||
|
assert.ok(!row.includes('pysimServicesSave(\'ist\')'), row);
|
||||||
|
assert.ok(row.includes('Save to EF.SST'), row);
|
||||||
|
assert.ok(row.includes('Save to EF.UST'), row);
|
||||||
|
// the per-button status spans the writes report into
|
||||||
|
assert.ok(row.includes('id="pysim-services-save-status-sst"'), row);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimServicesCollect reads the form controls into per-row state', () => {
|
||||||
|
const grid = {
|
||||||
|
querySelectorAll: () => [
|
||||||
|
{ getAttribute: k => ({ 'data-svc': 'sst', 'data-row': '3' })[k], type: 'select-one', value: 'off' },
|
||||||
|
{ getAttribute: k => ({ 'data-svc': 'ust', 'data-row': '3' })[k], type: 'checkbox', checked: true },
|
||||||
|
{ getAttribute: k => ({ 'data-svc': 'ust', 'data-row': '4' })[k], type: 'checkbox', checked: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => (id === 'pysim-services-grid' ? grid : null) };
|
||||||
|
assert.deepStrictEqual(pysimServicesCollect(), { 3: { sst: 'off', ust: true }, 4: { ust: false } });
|
||||||
|
globalThis.document = { getElementById: () => null };
|
||||||
|
assert.deepStrictEqual(pysimServicesCollect(), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- save ----
|
||||||
|
|
||||||
|
test('SST bytes: on allocates and activates, off never de-allocates', () => {
|
||||||
|
// service 3 (FDN) allocated but not activated -> 0x10 in byte 0
|
||||||
|
const files = { sst: { present: true, hex: '10000000' } };
|
||||||
|
const fdn = SERVICES_CROSS_REF.findIndex(f => f.f === 'Fixed Dialling Numbers (FDN)');
|
||||||
|
assert.strictEqual(efBytes('10000000')[0] & 0x30, 0x10); // sanity: pair 1
|
||||||
|
// "off" on an allocated-off service keeps it allocated (pair 1, not 0)
|
||||||
|
let out = servicesFileBytes('sst', { [fdn]: { sst: 'off' } }, files);
|
||||||
|
assert.strictEqual(out, '10000000');
|
||||||
|
// "on" allocates and activates (pair 3)
|
||||||
|
out = servicesFileBytes('sst', { [fdn]: { sst: 'on' } }, files);
|
||||||
|
assert.strictEqual(out, '30000000');
|
||||||
|
// switching an allocated+activated service to "off" de-activates but keeps
|
||||||
|
// the allocation (pair 1)
|
||||||
|
out = servicesFileBytes('sst', { [fdn]: { sst: 'off' } }, { sst: { present: true, hex: '30000000' } });
|
||||||
|
assert.strictEqual(out, '10000000');
|
||||||
|
// a deleted service stays deleted when the form renders it as "off"
|
||||||
|
out = servicesFileBytes('sst', { [fdn]: { sst: 'off' } }, { sst: { present: true, hex: '00000000' } });
|
||||||
|
assert.strictEqual(out, '00000000');
|
||||||
|
// ... and is allocated by an explicit "on"
|
||||||
|
out = servicesFileBytes('sst', { [fdn]: { sst: 'on' } }, { sst: { present: true, hex: '00000000' } });
|
||||||
|
assert.strictEqual(out, '30000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SST bytes preserve the unmodelled RFU slots and the file length', () => {
|
||||||
|
// service 8 = RFU, in byte 1 (services 5-8), pair 4 -> bits 6+7 = 0xC0
|
||||||
|
const files = { sst: { present: true, hex: '00C000' } };
|
||||||
|
assert.deepStrictEqual(servicesUnmodelled('sst'), [8, 20, 50]);
|
||||||
|
assert.strictEqual(servicesFileBytes('sst', {}, files), '00C000',
|
||||||
|
'the RFU pair and the trailing byte survive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UST/EST/IST bytes come from efEncServiceTable over the original bytes', () => {
|
||||||
|
// UST service 1 (ADN) set in byte 0; service 26 (RFU) in byte 3 bit 1
|
||||||
|
const files = { ust: { present: true, hex: '01000002' } };
|
||||||
|
const adn = SERVICES_CROSS_REF.findIndex(f => f.f === 'Local Phone Book (ADN)');
|
||||||
|
const sms = SERVICES_CROSS_REF.findIndex(f => f.f === 'Short Message Storage (SMS)');
|
||||||
|
let out = servicesFileBytes('ust', { [adn]: { ust: true }, [sms]: { ust: true } }, files);
|
||||||
|
// service 10 (SMS) -> byte 1 bit 1 = 0x02
|
||||||
|
assert.strictEqual(out, '01020002');
|
||||||
|
out = servicesFileBytes('ust', { [adn]: { ust: false }, [sms]: { ust: false } }, files);
|
||||||
|
assert.strictEqual(out, '00000002', 'the RFU bit is preserved, the modelled bits cleared');
|
||||||
|
// EST has its own numbering: FDN = 1
|
||||||
|
const estFiles = { est: { present: true, hex: '00' } };
|
||||||
|
const fdn = SERVICES_CROSS_REF.findIndex(f => f.f === 'Fixed Dialling Numbers (FDN)');
|
||||||
|
assert.strictEqual(servicesFileBytes('est', { [fdn]: { est: true } }, estFiles), '01');
|
||||||
|
assert.strictEqual(servicesFileBytes('est', { [fdn]: { est: false } }, estFiles), '00');
|
||||||
|
// ISIM IST: P-CSCF address = 1
|
||||||
|
const istFiles = { ist: { present: true, hex: '00' } };
|
||||||
|
const pcscf = SERVICES_CROSS_REF.findIndex(f => f.f === 'P-CSCF address');
|
||||||
|
assert.strictEqual(servicesFileBytes('ist', { [pcscf]: { ist: true } }, istFiles), '01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an absent file yields no bytes', () => {
|
||||||
|
assert.strictEqual(servicesFileBytes('sst', {}, { sst: { present: false } }), null);
|
||||||
|
assert.strictEqual(servicesFileBytes('ust', {}, {}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- filesystem check: the feature -> symbolic files table (docs/services-editor.md §B) ----
|
||||||
|
|
||||||
|
const UICC_FILES = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'uicc_files.json'), 'utf8'));
|
||||||
|
const FS_INDEX = servicesPathIndexBuild(UICC_FILES.files);
|
||||||
|
const fsResolve = (role, root) => FS_INDEX.get(servicesFsPathKey(role, root));
|
||||||
|
|
||||||
|
test('SERVICES_FS_MAP covers every feature exactly once and carries symbolic file names', () => {
|
||||||
|
const names = SERVICES_CROSS_REF.map(f => f.f);
|
||||||
|
assert.deepStrictEqual(Object.keys(SERVICES_FS_MAP), names,
|
||||||
|
'one entry per cross-reference row, in table order');
|
||||||
|
let backed = 0, capability = 0;
|
||||||
|
for (const [name, roles] of Object.entries(SERVICES_FS_MAP)) {
|
||||||
|
assert.ok(Array.isArray(roles), 'not an array: ' + name);
|
||||||
|
assert.strictEqual(new Set(roles).size, roles.length, 'duplicate role: ' + name);
|
||||||
|
// the standard file list's own names, never FIDs: a FID is ambiguous
|
||||||
|
// across applications (4F30 = EF.PBR and EF.SAI)
|
||||||
|
for (const role of roles) assert.match(role, /^(EF|DF)\.[A-Za-z0-9_+.-]+$/, name + ': ' + role);
|
||||||
|
if (roles.length) backed++; else capability++;
|
||||||
|
}
|
||||||
|
// docs/services-editor.md §B.1 lists 34 `capability` cells; the remaining
|
||||||
|
// 127 rows declare at least one file.
|
||||||
|
assert.strictEqual(capability, 34);
|
||||||
|
assert.strictEqual(backed, 127);
|
||||||
|
assert.strictEqual(SERVICES_CROSS_REF.length, backed + capability);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the capability features carry no file check', () => {
|
||||||
|
for (const name of [
|
||||||
|
'CHV1 disable function', 'Data download via SMS-CB', 'Data download via SMS-PP',
|
||||||
|
'Menu selection', 'Call control (by SIM/USIM)', 'Proactive SIM',
|
||||||
|
'MO-SMS control (by SIM/USIM)', 'RUN AT COMMAND command', 'Call control on GPRS by USIM',
|
||||||
|
'Additional TERMINAL PROFILE after UICC activation', 'Terminal Applications',
|
||||||
|
'URI support by UICC', 'HTTP Digest', 'Support of P-CSCF discovery for IMS Local Break Out',
|
||||||
|
]) {
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP[name], [], name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the well-known features map to their documented files', () => {
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['Fixed Dialling Numbers (FDN)'], ['EF.FDN', 'EF.EXT2']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['Service Provider Name (SPN)'], ['EF.SPN']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['GSM Access'], ['DF.GSM-ACCESS']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['Local Phone Book (ADN)'], ['EF.ADN', 'DF.PHONEBOOK', 'EF.PBR']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['SoLSA'], ['DF.SoLSA', 'EF.SAI', 'EF.SLL']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['MExE'], ['DF.MExE', 'EF.MExE-ST']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['A2X'], ['DF.A2X', 'EF.AST', 'EF.A2X_CONFIG']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['Advice of Charge (AoC)'], ['EF.ACM', 'EF.ACMmax', 'EF.PUCT']);
|
||||||
|
assert.deepStrictEqual(SERVICES_FS_MAP['Extension 4'], ['EF.EXT4'],
|
||||||
|
'one name: the SIM and the USIM copy have different FIDs but the same name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a role outside its feature\'s declaring tree lives in the shared DF.TELECOM subtree', () => {
|
||||||
|
// A UST/IST-gated DF may be a child of DF.TELECOM (TS 31.102 4.6.0), i.e.
|
||||||
|
// an MF path - the resolver's fallback. Anything else would be a mapping
|
||||||
|
// error, and the GSM application's mapped copies (EF.ADN/EF.EXT1/EF.ECCP)
|
||||||
|
// are deliberately not expected there.
|
||||||
|
const features = new Set();
|
||||||
|
for (const f of SERVICES_CROSS_REF) {
|
||||||
|
const trees = [];
|
||||||
|
if (f.sst !== null) trees.push('MF');
|
||||||
|
if (f.ust !== null || f.est !== null) trees.push('ADF.USIM');
|
||||||
|
if (f.ist !== null) trees.push('ADF.ISIM');
|
||||||
|
for (const role of (SERVICES_FS_MAP[f.f] || [])) {
|
||||||
|
if (trees.some(root => fsResolve(role, root))) continue;
|
||||||
|
const path = fsResolve(role, 'MF');
|
||||||
|
if (!path) continue; // a registry gap, pinned above
|
||||||
|
assert.match(path, /^MF\/7F10\//, f.f + ': ' + role + ' -> ' + path);
|
||||||
|
assert.ok(SERVICES_FS_GSM_MAPPED.indexOf(role) < 0, f.f + ': ' + role);
|
||||||
|
features.add(f.f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(Array.from(features),
|
||||||
|
['Multimedia Messages Storage', 'MCPTT', 'V2X', 'A2X']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every mapped role is a standard file, except the documented registry gaps', () => { // The names that resolve in none of the checked trees: files the standard
|
||||||
|
// list does not carry yet (pySim omits DF.GRAPHICS and the DF.TELECOM
|
||||||
|
// copies of DF.SoLSA/DF.MExE; EF.3GPPPSDATAOFFservicelist is a pySim TODO).
|
||||||
|
// The report lists these as `unchecked` instead of guessing a path.
|
||||||
|
const gaps = {
|
||||||
|
'Image (IMG)': ['DF.GRAPHICS', 'EF.IMG'],
|
||||||
|
'3GPP PS Data Off Service List': ['EF.3GPPPSDATAOFFservicelist'],
|
||||||
|
};
|
||||||
|
const unresolved = {};
|
||||||
|
for (const [name, roles] of Object.entries(SERVICES_FS_MAP)) {
|
||||||
|
for (const role of roles) {
|
||||||
|
if (SERVICES_FS_TREE_ORDER.some(root => fsResolve(role, root))) continue;
|
||||||
|
(unresolved[name] = unresolved[name] || []).push(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(unresolved, gaps);
|
||||||
|
// a mapped role that does resolve resolves through exactly one list entry
|
||||||
|
for (const roles of Object.values(SERVICES_FS_MAP)) {
|
||||||
|
for (const role of roles) {
|
||||||
|
for (const root of SERVICES_FS_TREE_ORDER) {
|
||||||
|
const path = fsResolve(role, root);
|
||||||
|
if (!path) continue;
|
||||||
|
const entries = UICC_FILES.files.filter(f => f.name === role
|
||||||
|
&& f.root.toUpperCase() === root && f.path.toUpperCase() === path);
|
||||||
|
assert.strictEqual(entries.length, 1, role + ' ' + root + ' -> ' + path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- filesystem check: the (name, root) -> path index ----
|
||||||
|
|
||||||
|
test('servicesPathIndexBuild keys the standard list by (name, root)', () => {
|
||||||
|
assert.ok(FS_INDEX.size >= 200, 'indexed files: ' + FS_INDEX.size);
|
||||||
|
// every value is one of the list's paths, and the list's roots are the keys
|
||||||
|
for (const [key, path] of FS_INDEX) {
|
||||||
|
const [name, root] = key.split('|');
|
||||||
|
assert.ok(UICC_FILES.files.some(f => f.name.toUpperCase() === name && f.root.toUpperCase() === root
|
||||||
|
&& f.path.toUpperCase() === path), key + ' -> ' + path);
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(servicesPathIndexBuild(null), new Map());
|
||||||
|
assert.deepStrictEqual(servicesPathIndexBuild([null, {}, { name: 'EF.X' }]), new Map());
|
||||||
|
// case-insensitive on both sides
|
||||||
|
assert.strictEqual(FS_INDEX.get(servicesFsPathKey('ef.spn', 'mf')), 'MF/7F20/6F46');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same FID under different names resolves to its own path', () => {
|
||||||
|
// EF.PBR and EF.SAI are both 4F30
|
||||||
|
assert.strictEqual(fsResolve('EF.PBR', 'ADF.USIM'), 'ADF.USIM/5F3A/4F30');
|
||||||
|
assert.strictEqual(fsResolve('EF.SAI', 'ADF.USIM'), 'ADF.USIM/5F70/4F30');
|
||||||
|
// EF.PBR exists in both trees, under different parents
|
||||||
|
assert.strictEqual(fsResolve('EF.PBR', 'MF'), 'MF/7F10/5F3A/4F30');
|
||||||
|
// a DF.TELECOM child resolves in the MF tree
|
||||||
|
assert.strictEqual(fsResolve('DF.A2X', 'MF'), 'MF/7F10/5F3F');
|
||||||
|
assert.strictEqual(fsResolve('EF.A2X_CONFIG', 'MF'), 'MF/7F10/5F3F/4F02');
|
||||||
|
// EF.SST and EF.UST are both 6F38
|
||||||
|
assert.strictEqual(fsResolve('EF.UST', 'ADF.USIM'), 'ADF.USIM/6F38');
|
||||||
|
assert.strictEqual(fsResolve('EF.IST', 'ADF.ISIM'), 'ADF.ISIM/6F07');
|
||||||
|
// the one duplicate (name, root): pySim models EF.SUCI_Calc_Info twice
|
||||||
|
// under ADF.USIM (DF.5GS/4F07 - the UST 125 file - and the eSIM profile
|
||||||
|
// package's DF.SAIP/4F01); the list's first entry, DF.5GS, wins
|
||||||
|
const suci = UICC_FILES.files.filter(f => f.name === 'EF.SUCI_Calc_Info' && f.root === 'ADF.USIM');
|
||||||
|
assert.strictEqual(suci.length, 2);
|
||||||
|
assert.strictEqual(fsResolve('EF.SUCI_Calc_Info', 'ADF.USIM'), 'ADF.USIM/5FC0/4F07');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsRolePath falls back to the shared DF.TELECOM subtree', () => {
|
||||||
|
// DF.MCS is a child of DF.TELECOM (TS 31.102 4.6.0): a UST declaration
|
||||||
|
// resolves it in the MF tree
|
||||||
|
assert.strictEqual(servicesFsRolePath(FS_INDEX, 'DF.MCS', 'ADF.USIM'), 'MF/7F10/5F3D');
|
||||||
|
assert.strictEqual(servicesFsRolePath(FS_INDEX, 'EF.MST', 'ADF.USIM'), 'MF/7F10/5F3D/4F01');
|
||||||
|
// an own-tree path wins over the fallback
|
||||||
|
assert.strictEqual(servicesFsRolePath(FS_INDEX, 'EF.FDN', 'ADF.USIM'), 'ADF.USIM/6F3B');
|
||||||
|
assert.strictEqual(servicesFsRolePath(FS_INDEX, 'EF.FDN', 'MF'), 'MF/7F10/6F3B');
|
||||||
|
// the GSM application's mapped copies are not expected by a USIM
|
||||||
|
// declaration (TS 31.102 4.5) ...
|
||||||
|
assert.strictEqual(servicesFsRolePath(FS_INDEX, 'EF.ADN', 'ADF.USIM'), undefined);
|
||||||
|
assert.ok(SERVICES_FS_GSM_MAPPED.indexOf('EF.ADN') >= 0);
|
||||||
|
// ... and a SIM (MF) declaration never falls back to an application
|
||||||
|
assert.strictEqual(servicesFsRolePath(FS_INDEX, 'DF.SoLSA', 'MF'), undefined);
|
||||||
|
assert.strictEqual(servicesFsRolePath(null, 'EF.SPN', 'MF'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- filesystem check: the report ----
|
||||||
|
|
||||||
|
const svcFiles = (sst, ust, est, ist) => ({
|
||||||
|
sst: sst ? { present: true, dec: sst } : { present: false },
|
||||||
|
ust: ust ? { present: true, dec: ust } : { present: false },
|
||||||
|
est: est ? { present: true, dec: est } : { present: false },
|
||||||
|
ist: ist ? { present: true, dec: ist } : { present: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// A file-manager tree with parent links (pysimFsNodePath walks up them).
|
||||||
|
function fsTree(children) {
|
||||||
|
const mk = (spec, parent) => {
|
||||||
|
const node = {
|
||||||
|
name: spec.name, fid: spec.fid, isDir: !!spec.isDir, exists: spec.exists,
|
||||||
|
children: null, parent: parent,
|
||||||
|
};
|
||||||
|
if (spec.children) node.children = spec.children.map(c => mk(c, node));
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
const root = { name: 'MF', path: 'MF', fid: '3F00', isDir: true, exists: true, parent: null, children: null };
|
||||||
|
root.children = (children || []).map(c => mk(c, root));
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('servicesFsPresentPaths collects the probed nodes\' canonical paths', () => {
|
||||||
|
const root = fsTree([
|
||||||
|
{ name: 'DF.TELECOM', fid: '7F10', isDir: true, exists: true, children: [
|
||||||
|
{ name: 'EF.SMS', fid: '6f3c', exists: true },
|
||||||
|
{ name: 'EF.LND', fid: '6F44', exists: false },
|
||||||
|
] },
|
||||||
|
{ name: 'EF.ICCID', fid: '2FE2', exists: null }, // never probed
|
||||||
|
{ name: 'ADF.USIM', isDir: true, exists: true, children: [
|
||||||
|
{ name: 'EF.UST', fid: '6F38', exists: true },
|
||||||
|
] },
|
||||||
|
{ name: 'DF.ABSENT', fid: '5F80', isDir: true, exists: false, children: [
|
||||||
|
{ name: 'EF.BCAST', fid: '4F01', exists: true }, // under an absent DF
|
||||||
|
] },
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(servicesFsPresentPaths(root),
|
||||||
|
['MF/7F10', 'MF/7F10/6F3C', 'ADF.USIM', 'ADF.USIM/6F38']);
|
||||||
|
assert.deepStrictEqual(servicesFsPresentPaths(null), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsDeclaredTrees groups the declared features by declaring application', () => {
|
||||||
|
const files = svcFiles(
|
||||||
|
sstDec({ 3: [1, 1], 4: [1, 0] }), // FDN on, SMS allocated-off
|
||||||
|
bitDec([2, 10]), // UST FDN + SMS
|
||||||
|
null, // no EF.EST on the card
|
||||||
|
bitDec([6])); // IST SMS
|
||||||
|
const trees = servicesFsDeclaredTrees(files);
|
||||||
|
assert.deepStrictEqual(Object.keys(trees), ['MF', 'ADF.USIM', 'ADF.ISIM']);
|
||||||
|
assert.ok(trees.MF.includes('Fixed Dialling Numbers (FDN)'), trees.MF.join(' | '));
|
||||||
|
assert.ok(!trees.MF.includes('Short Message Storage (SMS)'), 'an allocated-off SST service declares nothing');
|
||||||
|
assert.ok(trees['ADF.USIM'].includes('Fixed Dialling Numbers (FDN)'));
|
||||||
|
assert.ok(trees['ADF.USIM'].includes('Short Message Storage (SMS)'));
|
||||||
|
assert.ok(trees['ADF.ISIM'].includes('Short Message Storage (SMS)'));
|
||||||
|
// the tree's own order: cross-reference order, deduplicated
|
||||||
|
assert.deepStrictEqual(trees['ADF.USIM'], ['Fixed Dialling Numbers (FDN)', 'Short Message Storage (SMS)']);
|
||||||
|
// a tree whose service table is absent is not checked at all
|
||||||
|
assert.deepStrictEqual(servicesFsDeclaredTrees(svcFiles(null, bitDec([2]), null, null)),
|
||||||
|
{ 'ADF.USIM': ['Fixed Dialling Numbers (FDN)'] });
|
||||||
|
assert.deepStrictEqual(servicesFsDeclaredTrees(svcFiles(null, null, null, null)), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fsReport = (declaredByTree, present) => servicesFsReport(declaredByTree, FS_INDEX, present);
|
||||||
|
|
||||||
|
test('servicesFsReport: a declared feature with an absent path', () => {
|
||||||
|
const rep = fsReport({ MF: ['Fixed Dialling Numbers (FDN)'] }, ['MF/7F10/6F3B']);
|
||||||
|
assert.deepStrictEqual(rep.missing,
|
||||||
|
[{ feature: 'Fixed Dialling Numbers (FDN)', tree: 'MF', paths: ['MF/7F10/6F4B'] }]);
|
||||||
|
assert.deepStrictEqual(rep.extra, []);
|
||||||
|
assert.deepStrictEqual(rep.unchecked, []);
|
||||||
|
// both files gone, in the map's (doc) role order
|
||||||
|
assert.deepStrictEqual(fsReport({ MF: ['Fixed Dialling Numbers (FDN)'] }, []).missing,
|
||||||
|
[{ feature: 'Fixed Dialling Numbers (FDN)', tree: 'MF', paths: ['MF/7F10/6F3B', 'MF/7F10/6F4B'] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReport: the same FID in another DF no longer explains a service', () => {
|
||||||
|
// EF.PBR and EF.SAI are both 4F30: a card holding the phonebook copy but
|
||||||
|
// no DF.SoLSA leaves SoLSA's paths missing
|
||||||
|
const rep = fsReport({ 'ADF.USIM': ['SoLSA'] }, ['ADF.USIM/5F3A', 'ADF.USIM/5F3A/4F30']);
|
||||||
|
assert.deepStrictEqual(rep.missing, [{ feature: 'SoLSA', tree: 'ADF.USIM',
|
||||||
|
paths: ['ADF.USIM/5F70', 'ADF.USIM/5F70/4F30', 'ADF.USIM/5F70/4F31'] }]);
|
||||||
|
// the phonebook paths are present but ADN is not declared
|
||||||
|
assert.deepStrictEqual(rep.extra.map(e => e.path), ['ADF.USIM/5F3A', 'ADF.USIM/5F3A/4F30']);
|
||||||
|
assert.deepStrictEqual(rep.extra[1],
|
||||||
|
{ path: 'ADF.USIM/5F3A/4F30', role: 'EF.PBR', tree: 'ADF.USIM', features: ['Local Phone Book (ADN)'] });
|
||||||
|
// with the SoLSA paths present the report is clean
|
||||||
|
const ok = fsReport({ 'ADF.USIM': ['SoLSA', 'Local Phone Book (ADN)'] },
|
||||||
|
['ADF.USIM/5F70', 'ADF.USIM/5F70/4F30', 'ADF.USIM/5F70/4F31', 'ADF.USIM/5F3A', 'ADF.USIM/5F3A/4F30']);
|
||||||
|
assert.deepStrictEqual(ok, { missing: [], extra: [], unchecked: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReport: ADN\'s USIM files are the phonebook pair, not the GSM copy', () => {
|
||||||
|
// TS 31.102 4.5: EF.ADN under DF.TELECOM is the GSM application's mapping
|
||||||
|
const usim = fsReport({ 'ADF.USIM': ['Local Phone Book (ADN)'] }, ['ADF.USIM/5F3A', 'ADF.USIM/5F3A/4F30']);
|
||||||
|
assert.deepStrictEqual(usim, { missing: [], extra: [], unchecked: [] });
|
||||||
|
// ... so a USIM card without the mapped 6F3A is not missing anything
|
||||||
|
assert.deepStrictEqual(fsReport({ 'ADF.USIM': ['Local Phone Book (ADN)'] }, ['ADF.USIM/5F3A/4F30']).missing,
|
||||||
|
[{ feature: 'Local Phone Book (ADN)', tree: 'ADF.USIM', paths: ['ADF.USIM/5F3A'] }]);
|
||||||
|
// the SIM declaration does expect it
|
||||||
|
const sim = fsReport({ MF: ['Local Phone Book (ADN)'] }, ['MF/7F10/5F3A', 'MF/7F10/5F3A/4F30']);
|
||||||
|
assert.deepStrictEqual(sim.missing,
|
||||||
|
[{ feature: 'Local Phone Book (ADN)', tree: 'MF', paths: ['MF/7F10/6F3A'] }]);
|
||||||
|
assert.deepStrictEqual(sim.extra, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReport: a UST-declared DF.TELECOM file resolves in the MF tree', () => {
|
||||||
|
// MCPTT's DF.MCS is a child of DF.TELECOM (TS 31.102 4.6.0)
|
||||||
|
assert.deepStrictEqual(fsReport({ 'ADF.USIM': ['MCPTT'] }, ['MF/7F10/5F3D', 'MF/7F10/5F3D/4F01']),
|
||||||
|
{ missing: [], extra: [], unchecked: [] });
|
||||||
|
assert.deepStrictEqual(fsReport({ 'ADF.USIM': ['MCPTT'] }, []).missing,
|
||||||
|
[{ feature: 'MCPTT', tree: 'ADF.USIM', paths: ['MF/7F10/5F3D', 'MF/7F10/5F3D/4F01'] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReport: a declared feature the standard list cannot resolve is unchecked', () => {
|
||||||
|
const rep = fsReport({ 'ADF.USIM': ['Image (IMG)'] }, []);
|
||||||
|
assert.deepStrictEqual(rep, { missing: [], extra: [], unchecked: [
|
||||||
|
{ feature: 'Image (IMG)', tree: 'ADF.USIM', roles: ['DF.GRAPHICS', 'EF.IMG'] }] });
|
||||||
|
// a capability row has no files and is never unchecked
|
||||||
|
assert.deepStrictEqual(fsReport({ MF: ['CHV1 disable function'] }, []),
|
||||||
|
{ missing: [], extra: [], unchecked: [] });
|
||||||
|
// a tree the card does not have declares nothing there
|
||||||
|
assert.deepStrictEqual(fsReport({}, []), { missing: [], extra: [], unchecked: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReport: a shared file is explained by any declared feature', () => {
|
||||||
|
// DF.GSM-ACCESS is gated by both "GSM Access" and "GSM security context";
|
||||||
|
// declaring either one explains the present path
|
||||||
|
const present = ['ADF.USIM/5F3B', 'ADF.USIM/5F3B/4F20', 'ADF.USIM/5F3B/4F52'];
|
||||||
|
assert.deepStrictEqual(fsReport({ 'ADF.USIM': ['GSM security context'] }, present),
|
||||||
|
{ missing: [], extra: [], unchecked: [] });
|
||||||
|
// with neither declared the path is an extra, naming every mapping feature
|
||||||
|
assert.deepStrictEqual(fsReport({ 'ADF.USIM': [] }, ['ADF.USIM/5F3B']).extra,
|
||||||
|
[{ path: 'ADF.USIM/5F3B', role: 'DF.GSM-ACCESS', tree: 'ADF.USIM',
|
||||||
|
features: ['GSM Access', 'GSM security context'] }]);
|
||||||
|
// a lower-case present path matches the list's upper-case one
|
||||||
|
assert.deepStrictEqual(fsReport({ MF: ['Service Provider Name (SPN)'] }, ['mf/7f20/6f46']),
|
||||||
|
{ missing: [], extra: [], unchecked: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReport: a present path whose service is undeclared', () => {
|
||||||
|
const rep = fsReport({ MF: [] }, ['MF/7F10/6F3C']);
|
||||||
|
assert.deepStrictEqual(rep.missing, []);
|
||||||
|
assert.deepStrictEqual(rep.extra,
|
||||||
|
[{ path: 'MF/7F10/6F3C', role: 'EF.SMS', tree: 'MF', features: ['Short Message Storage (SMS)'] }]);
|
||||||
|
// extra is sorted by path, and a path without a mapped feature is ignored
|
||||||
|
const extra = fsReport({ MF: ['Service Provider Name (SPN)'] },
|
||||||
|
['MF/7F20/6F45', 'MF/7F10/6F44', 'MF/7F10/6F3C', 'MF/7F20/6F46', 'MF/2F06']).extra;
|
||||||
|
assert.deepStrictEqual(extra.map(e => e.path), ['MF/7F10/6F3C', 'MF/7F10/6F44', 'MF/7F20/6F45']);
|
||||||
|
assert.deepStrictEqual(extra[2].features, ['Cell Broadcast Message Identifier (CBMI)']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReport is deterministic and order-stable', () => {
|
||||||
|
const present = ['ADF.USIM/6F3B'];
|
||||||
|
const a = fsReport({ 'ADF.USIM': ['Service Provider Name (SPN)', 'Fixed Dialling Numbers (FDN)'] }, present);
|
||||||
|
const b = fsReport({ 'ADF.USIM': ['Fixed Dialling Numbers (FDN)', 'Service Provider Name (SPN)'] }, present);
|
||||||
|
assert.deepStrictEqual(a, b, 'cross-reference order, not input order');
|
||||||
|
assert.deepStrictEqual(a.missing, [
|
||||||
|
{ feature: 'Fixed Dialling Numbers (FDN)', tree: 'ADF.USIM', paths: ['ADF.USIM/6F4B'] },
|
||||||
|
{ feature: 'Service Provider Name (SPN)', tree: 'ADF.USIM', paths: ['ADF.USIM/6F46'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('servicesFsReportHtml renders the paths and the notes', () => {
|
||||||
|
const note = 'path-exact';
|
||||||
|
const empty = servicesFsReportHtml(fsReport({ MF: [] }, []));
|
||||||
|
assert.ok(empty.includes('The filesystem matches the declared services'), empty);
|
||||||
|
assert.ok(empty.includes(note), empty);
|
||||||
|
assert.ok(servicesFsReportHtml(null).includes('The filesystem matches the declared services'));
|
||||||
|
const rep = fsReport({ MF: ['Fixed Dialling Numbers (FDN)'] }, ['MF/7F10/6F3C']);
|
||||||
|
const html = servicesFsReportHtml(rep);
|
||||||
|
assert.ok(html.includes('Declared services whose files are missing'), html);
|
||||||
|
assert.ok(html.includes('Fixed Dialling Numbers (FDN)'), html);
|
||||||
|
assert.ok(html.includes('MF/7F10/6F4B'), 'the missing path is shown');
|
||||||
|
assert.ok(html.includes('Present files whose service is not declared'), html);
|
||||||
|
assert.ok(html.includes('MF/7F10/6F3C'), 'the extra path is shown');
|
||||||
|
assert.ok(html.includes('EF.SMS'), 'the extra path names its role');
|
||||||
|
assert.ok(html.includes('Short Message Storage (SMS)'), html);
|
||||||
|
assert.ok(html.includes(note), html);
|
||||||
|
assert.ok(!html.includes('The filesystem matches the declared services'), html);
|
||||||
|
// a single-sided report renders only its own heading
|
||||||
|
const one = servicesFsReportHtml(fsReport({ MF: ['Fixed Dialling Numbers (FDN)'] }, ['MF/7F10/6F3B']));
|
||||||
|
assert.ok(one.includes('Declared services whose files are missing'), one);
|
||||||
|
assert.ok(!one.includes('Present files whose service is not declared'), one);
|
||||||
|
assert.ok(!one.includes('Declared services that cannot be checked'), one);
|
||||||
|
// the unchecked bucket names the roles and explains itself
|
||||||
|
const unc = servicesFsReportHtml(fsReport({ 'ADF.USIM': ['Image (IMG)'] }, []));
|
||||||
|
assert.ok(unc.includes('Declared services that cannot be checked'), unc);
|
||||||
|
assert.ok(unc.includes('Image (IMG)') && unc.includes('DF.GRAPHICS') && unc.includes('EF.IMG'), unc);
|
||||||
|
assert.ok(unc.includes('cannot be verified'), unc);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- filesystem check: the wiring ----
|
||||||
|
|
||||||
|
test('the Services pill carries the Check filesystem action and its report panel', () => {
|
||||||
|
const start = html.indexOf('id="pysim-sub-services"');
|
||||||
|
const pill = html.slice(start, html.indexOf('id="tab-phone"', start));
|
||||||
|
assert.match(pill, /id="pysim-services-fs-btn"[^>]*data-needs="card"[^>]*onclick="pysimServicesFsCheck\(\)"/);
|
||||||
|
assert.match(pill, /data-l10n="Check filesystem"/);
|
||||||
|
assert.match(pill, /id="pysim-services-fs-status"/);
|
||||||
|
assert.match(pill, /id="pysim-services-fs-spin"[^>]*class="hidden animate-spin/);
|
||||||
|
assert.match(pill, /id="pysim-services-fs-report"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the check probes the tree once per loaded session and keeps the report across renders', () => {
|
||||||
|
// /api/tree lists files without testing them: only a completed probe sets
|
||||||
|
// the presence flag, and a refresh (card change) clears it
|
||||||
|
assert.match(html, /_pysimFsProbed = !probe\.stop && !error;/);
|
||||||
|
assert.match(html, /async function pysimFsRefresh\(\) \{[\s\S]{0,200}_pysimFsProbed = false;/);
|
||||||
|
// the check loads the tree if missing, probes when unprobed, and renders
|
||||||
|
// the pure report from the per-tree declarations, the standard list's path
|
||||||
|
// index and the tree's present paths
|
||||||
|
const fn = extractFunc(html, 'pysimServicesFsCheck');
|
||||||
|
assert.ok(fn.indexOf('await pysimFsRefresh()') > 0, fn);
|
||||||
|
assert.ok(fn.indexOf('await pysimFsProbeAll()') > 0, fn);
|
||||||
|
// a throbber shows while the scan runs and hides when it settles
|
||||||
|
assert.ok(fn.indexOf('pysim-services-fs-spin') > 0, fn);
|
||||||
|
assert.ok(fn.indexOf("spinEl.classList.remove('hidden')") > 0, fn);
|
||||||
|
assert.ok(fn.indexOf("spinEl.classList.add('hidden')") > 0, fn);
|
||||||
|
assert.ok(fn.indexOf('servicesFsDeclaredTrees(cache.files)') > 0, fn);
|
||||||
|
assert.ok(fn.indexOf('await servicesPathIndex()') > 0, fn);
|
||||||
|
assert.ok(fn.indexOf('index.size') > 0, 'an unloadable list is an error, not a wall of unchecked rows');
|
||||||
|
assert.ok(fn.indexOf('servicesFsPresentPaths(pysimFsTreeRoot)') > 0, fn);
|
||||||
|
assert.ok(fn.indexOf('servicesFsReport(') > 0, fn);
|
||||||
|
// the path index is loaded once from the standard file list
|
||||||
|
const loader = extractFunc(html, 'servicesPathIndex');
|
||||||
|
assert.ok(loader.indexOf('uiccStdFilesLoad()') > 0, loader);
|
||||||
|
assert.ok(loader.indexOf('servicesPathIndexBuild(') > 0, loader);
|
||||||
|
// the grid's render redraws the kept report (language switch)
|
||||||
|
const render = extractFunc(html, 'pysimServicesRender');
|
||||||
|
assert.ok(render.indexOf('pysimServicesFsRender()') > 0, render);
|
||||||
|
// and a card-session change drops it with the cache
|
||||||
|
assert.match(html, /function pysimServicesReset\(\) \{\s*_pysimServicesCache = null;\s*_pysimServicesFsReport = null;/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
// the editor's cap, read from the source so the stub cannot drift
|
||||||
|
const ITERATIONS_MAX = parseInt(html.match(/const TEST_ITERATIONS_MAX = (\d+);/)[1], 10);
|
||||||
|
|
||||||
|
function extractFunc(src, name) {
|
||||||
|
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
|
const m = re.exec(src);
|
||||||
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
|
let i = m.index + m[0].length - 1;
|
||||||
|
let depth = 0;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(m.index, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FNS = ['esc', 'testStatusBadge', 'testRenderScriptRun', 'testRenderReport',
|
||||||
|
'testIterationsInit', 'testIterationsChanged', 'testIterationsValue',
|
||||||
|
'testLoadTiming', 'testDurationText', 'testIterAvgText', 'testRunBannerText'];
|
||||||
|
const code = FNS.map(f => extractFunc(html, f)).join('\n');
|
||||||
|
|
||||||
|
function fakeEl(id) {
|
||||||
|
return { id, innerHTML: '', textContent: '', className: '', value: '',
|
||||||
|
querySelectorAll: () => [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(els, storage, curRef) {
|
||||||
|
const fn = new Function('els', 'storage', 'curRef', `
|
||||||
|
var _testRunState = null;
|
||||||
|
var _testRunScriptRef = curRef.value;
|
||||||
|
var TEST_ITERATIONS_MAX = ${ITERATIONS_MAX};
|
||||||
|
var _testIterationsReady = false;
|
||||||
|
var localStorage = storage;
|
||||||
|
var document = { getElementById: id => els[id] || null };
|
||||||
|
var t = k => k;
|
||||||
|
function testCurrentScript() { return curRef.value; }
|
||||||
|
function testRunStepHtml(s) { return '<STEP ' + (s.note || '') + '>'; }
|
||||||
|
function testReportMemberHtml(m, i) { return '<MEMBER ' + (m.name || '') + '>'; }
|
||||||
|
${code}
|
||||||
|
return {
|
||||||
|
setState: v => { _testRunState = v; },
|
||||||
|
getState: () => _testRunState,
|
||||||
|
testRenderScriptRun, testRenderReport,
|
||||||
|
testIterationsValue, testIterationsChanged, testIterationsInit,
|
||||||
|
testLoadTiming, testDurationText, testIterAvgText, testRunBannerText,
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
return fn(els, storage, curRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the iteration count clamps and persists (v3.37.0)', () => {
|
||||||
|
const els = { 'test-iterations': fakeEl('test-iterations'),
|
||||||
|
'test-suite-iterations': fakeEl('test-suite-iterations') };
|
||||||
|
const storage = { data: {}, getItem(k) { return this.data[k] === undefined ? null : this.data[k]; },
|
||||||
|
setItem(k, v) { this.data[k] = String(v); } };
|
||||||
|
const app = build(els, storage, { value: null });
|
||||||
|
|
||||||
|
els['test-iterations'].value = '5';
|
||||||
|
assert.strictEqual(app.testIterationsValue('test-iterations'), 5);
|
||||||
|
assert.strictEqual(app.testIterationsValue('missing'), 1);
|
||||||
|
els['test-iterations'].value = String(ITERATIONS_MAX + 1);
|
||||||
|
assert.strictEqual(app.testIterationsValue('test-iterations'), ITERATIONS_MAX);
|
||||||
|
els['test-iterations'].value = String(ITERATIONS_MAX);
|
||||||
|
assert.strictEqual(app.testIterationsValue('test-iterations'), ITERATIONS_MAX);
|
||||||
|
els['test-iterations'].value = 'nope';
|
||||||
|
assert.strictEqual(app.testIterationsValue('test-iterations'), 1);
|
||||||
|
|
||||||
|
app.testIterationsChanged(els['test-iterations']);
|
||||||
|
els['test-iterations'].value = '7';
|
||||||
|
app.testIterationsChanged(els['test-iterations']);
|
||||||
|
assert.strictEqual(storage.data.simple_test_iterations, '7');
|
||||||
|
assert.strictEqual(els['test-suite-iterations'].value, '7', 'the other input follows');
|
||||||
|
|
||||||
|
// init restores the remembered value
|
||||||
|
storage.data.simple_test_iterations = '12';
|
||||||
|
app.testIterationsInit();
|
||||||
|
assert.strictEqual(els['test-iterations'].value, '12');
|
||||||
|
assert.strictEqual(els['test-suite-iterations'].value, '12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the run bodies carry the iterations (v3.37.0)', () => {
|
||||||
|
const script = extractFunc(html, 'testRunStart');
|
||||||
|
assert.match(script, /body\.iterations = testIterationsValue\('test-iterations'\)/);
|
||||||
|
const suite = extractFunc(html, 'testSuiteRun');
|
||||||
|
assert.match(suite, /body\.iterations = testIterationsValue\('test-suite-iterations'\)/);
|
||||||
|
// the inputs live next to the two run buttons, with the raised cap
|
||||||
|
assert.ok(html.includes('id="test-iterations" type="number" min="1" max="' + ITERATIONS_MAX + '"'));
|
||||||
|
assert.ok(html.includes('id="test-suite-iterations" type="number" min="1" max="' + ITERATIONS_MAX + '"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a running load test shows the counter, not the steps (v3.37.0)', () => {
|
||||||
|
const els = { 'test-run-status': fakeEl('test-run-status'),
|
||||||
|
'test-run-steps': fakeEl('test-run-steps') };
|
||||||
|
const curRef = { value: { id: 's1' } };
|
||||||
|
const app = build(els, { getItem: () => null, setItem: () => {} }, curRef);
|
||||||
|
app.setState({ running: true, script_id: 's1', name: 'S', steps: [],
|
||||||
|
load: { enabled: true, iterations: 100, iteration: 3, done: 2 } });
|
||||||
|
app.testRenderScriptRun();
|
||||||
|
assert.match(els['test-run-status'].innerHTML, /Iteration 3 \/ 100/);
|
||||||
|
assert.match(els['test-run-status'].innerHTML, /done: 2/);
|
||||||
|
assert.strictEqual(els['test-run-steps'].innerHTML, '', 'steps are not tracked');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the live load timing computes elapsed, average and left', () => {
|
||||||
|
const app = build({}, { getItem: () => null, setItem: () => {} }, { value: null });
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
app.testLoadTiming({ enabled: true, started: 940, done: 10, iterations: 100 }, 1000000),
|
||||||
|
{ elapsed: 60000, avg: 6000, left: 540000, finished: false });
|
||||||
|
// no completed iteration yet: elapsed only (no bogus average/ETA)
|
||||||
|
const early = app.testLoadTiming({ enabled: true, started: 940, done: 0, iterations: 10 }, 1000000);
|
||||||
|
assert.strictEqual(early.elapsed, 60000);
|
||||||
|
assert.strictEqual(early.avg, null);
|
||||||
|
assert.strictEqual(early.left, null);
|
||||||
|
// a finished run uses the server's final numbers and has no estimate
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
app.testLoadTiming({ enabled: true, started: 1, finished: 2, elapsed_ms: 5000, avg_ms: 500 }, 1000000),
|
||||||
|
{ elapsed: 5000, avg: 500, left: null, finished: true });
|
||||||
|
assert.strictEqual(app.testLoadTiming({ enabled: false }, 1000000), null);
|
||||||
|
assert.strictEqual(app.testDurationText(60000), '1:00');
|
||||||
|
assert.strictEqual(app.testDurationText(3661000), '1:01:01');
|
||||||
|
assert.strictEqual(app.testDurationText(null), '');
|
||||||
|
assert.strictEqual(app.testIterAvgText(500), '500 ms');
|
||||||
|
assert.strictEqual(app.testIterAvgText(1500), '1.50 s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the run banner carries the live load timing', () => {
|
||||||
|
const app = build({}, { getItem: () => null, setItem: () => {} }, { value: null });
|
||||||
|
const now = Date.now();
|
||||||
|
const text = app.testRunBannerText({ running: true, name: 'Load suite',
|
||||||
|
load: { enabled: true, suite: true, iterations: 50, iteration: 6, done: 5,
|
||||||
|
started: now / 1000 - 10 } });
|
||||||
|
assert.match(text, /Test suite running: Load suite/);
|
||||||
|
assert.match(text, /Iteration 6\/50/);
|
||||||
|
assert.match(text, /elapsed 0:10/);
|
||||||
|
assert.match(text, /avg 2\.00 s/);
|
||||||
|
assert.match(text, /left ~1:30/);
|
||||||
|
// a non-load run has no timing part (only its position)
|
||||||
|
const plain = app.testRunBannerText({ running: true, name: 'S', index: 2, total: 5 });
|
||||||
|
assert.match(plain, /Test script running: S \(3\/5\)/);
|
||||||
|
assert.ok(!plain.includes('elapsed'), plain);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a running load test shows elapsed, average and left', () => {
|
||||||
|
const els = { 'test-run-status': fakeEl('test-run-status'),
|
||||||
|
'test-run-steps': fakeEl('test-run-steps') };
|
||||||
|
const app = build(els, { getItem: () => null, setItem: () => {} },
|
||||||
|
{ value: { id: 's1' } });
|
||||||
|
const now = Date.now();
|
||||||
|
app.setState({ running: true, script_id: 's1', name: 'S', steps: [],
|
||||||
|
load: { enabled: true, iterations: 100, iteration: 11, done: 10,
|
||||||
|
started: now / 1000 - 60 } });
|
||||||
|
app.testRenderScriptRun();
|
||||||
|
const head = els['test-run-status'].innerHTML;
|
||||||
|
assert.match(head, /elapsed 1:00/);
|
||||||
|
assert.match(head, /avg 6\.00 s/);
|
||||||
|
assert.match(head, /left ~9:00/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the running load suite summary shows the live timing', () => {
|
||||||
|
const els = { 'test-report-title': fakeEl('test-report-title'),
|
||||||
|
'test-report-summary': fakeEl('test-report-summary'),
|
||||||
|
'test-report-members': fakeEl('test-report-members') };
|
||||||
|
const app = build(els, { getItem: () => null, setItem: () => {} }, { value: null });
|
||||||
|
const now = Date.now();
|
||||||
|
app.setState({ running: true, status: null, name: 'Load suite',
|
||||||
|
load: { enabled: true, iterations: 50, iteration: 6, done: 5,
|
||||||
|
started: now / 1000 - 10 }, suite: null });
|
||||||
|
app.testRenderReport();
|
||||||
|
const sum = els['test-report-summary'].innerHTML;
|
||||||
|
assert.match(sum, /elapsed 0:10/);
|
||||||
|
assert.match(sum, /avg 2\.00 s/);
|
||||||
|
assert.match(sum, /left ~1:30/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a load-test failure shows the failing iteration steps (v3.37.0)', () => {
|
||||||
|
const els = { 'test-run-status': fakeEl('test-run-status'),
|
||||||
|
'test-run-steps': fakeEl('test-run-steps') };
|
||||||
|
const curRef = { value: { id: 's1' } };
|
||||||
|
const app = build(els, { getItem: () => null, setItem: () => {} }, curRef);
|
||||||
|
app.setState({ running: false, status: 'error', script_id: 's1', name: 'S',
|
||||||
|
steps: [{ note: 'boom' }], error: 'iteration 4 failed',
|
||||||
|
load: { enabled: true, iterations: 100, iteration: 4, done: 3, failed_iteration: 4 } });
|
||||||
|
app.testRenderScriptRun();
|
||||||
|
assert.match(els['test-run-status'].innerHTML, /Iteration 4 \/ 100/);
|
||||||
|
assert.match(els['test-run-status'].innerHTML, /iteration 4 failed/);
|
||||||
|
assert.match(els['test-run-steps'].innerHTML, /<STEP boom>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the suite report summarises a load run and hides successful iterations (v3.37.0)', () => {
|
||||||
|
const els = { 'test-report-title': fakeEl('test-report-title'),
|
||||||
|
'test-report-summary': fakeEl('test-report-summary'),
|
||||||
|
'test-report-members': fakeEl('test-report-members') };
|
||||||
|
const app = build(els, { getItem: () => null, setItem: () => {} }, { value: null });
|
||||||
|
// a completed load run: no suite state, only the summary
|
||||||
|
app.setState({ running: false, status: 'ok', name: 'Load suite',
|
||||||
|
load: { enabled: true, iterations: 50, iteration: 50, done: 50,
|
||||||
|
elapsed_ms: 12000, avg_ms: 240, failed_iteration: null }, suite: null });
|
||||||
|
app.testRenderReport();
|
||||||
|
assert.match(els['test-report-title'].innerHTML, /Load suite/);
|
||||||
|
assert.match(els['test-report-summary'].innerHTML, /Iteration 50 \/ 50/);
|
||||||
|
assert.match(els['test-report-summary'].innerHTML, /done: 50/);
|
||||||
|
assert.strictEqual(els['test-report-members'].innerHTML, '');
|
||||||
|
// a failure keeps that iteration's members
|
||||||
|
app.setState({ running: false, status: 'error', name: 'Load suite',
|
||||||
|
error: 'iteration 7 failed',
|
||||||
|
load: { enabled: true, iterations: 50, iteration: 7, done: 6,
|
||||||
|
elapsed_ms: 2000, avg_ms: 300, failed_iteration: 7 },
|
||||||
|
suite: { name: 'Load suite', summary: { ok: 0, warning: 0, error: 1, skipped: 0, stopped: false },
|
||||||
|
members: [{ name: 'A', status: 'error' }] } });
|
||||||
|
app.testRenderReport();
|
||||||
|
assert.match(els['test-report-summary'].innerHTML, /Iteration 7 \/ 50/);
|
||||||
|
assert.match(els['test-report-members'].innerHTML, /<MEMBER A>/);
|
||||||
|
// while running, the members are not shown (results are not tracked)
|
||||||
|
app.setState({ running: true, status: null, name: 'Load suite',
|
||||||
|
load: { enabled: true, iterations: 50, iteration: 2, done: 1 },
|
||||||
|
suite: { name: 'Load suite', summary: null, members: [{ name: 'A', status: 'running' }] } });
|
||||||
|
app.testRenderReport();
|
||||||
|
assert.strictEqual(els['test-report-members'].innerHTML, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful load run can be exported and reopened after a reload (v3.37.0)', () => {
|
||||||
|
// the report is renderable with suite: null, so the export buttons and the
|
||||||
|
// reload path must accept a load run too
|
||||||
|
for (const fn of ['testReportExportJson', 'testReportExportMarkdown']) {
|
||||||
|
const src = extractFunc(html, fn);
|
||||||
|
assert.match(src, /_testRunState\.load && _testRunState\.load\.enabled/);
|
||||||
|
}
|
||||||
|
const init = extractFunc(html, 'testInit');
|
||||||
|
assert.match(init, /st\.load && st\.load\.enabled/);
|
||||||
|
assert.match(init, /const idx = st\.suite\s*\?/, 'a null suite must not break the reopen');
|
||||||
|
// the banner knows a load suite even when its state is cleared
|
||||||
|
const banner = extractFunc(html, 'testRunBannerText');
|
||||||
|
assert.match(banner, /const isSuite = !!\(snap\.suite \|\| \(load && load\.suite\)\);/);
|
||||||
|
});
|
||||||
@@ -52,14 +52,31 @@ eval(extractFunc(html, 'testScriptProblem'));
|
|||||||
eval(extractFunc(html, 'testPorSpecSummary'));
|
eval(extractFunc(html, 'testPorSpecSummary'));
|
||||||
eval(extractFunc(html, 'testCheckSummary'));
|
eval(extractFunc(html, 'testCheckSummary'));
|
||||||
eval(extractFunc(html, 'testStepSummary'));
|
eval(extractFunc(html, 'testStepSummary'));
|
||||||
|
// the load-test run parameter (v3.37.0): the run bodies read the count
|
||||||
|
eval(html.match(/const TEST_ITERATIONS_MAX = \d+;/)[0].replace('const ', 'var '));
|
||||||
|
eval(extractFunc(html, 'testIterationsValue'));
|
||||||
eval(extractFunc(html, 'testCommandOptions'));
|
eval(extractFunc(html, 'testCommandOptions'));
|
||||||
globalThis.t = s => s;
|
globalThis.t = s => s;
|
||||||
globalThis.esc = s => String(s);
|
globalThis.esc = s => String(s);
|
||||||
|
// The file-action picker merges the tree/custom/standard sources in
|
||||||
|
// rfmFileEntries(); the editor tests stub a small list (an EF per root plus
|
||||||
|
// a DF and an ADF, which the picker must not offer).
|
||||||
|
globalThis.rfmFileEntries = () => [
|
||||||
|
{path: 'MF/2FE2', name: 'EF.ICCID', fid: '2FE2', kind: 'ef', root: 'MF', source: 'standard'},
|
||||||
|
{path: 'ADF.USIM/6F7E', name: 'EF.LOCI', fid: '6F7E', kind: 'ef', root: 'ADF.USIM', source: 'standard'},
|
||||||
|
{path: 'ADF.USIM/5F3A', name: 'DF.PHONEBOOK', fid: '5F3A', kind: 'df', root: 'ADF.USIM', source: 'standard'},
|
||||||
|
{path: 'ADF.USIM', name: 'ADF.USIM', fid: '', kind: 'adf', root: 'ADF.USIM', source: 'standard'},
|
||||||
|
];
|
||||||
globalThis.testScripts = () => _testScripts || [];
|
globalThis.testScripts = () => _testScripts || [];
|
||||||
globalThis.testSuites = () => _testSuites || [];
|
globalThis.testSuites = () => _testSuites || [];
|
||||||
eval(extractFunc(html, 'testFormRow'));
|
eval(extractFunc(html, 'testFormRow'));
|
||||||
eval(extractFunc(html, 'testFormInput'));
|
eval(extractFunc(html, 'testFormInput'));
|
||||||
eval(extractFunc(html, 'testFormSelect'));
|
eval(extractFunc(html, 'testFormSelect'));
|
||||||
|
// the file actions' known-file picker (v3.38.0)
|
||||||
|
eval(extractFunc(html, 'testPathEntries'));
|
||||||
|
eval(extractFunc(html, 'testPathOptionsHtml'));
|
||||||
|
eval(extractFunc(html, 'testPathPick'));
|
||||||
|
eval(extractFunc(html, 'testPathFilterInput'));
|
||||||
// the TERMINAL RESPONSE result tables + the shared result-row helpers
|
// the TERMINAL RESPONSE result tables + the shared result-row helpers
|
||||||
eval(extractBlock('const TR_RESULTS = [', 'const TR_RESULT_ADD_INFO = [').replace(/^const /gm, 'var '));
|
eval(extractBlock('const TR_RESULTS = [', 'const TR_RESULT_ADD_INFO = [').replace(/^const /gm, 'var '));
|
||||||
eval(extractBlock('const TR_RESULT_ADD_INFO = [', '// TS 102 223 v18.3.0 Tables 6.1/6.2').replace(/^const /gm, 'var '));
|
eval(extractBlock('const TR_RESULT_ADD_INFO = [', '// TS 102 223 v18.3.0 Tables 6.1/6.2').replace(/^const /gm, 'var '));
|
||||||
@@ -458,6 +475,55 @@ test('the menu-select step form renders the case select', () => {
|
|||||||
assert.match(els['test-step-body'].innerHTML, /<option value="false" selected>/);
|
assert.match(els['test-step-body'].innerHTML, /<option value="false" selected>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the file step offers the known-file picker', () => {
|
||||||
|
const els = {
|
||||||
|
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
'test-step-title': {}, 'test-step-body': {},
|
||||||
|
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
_testEditStep = { type: 'action', kind: 'file-write',
|
||||||
|
params: { path: 'MF/2FE2', data: 'FF' } };
|
||||||
|
_testEditChecks = [];
|
||||||
|
_testEditStepIndex = 0;
|
||||||
|
testStepRender();
|
||||||
|
const body = els['test-step-body'].innerHTML;
|
||||||
|
assert.match(body, /id="test-f-pathpick"/);
|
||||||
|
assert.match(body, /id="test-f-pathfilter"/);
|
||||||
|
assert.match(body, /<option value="MF\/2FE2" selected>/);
|
||||||
|
assert.match(body, /<optgroup label="ADF.USIM">/);
|
||||||
|
// EFs only: the DF and the ADF are not offered
|
||||||
|
assert.ok(!testPathOptionsHtml('', '').includes('5F3A'));
|
||||||
|
assert.ok(!testPathOptionsHtml('', '').includes('value="ADF.USIM"'));
|
||||||
|
// the filter matches the name or the path, the current path stays selected
|
||||||
|
const f = testPathOptionsHtml('loci', 'ADF.USIM/6F7E');
|
||||||
|
assert.ok(f.includes('value="ADF.USIM/6F7E" selected'), f);
|
||||||
|
assert.ok(!f.includes('MF/2FE2'), f);
|
||||||
|
assert.ok(testPathOptionsHtml('nope', '').includes('no matching files'));
|
||||||
|
// the read action gets the same picker
|
||||||
|
_testEditStep = { type: 'action', kind: 'file-read', params: { path: 'MF/2FE2' } };
|
||||||
|
testStepRender();
|
||||||
|
assert.match(els['test-step-body'].innerHTML, /id="test-f-pathpick"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the path picker fills the path field, the filter rebuilds the list', () => {
|
||||||
|
const els = {
|
||||||
|
'test-f-path': { value: '' },
|
||||||
|
'test-f-pathpick': { innerHTML: '', value: 'ADF.USIM/6F7E' },
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
testPathPick(els['test-f-pathpick']);
|
||||||
|
assert.strictEqual(els['test-f-path'].value, 'ADF.USIM/6F7E');
|
||||||
|
els['test-f-path'].value = 'ADF.USIM/6F7E';
|
||||||
|
testPathFilterInput({ value: 'loci' });
|
||||||
|
assert.ok(els['test-f-pathpick'].innerHTML.includes('value="ADF.USIM/6F7E" selected'));
|
||||||
|
assert.ok(!els['test-f-pathpick'].innerHTML.includes('MF/2FE2'));
|
||||||
|
// clearing the filter restores the full list (the path stays selected)
|
||||||
|
testPathFilterInput({ value: '' });
|
||||||
|
assert.ok(els['test-f-pathpick'].innerHTML.includes('MF/2FE2'));
|
||||||
|
assert.ok(els['test-f-pathpick'].innerHTML.includes('value="ADF.USIM/6F7E" selected'));
|
||||||
|
});
|
||||||
|
|
||||||
test('the envelope step collects the source override', () => {
|
test('the envelope step collects the source override', () => {
|
||||||
_testEditStep = { type: 'action', kind: 'envelope', params: {} };
|
_testEditStep = { type: 'action', kind: 'envelope', params: {} };
|
||||||
_testEditChecks = [];
|
_testEditChecks = [];
|
||||||
|
|||||||
+297
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generated": "2026-09-23",
|
"generated": "2026-10-25",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "MF/2F00",
|
"path": "MF/2F00",
|
||||||
@@ -161,6 +161,30 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "MF"
|
"root": "MF"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "MF/7F10/5F3F",
|
||||||
|
"name": "DF.A2X",
|
||||||
|
"sym": "MF/DF.TELECOM/DF.A2X",
|
||||||
|
"fid": "5F3F",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "MF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "MF/7F10/5F3F/4F01",
|
||||||
|
"name": "EF.AST",
|
||||||
|
"sym": "MF/DF.TELECOM/DF.A2X/EF.AST",
|
||||||
|
"fid": "4F01",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "MF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "MF/7F10/5F3F/4F02",
|
||||||
|
"name": "EF.A2X_CONFIG",
|
||||||
|
"sym": "MF/DF.TELECOM/DF.A2X/EF.A2X_CONFIG",
|
||||||
|
"fid": "4F02",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "MF"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "MF/7F10/6F3A",
|
"path": "MF/7F10/6F3A",
|
||||||
"name": "EF.ADN",
|
"name": "EF.ADN",
|
||||||
@@ -1030,6 +1054,46 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM"
|
"root": "ADF.USIM"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F3C",
|
||||||
|
"name": "DF.MExE",
|
||||||
|
"sym": "ADF.USIM/DF.MExE",
|
||||||
|
"fid": "5F3C",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F3C/4F40",
|
||||||
|
"name": "EF.MExE-ST",
|
||||||
|
"sym": "ADF.USIM/DF.MExE/EF.MExE-ST",
|
||||||
|
"fid": "4F40",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F3C/4F41",
|
||||||
|
"name": "EF.ORPK",
|
||||||
|
"sym": "ADF.USIM/DF.MExE/EF.ORPK",
|
||||||
|
"fid": "4F41",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F3C/4F42",
|
||||||
|
"name": "EF.ARPK",
|
||||||
|
"sym": "ADF.USIM/DF.MExE/EF.ARPK",
|
||||||
|
"fid": "4F42",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F3C/4F43",
|
||||||
|
"name": "EF.TPRPK",
|
||||||
|
"sym": "ADF.USIM/DF.MExE/EF.TPRPK",
|
||||||
|
"fid": "4F43",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM/5F40",
|
"path": "ADF.USIM/5F40",
|
||||||
"name": "DF.WLAN",
|
"name": "DF.WLAN",
|
||||||
@@ -1182,6 +1246,38 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM"
|
"root": "ADF.USIM"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F70",
|
||||||
|
"name": "DF.SoLSA",
|
||||||
|
"sym": "ADF.USIM/DF.SoLSA",
|
||||||
|
"fid": "5F70",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F70/4F30",
|
||||||
|
"name": "EF.SAI",
|
||||||
|
"sym": "ADF.USIM/DF.SoLSA/EF.SAI",
|
||||||
|
"fid": "4F30",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F70/4F31",
|
||||||
|
"name": "EF.SLL",
|
||||||
|
"sym": "ADF.USIM/DF.SoLSA/EF.SLL",
|
||||||
|
"fid": "4F31",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5F80",
|
||||||
|
"name": "DF.BCAST",
|
||||||
|
"sym": "ADF.USIM/DF.BCAST",
|
||||||
|
"fid": "5F80",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM/5F90",
|
"path": "ADF.USIM/5F90",
|
||||||
"name": "DF.ProSe",
|
"name": "DF.ProSe",
|
||||||
@@ -1302,6 +1398,30 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM"
|
"root": "ADF.USIM"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5FA0",
|
||||||
|
"name": "DF.ACDC",
|
||||||
|
"sym": "ADF.USIM/DF.ACDC",
|
||||||
|
"fid": "5FA0",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5FA0/4F01",
|
||||||
|
"name": "EF.ACDC_LIST",
|
||||||
|
"sym": "ADF.USIM/DF.ACDC/EF.ACDC_LIST",
|
||||||
|
"fid": "4F01",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/5FB0",
|
||||||
|
"name": "DF.TV",
|
||||||
|
"sym": "ADF.USIM/DF.TV",
|
||||||
|
"fid": "5FB0",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM/5FC0",
|
"path": "ADF.USIM/5FC0",
|
||||||
"name": "DF.5GS",
|
"name": "DF.5GS",
|
||||||
@@ -2262,6 +2382,14 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM"
|
"root": "ADF.USIM"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/6FDC",
|
||||||
|
"name": "EF.LRPLMNSI",
|
||||||
|
"sym": "ADF.USIM/EF.LRPLMNSI",
|
||||||
|
"fid": "6FDC",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM/6FDD",
|
"path": "ADF.USIM/6FDD",
|
||||||
"name": "EF.NAFKCA",
|
"name": "EF.NAFKCA",
|
||||||
@@ -2358,6 +2486,14 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM"
|
"root": "ADF.USIM"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/6FF0",
|
||||||
|
"name": "EF.IAL",
|
||||||
|
"sym": "ADF.USIM/EF.IAL",
|
||||||
|
"fid": "6FF0",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM/6FF1",
|
"path": "ADF.USIM/6FF1",
|
||||||
"name": "EF.IPS",
|
"name": "EF.IPS",
|
||||||
@@ -2366,6 +2502,14 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM"
|
"root": "ADF.USIM"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/6FF2",
|
||||||
|
"name": "EF.IPD",
|
||||||
|
"sym": "ADF.USIM/EF.IPD",
|
||||||
|
"fid": "6FF2",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM/6FF3",
|
"path": "ADF.USIM/6FF3",
|
||||||
"name": "EF.ePDGId",
|
"name": "EF.ePDGId",
|
||||||
@@ -2414,6 +2558,22 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM"
|
"root": "ADF.USIM"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/6FF9",
|
||||||
|
"name": "EF.3GPPPSDATAOFF",
|
||||||
|
"sym": "ADF.USIM/EF.3GPPPSDATAOFF",
|
||||||
|
"fid": "6FF9",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM/6FFB",
|
||||||
|
"name": "EF.TVCONFIG",
|
||||||
|
"sym": "ADF.USIM/EF.TVCONFIG",
|
||||||
|
"fid": "6FFB",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM/6FFC",
|
"path": "ADF.USIM/6FFC",
|
||||||
"name": "EF.XCAPConfigData",
|
"name": "EF.XCAPConfigData",
|
||||||
@@ -2527,6 +2687,46 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM-NON-IMSI"
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F3C",
|
||||||
|
"name": "DF.MExE",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.MExE",
|
||||||
|
"fid": "5F3C",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F3C/4F40",
|
||||||
|
"name": "EF.MExE-ST",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.MExE/EF.MExE-ST",
|
||||||
|
"fid": "4F40",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F3C/4F41",
|
||||||
|
"name": "EF.ORPK",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.MExE/EF.ORPK",
|
||||||
|
"fid": "4F41",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F3C/4F42",
|
||||||
|
"name": "EF.ARPK",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.MExE/EF.ARPK",
|
||||||
|
"fid": "4F42",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F3C/4F43",
|
||||||
|
"name": "EF.TPRPK",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.MExE/EF.TPRPK",
|
||||||
|
"fid": "4F43",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM-NON-IMSI/5F40",
|
"path": "ADF.USIM-NON-IMSI/5F40",
|
||||||
"name": "DF.WLAN",
|
"name": "DF.WLAN",
|
||||||
@@ -2679,6 +2879,38 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM-NON-IMSI"
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F70",
|
||||||
|
"name": "DF.SoLSA",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.SoLSA",
|
||||||
|
"fid": "5F70",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F70/4F30",
|
||||||
|
"name": "EF.SAI",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.SoLSA/EF.SAI",
|
||||||
|
"fid": "4F30",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F70/4F31",
|
||||||
|
"name": "EF.SLL",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.SoLSA/EF.SLL",
|
||||||
|
"fid": "4F31",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5F80",
|
||||||
|
"name": "DF.BCAST",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.BCAST",
|
||||||
|
"fid": "5F80",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM-NON-IMSI/5F90",
|
"path": "ADF.USIM-NON-IMSI/5F90",
|
||||||
"name": "DF.ProSe",
|
"name": "DF.ProSe",
|
||||||
@@ -2799,6 +3031,30 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM-NON-IMSI"
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5FA0",
|
||||||
|
"name": "DF.ACDC",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.ACDC",
|
||||||
|
"fid": "5FA0",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5FA0/4F01",
|
||||||
|
"name": "EF.ACDC_LIST",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.ACDC/EF.ACDC_LIST",
|
||||||
|
"fid": "4F01",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/5FB0",
|
||||||
|
"name": "DF.TV",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/DF.TV",
|
||||||
|
"fid": "5FB0",
|
||||||
|
"kind": "df",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM-NON-IMSI/5FC0",
|
"path": "ADF.USIM-NON-IMSI/5FC0",
|
||||||
"name": "DF.5GS",
|
"name": "DF.5GS",
|
||||||
@@ -3751,6 +4007,14 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM-NON-IMSI"
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/6FDC",
|
||||||
|
"name": "EF.LRPLMNSI",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/EF.LRPLMNSI",
|
||||||
|
"fid": "6FDC",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM-NON-IMSI/6FDD",
|
"path": "ADF.USIM-NON-IMSI/6FDD",
|
||||||
"name": "EF.NAFKCA",
|
"name": "EF.NAFKCA",
|
||||||
@@ -3847,6 +4111,14 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM-NON-IMSI"
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/6FF0",
|
||||||
|
"name": "EF.IAL",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/EF.IAL",
|
||||||
|
"fid": "6FF0",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM-NON-IMSI/6FF1",
|
"path": "ADF.USIM-NON-IMSI/6FF1",
|
||||||
"name": "EF.IPS",
|
"name": "EF.IPS",
|
||||||
@@ -3855,6 +4127,14 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM-NON-IMSI"
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/6FF2",
|
||||||
|
"name": "EF.IPD",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/EF.IPD",
|
||||||
|
"fid": "6FF2",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM-NON-IMSI/6FF3",
|
"path": "ADF.USIM-NON-IMSI/6FF3",
|
||||||
"name": "EF.ePDGId",
|
"name": "EF.ePDGId",
|
||||||
@@ -3903,6 +4183,22 @@
|
|||||||
"kind": "ef",
|
"kind": "ef",
|
||||||
"root": "ADF.USIM-NON-IMSI"
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/6FF9",
|
||||||
|
"name": "EF.3GPPPSDATAOFF",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/EF.3GPPPSDATAOFF",
|
||||||
|
"fid": "6FF9",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "ADF.USIM-NON-IMSI/6FFB",
|
||||||
|
"name": "EF.TVCONFIG",
|
||||||
|
"sym": "ADF.USIM-non-IMSI/EF.TVCONFIG",
|
||||||
|
"fid": "6FFB",
|
||||||
|
"kind": "ef",
|
||||||
|
"root": "ADF.USIM-NON-IMSI"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "ADF.USIM-NON-IMSI/6FFC",
|
"path": "ADF.USIM-NON-IMSI/6FFC",
|
||||||
"name": "EF.XCAPConfigData",
|
"name": "EF.XCAPConfigData",
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.33.0"
|
version = "3.39.0"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
license = { text = "GPL-2.0-or-later" }
|
license = { text = "GPL-2.0-or-later" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
+135
-18
@@ -7,7 +7,7 @@ import traceback
|
|||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
from pySim.card_handler import CardHandler
|
from pySim.card_handler import CardHandler
|
||||||
from pySim.commands import SimCardCommands
|
from pySim.commands import SimCardCommands
|
||||||
from pySim.exceptions import NoCardError
|
from pySim.exceptions import NoCardError, ReaderError
|
||||||
from pySim.log import PySimLogger
|
from pySim.log import PySimLogger
|
||||||
from pySim.cards import UiccCardBase
|
from pySim.cards import UiccCardBase
|
||||||
|
|
||||||
@@ -23,6 +23,115 @@ from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION,
|
|||||||
|
|
||||||
_server_start = 0
|
_server_start = 0
|
||||||
|
|
||||||
|
# PC/SC hresults (pyscard's exceptions carry them; the messages are often
|
||||||
|
# empty, so the hresult is the reliable signal).
|
||||||
|
_PCSC_E_UNKNOWN_READER = 0x80100009
|
||||||
|
_PCSC_E_NO_SERVICE = 0x8010001D
|
||||||
|
_PCSC_E_SERVICE_STOPPED = 0x8010001E
|
||||||
|
_PCSC_E_NO_ACCESS = 0x80100027
|
||||||
|
_PCSC_E_NO_READERS_AVAILABLE = 0x8010002E
|
||||||
|
|
||||||
|
|
||||||
|
class PcscUnavailable(RuntimeError):
|
||||||
|
"""The PC/SC stack cannot be used (kind: access/service/reader)."""
|
||||||
|
|
||||||
|
def __init__(self, kind, cause=None):
|
||||||
|
super().__init__(kind)
|
||||||
|
self.kind = kind
|
||||||
|
self.cause = cause
|
||||||
|
|
||||||
|
|
||||||
|
def _pcsc_error_kind(exc):
|
||||||
|
"""Classify a pyscard failure: 'access', 'service', 'reader' or None.
|
||||||
|
|
||||||
|
``SCARD_E_NO_ACCESS`` is the "Access denied" a user without permission to
|
||||||
|
pcscd gets; the message is a fallback because some pyscard paths raise a
|
||||||
|
CardConnectionException with an empty message and only the hresult."""
|
||||||
|
hr = getattr(exc, 'hresult', None)
|
||||||
|
msg = str(exc or '').lower()
|
||||||
|
if hr == _PCSC_E_NO_ACCESS or 'access denied' in msg or 'access is denied' in msg:
|
||||||
|
return 'access'
|
||||||
|
if hr in (_PCSC_E_NO_SERVICE, _PCSC_E_SERVICE_STOPPED) or \
|
||||||
|
'resource manager is not running' in msg or 'service not available' in msg:
|
||||||
|
return 'service'
|
||||||
|
if hr in (_PCSC_E_UNKNOWN_READER, _PCSC_E_NO_READERS_AVAILABLE) or \
|
||||||
|
'no smart card readers' in msg:
|
||||||
|
return 'reader'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _report_pcsc_unavailable(kind, exc=None):
|
||||||
|
"""Print an actionable reason instead of starting a readerless server."""
|
||||||
|
if kind == 'access':
|
||||||
|
user = os.environ.get('USER') or os.environ.get('LOGNAME') or '$USER'
|
||||||
|
sys.stderr.write('INIT: PC/SC access denied - the current user may not use pcscd.\n')
|
||||||
|
sys.stderr.write(' pcscd gates access through PolicyKit: the default policy allows\n')
|
||||||
|
sys.stderr.write(' only *active local* sessions, so an SSH/headless session is denied\n')
|
||||||
|
sys.stderr.write(' even when /run/pcscd/pcscd.comm is world-writable (0666).\n')
|
||||||
|
sys.stderr.write(' Allow the user in /etc/polkit-1/rules.d/99-pcscd-allow.rules:\n')
|
||||||
|
sys.stderr.write(' polkit.addRule(function(a, s) {\n')
|
||||||
|
sys.stderr.write(' if ((a.id == "org.debian.pcsc-lite.access_pcsc" ||\n')
|
||||||
|
sys.stderr.write(' a.id == "org.debian.pcsc-lite.access_card") &&\n')
|
||||||
|
sys.stderr.write(' s.user == "%s") return polkit.Result.YES;\n' % user)
|
||||||
|
sys.stderr.write(' });\n')
|
||||||
|
sys.stderr.write(' then restart polkit (sudo systemctl restart polkit) or log out/in;\n')
|
||||||
|
sys.stderr.write(' `pcsc_scan` shows the same "SCardEstablishContext: Access denied"\n')
|
||||||
|
sys.stderr.write(' error until the rule is active.\n')
|
||||||
|
elif kind == 'service':
|
||||||
|
sys.stderr.write('INIT: pcscd is not running or not reachable.\n')
|
||||||
|
sys.stderr.write(' Start it (sudo systemctl start pcscd) and restart the server.\n')
|
||||||
|
else:
|
||||||
|
sys.stderr.write('INIT: no PC/SC reader found.\n')
|
||||||
|
sys.stderr.write(' Connect a reader (pcsc_scan lists them) and restart the server.\n')
|
||||||
|
if exc is not None:
|
||||||
|
hr = getattr(exc, 'hresult', None)
|
||||||
|
detail = str(exc) or type(exc).__name__
|
||||||
|
if hr not in (None, -1):
|
||||||
|
detail += ' (hresult=0x%08X)' % hr
|
||||||
|
sys.stderr.write(' (%s)\n' % detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_pcsc_reader(retries=3, sleep_fn=time.sleep, readers_fn=None):
|
||||||
|
"""Return the first PC/SC reader name or raise PcscUnavailable.
|
||||||
|
|
||||||
|
``SCARD_E_NO_ACCESS``/service failures are raised immediately (retrying
|
||||||
|
cannot fix permissions or a stopped service); an empty list is retried
|
||||||
|
(USB enumeration) and reported as 'reader' after the last attempt."""
|
||||||
|
if readers_fn is None:
|
||||||
|
from smartcard.System import readers as readers_fn
|
||||||
|
last = None
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
found = readers_fn()
|
||||||
|
except Exception as exc:
|
||||||
|
last = exc
|
||||||
|
kind = _pcsc_error_kind(exc)
|
||||||
|
if kind in ('access', 'service'):
|
||||||
|
raise PcscUnavailable(kind, exc) from exc
|
||||||
|
else:
|
||||||
|
if found:
|
||||||
|
return str(found[0])
|
||||||
|
last = None
|
||||||
|
if attempt + 1 < retries:
|
||||||
|
sys.stderr.write('INIT: no PC/SC reader yet, retrying in 2s...\n')
|
||||||
|
sleep_fn(2)
|
||||||
|
raise PcscUnavailable('reader', last)
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_arg_given(argv=None):
|
||||||
|
"""True when the operator explicitly chose the serial reader (-d/--device).
|
||||||
|
|
||||||
|
The default (-d is /dev/ttyUSB0 in pySim's parser) must not masquerade as
|
||||||
|
an explicit choice: only a flag on the command line counts."""
|
||||||
|
argv = sys.argv[1:] if argv is None else argv
|
||||||
|
for arg in argv:
|
||||||
|
if arg in ('-d', '--device') or arg.startswith('--device='):
|
||||||
|
return True
|
||||||
|
if arg.startswith('-d') and not arg.startswith('--') and len(arg) > 2:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _log_stdout(msg):
|
def _log_stdout(msg):
|
||||||
elapsed = time.time() - _server_start
|
elapsed = time.time() - _server_start
|
||||||
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
||||||
@@ -164,22 +273,18 @@ def main():
|
|||||||
rs = None
|
rs = None
|
||||||
sim_menu = None
|
sim_menu = None
|
||||||
event_list = None
|
event_list = None
|
||||||
# Auto-detect PC/SC reader if none was explicitly specified.
|
# Auto-detect the PC/SC reader unless one was named explicitly or the
|
||||||
# Handles late pcscd startup and USB enumeration delays.
|
# operator chose the serial transport (-d). A PC/SC access denial or a
|
||||||
if opts.pcsc_dev is None and opts.pcsc_regex is None:
|
# missing service must be reported - silently falling back to the serial
|
||||||
|
# reader left the server "listening" without a usable card link (v3.38.1).
|
||||||
|
if opts.pcsc_dev is None and opts.pcsc_regex is None and not _serial_arg_given():
|
||||||
try:
|
try:
|
||||||
from smartcard.System import readers
|
reader = _probe_pcsc_reader()
|
||||||
for attempt in range(3):
|
except PcscUnavailable as e:
|
||||||
r = readers()
|
_report_pcsc_unavailable(e.kind, e.cause)
|
||||||
if r:
|
return 2
|
||||||
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % r[0])
|
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % reader)
|
||||||
opts.pcsc_dev = 0
|
opts.pcsc_dev = 0
|
||||||
break
|
|
||||||
if attempt < 2:
|
|
||||||
sys.stderr.write('INIT: no PC/SC readers found, retrying in 2s...\n')
|
|
||||||
time.sleep(2)
|
|
||||||
except Exception:
|
|
||||||
pass # smartcard module not available or pcscd unreachable
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@@ -223,7 +328,15 @@ def main():
|
|||||||
# defaults, which made VERIFY go out with CLA A0 on a UICC).
|
# defaults, which made VERIFY go out with CLA A0 on a UICC).
|
||||||
scc = card._scc
|
scc = card._scc
|
||||||
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
||||||
except Exception:
|
except ReaderError as e:
|
||||||
|
sys.stderr.write('INIT: no PC/SC reader found: %s\n' % e)
|
||||||
|
sys.stderr.write(' Connect the reader (pcsc_scan lists them) and restart the server.\n')
|
||||||
|
return 2
|
||||||
|
except Exception as e:
|
||||||
|
kind = _pcsc_error_kind(e)
|
||||||
|
if kind:
|
||||||
|
_report_pcsc_unavailable(kind, e)
|
||||||
|
return 2
|
||||||
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
ch = CardHandler(sl) if sl else None
|
ch = CardHandler(sl) if sl else None
|
||||||
@@ -293,7 +406,11 @@ def main():
|
|||||||
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
||||||
if not st_sw.startswith('91'):
|
if not st_sw.startswith('91'):
|
||||||
break
|
break
|
||||||
_handle_proactive_chain(scc, st_sw)
|
chain_sw = _handle_proactive_chain(scc, st_sw)
|
||||||
|
# the chain's trailing STATUS already proved the card idle;
|
||||||
|
# polling once more only costs an APDU (v3.38.1)
|
||||||
|
if chain_sw and not chain_sw.startswith('91'):
|
||||||
|
break
|
||||||
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_pcsc_error(e):
|
if _is_pcsc_error(e):
|
||||||
@@ -434,4 +551,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
sys.exit(main())
|
||||||
+523
-56
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.33.0'
|
VERSION = '3.39.0'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -483,6 +483,45 @@ def _decode_iccid(data_hex):
|
|||||||
return digits
|
return digits
|
||||||
|
|
||||||
|
|
||||||
|
def _read_selected_ef(lchan, kind, rec=None, length=None, offset=0):
|
||||||
|
"""Read the currently selected EF **without selecting it again**.
|
||||||
|
|
||||||
|
pySim's ``scc.read_binary``/``scc.read_record`` call ``select_path(ef)``
|
||||||
|
first. Our callers have already made a parent-scoped selection and hold
|
||||||
|
its FCP, so that second SELECT is redundant (one per file read, one per
|
||||||
|
record). A transparent file is read in ``max_cmd_len`` chunks from
|
||||||
|
``offset`` (the whole file when ``length`` is None, using the FCP file
|
||||||
|
size); a record file reads record ``rec`` (1-based) at the FCP's record
|
||||||
|
length. Returns ``(data, sw)`` like the pySim helpers; falls back to them
|
||||||
|
when the FCP lacks the needed value.
|
||||||
|
|
||||||
|
The caller must select the file first (``_select_path``/``_select_with_parent``)
|
||||||
|
and must not interleave another selection before the read."""
|
||||||
|
scc = getattr(lchan, 'scc', None)
|
||||||
|
if kind == 'record':
|
||||||
|
rec_len = lchan.selected_file_record_len()
|
||||||
|
if scc is not None and rec_len:
|
||||||
|
pdu = scc.cla_byte + 'b2%02x04%02x' % (int(rec), rec_len)
|
||||||
|
return scc.send_apdu_checksw(pdu)
|
||||||
|
return lchan.read_record(rec)
|
||||||
|
size = lchan.selected_file_size()
|
||||||
|
if scc is None or size is None:
|
||||||
|
return lchan.read_binary(length, offset)
|
||||||
|
if length is None:
|
||||||
|
length = size - offset
|
||||||
|
if length <= 0:
|
||||||
|
return '', '9000'
|
||||||
|
total, sw = '', ''
|
||||||
|
chunk = 0
|
||||||
|
while chunk < length:
|
||||||
|
n = min(scc.max_cmd_len, length - chunk)
|
||||||
|
pdu = scc.cla_byte + 'b0%04x%02x' % (offset + chunk, n)
|
||||||
|
data, sw = scc.send_apdu_checksw(pdu)
|
||||||
|
total += data or ''
|
||||||
|
chunk += n
|
||||||
|
return total, sw
|
||||||
|
|
||||||
|
|
||||||
def _read_iccid(app):
|
def _read_iccid(app):
|
||||||
"""Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None.
|
"""Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None.
|
||||||
|
|
||||||
@@ -495,7 +534,7 @@ def _read_iccid(app):
|
|||||||
cleanup = None
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
_, cleanup = _select_path(lchan, 'MF/2FE2', app)
|
_, cleanup = _select_path(lchan, 'MF/2FE2', app)
|
||||||
data, _sw = lchan.read_binary()
|
data, _sw = _read_selected_ef(lchan, 'transparent')
|
||||||
return _decode_iccid(data)
|
return _decode_iccid(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@@ -536,7 +575,7 @@ def _read_arr(app):
|
|||||||
n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
|
n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
|
||||||
records = []
|
records = []
|
||||||
for i in range(1, n + 1):
|
for i in range(1, n + 1):
|
||||||
rv = lchan.read_record(i)
|
rv = _read_selected_ef(lchan, 'record', i)
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
records.append((data or '').upper())
|
records.append((data or '').upper())
|
||||||
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
||||||
@@ -602,7 +641,7 @@ def _read_pbr(app):
|
|||||||
n = min(lchan.selected_file_num_of_rec() or 1, PBR_RECORD_LIMIT)
|
n = min(lchan.selected_file_num_of_rec() or 1, PBR_RECORD_LIMIT)
|
||||||
records = []
|
records = []
|
||||||
for i in range(1, n + 1):
|
for i in range(1, n + 1):
|
||||||
rv = lchan.read_record(i)
|
rv = _read_selected_ef(lchan, 'record', i)
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
records.append((data or '').upper())
|
records.append((data or '').upper())
|
||||||
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
||||||
@@ -886,14 +925,14 @@ def _netstate_read(app, keys=None):
|
|||||||
records = []
|
records = []
|
||||||
n = lchan.selected_file_num_of_rec() or 1
|
n = lchan.selected_file_num_of_rec() or 1
|
||||||
for i in range(1, n + 1):
|
for i in range(1, n + 1):
|
||||||
rv = lchan.read_record(i)
|
rv = _read_selected_ef(lchan, 'record', i)
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
records.append({'num': i,
|
records.append({'num': i,
|
||||||
'data': (data or '').upper()})
|
'data': (data or '').upper()})
|
||||||
entry.update({'present': True, 'kind': 'record',
|
entry.update({'present': True, 'kind': 'record',
|
||||||
'records': records})
|
'records': records})
|
||||||
else:
|
else:
|
||||||
rv = lchan.read_binary()
|
rv = _read_selected_ef(lchan, 'transparent')
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
entry.update({'present': True, 'kind': 'transparent',
|
entry.update({'present': True, 'kind': 'transparent',
|
||||||
'data': (data or '').upper()})
|
'data': (data or '').upper()})
|
||||||
@@ -2914,7 +2953,7 @@ def _ram_batch_new_state(plan):
|
|||||||
return {'active': True, 'phase': 'waiting_card', 'stop': False,
|
return {'active': True, 'phase': 'waiting_card', 'stop': False,
|
||||||
'started': time.time(), 'stopped': None, 'plan': plan,
|
'started': time.time(), 'stopped': None, 'plan': plan,
|
||||||
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
|
'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': ''}
|
'current': None, 'notice': ''}
|
||||||
|
|
||||||
|
|
||||||
@@ -2939,6 +2978,8 @@ def _ram_batch_record(state, rec):
|
|||||||
verb = {'delete': 'deleted',
|
verb = {'delete': 'deleted',
|
||||||
'reinstall-app': 'reinstalled'}.get(kind, 'installed')
|
'reinstall-app': 'reinstalled'}.get(kind, 'installed')
|
||||||
state['notice'] = 'Card %s %s.' % (who, verb)
|
state['notice'] = 'Card %s %s.' % (who, verb)
|
||||||
|
elif result == 'already_absent':
|
||||||
|
state['notice'] = 'Card %s was already absent.' % who
|
||||||
elif result == 'failed':
|
elif result == 'failed':
|
||||||
step = rec.get('failed_step') or ''
|
step = rec.get('failed_step') or ''
|
||||||
sw = rec.get('por_sw') or rec.get('sw') or ''
|
sw = rec.get('por_sw') or rec.get('sw') or ''
|
||||||
@@ -2947,8 +2988,9 @@ def _ram_batch_record(state, rec):
|
|||||||
else:
|
else:
|
||||||
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
|
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
|
||||||
detail = rec.get('reason') or rec.get('failed_step') 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' % (
|
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):
|
def _ram_batch_failure_fields(result):
|
||||||
@@ -3044,6 +3086,15 @@ def _ram_batch_handle_card(state, server):
|
|||||||
'module_aid': result.get('module_aid'),
|
'module_aid': result.get('module_aid'),
|
||||||
'nv_delta': result.get('nv_delta')}
|
'nv_delta': result.get('nv_delta')}
|
||||||
rec.update(_ram_batch_steps_fields(result))
|
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:
|
elif result is not None:
|
||||||
outcome = 'failed'
|
outcome = 'failed'
|
||||||
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
||||||
@@ -3178,7 +3229,7 @@ def _ram_batch_status():
|
|||||||
if _RAM_BATCH is None:
|
if _RAM_BATCH is None:
|
||||||
return {'active': False, 'phase': 'idle', 'attempts': [],
|
return {'active': False, 'phase': 'idle', 'attempts': [],
|
||||||
'attempts_total': 0, 'current': None, 'notice': '',
|
'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,
|
'started': None, 'stopped': None, 'progress': None,
|
||||||
'kind': None}
|
'kind': None}
|
||||||
return {'active': bool(_RAM_BATCH.get('active')),
|
return {'active': bool(_RAM_BATCH.get('active')),
|
||||||
@@ -3580,9 +3631,12 @@ PLI_QUALIFIER_NAMES = {
|
|||||||
# specs define no contents for them ("reserved for 3GPP/3GPP2") and a
|
# specs define no contents for them ("reserved for 3GPP/3GPP2") and a
|
||||||
# fabricated object would mislead the application more than no answer.
|
# fabricated object would mislead the application more than no answer.
|
||||||
PLI_DEFAULTS = {
|
PLI_DEFAULTS = {
|
||||||
# Location Information (8.19): PLMN 001/01 (the test network, TS 23.003)
|
# Location Information (TS 31.111 8.19.1, the GERAN form a PLI answer
|
||||||
# + LAC 0000
|
# uses): PLMN 001/01 (the test network, TS 23.003) + LAC 0001 + Cell ID
|
||||||
0x00: '1305' + '00F110' + '0000',
|
# 0001. Length 05 (no Cell ID) is the *Network Rejection event* form -
|
||||||
|
# a PLI answer must carry the Cell ID (length 07), otherwise the location
|
||||||
|
# reads as empty and a card applet may re-request it forever.
|
||||||
|
0x00: '1307' + '00F110' + '0001' + '0001',
|
||||||
# IMEI (8.20): the classic test IMEI 490154203237518, nibble-swapped
|
# IMEI (8.20): the classic test IMEI 490154203237518, nibble-swapped
|
||||||
# BCD with the odd 15th digit padded by F (TS 124 008 Mobile Identity)
|
# BCD with the odd 15th digit padded by F (TS 124 008 Mobile Identity)
|
||||||
0x01: '1408' + '94104502237315F8',
|
0x01: '1408' + '94104502237315F8',
|
||||||
@@ -6596,15 +6650,18 @@ def _parse_setup_menu_items(raw):
|
|||||||
return _attach_nai(items, nai)
|
return _attach_nai(items, nai)
|
||||||
|
|
||||||
|
|
||||||
def _handle_proactive_chain(scc, sw91, on_fetch=None, status_poll=True, max_rounds=None):
|
def _handle_proactive_chain(scc, sw91, on_fetch=None, status_poll=True,
|
||||||
|
max_rounds=_POLL_CHAIN_MAX):
|
||||||
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
|
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
|
||||||
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait.
|
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait.
|
||||||
``status_poll=False`` skips the trailing STATUS (used by the profile
|
``status_poll=False`` skips the trailing STATUS (used by the profile
|
||||||
switch, where the card is mid-switch and must not be queried further).
|
switch, where the card is mid-switch and must not be queried further).
|
||||||
``max_rounds`` bounds the answers per call (the background poll passes
|
``max_rounds`` bounds the answers per call - the default is
|
||||||
_POLL_CHAIN_MAX so a card that re-queues a command cannot hold the card
|
_POLL_CHAIN_MAX, so a card that re-queues a command (a chatty applet)
|
||||||
lock forever); ``_TEST_RUNNING`` aborts the chain before the next FETCH -
|
cannot hold the card lock forever from *any* call site; a caller may pass
|
||||||
a test run owns the card and the pending command stays for the script."""
|
a different bound. ``_TEST_RUNNING`` aborts the chain before the next
|
||||||
|
FETCH - a test run owns the card and the pending command stays for the
|
||||||
|
script."""
|
||||||
global _PROACTIVE_BUSY
|
global _PROACTIVE_BUSY
|
||||||
_PROACTIVE_BUSY = True
|
_PROACTIVE_BUSY = True
|
||||||
try:
|
try:
|
||||||
@@ -7044,9 +7101,19 @@ _TEST_RUN = {
|
|||||||
'preset': None, 'script_id': None,
|
'preset': None, 'script_id': None,
|
||||||
'scp80_counter': None, 'scp80_counters': {},
|
'scp80_counter': None, 'scp80_counters': {},
|
||||||
'started': None, 'finished': None, 'error': None,
|
'started': None, 'finished': None, 'error': None,
|
||||||
|
# the stuck-card watchdog (v3.37.0): `heartbeat` is the last runner
|
||||||
|
# progress mark; `stuck` makes the final status an error even when the
|
||||||
|
# stop flag ended the loop
|
||||||
|
'stuck': False, 'heartbeat': None,
|
||||||
# suite runs (v3.22.0): None for a single script; the members list and the
|
# suite runs (v3.22.0): None for a single script; the members list and the
|
||||||
# report summary live here, plus the run-log buffer/adm state
|
# report summary live here, plus the run-log buffer/adm state
|
||||||
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
|
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
|
||||||
|
# load testing (v3.37.0): a run parameter, not a stored option. `done`
|
||||||
|
# counts the successful iterations (their results are discarded); the
|
||||||
|
# failing iteration's steps/log stay as the report.
|
||||||
|
'load': {'enabled': False, 'iterations': None, 'iteration': 0, 'done': 0,
|
||||||
|
'failed_iteration': None, 'started': None, 'finished': None,
|
||||||
|
'elapsed_ms': None, 'avg_ms': None},
|
||||||
}
|
}
|
||||||
_TEST_LOCK = threading.Lock()
|
_TEST_LOCK = threading.Lock()
|
||||||
_TEST_THREAD = None
|
_TEST_THREAD = None
|
||||||
@@ -7136,11 +7203,158 @@ def _test_command_type(name):
|
|||||||
return _TEST_COMMAND_TYPES.get((name or '').upper())
|
return _TEST_COMMAND_TYPES.get((name or '').upper())
|
||||||
|
|
||||||
|
|
||||||
|
_TEST_LOAD_MAX = 1000000000
|
||||||
|
|
||||||
|
|
||||||
|
def _test_load_state(iterations, suite=False):
|
||||||
|
"""The `load` block of a run: disabled for a normal single run. `suite`
|
||||||
|
lets the PWA label a suite load test even after its state is cleared."""
|
||||||
|
enabled = iterations > 1
|
||||||
|
return {'enabled': enabled, 'suite': bool(suite),
|
||||||
|
'iterations': iterations if enabled else None,
|
||||||
|
'iteration': 0, 'done': 0, 'failed_iteration': None,
|
||||||
|
'started': time.time() if enabled else None, 'finished': None,
|
||||||
|
'elapsed_ms': None, 'avg_ms': None}
|
||||||
|
|
||||||
|
|
||||||
|
def _test_load_iterations(body):
|
||||||
|
"""The run's iteration count (1 when omitted). Raises ValueError for a
|
||||||
|
bad value (the endpoint answers 400)."""
|
||||||
|
raw = (body or {}).get('iterations')
|
||||||
|
if raw is None or raw == '':
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
n = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('iterations must be an integer')
|
||||||
|
if isinstance(raw, float) and raw != n:
|
||||||
|
raise ValueError('iterations must be an integer')
|
||||||
|
if n < 1 or n > _TEST_LOAD_MAX:
|
||||||
|
raise ValueError('iterations must be between 1 and %d' % _TEST_LOAD_MAX)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _test_state_snapshot():
|
def _test_state_snapshot():
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
return json.loads(json.dumps(_TEST_RUN))
|
return json.loads(json.dumps(_TEST_RUN))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── the stuck-card watchdog (v3.37.0) ───────────────────────────────────
|
||||||
|
# pyscard's SCardTransmit is blocking and has no timeout: a card that stops
|
||||||
|
# answering (no SW, no driver error) would leave the runner thread inside the
|
||||||
|
# step while it holds _CARD_LOCK - Stop cannot take effect, the card monitor
|
||||||
|
# waits for the lock and a load test hangs in that iteration. The watchdog
|
||||||
|
# watches the runner's progress heartbeat; after _TEST_STEP_TIMEOUT seconds
|
||||||
|
# without one it flags the run stuck (error + stop) and tries to cancel the
|
||||||
|
# pending transmit by disconnecting the PC/SC link. That attempt runs in the
|
||||||
|
# background because PC/SC clients may serialize the disconnect behind the
|
||||||
|
# pending transmit (pcsc-lite locks the context for both calls): when the
|
||||||
|
# cancel works, the blocking call raises and the normal step-failure path
|
||||||
|
# finalises the run; when it does not, the driver's own timeout eventually
|
||||||
|
# returns and the run still finalises as an error, and the status already
|
||||||
|
# explains the wait. The transport is marked stale so the next equip rebuilds
|
||||||
|
# it.
|
||||||
|
|
||||||
|
_TEST_STEP_TIMEOUT = 30 # seconds without runner progress => stuck card
|
||||||
|
_TEST_WATCHDOG = None
|
||||||
|
_TEST_WATCHDOG_ABORTED = None # heartbeat value already aborted
|
||||||
|
|
||||||
|
|
||||||
|
def _test_heartbeat():
|
||||||
|
"""Mark runner progress for the watchdog. A bounded STATUS/drain loop may
|
||||||
|
legitimately run longer than one exchange, so it beats once per attempt; a
|
||||||
|
single exchange beats when its step starts and when it finishes (so the
|
||||||
|
gap before the next step or the cleanup drain is covered)."""
|
||||||
|
with _TEST_LOCK:
|
||||||
|
_TEST_RUN['heartbeat'] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _abort_card_transport():
|
||||||
|
"""Try to cancel a blocking SCardTransmit, then mark the transport stale.
|
||||||
|
|
||||||
|
The disconnect runs on its own daemon thread: PC/SC clients may serialize
|
||||||
|
it behind the pending transmit (pcsc-lite takes the per-context lock in
|
||||||
|
both calls), so calling it here would block the watchdog instead of
|
||||||
|
cancelling anything. Where the platform does cancel, the blocked call
|
||||||
|
raises and the normal step-failure path finalises the run; where it does
|
||||||
|
not, the run stays flagged stuck until the driver returns. Never takes
|
||||||
|
_CARD_LOCK - the stuck runner holds it."""
|
||||||
|
global _TRANSPORT_STALE
|
||||||
|
scc = getattr(_server_ref, 'scc', None) if _server_ref else None
|
||||||
|
con = getattr(getattr(scc, '_tp', None), '_con', None)
|
||||||
|
if con is None:
|
||||||
|
sys.stderr.write('TEST-WATCHDOG: no PC/SC connection to abort\n')
|
||||||
|
_TRANSPORT_STALE = True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
global _TRANSPORT_STALE
|
||||||
|
try:
|
||||||
|
con.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('TEST-WATCHDOG: PC/SC disconnect failed: %s\n' % e)
|
||||||
|
finally:
|
||||||
|
_TRANSPORT_STALE = True
|
||||||
|
|
||||||
|
threading.Thread(target=_run, name='test-abort', daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _test_watchdog_check(now=None):
|
||||||
|
"""One watchdog pass. The heartbeat is the authority (it is beaten at
|
||||||
|
every step start/finish and inside the bounded drains), so a stale one
|
||||||
|
means the runner is stuck inside a card exchange. Returns the stuck error
|
||||||
|
string when it acted (once per heartbeat), else None. Split from the
|
||||||
|
loop so the rule is testable."""
|
||||||
|
global _TEST_WATCHDOG_ABORTED
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
with _TEST_LOCK:
|
||||||
|
if not _TEST_RUN.get('running'):
|
||||||
|
_TEST_WATCHDOG_ABORTED = None
|
||||||
|
return None
|
||||||
|
hb = _TEST_RUN.get('heartbeat')
|
||||||
|
if not hb:
|
||||||
|
return None
|
||||||
|
idle = now - hb
|
||||||
|
if idle < _TEST_STEP_TIMEOUT:
|
||||||
|
return None
|
||||||
|
if _TEST_WATCHDOG_ABORTED == hb:
|
||||||
|
return None
|
||||||
|
_TEST_WATCHDOG_ABORTED = hb
|
||||||
|
steps = _TEST_RUN.get('steps') or []
|
||||||
|
cur = steps[-1] if steps else None
|
||||||
|
index = cur.get('index') if cur is not None else _TEST_RUN.get('index')
|
||||||
|
msg = ('the card stopped responding (step %d, no answer for %d s)'
|
||||||
|
% ((index or 0) + 1, int(idle)))
|
||||||
|
_TEST_RUN['stuck'] = True
|
||||||
|
_TEST_RUN['error'] = msg
|
||||||
|
_TEST_RUN['stop'] = True
|
||||||
|
sys.stderr.write('TEST-WATCHDOG: %s - trying to cancel the pending '
|
||||||
|
'transmit\n' % msg)
|
||||||
|
_abort_card_transport()
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _test_watchdog_loop():
|
||||||
|
while True:
|
||||||
|
time.sleep(1.0)
|
||||||
|
try:
|
||||||
|
_test_watchdog_check()
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('TEST-WATCHDOG error: %s\n' % e)
|
||||||
|
|
||||||
|
|
||||||
|
def _test_watchdog_start():
|
||||||
|
"""Start the singleton watchdog thread (idempotent; it exits with the
|
||||||
|
process)."""
|
||||||
|
global _TEST_WATCHDOG
|
||||||
|
if _TEST_WATCHDOG is not None and _TEST_WATCHDOG.is_alive():
|
||||||
|
return
|
||||||
|
_TEST_WATCHDOG = threading.Thread(target=_test_watchdog_loop,
|
||||||
|
name='test-watchdog', daemon=True)
|
||||||
|
_TEST_WATCHDOG.start()
|
||||||
|
|
||||||
|
|
||||||
def _test_suite_refs_error(script_store, suite):
|
def _test_suite_refs_error(script_store, suite):
|
||||||
"""The cross-store rule: every member script must exist in the script
|
"""The cross-store rule: every member script must exist in the script
|
||||||
store (a suite holds references; the scripts are the single source of
|
store (a suite holds references; the scripts are the single source of
|
||||||
@@ -7457,6 +7671,7 @@ def _test_finish_entry(entry, result):
|
|||||||
'command', 'drained'):
|
'command', 'drained'):
|
||||||
if result.get(key) is not None:
|
if result.get(key) is not None:
|
||||||
fields[key] = result[key]
|
fields[key] = result[key]
|
||||||
|
_test_heartbeat() # the exchange finished: runner progress
|
||||||
_test_entry_update(entry, fields)
|
_test_entry_update(entry, fields)
|
||||||
|
|
||||||
|
|
||||||
@@ -7550,6 +7765,7 @@ def _test_run_status(scc, step):
|
|||||||
data, sw = '', ''
|
data, sw = '', ''
|
||||||
used = 0
|
used = 0
|
||||||
for n in range(p['attempts']):
|
for n in range(p['attempts']):
|
||||||
|
_test_heartbeat()
|
||||||
data, sw = _send_status(scc)
|
data, sw = _send_status(scc)
|
||||||
used = n + 1
|
used = n + 1
|
||||||
_test_log('STATUS %d/%d -> %s' % (used, p['attempts'], sw))
|
_test_log('STATUS %d/%d -> %s' % (used, p['attempts'], sw))
|
||||||
@@ -7638,6 +7854,7 @@ def _test_run_drain(server, step):
|
|||||||
sw = ''
|
sw = ''
|
||||||
polls = 0
|
polls = 0
|
||||||
for n in range(p['attempts']):
|
for n in range(p['attempts']):
|
||||||
|
_test_heartbeat()
|
||||||
data, sw = _send_status(scc)
|
data, sw = _send_status(scc)
|
||||||
polls = n + 1
|
polls = n + 1
|
||||||
_test_log('STATUS %d/%d -> %s' % (polls, p['attempts'], sw))
|
_test_log('STATUS %d/%d -> %s' % (polls, p['attempts'], sw))
|
||||||
@@ -8267,6 +8484,7 @@ def _test_drain_pending(scc, fetch_len, result=0x00, max_commands=3):
|
|||||||
answered = []
|
answered = []
|
||||||
try:
|
try:
|
||||||
while len(answered) < max_commands:
|
while len(answered) < max_commands:
|
||||||
|
_test_heartbeat() # a bounded drain still makes progress
|
||||||
# fetch_len 0 (a 9100 SW) means "length unknown": fetch the full
|
# fetch_len 0 (a 9100 SW) means "length unknown": fetch the full
|
||||||
# 255 bytes - `%02x` of 0x100 would emit an odd-length APDU
|
# 255 bytes - `%02x` of 0x100 would emit an odd-length APDU
|
||||||
fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla,
|
fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla,
|
||||||
@@ -8341,6 +8559,7 @@ def _test_run_script(server, script, preset, shared=None):
|
|||||||
entry = _test_entry(step, index)
|
entry = _test_entry(step, index)
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
_TEST_RUN['steps'].append(entry)
|
_TEST_RUN['steps'].append(entry)
|
||||||
|
_TEST_RUN['heartbeat'] = time.time()
|
||||||
# A proactive-drain step is the tool that consumes a pending command
|
# A proactive-drain step is the tool that consumes a pending command
|
||||||
# (it polls STATUS - the card re-announces the unfetched command -
|
# (it polls STATUS - the card re-announces the unfetched command -
|
||||||
# then fetches and answers it), so the guard does not apply to it
|
# then fetches and answers it), so the guard does not apply to it
|
||||||
@@ -8388,14 +8607,16 @@ def _test_run_script(server, script, preset, shared=None):
|
|||||||
'note': str(e)})
|
'note': str(e)})
|
||||||
sys.stderr.write('TEST-RUN step %d failed (%s): %s\n'
|
sys.stderr.write('TEST-RUN step %d failed (%s): %s\n'
|
||||||
% (index + 1, level, e))
|
% (index + 1, level, e))
|
||||||
if pending is not None:
|
# A stuck card's transport is already being torn down: draining
|
||||||
|
# would only raise again on the dead link.
|
||||||
|
if pending is not None and not _TEST_RUN.get('stuck'):
|
||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
drained = _test_drain_pending(scc, pending)
|
drained = _test_drain_pending(scc, pending)
|
||||||
_test_entry_update(entry, {k: v for k, v in drained.items() if k != 'note'})
|
_test_entry_update(entry, {k: v for k, v in drained.items() if k != 'note'})
|
||||||
pending = None
|
pending = None
|
||||||
if level == 'error':
|
if level == 'error':
|
||||||
break
|
break
|
||||||
if pending is not None:
|
if pending is not None and not _TEST_RUN.get('stuck'):
|
||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
drained = _test_drain_pending(scc, pending)
|
drained = _test_drain_pending(scc, pending)
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
@@ -8423,7 +8644,11 @@ def _test_run_execute(server, script, preset):
|
|||||||
_TEST_RUN['finished'] = time.time()
|
_TEST_RUN['finished'] = time.time()
|
||||||
if _TEST_RUN['status'] is None:
|
if _TEST_RUN['status'] is None:
|
||||||
levels = [s.get('status') for s in _TEST_RUN['steps']]
|
levels = [s.get('status') for s in _TEST_RUN['steps']]
|
||||||
if stopped:
|
if _TEST_RUN.get('stuck'):
|
||||||
|
# the watchdog's stop flag ended the loop; the run still
|
||||||
|
# failed (a stuck card is not a user stop)
|
||||||
|
_TEST_RUN['status'] = 'error'
|
||||||
|
elif stopped:
|
||||||
_TEST_RUN['status'] = 'stopped'
|
_TEST_RUN['status'] = 'stopped'
|
||||||
elif 'error' in levels:
|
elif 'error' in levels:
|
||||||
_TEST_RUN['status'] = 'error'
|
_TEST_RUN['status'] = 'error'
|
||||||
@@ -8462,9 +8687,12 @@ def _test_member_prefix(index, member):
|
|||||||
return '[%d/%d %s %s] ' % (index + 1, total, role, tag)
|
return '[%d/%d %s %s] ' % (index + 1, total, role, tag)
|
||||||
|
|
||||||
|
|
||||||
def _test_member_status(steps, stopped):
|
def _test_member_status(steps, stopped, stuck=False):
|
||||||
"""A suite member's verdict from its step levels (an error stops that
|
"""A suite member's verdict from its step levels (an error stops that
|
||||||
script); a stop request marks the member stopped."""
|
script); a stop request marks the member stopped. A stuck card is an
|
||||||
|
error even though the watchdog's stop flag ended the script."""
|
||||||
|
if stuck:
|
||||||
|
return 'error'
|
||||||
if stopped:
|
if stopped:
|
||||||
return 'stopped'
|
return 'stopped'
|
||||||
levels = [s.get('status') for s in steps]
|
levels = [s.get('status') for s in steps]
|
||||||
@@ -8547,6 +8775,7 @@ def _test_run_spawn(server, target, args, thread_name):
|
|||||||
"""The shared run start: the run owns the card from here - suspend the
|
"""The shared run start: the run owns the card from here - suspend the
|
||||||
background poll, answer a paused interactive command, spawn the worker."""
|
background poll, answer a paused interactive command, spawn the worker."""
|
||||||
global _TEST_THREAD, _TEST_RUNNING, _POLL_TIMER
|
global _TEST_THREAD, _TEST_RUNNING, _POLL_TIMER
|
||||||
|
_test_watchdog_start()
|
||||||
_TEST_RUNNING = True
|
_TEST_RUNNING = True
|
||||||
if _POLL_TIMER is not None:
|
if _POLL_TIMER is not None:
|
||||||
_POLL_TIMER.cancel()
|
_POLL_TIMER.cancel()
|
||||||
@@ -8574,31 +8803,41 @@ def _test_run_spawn(server, target, args, thread_name):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _test_run_start(server, script, preset, script_id=None, adm_info=None):
|
def _test_run_start(server, script, preset, script_id=None, adm_info=None,
|
||||||
"""Start a single-script run."""
|
iterations=1):
|
||||||
|
"""Start a single-script run; `iterations` > 1 makes it a load test."""
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
_TEST_RUN.update({
|
_TEST_RUN.update({
|
||||||
'running': True, 'stop': False, 'name': script['name'], 'status': None,
|
'running': True, 'stop': False, 'name': script['name'], 'status': None,
|
||||||
'session': getattr(server, 'card_session', 0), 'index': 0,
|
'session': getattr(server, 'card_session', 0), 'index': 0,
|
||||||
'total': len(script['steps']), 'steps': [], 'started': time.time(),
|
'total': len(script['steps']), 'steps': [], 'started': time.time(),
|
||||||
'finished': None, 'error': None, 'scp80_counter': None,
|
'finished': None, 'error': None, 'stuck': False,
|
||||||
|
'heartbeat': time.time(), 'scp80_counter': None,
|
||||||
'scp80_counters': {}, 'script_id': script_id,
|
'scp80_counters': {}, 'script_id': script_id,
|
||||||
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
||||||
'suite': None, 'log': [], 'log_prefix': '',
|
'suite': None, 'log': [], 'log_prefix': '',
|
||||||
'adm': adm_info or {'required': False},
|
'adm': adm_info or {'required': False},
|
||||||
|
'load': _test_load_state(iterations),
|
||||||
})
|
})
|
||||||
_test_run_spawn(server, _test_run_worker, (server, script, preset), 'test-script')
|
if iterations > 1:
|
||||||
|
sys.stderr.write('TEST-LOAD: script %r, %d iterations\n'
|
||||||
|
% (script.get('name') or script_id or 'script',
|
||||||
|
iterations))
|
||||||
|
_test_run_spawn(server, _test_load_script_worker,
|
||||||
|
(server, script, preset, script_id, iterations), 'test-load')
|
||||||
|
else:
|
||||||
|
_test_run_spawn(server, _test_run_worker, (server, script, preset),
|
||||||
|
'test-script')
|
||||||
|
|
||||||
|
|
||||||
def _test_suite_execute(server, suite, members, preset, preset_id):
|
def _test_suite_execute_once(server, suite, members, preset, preset_id, state,
|
||||||
"""The suite worker body: setup -> members in order (per-member on_fail)
|
shared=None):
|
||||||
-> teardown, one card session, no resume. The teardown runs on every
|
"""One suite iteration: setup -> members in order (per-member on_fail) ->
|
||||||
stop - its purpose is to leave the card ready for a new test - as long as
|
teardown, one card session, no resume. Fills `state` and the live
|
||||||
the card is in the reader; a card reset stops the suite and the remaining
|
`_TEST_RUN` steps/log; returns ``(status, stopped, session_changed)``.
|
||||||
members are skipped (the report flags it)."""
|
The caller owns the run lifecycle - a load test repeats this per
|
||||||
global _TEST_RUNNING
|
iteration."""
|
||||||
session_start = getattr(server, 'card_session', 0)
|
session_start = getattr(server, 'card_session', 0)
|
||||||
state = _TEST_RUN['suite']
|
|
||||||
stopped = False
|
stopped = False
|
||||||
session_changed = False
|
session_changed = False
|
||||||
setup_i = next((i for i, (e, _s) in enumerate(members)
|
setup_i = next((i for i, (e, _s) in enumerate(members)
|
||||||
@@ -8610,7 +8849,7 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
# suite, so a second SCP80 script starts from what the first consumed
|
# suite, so a second SCP80 script starts from what the first consumed
|
||||||
# (the store persists each packet's counter; the preset dict here is the
|
# (the store persists each packet's counter; the preset dict here is the
|
||||||
# suite-start snapshot).
|
# suite-start snapshot).
|
||||||
shared = {}
|
shared = shared if shared is not None else {}
|
||||||
|
|
||||||
def run_member(i):
|
def run_member(i):
|
||||||
entry, script = members[i]
|
entry, script = members[i]
|
||||||
@@ -8620,6 +8859,7 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
member['started'] = time.time()
|
member['started'] = time.time()
|
||||||
_TEST_RUN['steps'] = []
|
_TEST_RUN['steps'] = []
|
||||||
_TEST_RUN['index'] = 0
|
_TEST_RUN['index'] = 0
|
||||||
|
_TEST_RUN['heartbeat'] = time.time()
|
||||||
_TEST_RUN['total'] = len(script['steps'])
|
_TEST_RUN['total'] = len(script['steps'])
|
||||||
_TEST_RUN['log_prefix'] = _test_member_prefix(i, member)
|
_TEST_RUN['log_prefix'] = _test_member_prefix(i, member)
|
||||||
log_from = len(_TEST_RUN['log'])
|
log_from = len(_TEST_RUN['log'])
|
||||||
@@ -8628,7 +8868,9 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
steps = json.loads(json.dumps(_TEST_RUN['steps']))
|
steps = json.loads(json.dumps(_TEST_RUN['steps']))
|
||||||
log = list(_TEST_RUN['log'][log_from:])
|
log = list(_TEST_RUN['log'][log_from:])
|
||||||
member.update({
|
member.update({
|
||||||
'status': _test_member_status(steps, member_stopped),
|
'status': _test_member_status(
|
||||||
|
steps, member_stopped,
|
||||||
|
stuck=bool(_TEST_RUN.get('stuck'))),
|
||||||
'steps': steps,
|
'steps': steps,
|
||||||
'log': log[-_TEST_MEMBER_LOG_MAX:],
|
'log': log[-_TEST_MEMBER_LOG_MAX:],
|
||||||
'log_truncated': len(log) > _TEST_MEMBER_LOG_MAX,
|
'log_truncated': len(log) > _TEST_MEMBER_LOG_MAX,
|
||||||
@@ -8647,8 +8889,11 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
|
|
||||||
abort = False
|
abort = False
|
||||||
if setup_i is not None:
|
if setup_i is not None:
|
||||||
status, _s = run_member(setup_i)
|
status, setup_stopped = run_member(setup_i)
|
||||||
if status not in ('ok', 'warning'):
|
if setup_stopped:
|
||||||
|
stopped = True
|
||||||
|
abort = True
|
||||||
|
elif status not in ('ok', 'warning'):
|
||||||
abort = True
|
abort = True
|
||||||
skip_pending('the setup script failed')
|
skip_pending('the setup script failed')
|
||||||
for i in member_idx:
|
for i in member_idx:
|
||||||
@@ -8664,13 +8909,17 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
session_changed = True
|
session_changed = True
|
||||||
skip_pending('card session changed (card removed or re-equipped)')
|
skip_pending('card session changed (card removed or re-equipped)')
|
||||||
break
|
break
|
||||||
status, _s = run_member(i)
|
status, member_stopped = run_member(i)
|
||||||
|
if member_stopped:
|
||||||
|
stopped = True
|
||||||
|
abort = True
|
||||||
|
break
|
||||||
if status not in ('ok', 'warning') and members[i][0]['on_fail'] == 'stop':
|
if status not in ('ok', 'warning') and members[i][0]['on_fail'] == 'stop':
|
||||||
abort = True
|
abort = True
|
||||||
if abort:
|
if stopped:
|
||||||
skip_pending('a previous script failed (on_fail: stop)')
|
|
||||||
else:
|
|
||||||
skip_pending('stopped')
|
skip_pending('stopped')
|
||||||
|
elif abort:
|
||||||
|
skip_pending('a previous script failed (on_fail: stop)')
|
||||||
if teardown_i is not None:
|
if teardown_i is not None:
|
||||||
if not getattr(server, 'card_present', False):
|
if not getattr(server, 'card_present', False):
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
@@ -8703,29 +8952,40 @@ def _test_suite_execute(server, suite, members, preset, preset_id):
|
|||||||
'warning': statuses.count('warning'),
|
'warning': statuses.count('warning'),
|
||||||
'error': statuses.count('error'),
|
'error': statuses.count('error'),
|
||||||
'skipped': statuses.count('skipped'),
|
'skipped': statuses.count('skipped'),
|
||||||
'stopped': bool(stopped),
|
'stopped': bool(stopped or statuses.count('stopped')),
|
||||||
'session_changed': state['session_changed'],
|
'session_changed': state['session_changed'],
|
||||||
'wall_ms': int((time.time() - (_TEST_RUN['started'] or time.time())) * 1000),
|
'wall_ms': int((time.time() - (state.get('started') or _TEST_RUN['started'] or time.time())) * 1000),
|
||||||
'counters_before': state.get('counters_before'),
|
'counters_before': state.get('counters_before'),
|
||||||
'counters_after': after,
|
'counters_after': after,
|
||||||
'setup': state['members'][setup_i]['status'] if setup_i is not None else None,
|
'setup': state['members'][setup_i]['status'] if setup_i is not None else None,
|
||||||
'teardown': state['members'][teardown_i]['status'] if teardown_i is not None else None,
|
'teardown': state['members'][teardown_i]['status'] if teardown_i is not None else None,
|
||||||
}
|
}
|
||||||
if 'error' in statuses:
|
if 'error' in statuses:
|
||||||
_TEST_RUN['status'] = 'error'
|
status = 'error'
|
||||||
elif 'warning' in statuses:
|
elif 'warning' in statuses:
|
||||||
_TEST_RUN['status'] = 'warning'
|
status = 'warning'
|
||||||
elif stopped:
|
elif stopped or 'stopped' in statuses:
|
||||||
_TEST_RUN['status'] = 'stopped'
|
status = 'stopped'
|
||||||
else:
|
else:
|
||||||
_TEST_RUN['status'] = 'ok'
|
status = 'ok'
|
||||||
|
return status, stopped, session_changed
|
||||||
|
|
||||||
|
|
||||||
|
def _test_suite_execute(server, suite, members, preset, preset_id, shared=None):
|
||||||
|
"""The single suite run: one iteration, then the run state is finalised.
|
||||||
|
(A load test calls `_test_suite_execute_once` itself.)"""
|
||||||
|
global _TEST_RUNNING
|
||||||
|
state = _TEST_RUN['suite']
|
||||||
|
status, _stopped, _session_changed = _test_suite_execute_once(
|
||||||
|
server, suite, members, preset, preset_id, state, shared)
|
||||||
|
with _TEST_LOCK:
|
||||||
|
_TEST_RUN['status'] = status
|
||||||
_TEST_RUN['running'] = False
|
_TEST_RUN['running'] = False
|
||||||
_TEST_RUN['finished'] = time.time()
|
_TEST_RUN['finished'] = time.time()
|
||||||
_TEST_RUN['log_prefix'] = '' # the report is done: no stale prefix
|
_TEST_RUN['log_prefix'] = '' # the report is done: no stale prefix
|
||||||
_TEST_RUNNING = False
|
_TEST_RUNNING = False
|
||||||
_sim_resume_poll()
|
_sim_resume_poll()
|
||||||
|
|
||||||
|
|
||||||
def _test_suite_worker(server, suite, members, preset, preset_id):
|
def _test_suite_worker(server, suite, members, preset, preset_id):
|
||||||
global _TEST_RUNNING
|
global _TEST_RUNNING
|
||||||
try:
|
try:
|
||||||
@@ -8776,6 +9036,7 @@ def _test_suite_state(suite, members, preset, preset_id, session, reset_info=Non
|
|||||||
'require_adm': bool(suite.get('require_adm')),
|
'require_adm': bool(suite.get('require_adm')),
|
||||||
'start_reset': bool(suite.get('start_reset')),
|
'start_reset': bool(suite.get('start_reset')),
|
||||||
'reset': reset_info or None,
|
'reset': reset_info or None,
|
||||||
|
'started': time.time(),
|
||||||
'session_start': session, 'session_end': None,
|
'session_start': session, 'session_end': None,
|
||||||
'session_changed': False, 'preset_id': preset_id,
|
'session_changed': False, 'preset_id': preset_id,
|
||||||
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
||||||
@@ -8792,7 +9053,7 @@ def _test_suite_state(suite, members, preset, preset_id, session, reset_info=Non
|
|||||||
|
|
||||||
|
|
||||||
def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None,
|
def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None,
|
||||||
reset_info=None):
|
reset_info=None, iterations=1):
|
||||||
"""Start a suite run: `members` is the resolved, normalised
|
"""Start a suite run: `members` is the resolved, normalised
|
||||||
``[(entry, script)]`` list in run order. `reset_info` records the
|
``[(entry, script)]`` list in run order. `reset_info` records the
|
||||||
start-of-suite card reset the caller already performed."""
|
start-of-suite card reset the caller already performed."""
|
||||||
@@ -8803,17 +9064,214 @@ def _test_suite_start(server, suite, members, preset, preset_id, adm_info=None,
|
|||||||
'name': suite.get('name') or 'test suite', 'status': None,
|
'name': suite.get('name') or 'test suite', 'status': None,
|
||||||
'session': session, 'index': 0, 'total': 0, 'steps': [],
|
'session': session, 'index': 0, 'total': 0, 'steps': [],
|
||||||
'started': time.time(), 'finished': None, 'error': None,
|
'started': time.time(), 'finished': None, 'error': None,
|
||||||
|
'stuck': False, 'heartbeat': time.time(),
|
||||||
'scp80_counter': None, 'scp80_counters': {},
|
'scp80_counter': None, 'scp80_counters': {},
|
||||||
'script_id': None,
|
'script_id': None,
|
||||||
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
|
||||||
'suite': _test_suite_state(suite, members, preset, preset_id, session,
|
'suite': _test_suite_state(suite, members, preset, preset_id, session,
|
||||||
reset_info=reset_info),
|
reset_info=reset_info),
|
||||||
'log': [], 'log_prefix': '', 'adm': adm_info or {'required': False},
|
'log': [], 'log_prefix': '', 'adm': adm_info or {'required': False},
|
||||||
|
'load': _test_load_state(iterations, suite=True),
|
||||||
})
|
})
|
||||||
|
if iterations > 1:
|
||||||
|
sys.stderr.write('TEST-LOAD: suite %r, %d iterations\n'
|
||||||
|
% (suite.get('name') or 'suite', iterations))
|
||||||
|
_test_run_spawn(server, _test_load_suite_worker,
|
||||||
|
(server, suite, members, preset, preset_id, iterations),
|
||||||
|
'test-load')
|
||||||
|
else:
|
||||||
_test_run_spawn(server, _test_suite_worker,
|
_test_run_spawn(server, _test_suite_worker,
|
||||||
(server, suite, members, preset, preset_id), 'test-suite')
|
(server, suite, members, preset, preset_id), 'test-suite')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _test_load_finalise(status, done, iterations, failed, attempted=None):
|
||||||
|
"""Close a load run: the counters, the timing and the final status. Only
|
||||||
|
the failing iteration's steps/log stay (successful ones were discarded).
|
||||||
|
`attempted` counts the started iterations (the failing/partial one
|
||||||
|
included), so `avg_ms` is a real per-iteration time."""
|
||||||
|
with _TEST_LOCK:
|
||||||
|
now = time.time()
|
||||||
|
load = _TEST_RUN['load']
|
||||||
|
started = load.get('started') or _TEST_RUN.get('started') or now
|
||||||
|
elapsed = int((now - started) * 1000)
|
||||||
|
attempted = attempted if attempted is not None else done
|
||||||
|
load.update({'done': done, 'failed_iteration': failed, 'finished': now,
|
||||||
|
'elapsed_ms': elapsed,
|
||||||
|
'avg_ms': int(elapsed / attempted) if attempted else None})
|
||||||
|
_TEST_RUN['running'] = False
|
||||||
|
_TEST_RUN['finished'] = now
|
||||||
|
_TEST_RUN['status'] = status
|
||||||
|
_TEST_RUN['log_prefix'] = ''
|
||||||
|
if failed:
|
||||||
|
how = 'failed at iteration %d/%d' % (failed, iterations)
|
||||||
|
elif status == 'stopped':
|
||||||
|
how = 'stopped after %d/%d' % (done, iterations)
|
||||||
|
else:
|
||||||
|
how = 'done %d/%d' % (done, iterations)
|
||||||
|
sys.stderr.write('TEST-LOAD: %s (%s)\n'
|
||||||
|
% (_TEST_RUN.get('name') or 'run', how))
|
||||||
|
|
||||||
|
|
||||||
|
def _test_load_script_worker(server, script, preset, script_id, iterations):
|
||||||
|
"""Repeat one script `iterations` times: successful runs are discarded,
|
||||||
|
an error (or a stop) keeps its steps/log as the report (v3.37.0)."""
|
||||||
|
global _TEST_RUNNING
|
||||||
|
shared = {} # the SCP80 counter continues across runs
|
||||||
|
session = getattr(server, 'card_session', 0)
|
||||||
|
done = 0
|
||||||
|
attempted = 0
|
||||||
|
failed = None
|
||||||
|
final = 'ok'
|
||||||
|
worst = 'ok'
|
||||||
|
try:
|
||||||
|
for i in range(1, iterations + 1):
|
||||||
|
with _TEST_LOCK:
|
||||||
|
if _TEST_RUN['stop']:
|
||||||
|
final = 'stopped'
|
||||||
|
break
|
||||||
|
_TEST_RUN.update({'steps': [], 'index': 0, 'status': None,
|
||||||
|
'error': None, 'stuck': False,
|
||||||
|
'heartbeat': time.time(), 'log': [],
|
||||||
|
'log_prefix': ''})
|
||||||
|
_TEST_RUN['load'].update({'iteration': i, 'done': done})
|
||||||
|
attempted = i
|
||||||
|
if getattr(server, 'card_session', 0) != session:
|
||||||
|
with _TEST_LOCK:
|
||||||
|
_TEST_RUN['error'] = ('the card session changed during the '
|
||||||
|
'load test (card removed or re-equipped)')
|
||||||
|
failed = i
|
||||||
|
final = 'error'
|
||||||
|
break
|
||||||
|
_ctx, stopped = _test_run_script(server, script, preset, shared=shared)
|
||||||
|
with _TEST_LOCK:
|
||||||
|
levels = [st.get('status') for st in _TEST_RUN['steps']]
|
||||||
|
if _TEST_RUN.get('stuck'):
|
||||||
|
status = 'error'
|
||||||
|
elif stopped:
|
||||||
|
status = 'stopped'
|
||||||
|
elif 'error' in levels:
|
||||||
|
status = 'error'
|
||||||
|
elif 'warning' in levels:
|
||||||
|
status = 'warning'
|
||||||
|
else:
|
||||||
|
status = 'ok'
|
||||||
|
if status in ('error', 'stopped'):
|
||||||
|
if status == 'error':
|
||||||
|
failed = i
|
||||||
|
if not _TEST_RUN.get('error'):
|
||||||
|
_TEST_RUN['error'] = 'iteration %d failed' % i
|
||||||
|
final = status
|
||||||
|
else:
|
||||||
|
if status == 'warning':
|
||||||
|
worst = 'warning'
|
||||||
|
done = i
|
||||||
|
_TEST_RUN['steps'] = []
|
||||||
|
_TEST_RUN['log'] = []
|
||||||
|
_TEST_RUN['load']['done'] = done
|
||||||
|
if final != 'ok':
|
||||||
|
break
|
||||||
|
if final == 'ok':
|
||||||
|
final = worst if done == iterations else 'stopped'
|
||||||
|
_test_load_finalise(final, done, iterations, failed, attempted)
|
||||||
|
except Exception as e:
|
||||||
|
# a raised runner error must not leave the run "running" forever (the
|
||||||
|
# PWA would poll a dead run and Clear would stay refused)
|
||||||
|
traceback.print_exc()
|
||||||
|
with _TEST_LOCK:
|
||||||
|
_TEST_RUN['error'] = str(e)
|
||||||
|
if failed is None:
|
||||||
|
failed = _TEST_RUN['load'].get('iteration') or (done + 1)
|
||||||
|
_test_load_finalise('error', done, iterations, failed, attempted)
|
||||||
|
finally:
|
||||||
|
_TEST_RUNNING = False
|
||||||
|
_sim_resume_poll()
|
||||||
|
|
||||||
|
|
||||||
|
def _test_load_suite_worker(server, suite, members, preset, preset_id, iterations):
|
||||||
|
"""Repeat a whole suite `iterations` times (setup/members/teardown per
|
||||||
|
iteration, one shared counter state); the same success/failure policy as
|
||||||
|
the script load test (v3.37.0)."""
|
||||||
|
global _TEST_RUNNING
|
||||||
|
shared = {}
|
||||||
|
session = getattr(server, 'card_session', 0)
|
||||||
|
done = 0
|
||||||
|
attempted = 0
|
||||||
|
failed = None
|
||||||
|
final = 'ok'
|
||||||
|
worst = 'ok'
|
||||||
|
try:
|
||||||
|
for i in range(1, iterations + 1):
|
||||||
|
with _TEST_LOCK:
|
||||||
|
if _TEST_RUN['stop']:
|
||||||
|
final = 'stopped'
|
||||||
|
break
|
||||||
|
# the report's counters_before is this iteration's start; the
|
||||||
|
# protocol state (keys/TARs) stays the run-start snapshot
|
||||||
|
fresh = preset
|
||||||
|
store = getattr(server, 'card_presets', None)
|
||||||
|
if preset_id and store is not None:
|
||||||
|
fresh = store.get(preset_id) or preset
|
||||||
|
state = _test_suite_state(suite, members, fresh, preset_id, session)
|
||||||
|
_TEST_RUN.update({'suite': state, 'steps': [], 'index': 0,
|
||||||
|
'total': 0, 'status': None, 'error': None,
|
||||||
|
'stuck': False, 'heartbeat': time.time(),
|
||||||
|
'log': [], 'log_prefix': ''})
|
||||||
|
_TEST_RUN['load'].update({'iteration': i, 'done': done})
|
||||||
|
attempted = i
|
||||||
|
if getattr(server, 'card_session', 0) != session:
|
||||||
|
with _TEST_LOCK:
|
||||||
|
_TEST_RUN['error'] = ('the card session changed during the '
|
||||||
|
'load test (card removed or re-equipped)')
|
||||||
|
failed = i
|
||||||
|
final = 'error'
|
||||||
|
break
|
||||||
|
status, stopped, session_changed = _test_suite_execute_once(
|
||||||
|
server, suite, members, preset, preset_id, state, shared)
|
||||||
|
if session_changed:
|
||||||
|
with _TEST_LOCK:
|
||||||
|
_TEST_RUN['error'] = ('the card session changed during the '
|
||||||
|
'load test (card removed or re-equipped)')
|
||||||
|
failed = i
|
||||||
|
final = 'error'
|
||||||
|
break
|
||||||
|
# an error wins over the stop flag: a stuck card sets both, and
|
||||||
|
# the report must say it failed, not that the operator stopped it
|
||||||
|
if status == 'error':
|
||||||
|
failed = i
|
||||||
|
final = 'error'
|
||||||
|
with _TEST_LOCK:
|
||||||
|
if not _TEST_RUN.get('error'):
|
||||||
|
_TEST_RUN['error'] = 'iteration %d failed' % i
|
||||||
|
break
|
||||||
|
if stopped or status == 'stopped':
|
||||||
|
final = 'stopped'
|
||||||
|
break
|
||||||
|
if status == 'warning':
|
||||||
|
worst = 'warning'
|
||||||
|
with _TEST_LOCK:
|
||||||
|
done = i
|
||||||
|
_TEST_RUN['load']['done'] = done
|
||||||
|
_TEST_RUN['steps'] = []
|
||||||
|
_TEST_RUN['log'] = []
|
||||||
|
# successful iterations are not tracked (a failure keeps its
|
||||||
|
# own state for the report)
|
||||||
|
_TEST_RUN['suite'] = None
|
||||||
|
if final == 'ok':
|
||||||
|
final = worst if done == iterations else 'stopped'
|
||||||
|
_test_load_finalise(final, done, iterations, failed, attempted)
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
with _TEST_LOCK:
|
||||||
|
_TEST_RUN['error'] = str(e)
|
||||||
|
if failed is None:
|
||||||
|
failed = _TEST_RUN['load'].get('iteration') or (done + 1)
|
||||||
|
_test_load_finalise('error', done, iterations, failed, attempted)
|
||||||
|
finally:
|
||||||
|
_TEST_RUNNING = False
|
||||||
|
_sim_resume_poll()
|
||||||
|
|
||||||
|
|
||||||
class PysimHandler(BaseHTTPRequestHandler):
|
class PysimHandler(BaseHTTPRequestHandler):
|
||||||
def _send_json(self, data, status=200):
|
def _send_json(self, data, status=200):
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
@@ -8883,7 +9341,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(resp, 500)
|
self._send_json(resp, 500)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
|
|
||||||
def _test_suite_run_request(self, suite_id, body):
|
def _test_suite_run_request(self, suite_id, body, iterations=1):
|
||||||
"""Start a suite run: resolve the members and the preset, run the ADM
|
"""Start a suite run: resolve the members and the preset, run the ADM
|
||||||
prerequisite, then hand over to the suite runner (one card session,
|
prerequisite, then hand over to the suite runner (one card session,
|
||||||
no resume)."""
|
no resume)."""
|
||||||
@@ -8994,7 +9452,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
_test_suite_start(self.server, suite, members, preset,
|
_test_suite_start(self.server, suite, members, preset,
|
||||||
str(body.get('preset_id') or '') or None,
|
str(body.get('preset_id') or '') or None,
|
||||||
adm_info=adm_info, reset_info=reset_info)
|
adm_info=adm_info, reset_info=reset_info,
|
||||||
|
iterations=iterations)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resp = {'error': 'could not start the suite run: %s' % e}
|
resp = {'error': 'could not start the suite run: %s' % e}
|
||||||
self._send_json(resp, 500)
|
self._send_json(resp, 500)
|
||||||
@@ -9618,7 +10077,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
||||||
resp = {'sw': st_sw}
|
resp = {'sw': st_sw}
|
||||||
if st_sw.startswith('91'):
|
if st_sw.startswith('91'):
|
||||||
_handle_proactive_chain(scc, st_sw)
|
_handle_proactive_chain(scc, st_sw, max_rounds=_POLL_CHAIN_MAX)
|
||||||
resp['proactive'] = True
|
resp['proactive'] = True
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
@@ -11274,9 +11733,16 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(resp, 409)
|
self._send_json(resp, 409)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
iterations = _test_load_iterations(body)
|
||||||
|
except ValueError as e:
|
||||||
|
resp = {'error': str(e)}
|
||||||
|
self._send_json(resp, 400)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
suite_id = str(body.get('suite_id') or '').strip().lower()
|
suite_id = str(body.get('suite_id') or '').strip().lower()
|
||||||
if suite_id:
|
if suite_id:
|
||||||
self._test_suite_run_request(suite_id, body)
|
self._test_suite_run_request(suite_id, body, iterations)
|
||||||
return
|
return
|
||||||
script_raw, script_id, script_err = _test_run_script_from_body(self.server, body)
|
script_raw, script_id, script_err = _test_run_script_from_body(self.server, body)
|
||||||
if script_err:
|
if script_err:
|
||||||
@@ -11317,7 +11783,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
_test_run_start(self.server, script, preset, script_id or None,
|
_test_run_start(self.server, script, preset, script_id or None,
|
||||||
adm_info=adm_info)
|
adm_info=adm_info, iterations=iterations)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resp = {'error': 'could not start the test run: %s' % e}
|
resp = {'error': 'could not start the test run: %s' % e}
|
||||||
self._send_json(resp, 500)
|
self._send_json(resp, 500)
|
||||||
@@ -11346,6 +11812,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'scp80_counters': {},
|
'scp80_counters': {},
|
||||||
'started': None, 'finished': None, 'error': None,
|
'started': None, 'finished': None, 'error': None,
|
||||||
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
|
'suite': None, 'log': [], 'log_prefix': '', 'adm': None,
|
||||||
|
'load': _test_load_state(1),
|
||||||
})
|
})
|
||||||
if busy:
|
if busy:
|
||||||
resp = {'error': 'test script is running'}
|
resp = {'error': 'test script is running'}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ classes - the same model pySim assembles at equip time:
|
|||||||
It ships as ``frontend/uicc_files.json`` so the SIM/USIM RFM SELECT picker has
|
It ships as ``frontend/uicc_files.json`` so the SIM/USIM RFM SELECT picker has
|
||||||
a file list offline (no card, no server); when a card is equipped the picker
|
a file list offline (no card, no server); when a card is equipped the picker
|
||||||
merges the live tree over it and hides probed-absent files.
|
merges the live tree over it and hides probed-absent files.
|
||||||
|
The optional / service-gated files pySim's model does not carry (the SoLSA,
|
||||||
|
MExE, BCAST, ACDC, TV and A2X DFs, EF.LRPLMNSI, EF.IAL, EF.IPD, EF.TVCONFIG,
|
||||||
|
EF.3GPPPSDATAOFF) are appended from ``SUPPLEMENT`` with their spec locations -
|
||||||
|
the decoded-editing filesystem check resolves a file by its (name, root), so a
|
||||||
|
same-FID file under another DF must stay a distinct entry.
|
||||||
``tests/test_uicc_files.py`` regenerates the list and fails when pySim adds or
|
``tests/test_uicc_files.py`` regenerates the list and fails when pySim adds or
|
||||||
renames files, so the asset cannot silently drift.
|
renames files, so the asset cannot silently drift.
|
||||||
|
|
||||||
@@ -55,6 +60,62 @@ def _entry(fid_path, sym_path, name, fid, kind, root, aid=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Files the pySim model does not carry (optional / service-gated), with their
|
||||||
|
# spec locations: TS 31.102 4.2.86 (EF.LRPLMNSI), 4.2.100 (EF.IAL), 4.2.102
|
||||||
|
# (EF.IPD), 4.2.108 (EF.TVCONFIG), 4.2.109 (EF.3GPPPSDATAOFF), 4.4.1
|
||||||
|
# (DF.SoLSA), 4.4.4 (DF.MExE), 4.4.7 (DF.BCAST - its files are OMA BCAST, so
|
||||||
|
# the DF is listed alone), 4.4.9 (DF.ACDC), 4.4.10 (DF.TV - EF.TVUSD is a
|
||||||
|
# variable 4Fxx, omitted) and 4.6.6 (DF.A2X); the FID map is
|
||||||
|
# docs/uicc/UICC_FILES.md 4.13. Entries are (root, parent FID chain, fid,
|
||||||
|
# name); a DF is a 'DF.' name and precedes its children so the symbolic chain
|
||||||
|
# can be resolved.
|
||||||
|
SUPPLEMENT = (
|
||||||
|
('ADF.USIM', (), '6FDC', 'EF.LRPLMNSI'),
|
||||||
|
('ADF.USIM', (), '6FF0', 'EF.IAL'),
|
||||||
|
('ADF.USIM', (), '6FF2', 'EF.IPD'),
|
||||||
|
('ADF.USIM', (), '6FF9', 'EF.3GPPPSDATAOFF'),
|
||||||
|
('ADF.USIM', (), '6FFB', 'EF.TVCONFIG'),
|
||||||
|
('ADF.USIM', (), '5F3C', 'DF.MExE'),
|
||||||
|
('ADF.USIM', ('5F3C',), '4F40', 'EF.MExE-ST'),
|
||||||
|
('ADF.USIM', ('5F3C',), '4F41', 'EF.ORPK'),
|
||||||
|
('ADF.USIM', ('5F3C',), '4F42', 'EF.ARPK'),
|
||||||
|
('ADF.USIM', ('5F3C',), '4F43', 'EF.TPRPK'),
|
||||||
|
('ADF.USIM', (), '5F70', 'DF.SoLSA'),
|
||||||
|
('ADF.USIM', ('5F70',), '4F30', 'EF.SAI'),
|
||||||
|
('ADF.USIM', ('5F70',), '4F31', 'EF.SLL'),
|
||||||
|
('ADF.USIM', (), '5F80', 'DF.BCAST'),
|
||||||
|
('ADF.USIM', (), '5FA0', 'DF.ACDC'),
|
||||||
|
('ADF.USIM', ('5FA0',), '4F01', 'EF.ACDC_LIST'),
|
||||||
|
('ADF.USIM', (), '5FB0', 'DF.TV'),
|
||||||
|
('MF', ('7F10',), '5F3F', 'DF.A2X'),
|
||||||
|
('MF', ('7F10', '5F3F'), '4F01', 'EF.AST'),
|
||||||
|
('MF', ('7F10', '5F3F'), '4F02', 'EF.A2X_CONFIG'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The non-IMSI USIM application repeats the ADF.USIM files under its own root
|
||||||
|
# (the walk emits it as a separate ADF with the same file set).
|
||||||
|
USIM_ROOT = 'ADF.USIM'
|
||||||
|
USIM_MIRROR = 'ADF.USIM-NON-IMSI'
|
||||||
|
|
||||||
|
|
||||||
|
def _add_supplement(out, seen, sym_by_path):
|
||||||
|
"""Append SUPPLEMENT, mirroring the ADF.USIM files under the non-IMSI root."""
|
||||||
|
for root, parents, fid, name in SUPPLEMENT:
|
||||||
|
roots = [root]
|
||||||
|
if root == USIM_ROOT and USIM_MIRROR in seen:
|
||||||
|
roots.append(USIM_MIRROR)
|
||||||
|
for target in roots:
|
||||||
|
key = '/'.join((target,) + tuple(parents) + (fid,)).upper()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
parent_key = '/'.join((target,) + tuple(parents)).upper()
|
||||||
|
sym = '%s/%s' % (sym_by_path.get(parent_key, parent_key), name)
|
||||||
|
seen.add(key)
|
||||||
|
out.append(_entry(key, sym, name, fid,
|
||||||
|
'df' if name.startswith('DF.') else 'ef', target))
|
||||||
|
sym_by_path[key] = sym
|
||||||
|
|
||||||
|
|
||||||
def _walk(node, fid_path, sym_path, root, out, seen):
|
def _walk(node, fid_path, sym_path, root, out, seen):
|
||||||
"""Depth-first walk of a DF/ADF subtree (children is a fid -> CardFile dict)."""
|
"""Depth-first walk of a DF/ADF subtree (children is a fid -> CardFile dict)."""
|
||||||
for fid, child in sorted(node.children.items()):
|
for fid, child in sorted(node.children.items()):
|
||||||
@@ -116,6 +177,8 @@ def build():
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
out.append(_entry(key, adf.name, adf.name, '', 'adf', adf.name, app.aid))
|
out.append(_entry(key, adf.name, adf.name, '', 'adf', adf.name, app.aid))
|
||||||
_walk(adf, key, adf.name, adf.name, out, seen)
|
_walk(adf, key, adf.name, adf.name, out, seen)
|
||||||
|
# The optional / service-gated files pySim's model does not carry.
|
||||||
|
_add_supplement(out, seen, {e['path']: e['sym'] for e in out})
|
||||||
# Stable order: MF first, then the ADFs alphabetically; DFs before their EFs
|
# Stable order: MF first, then the ADFs alphabetically; DFs before their EFs
|
||||||
# is implicit in the walk, paths sort consistently inside a root.
|
# is implicit in the walk, paths sort consistently inside a root.
|
||||||
out.sort(key=lambda e: (0 if e['root'] == 'MF' else 1, e['root'], e['path']))
|
out.sort(key=lambda e: (0 if e['root'] == 'MF' else 1, e['root'], e['path']))
|
||||||
|
|||||||
@@ -34,10 +34,35 @@ echo Found Git
|
|||||||
git --version
|
git --version
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
REM Create venv
|
REM Create the venv. A failed creation leaves a partial .venv behind: remove
|
||||||
|
REM it so the next run starts over (v3.38.1).
|
||||||
|
set "VENV_OK="
|
||||||
|
if exist "%VENV_DIR%" (
|
||||||
|
set "VENV_OK=1"
|
||||||
|
if not exist "%VENV_DIR%\Scripts\activate.bat" set "VENV_OK="
|
||||||
|
if not exist "%VENV_DIR%\Scripts\python.exe" set "VENV_OK="
|
||||||
|
if not exist "%VENV_DIR%\Scripts\pip.exe" set "VENV_OK="
|
||||||
|
if not defined VENV_OK (
|
||||||
|
echo Removing an incomplete virtual environment (a previous setup did not finish)...
|
||||||
|
rmdir /s /q "%VENV_DIR%"
|
||||||
|
)
|
||||||
|
)
|
||||||
if not exist "%VENV_DIR%" (
|
if not exist "%VENV_DIR%" (
|
||||||
echo Creating virtual environment...
|
echo Creating virtual environment...
|
||||||
python -m venv "%VENV_DIR%"
|
python -m venv "%VENV_DIR%"
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
if exist "%VENV_DIR%" rmdir /s /q "%VENV_DIR%"
|
||||||
|
echo Error: could not create the virtual environment.
|
||||||
|
echo Make sure the Python installation includes venv and pip.
|
||||||
|
echo Run the Python installer, choose Modify, enable pip/venv, then run setup.bat again.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not exist "%VENV_DIR%\Scripts\activate.bat" (
|
||||||
|
echo Error: the virtual environment has no Scripts\activate.bat.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
)
|
)
|
||||||
call "%VENV_DIR%\Scripts\activate.bat"
|
call "%VENV_DIR%\Scripts\activate.bat"
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,24 @@ command -v git >/dev/null 2>&1 || { echo "Error: Git is required but not found.
|
|||||||
PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
echo "Found Python $PY_VER"
|
echo "Found Python $PY_VER"
|
||||||
|
|
||||||
# Create venv
|
# Create the venv. A failed creation (missing venv/ensurepip) leaves a partial
|
||||||
|
# .venv behind: validate it and remove it so the next run starts over (v3.38.1).
|
||||||
|
if [ -d "$VENV_DIR" ] && { [ ! -f "$VENV_DIR/bin/activate" ] || [ ! -x "$VENV_DIR/bin/python" ] || [ ! -x "$VENV_DIR/bin/pip" ]; }; then
|
||||||
|
echo "Removing an incomplete virtual environment (a previous setup did not finish)..."
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
echo "Creating virtual environment..."
|
echo "Creating virtual environment..."
|
||||||
python3 -m venv "$VENV_DIR"
|
if ! python3 -m venv "$VENV_DIR"; then
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
echo "Error: could not create the virtual environment."
|
||||||
|
echo " The Python venv/ensurepip component is missing or broken."
|
||||||
|
echo " Debian/Ubuntu: sudo apt install python3-venv"
|
||||||
|
echo " Fedora: sudo dnf install python3-libs"
|
||||||
|
echo " Arch: venv and ensurepip are part of python"
|
||||||
|
echo " Install it and run ./setup.sh again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
PIP="$VENV_DIR/bin/pip"
|
PIP="$VENV_DIR/bin/pip"
|
||||||
|
|||||||
@@ -12,15 +12,41 @@ set VENV_DIR=%~dp0.venv
|
|||||||
set LOG=%~dp0simple_lastrun.log
|
set LOG=%~dp0simple_lastrun.log
|
||||||
set PYTHONUNBUFFERED=1
|
set PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
REM The address the opening line names (v3.38.2). The server parses the same
|
||||||
|
REM flags; this mirrors its last-one-wins rule for the echo. %* is not
|
||||||
|
REM affected by SHIFT, so the server still receives every argument.
|
||||||
|
set "HTTP_HOST=127.0.0.1"
|
||||||
|
set "HTTP_PORT=8080"
|
||||||
|
:parse_http_args
|
||||||
|
if "%~1"=="" goto http_args_done
|
||||||
|
if /i "%~1"=="--http-host" (
|
||||||
|
set "HTTP_HOST=%~2"
|
||||||
|
shift
|
||||||
|
shift
|
||||||
|
goto parse_http_args
|
||||||
|
)
|
||||||
|
if /i "%~1"=="--http-port" (
|
||||||
|
set "HTTP_PORT=%~2"
|
||||||
|
shift
|
||||||
|
shift
|
||||||
|
goto parse_http_args
|
||||||
|
)
|
||||||
|
set "ARG=%~1"
|
||||||
|
if /i "%ARG:~0,12%"=="--http-host=" set "HTTP_HOST=%ARG:~12%"
|
||||||
|
if /i "%ARG:~0,12%"=="--http-port=" set "HTTP_PORT=%ARG:~12%"
|
||||||
|
shift
|
||||||
|
goto parse_http_args
|
||||||
|
:http_args_done
|
||||||
|
|
||||||
if exist "%VENV_DIR%\Scripts\pysim-simple-server.exe" (
|
if exist "%VENV_DIR%\Scripts\pysim-simple-server.exe" (
|
||||||
echo Starting pysim-simple-server from venv on http://127.0.0.1:8080
|
echo Starting pysim-simple-server from venv on http://%HTTP_HOST%:%HTTP_PORT%
|
||||||
echo Log: simple_lastrun.log (overwritten on each start^). Press Ctrl+C to stop.
|
echo Log: simple_lastrun.log (overwritten on each start^). Press Ctrl+C to stop.
|
||||||
"%VENV_DIR%\Scripts\pysim-simple-server.exe" --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
|
"%VENV_DIR%\Scripts\pysim-simple-server.exe" --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
|
||||||
goto :eof
|
goto :eof
|
||||||
)
|
)
|
||||||
|
|
||||||
if exist "%~dp0pysim_simple_server\__main__.py" (
|
if exist "%~dp0pysim_simple_server\__main__.py" (
|
||||||
echo Starting pysim-simple-server from source on http://127.0.0.1:8080
|
echo Starting pysim-simple-server from source on http://%HTTP_HOST%:%HTTP_PORT%
|
||||||
echo Log: simple_lastrun.log (overwritten on each start^). Press Ctrl+C to stop.
|
echo Log: simple_lastrun.log (overwritten on each start^). Press Ctrl+C to stop.
|
||||||
python -m pysim_simple_server --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
|
python -m pysim_simple_server --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
|
||||||
goto :eof
|
goto :eof
|
||||||
@@ -28,7 +54,7 @@ if exist "%~dp0pysim_simple_server\__main__.py" (
|
|||||||
|
|
||||||
where pysim-simple-server >nul 2>&1
|
where pysim-simple-server >nul 2>&1
|
||||||
if %errorlevel% equ 0 (
|
if %errorlevel% equ 0 (
|
||||||
echo Starting pysim-simple-server on http://127.0.0.1:8080
|
echo Starting pysim-simple-server on http://%HTTP_HOST%:%HTTP_PORT%
|
||||||
echo Log: simple_lastrun.log (overwritten on each start^). Press Ctrl+C to stop.
|
echo Log: simple_lastrun.log (overwritten on each start^). Press Ctrl+C to stop.
|
||||||
pysim-simple-server --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
|
pysim-simple-server --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
|
||||||
goto :eof
|
goto :eof
|
||||||
|
|||||||
@@ -10,20 +10,77 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||||
|
|
||||||
|
# The address the opening line names (v3.38.2). The server parses the same
|
||||||
|
# flags; start.sh passes its own --http-port 8080 before "$@", so a later user
|
||||||
|
# value wins (argparse keeps the last one) - mirror that here.
|
||||||
|
HTTP_HOST=127.0.0.1
|
||||||
|
HTTP_PORT=8080
|
||||||
|
PREV_ARG=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$PREV_ARG" in
|
||||||
|
--http-host) HTTP_HOST="$arg" ;;
|
||||||
|
--http-port) HTTP_PORT="$arg" ;;
|
||||||
|
esac
|
||||||
|
case "$arg" in
|
||||||
|
--http-host=*) HTTP_HOST="${arg#*=}" ;;
|
||||||
|
--http-port=*) HTTP_PORT="${arg#*=}" ;;
|
||||||
|
esac
|
||||||
|
PREV_ARG="$arg"
|
||||||
|
done
|
||||||
|
|
||||||
# Console + log file. The log contains card keys and request payloads (test
|
# Console + log file. The log contains card keys and request payloads (test
|
||||||
# cards) - it is gitignored and must not be committed.
|
# cards) - it is gitignored and must not be committed.
|
||||||
LOG_FILE="$SCRIPT_DIR/simple_lastrun.log"
|
LOG_FILE="$SCRIPT_DIR/simple_lastrun.log"
|
||||||
exec > >(tee "$LOG_FILE") 2>&1
|
exec > >(tee "$LOG_FILE") 2>&1
|
||||||
export PYTHONUNBUFFERED=1 # keep the log live: a piped stdout is block-buffered
|
export PYTHONUNBUFFERED=1 # keep the log live: a piped stdout is block-buffered
|
||||||
|
|
||||||
# Auto-detect reader
|
# PC/SC pre-flight (v3.38.1): a user without access to pcscd must see the real
|
||||||
|
# cause - the server no longer falls back to /dev/ttyUSB0 silently. An
|
||||||
|
# explicit serial reader (-d/--device) keeps the serial path.
|
||||||
|
SERIAL=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-d|--device|--device=*|-d*) SERIAL=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
READER_ARGS=""
|
READER_ARGS=""
|
||||||
if command -v pcscd > /dev/null 2>&1; then
|
if [ "$SERIAL" -eq 0 ]; then
|
||||||
# Give pcscd a moment if it's not running yet (USB enumeration delay)
|
PYTHON_BIN="$VENV_DIR/bin/python"
|
||||||
if ! pgrep -x pcscd > /dev/null 2>&1 && ! pgrep -x pcscd.bin > /dev/null 2>&1; then
|
[ -x "$PYTHON_BIN" ] || PYTHON_BIN="$(command -v python3 || true)"
|
||||||
sleep 1
|
if [ -n "$PYTHON_BIN" ]; then
|
||||||
fi
|
PCSC_ERR="$("$PYTHON_BIN" - <<'PY' 2>&1
|
||||||
if pgrep -x pcscd > /dev/null 2>&1 || pgrep -x pcscd.bin > /dev/null 2>&1; then
|
import time
|
||||||
|
from smartcard.System import readers
|
||||||
|
last = None
|
||||||
|
for _ in range(3):
|
||||||
|
try:
|
||||||
|
readers()
|
||||||
|
raise SystemExit(0)
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last = e
|
||||||
|
hr = getattr(e, 'hresult', None)
|
||||||
|
if hr == 0x80100027 or 'access denied' in str(e).lower():
|
||||||
|
break # permissions do not fix themselves
|
||||||
|
time.sleep(1) # pcscd may still be starting (socket activation)
|
||||||
|
hr = getattr(last, 'hresult', -1)
|
||||||
|
msg = str(last) or 'PC/SC error'
|
||||||
|
print(msg + ((' (hresult=0x%08X)' % hr) if hr not in (None, -1) else ''))
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
)" || {
|
||||||
|
echo "Error: PC/SC is not accessible: ${PCSC_ERR:-unknown error}"
|
||||||
|
echo " The server will not fall back to a serial reader silently."
|
||||||
|
echo " - pcscd uses PolicyKit (the default allows only active local sessions,"
|
||||||
|
echo " so SSH/headless is denied even with a 0666 socket). Allow the user in"
|
||||||
|
echo " /etc/polkit-1/rules.d/99-pcscd-allow.rules: return polkit.Result.YES for"
|
||||||
|
echo " org.debian.pcsc-lite.access_pcsc and access_card, subject.user == \"$USER\";"
|
||||||
|
echo " then 'sudo systemctl restart polkit' (or log out/in);"
|
||||||
|
echo " - 'sudo systemctl start pcscd' if the service is not running;"
|
||||||
|
echo " - 'pcsc_scan' shows the same 'Access denied' error until the rule is active."
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
READER_ARGS="-p 0"
|
READER_ARGS="-p 0"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -34,7 +91,7 @@ if [ -f "$VENV_DIR/bin/pysim-simple-server" ]; then
|
|||||||
elif command -v pysim-simple-server &> /dev/null; then
|
elif command -v pysim-simple-server &> /dev/null; then
|
||||||
SERVER="pysim-simple-server"
|
SERVER="pysim-simple-server"
|
||||||
elif [ -f "$SCRIPT_DIR/pysim_simple_server/__main__.py" ]; then
|
elif [ -f "$SCRIPT_DIR/pysim_simple_server/__main__.py" ]; then
|
||||||
echo "Starting pysim-simple-server from source on http://127.0.0.1:8080"
|
echo "Starting pysim-simple-server from source on http://$HTTP_HOST:$HTTP_PORT"
|
||||||
echo "Log: simple_lastrun.log (overwritten on each start)."
|
echo "Log: simple_lastrun.log (overwritten on each start)."
|
||||||
cd "$SCRIPT_DIR" && python3 -m pysim_simple_server --http-port 8080 $READER_ARGS "$@"
|
cd "$SCRIPT_DIR" && python3 -m pysim_simple_server --http-port 8080 $READER_ARGS "$@"
|
||||||
exit $?
|
exit $?
|
||||||
@@ -45,7 +102,7 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting pysim-simple-server on http://127.0.0.1:8080"
|
echo "Starting pysim-simple-server on http://$HTTP_HOST:$HTTP_PORT"
|
||||||
echo "Log: simple_lastrun.log (overwritten on each start)."
|
echo "Log: simple_lastrun.log (overwritten on each start)."
|
||||||
echo "Press Ctrl+C to stop."
|
echo "Press Ctrl+C to stop."
|
||||||
echo "Extra arguments are passed to the server (e.g. ./start.sh --gsmtap)."
|
echo "Extra arguments are passed to the server (e.g. ./start.sh --gsmtap)."
|
||||||
|
|||||||
+21
-5
@@ -23,13 +23,29 @@ class FakeFile:
|
|||||||
self.fid = fid
|
self.fid = fid
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
"""Serves READ RECORD for the selected fake ARR file."""
|
||||||
|
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000')
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
"""One selected ARR file: read_record serves its records."""
|
"""One selected ARR file: READ RECORD serves its records."""
|
||||||
|
|
||||||
def __init__(self, files):
|
def __init__(self, files):
|
||||||
self.files = files # path -> [record hex]
|
self.files = files # path -> [record hex]
|
||||||
self._cur = None
|
self._cur = None
|
||||||
self.selected_file = None
|
self.selected_file = None
|
||||||
|
self.scc = FakeScc(self)
|
||||||
|
|
||||||
def selected_file_num_of_rec(self):
|
def selected_file_num_of_rec(self):
|
||||||
return len(self.files.get(self._cur) or [])
|
return len(self.files.get(self._cur) or [])
|
||||||
@@ -96,13 +112,13 @@ class ReadArrTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_a_failing_read_skips_the_file(self):
|
def test_a_failing_read_skips_the_file(self):
|
||||||
app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']})
|
app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']})
|
||||||
orig = lchan.read_record
|
orig = lchan.scc.send_apdu_checksw
|
||||||
|
|
||||||
def flaky(n):
|
def flaky(pdu):
|
||||||
if lchan._cur == 'MF/2F06':
|
if lchan._cur == 'MF/2F06':
|
||||||
raise RuntimeError('6A82')
|
raise RuntimeError('6A82')
|
||||||
return orig(n)
|
return orig(pdu)
|
||||||
lchan.read_record = flaky
|
lchan.scc.send_apdu_checksw = flaky
|
||||||
arrs = _read(app)
|
arrs = _read(app)
|
||||||
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
|
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class PliDictHttpTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
status, resp = self._req('GET', '/api/pli-dict')
|
status, resp = self._req('GET', '/api/pli-dict')
|
||||||
self.assertEqual(status, 200, resp)
|
self.assertEqual(status, 200, resp)
|
||||||
self.assertEqual(resp['00'], '130500F1100000')
|
self.assertEqual(resp['00'], '130700F11000010001')
|
||||||
self.assertEqual(resp['0A'], '630104')
|
self.assertEqual(resp['0A'], '630104')
|
||||||
self.assertEqual(resp['03'], '') # the host clock answers it
|
self.assertEqual(resp['03'], '') # the host clock answers it
|
||||||
self.assertEqual(resp['02'], '') # no default
|
self.assertEqual(resp['02'], '') # no default
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ class FakeFile:
|
|||||||
self.applications = {}
|
self.applications = {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
if self.lchan.error:
|
||||||
|
raise self.lchan.error
|
||||||
|
return self.lchan.data, '9000'
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
def __init__(self, mf, data=None, error=None):
|
def __init__(self, mf, data=None, error=None):
|
||||||
self.selected_file = mf
|
self.selected_file = mf
|
||||||
@@ -39,6 +54,10 @@ class FakeLchan:
|
|||||||
self.error = error
|
self.error = error
|
||||||
self.selects = []
|
self.selects = []
|
||||||
self.probes = []
|
self.probes = []
|
||||||
|
self.scc = FakeScc(self)
|
||||||
|
|
||||||
|
def selected_file_size(self):
|
||||||
|
return len(self.data or '') // 2
|
||||||
|
|
||||||
def select_file(self, f, app=None):
|
def select_file(self, f, app=None):
|
||||||
self.selected_file = f
|
self.selected_file = f
|
||||||
@@ -94,6 +113,8 @@ class ReadIccidTest(unittest.TestCase):
|
|||||||
self.assertIs(lchan.selected_file, iccid)
|
self.assertIs(lchan.selected_file, iccid)
|
||||||
self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID'])
|
self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID'])
|
||||||
self.assertEqual(lchan.probes, [])
|
self.assertEqual(lchan.probes, [])
|
||||||
|
# one READ BINARY, no second SELECT (v3.38.1)
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b000000a'])
|
||||||
|
|
||||||
def test_missing_model_entry_is_probed_and_detached(self):
|
def test_missing_model_entry_is_probed_and_detached(self):
|
||||||
app, lchan, mf, _ = make_app(with_iccid=False)
|
app, lchan, mf, _ = make_app(with_iccid=False)
|
||||||
|
|||||||
@@ -13,6 +13,28 @@ from types import SimpleNamespace
|
|||||||
from pysim_simple_server import server
|
from pysim_simple_server import server
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
"""Serves the selected fake file for direct READ BINARY/RECORD APDUs."""
|
||||||
|
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
kind, val = self.lchan.files[self.lchan.selected]
|
||||||
|
if pdu[2:4] == 'b0':
|
||||||
|
off = int(pdu[4:8], 16)
|
||||||
|
ln = int(pdu[8:10], 16)
|
||||||
|
return val[off * 2:(off + ln) * 2], '9000'
|
||||||
|
if pdu[2:4] == 'b2':
|
||||||
|
return val[int(pdu[4:6], 16) - 1], '9000'
|
||||||
|
raise AssertionError('unexpected APDU %s' % pdu)
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
def __init__(self, files):
|
def __init__(self, files):
|
||||||
self.files = files # path -> ('transparent', hex) | ('record', [hex])
|
self.files = files # path -> ('transparent', hex) | ('record', [hex])
|
||||||
@@ -23,6 +45,9 @@ class FakeLchan:
|
|||||||
self.selected_file_type = lambda: 'ef'
|
self.selected_file_type = lambda: 'ef'
|
||||||
self.selected_file_structure = lambda: self.files[self.selected][0]
|
self.selected_file_structure = lambda: self.files[self.selected][0]
|
||||||
self.selected_file_num_of_rec = lambda: len(self.files[self.selected][1])
|
self.selected_file_num_of_rec = lambda: len(self.files[self.selected][1])
|
||||||
|
self.selected_file_record_len = lambda: 2
|
||||||
|
self.selected_file_size = lambda: len(self.files[self.selected][1]) // 2
|
||||||
|
self.scc = FakeScc(self)
|
||||||
self.read_binary = lambda: (self.files[self.selected][1], '9000')
|
self.read_binary = lambda: (self.files[self.selected][1], '9000')
|
||||||
self.read_record = lambda i: (self.files[self.selected][1][i - 1], '9000')
|
self.read_record = lambda i: (self.files[self.selected][1][i - 1], '9000')
|
||||||
|
|
||||||
@@ -68,6 +93,8 @@ class NetStateServerTests(unittest.TestCase):
|
|||||||
('select', 'DF.GSM/6F07'),
|
('select', 'DF.GSM/6F07'),
|
||||||
('select', 'ADF.USIM/6FCD'),
|
('select', 'ADF.USIM/6FCD'),
|
||||||
('select', 'ADF.USIM/6FE4')])
|
('select', 'ADF.USIM/6FE4')])
|
||||||
|
# v3.38.1: the reads use the selected file directly - no second SELECT
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b0000003', '00b2010402', '00b2020402'])
|
||||||
|
|
||||||
def test_install_builds_the_state_and_network(self):
|
def test_install_builds_the_state_and_network(self):
|
||||||
files = {'imsi': {'name': 'EF.IMSI', 'fid': '6F07', 'present': True,
|
files = {'imsi': {'name': 'EF.IMSI', 'fid': '6F07', 'present': True,
|
||||||
|
|||||||
@@ -1041,7 +1041,10 @@ class TestProactiveDecode(unittest.TestCase):
|
|||||||
# define (TS 102 223 8.x, tags per TS 101 220 V18.3.0 Table 7.23)
|
# define (TS 102 223 8.x, tags per TS 101 220 V18.3.0 Table 7.23)
|
||||||
import pysim_simple_server.server as srv
|
import pysim_simple_server.server as srv
|
||||||
d = srv.PLI_DEFAULTS
|
d = srv.PLI_DEFAULTS
|
||||||
self.assertEqual(d[0x00], '130500F1100000') # 001/01 + LAC 0000
|
# the 7-byte PLI form: 001/01 + LAC 0001 + Cell ID 0001 (length 05
|
||||||
|
# is the Network Rejection event form, TS 31.111 8.19.1)
|
||||||
|
self.assertEqual(d[0x00], '130700F11000010001')
|
||||||
|
self.assertEqual(len(bytes.fromhex(d[0x00])), 9)
|
||||||
self.assertEqual(d[0x01], '140894104502237315F8') # IMEI, F pad
|
self.assertEqual(d[0x01], '140894104502237315F8') # IMEI, F pad
|
||||||
self.assertEqual(d[0x04], '2D02656E') # 'en'
|
self.assertEqual(d[0x04], '2D02656E') # 'en'
|
||||||
self.assertEqual(d[0x05], '2E020000') # Timing Advance
|
self.assertEqual(d[0x05], '2E020000') # Timing Advance
|
||||||
@@ -1073,6 +1076,19 @@ class TestProactiveDecode(unittest.TestCase):
|
|||||||
srv._PLI_DATA.clear()
|
srv._PLI_DATA.clear()
|
||||||
srv._PLI_DATA.update(saved)
|
srv._PLI_DATA.update(saved)
|
||||||
|
|
||||||
|
def test_the_pli_tr_carries_the_7_byte_location(self):
|
||||||
|
# the TR builder injects the location object after the Result; a
|
||||||
|
# 5-byte object (no Cell ID) is the Network Rejection form, not a PLI
|
||||||
|
import pysim_simple_server.server as srv
|
||||||
|
saved = srv._PLI_DATA.pop(0x00, None)
|
||||||
|
try:
|
||||||
|
tr = srv._build_tr(object(), 1, 0x26, 0x81, 0x82, 0x00)
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
srv._PLI_DATA[0x00] = saved
|
||||||
|
self.assertTrue(tr.startswith(bytes.fromhex('810301260002028281030100')))
|
||||||
|
self.assertTrue(tr.endswith(bytes.fromhex('130700F11000010001')))
|
||||||
|
|
||||||
def test_decode_cmd_empty_raw(self):
|
def test_decode_cmd_empty_raw(self):
|
||||||
self.assertEqual(_decode_cmd(0x26, b'', None), [])
|
self.assertEqual(_decode_cmd(0x26, b'', None), [])
|
||||||
self.assertEqual(_decode_cmd(0x03, None, None), [])
|
self.assertEqual(_decode_cmd(0x03, None, None), [])
|
||||||
|
|||||||
+21
-5
@@ -34,13 +34,29 @@ class FakeFile:
|
|||||||
self.fid = fid
|
self.fid = fid
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
"""Serves READ RECORD for the selected fake PBR file."""
|
||||||
|
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000')
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
"""One selected PBR file: read_record serves its records."""
|
"""One selected PBR file: READ RECORD serves its records."""
|
||||||
|
|
||||||
def __init__(self, files):
|
def __init__(self, files):
|
||||||
self.files = files # path -> [record hex]
|
self.files = files # path -> [record hex]
|
||||||
self._cur = None
|
self._cur = None
|
||||||
self.selected_file = None
|
self.selected_file = None
|
||||||
|
self.scc = FakeScc(self)
|
||||||
|
|
||||||
def selected_file_num_of_rec(self):
|
def selected_file_num_of_rec(self):
|
||||||
return len(self.files.get(self._cur) or [])
|
return len(self.files.get(self._cur) or [])
|
||||||
@@ -101,13 +117,13 @@ class ReadPbrTests(unittest.TestCase):
|
|||||||
def test_a_failing_read_skips_the_file(self):
|
def test_a_failing_read_skips_the_file(self):
|
||||||
app, lchan = _app({'MF/7F10/5F3A/4F30': ['A80AC0034F3A02'],
|
app, lchan = _app({'MF/7F10/5F3A/4F30': ['A80AC0034F3A02'],
|
||||||
'ADF.USIM/5F3A/4F30': ['A80AC0034F3A02']})
|
'ADF.USIM/5F3A/4F30': ['A80AC0034F3A02']})
|
||||||
orig = lchan.read_record
|
orig = lchan.scc.send_apdu_checksw
|
||||||
|
|
||||||
def flaky(n):
|
def flaky(pdu):
|
||||||
if lchan._cur == 'MF/7F10/5F3A/4F30':
|
if lchan._cur == 'MF/7F10/5F3A/4F30':
|
||||||
raise RuntimeError('6A82')
|
raise RuntimeError('6A82')
|
||||||
return orig(n)
|
return orig(pdu)
|
||||||
lchan.read_record = flaky
|
lchan.scc.send_apdu_checksw = flaky
|
||||||
pbrs = _read(app)
|
pbrs = _read(app)
|
||||||
self.assertEqual([p['path'] for p in pbrs], ['ADF.USIM/5F3A/4F30'])
|
self.assertEqual([p['path'] for p in pbrs], ['ADF.USIM/5F3A/4F30'])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""PC/SC access-denied handling at startup (v3.38.1).
|
||||||
|
|
||||||
|
A user without permission to pcscd used to get a silent serial fallback that
|
||||||
|
failed on /dev/ttyUSB0 while the server still printed "listening". The
|
||||||
|
startup now classifies the pyscard failure, reports the cause with guidance
|
||||||
|
and exits; ``start.sh`` runs the same access pre-flight.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import io
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pysim_simple_server import __main__ as m
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExc(Exception):
|
||||||
|
def __init__(self, hresult=None, message=''):
|
||||||
|
super().__init__(message)
|
||||||
|
self.hresult = hresult
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorKindTests(unittest.TestCase):
|
||||||
|
def test_access_denied_by_hresult(self):
|
||||||
|
# pyscard raises CardConnectionException(hresult=...) with an empty
|
||||||
|
# message on the SCardEstablishContext failure
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x80100027)), 'access')
|
||||||
|
|
||||||
|
def test_access_denied_by_message(self):
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(None, 'Access denied.')),
|
||||||
|
'access')
|
||||||
|
|
||||||
|
def test_service_errors(self):
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010001D)), 'service')
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010001E)), 'service')
|
||||||
|
|
||||||
|
def test_reader_errors(self):
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010002E)), 'reader')
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x80100009)), 'reader')
|
||||||
|
|
||||||
|
def test_other_failures_are_not_classified(self):
|
||||||
|
self.assertIsNone(m._pcsc_error_kind(FakeExc(0x8010000B))) # sharing
|
||||||
|
self.assertIsNone(m._pcsc_error_kind(ValueError('boom')))
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeTests(unittest.TestCase):
|
||||||
|
def test_the_first_reader_is_returned(self):
|
||||||
|
self.assertEqual(
|
||||||
|
m._probe_pcsc_reader(readers_fn=lambda: ['Reader X']), 'Reader X')
|
||||||
|
|
||||||
|
def test_access_denied_is_raised_immediately(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def denied():
|
||||||
|
calls.append(1)
|
||||||
|
raise FakeExc(0x80100027)
|
||||||
|
|
||||||
|
with self.assertRaises(m.PcscUnavailable) as cm:
|
||||||
|
m._probe_pcsc_reader(retries=3, sleep_fn=lambda s: None,
|
||||||
|
readers_fn=denied)
|
||||||
|
self.assertEqual(cm.exception.kind, 'access')
|
||||||
|
self.assertEqual(len(calls), 1) # permissions do not fix themselves
|
||||||
|
|
||||||
|
def test_an_empty_list_is_retried_then_reported(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def empty():
|
||||||
|
calls.append(1)
|
||||||
|
return []
|
||||||
|
|
||||||
|
with self.assertRaises(m.PcscUnavailable) as cm:
|
||||||
|
m._probe_pcsc_reader(retries=2, sleep_fn=lambda s: None,
|
||||||
|
readers_fn=empty)
|
||||||
|
self.assertEqual(cm.exception.kind, 'reader')
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
|
||||||
|
def test_a_transient_failure_is_retried(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def flaky():
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) == 1:
|
||||||
|
raise FakeExc(0x8010000B) # sharing violation
|
||||||
|
return ['Reader Y']
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
m._probe_pcsc_reader(retries=2, sleep_fn=lambda s: None,
|
||||||
|
readers_fn=flaky), 'Reader Y')
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTests(unittest.TestCase):
|
||||||
|
def _report(self, kind, exc=None):
|
||||||
|
err = io.StringIO()
|
||||||
|
with mock.patch('sys.stderr', err):
|
||||||
|
m._report_pcsc_unavailable(kind, exc)
|
||||||
|
return err.getvalue()
|
||||||
|
|
||||||
|
def test_access_guidance(self):
|
||||||
|
text = self._report('access', FakeExc(0x80100027, 'Access denied.'))
|
||||||
|
low = text.lower()
|
||||||
|
self.assertIn('access denied', low)
|
||||||
|
self.assertIn('pcscd', low)
|
||||||
|
# polkit, not the socket group, is the gate (the socket can be 0666)
|
||||||
|
self.assertIn('polkit', low)
|
||||||
|
self.assertIn('org.debian.pcsc-lite.access_pcsc', low)
|
||||||
|
self.assertIn('org.debian.pcsc-lite.access_card', low)
|
||||||
|
self.assertIn('99-pcscd-allow.rules', low)
|
||||||
|
self.assertIn('0666', low)
|
||||||
|
self.assertIn('pcsc_scan', low)
|
||||||
|
self.assertIn('0x80100027', text)
|
||||||
|
|
||||||
|
def test_service_guidance(self):
|
||||||
|
text = self._report('service')
|
||||||
|
self.assertIn('pcscd is not running', text)
|
||||||
|
self.assertIn('systemctl start pcscd', text)
|
||||||
|
|
||||||
|
def test_reader_guidance(self):
|
||||||
|
text = self._report('reader')
|
||||||
|
self.assertIn('no PC/SC reader', text)
|
||||||
|
self.assertIn('pcsc_scan', text)
|
||||||
|
|
||||||
|
|
||||||
|
class SerialArgTests(unittest.TestCase):
|
||||||
|
def test_explicit_serial_flags(self):
|
||||||
|
for argv in (['-d', '/dev/ttyS0'], ['--device', 'x'],
|
||||||
|
['--device=x'], ['-d/dev/ttyS0']):
|
||||||
|
self.assertTrue(m._serial_arg_given(argv), argv)
|
||||||
|
|
||||||
|
def test_the_default_is_not_an_explicit_choice(self):
|
||||||
|
for argv in ([], ['--http-port', '8080'], ['-p', '0']):
|
||||||
|
self.assertFalse(m._serial_arg_given(argv), argv)
|
||||||
|
|
||||||
|
|
||||||
|
class SourcePins(unittest.TestCase):
|
||||||
|
def test_the_module_propagates_the_exit_code(self):
|
||||||
|
src = Path(m.__file__).read_text()
|
||||||
|
self.assertIn('sys.exit(main())', src)
|
||||||
|
|
||||||
|
def test_the_start_script_probes_access_and_guides(self):
|
||||||
|
src = (Path(m.__file__).parent.parent / 'start.sh').read_text()
|
||||||
|
self.assertIn('from smartcard.System import readers', src)
|
||||||
|
self.assertIn('polkit', src)
|
||||||
|
self.assertIn('org.debian.pcsc-lite.access_pcsc', src)
|
||||||
|
self.assertIn('99-pcscd-allow.rules', src)
|
||||||
|
self.assertIn('pcsc_scan', src)
|
||||||
|
# the group route is not the gate (the socket may be 0666)
|
||||||
|
self.assertNotIn('usermod -aG pcscd', src)
|
||||||
|
# the old "is pcscd running" heuristic is gone
|
||||||
|
self.assertNotIn('pgrep -x pcscd', src)
|
||||||
|
|
||||||
|
def test_the_start_message_names_the_requested_address(self):
|
||||||
|
src = (Path(m.__file__).parent.parent / 'start.sh').read_text()
|
||||||
|
self.assertIn('--http-host=*) HTTP_HOST="${arg#*=}"', src)
|
||||||
|
self.assertIn('--http-port=*) HTTP_PORT="${arg#*=}"', src)
|
||||||
|
self.assertIn('http://$HTTP_HOST:$HTTP_PORT', src)
|
||||||
|
# the old hardcoded address is gone (the defaults are named separately)
|
||||||
|
self.assertNotIn('http://127.0.0.1:8080', src)
|
||||||
|
|
||||||
|
def test_the_start_bat_message_names_the_requested_address(self):
|
||||||
|
src = (Path(m.__file__).parent.parent / 'start.bat').read_text()
|
||||||
|
self.assertIn('--http-host=', src)
|
||||||
|
self.assertIn('--http-port=', src)
|
||||||
|
self.assertIn('%HTTP_HOST%:%HTTP_PORT%', src)
|
||||||
|
self.assertNotIn('http://127.0.0.1:8080', src)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+17
-3
@@ -201,6 +201,20 @@ class TestProactiveChainOwnership(unittest.TestCase):
|
|||||||
S._handle_proactive_chain(scc, '910b', max_rounds=3)
|
S._handle_proactive_chain(scc, '910b', max_rounds=3)
|
||||||
self.assertEqual(len(self._fetches(scc)), 3)
|
self.assertEqual(len(self._fetches(scc)), 3)
|
||||||
|
|
||||||
|
def test_every_chain_call_is_bounded_by_default(self):
|
||||||
|
# v3.37.0: the bound is the default, not only the background poll's
|
||||||
|
# choice - a chatty card must not wedge any call site (the manual
|
||||||
|
# STATUS poll and the ENVELOPE chain used to loop forever)
|
||||||
|
scc = FakeChattyScc()
|
||||||
|
S._handle_proactive_chain(scc, '910b')
|
||||||
|
self.assertEqual(len(self._fetches(scc)), S._POLL_CHAIN_MAX)
|
||||||
|
|
||||||
|
def test_the_manual_status_poll_passes_the_bound(self):
|
||||||
|
import inspect
|
||||||
|
src = inspect.getsource(S.PysimHandler._do_POST)
|
||||||
|
branch = src.split("'/api/status-poll'", 1)[1].split('elif self.path', 1)[0]
|
||||||
|
self.assertIn('max_rounds=_POLL_CHAIN_MAX', branch)
|
||||||
|
|
||||||
def test_the_chain_stops_when_a_run_owns_the_card(self):
|
def test_the_chain_stops_when_a_run_owns_the_card(self):
|
||||||
scc = FakeChattyScc()
|
scc = FakeChattyScc()
|
||||||
S._TEST_RUNNING = True
|
S._TEST_RUNNING = True
|
||||||
@@ -264,12 +278,12 @@ class TestProactiveChainOwnership(unittest.TestCase):
|
|||||||
take.assert_called_once()
|
take.assert_called_once()
|
||||||
|
|
||||||
def test_every_run_end_resumes_the_poll(self):
|
def test_every_run_end_resumes_the_poll(self):
|
||||||
# the two worker bodies, their two exception paths and the spawn
|
# the two worker bodies, their two exception paths, the spawn failure
|
||||||
# failure all call the helper
|
# and the two load workers (v3.37.0) all call the helper
|
||||||
import inspect
|
import inspect
|
||||||
src = inspect.getsource(S)
|
src = inspect.getsource(S)
|
||||||
self.assertEqual(src.count('\n _sim_resume_poll()'), 2)
|
self.assertEqual(src.count('\n _sim_resume_poll()'), 2)
|
||||||
self.assertEqual(src.count('\n _sim_resume_poll()'), 3)
|
self.assertEqual(src.count('\n _sim_resume_poll()'), 5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+45
-1
@@ -88,7 +88,8 @@ class BatchStateTests(unittest.TestCase):
|
|||||||
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
|
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
|
||||||
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
|
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
|
||||||
'reason': 'no preset matches this ICCID'})
|
'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)
|
self.assertEqual(st['attempts_total'], 3)
|
||||||
# the notice names the last attempt's outcome
|
# the notice names the last attempt's outcome
|
||||||
self.assertIn('skipped: no preset matches this ICCID', st['notice'])
|
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.assertEqual(seen['current']['iccid'], '8970119000004600098')
|
||||||
self.assertIn('installed', st['notice']) # ...and set for the new outcome
|
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):
|
def test_a_card_already_processed_is_reported_not_reinstalled(self):
|
||||||
st = srv._ram_batch_new_state(_plan())
|
st = srv._ram_batch_new_state(_plan())
|
||||||
preset = _preset('8970119000004600098', 'Alfa')
|
preset = _preset('8970119000004600098', 'Alfa')
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""The direct read of an already-selected EF (v3.38.1).
|
||||||
|
|
||||||
|
pySim's ``scc.read_binary``/``scc.read_record`` select the file first; the
|
||||||
|
equip/init readers have already made a parent-scoped selection and hold its
|
||||||
|
FCP, so that second SELECT is pure overhead (one per file, one per record).
|
||||||
|
These tests pin the direct READ BINARY/READ RECORD form, the chunking and the
|
||||||
|
fallback when the FCP lacks the size/record length.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pysim_simple_server import server as S
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, data='AABB'):
|
||||||
|
self.sent = []
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
return self.data, '9000'
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLchan:
|
||||||
|
def __init__(self, size=2, rec_len=2, data='AABB'):
|
||||||
|
self.scc = FakeScc(data)
|
||||||
|
self.size = size
|
||||||
|
self.rec_len = rec_len
|
||||||
|
|
||||||
|
def selected_file_size(self):
|
||||||
|
return self.size
|
||||||
|
|
||||||
|
def selected_file_record_len(self):
|
||||||
|
return self.rec_len
|
||||||
|
|
||||||
|
def read_binary(self, *args):
|
||||||
|
raise AssertionError('pySim read_binary (re-select) used')
|
||||||
|
|
||||||
|
def read_record(self, *args):
|
||||||
|
raise AssertionError('pySim read_record (re-select) used')
|
||||||
|
|
||||||
|
|
||||||
|
class ReadSelectedTests(unittest.TestCase):
|
||||||
|
def test_a_transparent_read_is_direct(self):
|
||||||
|
lchan = FakeLchan(size=2)
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
|
||||||
|
('AABB', '9000'))
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b0000002'])
|
||||||
|
|
||||||
|
def test_a_transparent_file_is_chunked_at_max_cmd_len(self):
|
||||||
|
lchan = FakeLchan(size=300)
|
||||||
|
S._read_selected_ef(lchan, 'transparent')
|
||||||
|
# 255 bytes at offset 0, then 45 bytes at offset 0x00ff
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b00000ff', '00b000ff2d'])
|
||||||
|
|
||||||
|
def test_an_empty_file_reads_nothing(self):
|
||||||
|
lchan = FakeLchan(size=0)
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
|
||||||
|
('', '9000'))
|
||||||
|
self.assertEqual(lchan.scc.sent, [])
|
||||||
|
|
||||||
|
def test_a_record_read_is_direct(self):
|
||||||
|
lchan = FakeLchan(rec_len=6)
|
||||||
|
S._read_selected_ef(lchan, 'record', 3)
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b2030406'])
|
||||||
|
|
||||||
|
def test_a_missing_fcp_falls_back_to_the_pysim_helpers(self):
|
||||||
|
lchan = FakeLchan(size=None, rec_len=None)
|
||||||
|
lchan.read_binary = lambda *a: ('CC', '9000')
|
||||||
|
lchan.read_record = lambda n: ('DD', '9000')
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
|
||||||
|
('CC', '9000'))
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'record', 1),
|
||||||
|
('DD', '9000'))
|
||||||
|
|
||||||
|
|
||||||
|
class StartupDrainPin(unittest.TestCase):
|
||||||
|
"""The startup drain must not poll STATUS again after the chain already
|
||||||
|
returned an idle SW (v3.38.1)."""
|
||||||
|
|
||||||
|
def test_the_drain_uses_the_chain_result(self):
|
||||||
|
src = (pathlib.Path(S.__file__).parent / '__main__.py').read_text()
|
||||||
|
self.assertIn('chain_sw = _handle_proactive_chain(scc, st_sw)', src)
|
||||||
|
self.assertIn("if chain_sw and not chain_sw.startswith('91'):", src)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""The setup scripts recover from an incomplete .venv (v3.38.1).
|
||||||
|
|
||||||
|
A failed venv creation (the Debian/Ubuntu python3-venv package missing) used to
|
||||||
|
leave the .venv directory behind; the next run saw it, skipped creation and
|
||||||
|
failed to activate a venv without activate/pip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class SetupShTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.src = (ROOT / 'setup.sh').read_text()
|
||||||
|
|
||||||
|
def test_an_incomplete_venv_is_removed_before_creation(self):
|
||||||
|
self.assertIn('Removing an incomplete virtual environment', self.src)
|
||||||
|
self.assertIn('rm -rf "$VENV_DIR"', self.src)
|
||||||
|
self.assertIn('bin/activate', self.src)
|
||||||
|
self.assertIn('bin/python', self.src)
|
||||||
|
self.assertIn('bin/pip', self.src)
|
||||||
|
|
||||||
|
def test_a_failed_creation_removes_the_partial_dir_and_guides(self):
|
||||||
|
self.assertIn('if ! python3 -m venv "$VENV_DIR"; then', self.src)
|
||||||
|
self.assertIn('python3-venv', self.src)
|
||||||
|
fails = self.src[self.src.index('if ! python3 -m venv'):]
|
||||||
|
self.assertLess(fails.index('rm -rf "$VENV_DIR"'),
|
||||||
|
fails.index('could not create the virtual environment'))
|
||||||
|
|
||||||
|
def test_the_script_is_valid_bash(self):
|
||||||
|
bash = shutil.which('bash')
|
||||||
|
if not bash:
|
||||||
|
self.skipTest('bash not available')
|
||||||
|
res = subprocess.run([bash, '-n', str(ROOT / 'setup.sh')],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0, res.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupBatTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.src = (ROOT / 'setup.bat').read_text()
|
||||||
|
|
||||||
|
def test_an_incomplete_venv_is_removed_before_creation(self):
|
||||||
|
self.assertIn('Removing an incomplete virtual environment', self.src)
|
||||||
|
self.assertIn('rmdir /s /q "%VENV_DIR%"', self.src)
|
||||||
|
for marker in ('Scripts\\activate.bat', 'Scripts\\python.exe',
|
||||||
|
'Scripts\\pip.exe'):
|
||||||
|
self.assertIn(marker, self.src)
|
||||||
|
|
||||||
|
def test_a_failed_creation_removes_the_partial_dir_and_guides(self):
|
||||||
|
self.assertIn('if !errorlevel! neq 0', self.src)
|
||||||
|
self.assertIn('python -m venv "%VENV_DIR%"', self.src)
|
||||||
|
fails = self.src[self.src.index('if !errorlevel! neq 0'):]
|
||||||
|
self.assertLess(fails.index('rmdir /s /q "%VENV_DIR%"'),
|
||||||
|
fails.index('could not create the virtual environment'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""The load-testing run mode (v3.37.0): a script or suite repeated N times.
|
||||||
|
|
||||||
|
Successful iterations are discarded (only the count grows); the first error
|
||||||
|
keeps that iteration's steps/log as the report; a manual stop or a card session
|
||||||
|
change ends the loop. The workers are driven here with the runner mocked, so
|
||||||
|
the loop itself is what the tests pin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pysim_simple_server import server as srv
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeServer:
|
||||||
|
def __init__(self, session=7):
|
||||||
|
self.card_session = session
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_run():
|
||||||
|
srv._TEST_RUN.update({
|
||||||
|
'running': True, 'stop': False, 'name': 'load', 'status': None,
|
||||||
|
'session': 7, 'index': 0, 'total': 0, 'steps': [], 'preset': 'p',
|
||||||
|
'script_id': 's1', 'scp80_counter': None, 'scp80_counters': {},
|
||||||
|
'started': time.time(), 'finished': None, 'error': None,
|
||||||
|
'suite': None, 'log': [], 'log_prefix': '', 'adm': {'required': False},
|
||||||
|
'load': srv._test_load_state(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class LoadIterationsTests(unittest.TestCase):
|
||||||
|
def test_default_is_one(self):
|
||||||
|
self.assertEqual(srv._test_load_iterations({}), 1)
|
||||||
|
self.assertEqual(srv._test_load_iterations({'iterations': None}), 1)
|
||||||
|
self.assertEqual(srv._test_load_iterations({'iterations': ''}), 1)
|
||||||
|
|
||||||
|
def test_the_count_is_validated(self):
|
||||||
|
self.assertEqual(srv._test_load_iterations({'iterations': '5'}), 5)
|
||||||
|
self.assertEqual(srv._test_load_iterations({'iterations': 7}), 7)
|
||||||
|
for bad in (0, -1, 1.5, 'abc', srv._TEST_LOAD_MAX + 1, [2]):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
srv._test_load_iterations({'iterations': bad})
|
||||||
|
|
||||||
|
def test_the_state_flags_only_repeated_runs(self):
|
||||||
|
one = srv._test_load_state(1)
|
||||||
|
self.assertFalse(one['enabled'])
|
||||||
|
self.assertIsNone(one['iterations'])
|
||||||
|
many = srv._test_load_state(100)
|
||||||
|
self.assertTrue(many['enabled'])
|
||||||
|
self.assertEqual(many['iterations'], 100)
|
||||||
|
self.assertEqual(many['done'], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptLoadTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
_reset_run()
|
||||||
|
self._saved = copy.deepcopy(srv._TEST_RUN)
|
||||||
|
srv._TEST_RUNNING = True
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
srv._TEST_RUN.clear()
|
||||||
|
srv._TEST_RUN.update(self._saved)
|
||||||
|
srv._TEST_RUNNING = False
|
||||||
|
|
||||||
|
def _run(self, statuses, iterations, stop_after=None):
|
||||||
|
"""Drive the load worker with `statuses` per iteration; returns the
|
||||||
|
shared dicts passed to the runner and the run counter."""
|
||||||
|
calls = []
|
||||||
|
state = {'i': 0}
|
||||||
|
|
||||||
|
def fake_run(server, script, preset, shared=None):
|
||||||
|
i = state['i']
|
||||||
|
state['i'] += 1
|
||||||
|
calls.append(shared)
|
||||||
|
status = statuses[i] if i < len(statuses) else 'ok'
|
||||||
|
srv._TEST_RUN['steps'].append({'index': 0, 'status': status,
|
||||||
|
'label': 'step %d' % (i + 1)})
|
||||||
|
if status == 'error':
|
||||||
|
srv._TEST_RUN['steps'][-1]['note'] = 'boom'
|
||||||
|
if stop_after is not None and i + 1 >= stop_after:
|
||||||
|
srv._TEST_RUN['stop'] = True
|
||||||
|
return {}, status == 'stopped'
|
||||||
|
|
||||||
|
with mock.patch.object(srv, '_test_run_script', fake_run), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_script_worker(_FakeServer(),
|
||||||
|
{'name': 'S', 'steps': []}, None,
|
||||||
|
's1', iterations)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def test_successful_iterations_are_discarded(self):
|
||||||
|
calls = self._run(['ok'] * 5, 5)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(load['done'], 5)
|
||||||
|
self.assertEqual(load['iteration'], 5)
|
||||||
|
self.assertIsNone(load['failed_iteration'])
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'ok')
|
||||||
|
self.assertEqual(srv._TEST_RUN['steps'], []) # no tracked results
|
||||||
|
self.assertEqual(srv._TEST_RUN['log'], [])
|
||||||
|
self.assertFalse(srv._TEST_RUN['running'])
|
||||||
|
self.assertIsNotNone(load['elapsed_ms'])
|
||||||
|
self.assertIsNotNone(load['avg_ms'])
|
||||||
|
# the counter state is shared by every iteration
|
||||||
|
self.assertEqual(len(calls), 5)
|
||||||
|
for shared in calls[1:]:
|
||||||
|
self.assertIs(shared, calls[0])
|
||||||
|
|
||||||
|
def test_an_error_stops_the_loop_and_keeps_that_iteration(self):
|
||||||
|
self._run(['ok', 'ok', 'error', 'error', 'error'], 5)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(load['done'], 2)
|
||||||
|
self.assertEqual(load['iteration'], 3)
|
||||||
|
self.assertEqual(load['failed_iteration'], 3)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertEqual(srv._TEST_RUN['error'], 'iteration 3 failed')
|
||||||
|
self.assertEqual(len(srv._TEST_RUN['steps']), 1)
|
||||||
|
self.assertEqual(srv._TEST_RUN['steps'][0]['note'], 'boom')
|
||||||
|
self.assertFalse(srv._TEST_RUN['running'])
|
||||||
|
|
||||||
|
def test_a_warning_does_not_stop_the_loop(self):
|
||||||
|
self._run(['warning', 'ok', 'warning'], 3)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(load['done'], 3)
|
||||||
|
self.assertIsNone(load['failed_iteration'])
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'warning')
|
||||||
|
|
||||||
|
def test_a_manual_stop_ends_the_loop(self):
|
||||||
|
# the fake sets the stop flag after the second iteration
|
||||||
|
self._run(['ok', 'ok', 'ok'], 50, stop_after=2)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(load['done'], 2)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'stopped')
|
||||||
|
self.assertFalse(srv._TEST_RUN['running'])
|
||||||
|
|
||||||
|
def test_a_card_session_change_between_iterations_is_an_error(self):
|
||||||
|
calls = []
|
||||||
|
state = {'i': 0}
|
||||||
|
|
||||||
|
def fake_run(server, script, preset, shared=None):
|
||||||
|
state['i'] += 1
|
||||||
|
calls.append(1)
|
||||||
|
srv._TEST_RUN['steps'].append({'index': 0, 'status': 'ok'})
|
||||||
|
server.card_session = 99 # the card was swapped
|
||||||
|
return {}, False
|
||||||
|
|
||||||
|
with mock.patch.object(srv, '_test_run_script', fake_run), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
|
||||||
|
None, 's1', 10)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(load['failed_iteration'], 2)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertIn('card session changed', srv._TEST_RUN['error'])
|
||||||
|
|
||||||
|
|
||||||
|
class LoadRegressionTests(unittest.TestCase):
|
||||||
|
"""The review findings: a raised error, warning aggregation, the average
|
||||||
|
and the per-iteration clearing."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
_reset_run()
|
||||||
|
self._saved = copy.deepcopy(srv._TEST_RUN)
|
||||||
|
srv._TEST_RUNNING = True
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
srv._TEST_RUN.clear()
|
||||||
|
srv._TEST_RUN.update(self._saved)
|
||||||
|
srv._TEST_RUNNING = False
|
||||||
|
|
||||||
|
def test_a_raised_runner_error_finalises_the_run(self):
|
||||||
|
# the PWA would poll a dead run forever and Clear would stay refused
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise OSError('card vanished')
|
||||||
|
with mock.patch.object(srv, '_test_run_script', boom), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
|
||||||
|
None, 's1', 5)
|
||||||
|
self.assertFalse(srv._TEST_RUN['running'])
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertIn('card vanished', srv._TEST_RUN['error'])
|
||||||
|
self.assertEqual(srv._TEST_RUN['load']['failed_iteration'], 1)
|
||||||
|
self.assertIsNotNone(srv._TEST_RUN['load']['finished'])
|
||||||
|
|
||||||
|
def test_a_warning_stays_in_the_final_status(self):
|
||||||
|
statuses = iter(['warning', 'ok', 'ok'])
|
||||||
|
with mock.patch.object(srv, '_test_run_script',
|
||||||
|
lambda *a, **k: (srv._TEST_RUN['steps'].append(
|
||||||
|
{'status': next(statuses)}), ({}, False))[1]), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
|
||||||
|
None, 's1', 3)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'warning')
|
||||||
|
self.assertEqual(srv._TEST_RUN['load']['done'], 3)
|
||||||
|
|
||||||
|
def test_the_average_covers_the_failing_iteration(self):
|
||||||
|
statuses = iter(['ok', 'ok', 'error'])
|
||||||
|
def fake(*a, **k):
|
||||||
|
st = next(statuses)
|
||||||
|
srv._TEST_RUN['steps'].append({'status': st})
|
||||||
|
return {}, False
|
||||||
|
with mock.patch.object(srv, '_test_run_script', fake), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
|
||||||
|
None, 's1', 10)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(load['done'], 2)
|
||||||
|
self.assertIsNotNone(load['avg_ms'])
|
||||||
|
self.assertLessEqual(load['avg_ms'], load['elapsed_ms'])
|
||||||
|
|
||||||
|
def test_the_steps_are_cleared_before_each_iteration(self):
|
||||||
|
seen = []
|
||||||
|
def fake(*a, **k):
|
||||||
|
seen.append(len(srv._TEST_RUN['steps']))
|
||||||
|
srv._TEST_RUN['steps'].append({'status': 'ok'})
|
||||||
|
return {}, False
|
||||||
|
with mock.patch.object(srv, '_test_run_script', fake), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_script_worker(_FakeServer(), {'name': 'S', 'steps': []},
|
||||||
|
None, 's1', 4)
|
||||||
|
self.assertEqual(seen, [0, 0, 0, 0], 'every iteration starts empty')
|
||||||
|
|
||||||
|
|
||||||
|
class SuiteLoadTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
_reset_run()
|
||||||
|
self._saved = copy.deepcopy(srv._TEST_RUN)
|
||||||
|
srv._TEST_RUNNING = True
|
||||||
|
self.suite = {'id': 'suite1', 'name': 'S', 'require_adm': False,
|
||||||
|
'start_reset': False, 'scripts': []}
|
||||||
|
self.members = [({'script_id': 'a', 'role': 'member', 'on_fail': 'stop'},
|
||||||
|
{'name': 'A', 'steps': []})]
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
srv._TEST_RUN.clear()
|
||||||
|
srv._TEST_RUN.update(self._saved)
|
||||||
|
srv._TEST_RUNNING = False
|
||||||
|
|
||||||
|
def _run(self, statuses, iterations):
|
||||||
|
calls = []
|
||||||
|
counter = {'i': 0}
|
||||||
|
|
||||||
|
def fake_once(server, suite, members, preset, preset_id, state, shared=None):
|
||||||
|
i = counter['i']
|
||||||
|
counter['i'] += 1
|
||||||
|
calls.append(shared)
|
||||||
|
state['members'][0]['status'] = 'ok'
|
||||||
|
state['summary'] = {'ok': 1, 'warning': 0, 'error': 0, 'skipped': 0,
|
||||||
|
'stopped': False, 'wall_ms': 1}
|
||||||
|
return statuses[i] if i < len(statuses) else 'ok', False, False
|
||||||
|
|
||||||
|
with mock.patch.object(srv, '_test_suite_execute_once', fake_once), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_suite_worker(_FakeServer(), self.suite, self.members,
|
||||||
|
None, None, iterations)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def test_successful_iterations_are_discarded(self):
|
||||||
|
calls = self._run(['ok'] * 4, 4)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(load['done'], 4)
|
||||||
|
self.assertIsNone(srv._TEST_RUN['suite']) # no tracked reports
|
||||||
|
self.assertEqual(srv._TEST_RUN['steps'], [])
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'ok')
|
||||||
|
self.assertEqual(len(calls), 4)
|
||||||
|
self.assertIs(calls[0], calls[-1]) # one counter state
|
||||||
|
|
||||||
|
def test_an_error_keeps_the_failing_iteration_report(self):
|
||||||
|
self._run(['ok', 'error', 'error'], 3)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(load['done'], 1)
|
||||||
|
self.assertEqual(load['failed_iteration'], 2)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertIsNotNone(srv._TEST_RUN['suite'])
|
||||||
|
self.assertEqual(srv._TEST_RUN['suite']['name'], 'S')
|
||||||
|
|
||||||
|
def test_a_session_change_between_suite_iterations_is_an_error(self):
|
||||||
|
def fake_once(server, suite, members, preset, preset_id, state, shared=None):
|
||||||
|
return 'ok', False, True
|
||||||
|
with mock.patch.object(srv, '_test_suite_execute_once', fake_once), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_suite_worker(_FakeServer(), self.suite, self.members,
|
||||||
|
None, None, 5)
|
||||||
|
self.assertEqual(srv._TEST_RUN['load']['failed_iteration'], 1)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertIn('card session changed', srv._TEST_RUN['error'])
|
||||||
|
self.assertIsNotNone(srv._TEST_RUN['suite'])
|
||||||
|
|
||||||
|
def test_a_stopped_member_ends_the_suite_loop(self):
|
||||||
|
def fake_once(server, suite, members, preset, preset_id, state, shared=None):
|
||||||
|
return 'ok', True, False
|
||||||
|
with mock.patch.object(srv, '_test_suite_execute_once', fake_once), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_suite_worker(_FakeServer(), self.suite, self.members,
|
||||||
|
None, None, 5)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'stopped')
|
||||||
|
self.assertEqual(srv._TEST_RUN['load']['done'], 0)
|
||||||
|
|
||||||
|
def test_a_stopped_iteration_ends_the_loop(self):
|
||||||
|
self._run(['stopped'], 5)
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'stopped')
|
||||||
|
self.assertEqual(srv._TEST_RUN['load']['done'], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""The test-runner stuck-card watchdog (v3.37.0).
|
||||||
|
|
||||||
|
pyscard's SCardTransmit is blocking and has no timeout: a card that stops
|
||||||
|
answering (no SW, no driver error) leaves the runner inside a step holding
|
||||||
|
_CARD_LOCK, so Stop cannot take effect and a load test hangs in that iteration.
|
||||||
|
The watchdog notices the missing runner progress, fails the run, and
|
||||||
|
disconnects the PC/SC link - the pending transmit then raises and the normal
|
||||||
|
step-failure path finalises the run. The transport is marked stale so the next
|
||||||
|
equip/operation rebuilds it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pysim_simple_server import server as srv
|
||||||
|
from pysim_simple_server import testscript as T
|
||||||
|
|
||||||
|
|
||||||
|
def _warning_script():
|
||||||
|
return T.normalise_script({'name': 'stuck', 'steps': [
|
||||||
|
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1},
|
||||||
|
'check': {'sw': '9000'}, 'on_fail': 'warning'}]},
|
||||||
|
srv._test_command_type)
|
||||||
|
|
||||||
|
|
||||||
|
class _RaisingScc:
|
||||||
|
"""send_apdu raises like a disconnected PC/SC handle after the abort."""
|
||||||
|
cat_cla = '80'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tp = self
|
||||||
|
|
||||||
|
def send_apdu(self, apdu):
|
||||||
|
raise RuntimeError('SCARD_E_INVALID_HANDLE')
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeServer:
|
||||||
|
def __init__(self, scc=None, session=7):
|
||||||
|
self.scc = scc
|
||||||
|
self.card_session = session
|
||||||
|
self.card_present = True
|
||||||
|
self.sms_oa = '12345'
|
||||||
|
self.sms_sc = '12345678912'
|
||||||
|
self.stk_pending = None
|
||||||
|
self.menu_active = False
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCon:
|
||||||
|
def __init__(self, fail=False):
|
||||||
|
self.calls = 0
|
||||||
|
self.fail = fail
|
||||||
|
self.done = threading.Event()
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.calls += 1
|
||||||
|
try:
|
||||||
|
if self.fail:
|
||||||
|
raise RuntimeError('dead handle')
|
||||||
|
finally:
|
||||||
|
self.done.set()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScc:
|
||||||
|
def __init__(self, con):
|
||||||
|
self._tp = type('T', (), {'_con': con})()
|
||||||
|
|
||||||
|
|
||||||
|
class _WatchdogTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.saved_run = copy.deepcopy(srv._TEST_RUN)
|
||||||
|
self.saved_ref = srv._server_ref
|
||||||
|
self.saved_stale = srv._TRANSPORT_STALE
|
||||||
|
self.saved_aborted = srv._TEST_WATCHDOG_ABORTED
|
||||||
|
srv._TEST_WATCHDOG_ABORTED = None
|
||||||
|
srv._TRANSPORT_STALE = False
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
srv._TEST_RUN.clear()
|
||||||
|
srv._TEST_RUN.update(self.saved_run)
|
||||||
|
srv._server_ref = self.saved_ref
|
||||||
|
srv._TRANSPORT_STALE = self.saved_stale
|
||||||
|
srv._TEST_WATCHDOG_ABORTED = self.saved_aborted
|
||||||
|
|
||||||
|
def _run(self, **fields):
|
||||||
|
srv._TEST_RUN.update({
|
||||||
|
'running': True, 'stop': False, 'status': None, 'error': None,
|
||||||
|
'stuck': False, 'heartbeat': None, 'steps': [], 'index': 0,
|
||||||
|
})
|
||||||
|
srv._TEST_RUN.update(fields)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _step(started, index=0, status='running'):
|
||||||
|
return {'index': index, 'status': status, 'started': started}
|
||||||
|
|
||||||
|
|
||||||
|
class WatchdogCheckTests(_WatchdogTestCase):
|
||||||
|
def test_an_idle_runner_is_not_watched(self):
|
||||||
|
now = time.time()
|
||||||
|
self._run(running=False, heartbeat=now - 100,
|
||||||
|
steps=[self._step(now - 100)])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
self.assertIsNone(srv._test_watchdog_check(now))
|
||||||
|
abort.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_step_within_the_timeout_is_ignored(self):
|
||||||
|
now = time.time()
|
||||||
|
started = now - srv._TEST_STEP_TIMEOUT + 5
|
||||||
|
self._run(heartbeat=started, steps=[self._step(started)])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
self.assertIsNone(srv._test_watchdog_check(now))
|
||||||
|
abort.assert_not_called()
|
||||||
|
|
||||||
|
def test_finished_work_with_fresh_progress_is_ignored(self):
|
||||||
|
now = time.time()
|
||||||
|
self._run(heartbeat=now - 1,
|
||||||
|
steps=[self._step(now - 100, status='ok')])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
self.assertIsNone(srv._test_watchdog_check(now))
|
||||||
|
abort.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_stale_heartbeat_between_steps_still_fires(self):
|
||||||
|
# the bounded drains run with no step entry "running": the heartbeat
|
||||||
|
# is the authority, so a hang there is caught too
|
||||||
|
now = time.time()
|
||||||
|
self._run(heartbeat=now - 100,
|
||||||
|
steps=[self._step(now - 100, index=4, status='ok')])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
msg = srv._test_watchdog_check(now)
|
||||||
|
self.assertIn('step 5', msg)
|
||||||
|
abort.assert_called_once()
|
||||||
|
|
||||||
|
def test_a_stuck_step_fails_the_run_and_aborts_the_link(self):
|
||||||
|
now = time.time()
|
||||||
|
started = now - srv._TEST_STEP_TIMEOUT - 2
|
||||||
|
self._run(heartbeat=started, steps=[self._step(started)])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
msg = srv._test_watchdog_check(now)
|
||||||
|
self.assertIn('the card stopped responding', msg)
|
||||||
|
self.assertIn('step 1', msg)
|
||||||
|
self.assertIn('no answer for', msg)
|
||||||
|
self.assertTrue(srv._TEST_RUN['stuck'])
|
||||||
|
self.assertTrue(srv._TEST_RUN['stop'])
|
||||||
|
self.assertEqual(srv._TEST_RUN['error'], msg)
|
||||||
|
abort.assert_called_once()
|
||||||
|
|
||||||
|
def test_the_abort_happens_once_per_heartbeat(self):
|
||||||
|
now = time.time()
|
||||||
|
started = now - 100
|
||||||
|
self._run(heartbeat=started, steps=[self._step(started)])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
self.assertIsNotNone(srv._test_watchdog_check(now))
|
||||||
|
self.assertIsNone(srv._test_watchdog_check(now + 1))
|
||||||
|
self.assertIsNone(srv._test_watchdog_check(now + 2))
|
||||||
|
abort.assert_called_once()
|
||||||
|
|
||||||
|
def test_new_progress_rearms_the_watchdog(self):
|
||||||
|
now = time.time()
|
||||||
|
started = now - 100
|
||||||
|
self._run(heartbeat=started, steps=[self._step(started)])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
self.assertIsNotNone(srv._test_watchdog_check(now))
|
||||||
|
# the runner made progress (a new beat) and then hung again
|
||||||
|
srv._TEST_RUN['steps'].append(self._step(started + 1, index=1))
|
||||||
|
srv._TEST_RUN['heartbeat'] = started + 1
|
||||||
|
self.assertIsNotNone(srv._test_watchdog_check(now))
|
||||||
|
self.assertEqual(abort.call_count, 2)
|
||||||
|
|
||||||
|
def test_a_heartbeat_keeps_a_long_poll_loop_alive(self):
|
||||||
|
# a bounded STATUS/drain loop may legitimately run longer than one
|
||||||
|
# exchange: its per-attempt heartbeat must keep the watchdog quiet
|
||||||
|
now = time.time()
|
||||||
|
self._run(heartbeat=now - 1, steps=[self._step(now - 120)])
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
self.assertIsNone(srv._test_watchdog_check(now))
|
||||||
|
abort.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TransportAbortTests(_WatchdogTestCase):
|
||||||
|
def _wait_stale(self):
|
||||||
|
for _ in range(200):
|
||||||
|
if srv._TRANSPORT_STALE:
|
||||||
|
return True
|
||||||
|
time.sleep(0.01)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_the_connection_is_disconnected_and_marked_stale(self):
|
||||||
|
# the disconnect is attempted on a background thread (a synchronous
|
||||||
|
# one would block behind the pending transmit on pcsc-lite)
|
||||||
|
con = _FakeCon()
|
||||||
|
srv._server_ref = _FakeServer(_FakeScc(con))
|
||||||
|
self.assertTrue(srv._abort_card_transport())
|
||||||
|
self.assertTrue(con.done.wait(2))
|
||||||
|
self.assertEqual(con.calls, 1)
|
||||||
|
self.assertTrue(self._wait_stale())
|
||||||
|
|
||||||
|
def test_a_missing_connection_is_not_fatal(self):
|
||||||
|
srv._server_ref = _FakeServer(None)
|
||||||
|
self.assertFalse(srv._abort_card_transport())
|
||||||
|
self.assertTrue(srv._TRANSPORT_STALE)
|
||||||
|
|
||||||
|
def test_a_failing_disconnect_is_swallowed(self):
|
||||||
|
con = _FakeCon(fail=True)
|
||||||
|
srv._server_ref = _FakeServer(_FakeScc(con))
|
||||||
|
self.assertTrue(srv._abort_card_transport())
|
||||||
|
self.assertTrue(con.done.wait(2))
|
||||||
|
self.assertEqual(con.calls, 1)
|
||||||
|
self.assertTrue(self._wait_stale())
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatTests(_WatchdogTestCase):
|
||||||
|
def test_a_finished_step_beats(self):
|
||||||
|
# between steps the heartbeat must stay fresh (the drains run with no
|
||||||
|
# step entry "running")
|
||||||
|
self._run(heartbeat=0)
|
||||||
|
entry = {'index': 0, 'started': time.time() - 5}
|
||||||
|
before = time.time()
|
||||||
|
srv._test_finish_entry(entry, {'status': 'ok'})
|
||||||
|
self.assertGreaterEqual(srv._TEST_RUN['heartbeat'], before)
|
||||||
|
|
||||||
|
|
||||||
|
class StuckStatusTests(_WatchdogTestCase):
|
||||||
|
def test_a_stuck_member_is_an_error_not_a_stop(self):
|
||||||
|
self.assertEqual(
|
||||||
|
srv._test_member_status([{'status': 'warning'}], True, stuck=True),
|
||||||
|
'error')
|
||||||
|
self.assertEqual(
|
||||||
|
srv._test_member_status([{'status': 'warning'}], True),
|
||||||
|
'stopped')
|
||||||
|
|
||||||
|
def test_a_stuck_run_finalises_as_error(self):
|
||||||
|
script = _warning_script()
|
||||||
|
self._run(stuck=True, heartbeat=time.time(), stop=True,
|
||||||
|
total=1, name='stuck')
|
||||||
|
srv._test_run_execute(_FakeServer(_RaisingScc()), script, {})
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertFalse(srv._TEST_RUN['running'])
|
||||||
|
|
||||||
|
def test_the_control_warning_run_without_the_stuck_flag(self):
|
||||||
|
# the same raising warning step is only a warning when the watchdog
|
||||||
|
# did not mark the run: the stuck flag is what changes the verdict
|
||||||
|
script = _warning_script()
|
||||||
|
self._run(stuck=False, heartbeat=time.time(), total=1, name='stuck')
|
||||||
|
srv._test_run_execute(_FakeServer(_RaisingScc()), script, {})
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'warning')
|
||||||
|
|
||||||
|
def test_a_blocked_runner_step_ends_the_run_as_error(self):
|
||||||
|
# end to end: a step blocked inside send_apdu is flagged by the
|
||||||
|
# watchdog; when the call returns (the driver's timeout / the abort)
|
||||||
|
# the ordinary failure path finalises the run as an error even though
|
||||||
|
# the step was a warning one
|
||||||
|
entered = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
|
class _BlockingScc:
|
||||||
|
cat_cla = '80'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tp = self
|
||||||
|
|
||||||
|
def send_apdu(self, apdu):
|
||||||
|
entered.set()
|
||||||
|
release.wait(5)
|
||||||
|
raise RuntimeError('SCARD_E_NOT_TRANSACTED')
|
||||||
|
|
||||||
|
script = _warning_script()
|
||||||
|
self._run(total=1, name='stuck')
|
||||||
|
saved_timeout = srv._TEST_STEP_TIMEOUT
|
||||||
|
srv._TEST_STEP_TIMEOUT = 0.2
|
||||||
|
try:
|
||||||
|
runner = threading.Thread(
|
||||||
|
target=srv._test_run_execute,
|
||||||
|
args=(_FakeServer(_BlockingScc()), script, {}))
|
||||||
|
runner.start()
|
||||||
|
self.assertTrue(entered.wait(2))
|
||||||
|
time.sleep(0.3)
|
||||||
|
with mock.patch.object(srv, '_abort_card_transport') as abort:
|
||||||
|
msg = srv._test_watchdog_check()
|
||||||
|
self.assertIn('stopped responding', msg)
|
||||||
|
abort.assert_called_once()
|
||||||
|
release.set()
|
||||||
|
runner.join(5)
|
||||||
|
finally:
|
||||||
|
srv._TEST_STEP_TIMEOUT = saved_timeout
|
||||||
|
self.assertFalse(runner.is_alive())
|
||||||
|
self.assertTrue(srv._TEST_RUN['stuck'])
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertFalse(srv._TEST_RUN['running'])
|
||||||
|
|
||||||
|
def test_a_stuck_load_iteration_is_a_failure(self):
|
||||||
|
def fake_run(server, script, preset, shared=None):
|
||||||
|
srv._TEST_RUN['steps'].append(
|
||||||
|
{'index': 0, 'status': 'warning', 'label': 's'})
|
||||||
|
srv._TEST_RUN['stuck'] = True
|
||||||
|
srv._TEST_RUN['error'] = ('the card stopped responding '
|
||||||
|
'(step 1, no answer for 31 s)')
|
||||||
|
return {}, True
|
||||||
|
|
||||||
|
self._run(load=srv._test_load_state(3), name='load')
|
||||||
|
srv._TEST_RUN['load'].update({'iteration': 1})
|
||||||
|
with mock.patch.object(srv, '_test_run_script', fake_run), \
|
||||||
|
mock.patch.object(srv, '_sim_resume_poll'):
|
||||||
|
srv._test_load_script_worker(_FakeServer(), {'name': 'S',
|
||||||
|
'steps': []}, None,
|
||||||
|
's1', 3)
|
||||||
|
load = srv._TEST_RUN['load']
|
||||||
|
self.assertEqual(srv._TEST_RUN['status'], 'error')
|
||||||
|
self.assertEqual(load['failed_iteration'], 1)
|
||||||
|
self.assertEqual(load['done'], 0)
|
||||||
|
self.assertIn('stopped responding', srv._TEST_RUN['error'])
|
||||||
|
self.assertFalse(srv._TEST_RUN['running'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -72,6 +72,60 @@ class TestUiccFilesAsset(unittest.TestCase):
|
|||||||
self.assertRegex(f['fid'], r'^[0-9A-F]{4}$')
|
self.assertRegex(f['fid'], r'^[0-9A-F]{4}$')
|
||||||
self.assertTrue(f['name'].startswith(('EF.', 'DF.')))
|
self.assertTrue(f['name'].startswith(('EF.', 'DF.')))
|
||||||
|
|
||||||
|
def test_optional_files_missing_from_the_pysim_model(self):
|
||||||
|
"""The service-gated files pySim does not model (the SUPPLEMENT)."""
|
||||||
|
files = _load()['files']
|
||||||
|
by_path = {f['path']: f for f in files}
|
||||||
|
expected = {
|
||||||
|
# TS 31.102 4.2.86 / 4.2.100 / 4.2.102 / 4.2.108 / 4.2.109
|
||||||
|
'ADF.USIM/6FDC': 'EF.LRPLMNSI',
|
||||||
|
'ADF.USIM/6FF0': 'EF.IAL',
|
||||||
|
'ADF.USIM/6FF2': 'EF.IPD',
|
||||||
|
'ADF.USIM/6FF9': 'EF.3GPPPSDATAOFF',
|
||||||
|
'ADF.USIM/6FFB': 'EF.TVCONFIG',
|
||||||
|
# 4.4.4 DF.MExE
|
||||||
|
'ADF.USIM/5F3C': 'DF.MExE',
|
||||||
|
'ADF.USIM/5F3C/4F40': 'EF.MExE-ST',
|
||||||
|
'ADF.USIM/5F3C/4F41': 'EF.ORPK',
|
||||||
|
'ADF.USIM/5F3C/4F42': 'EF.ARPK',
|
||||||
|
'ADF.USIM/5F3C/4F43': 'EF.TPRPK',
|
||||||
|
# 4.4.1 DF.SoLSA
|
||||||
|
'ADF.USIM/5F70': 'DF.SoLSA',
|
||||||
|
'ADF.USIM/5F70/4F30': 'EF.SAI',
|
||||||
|
'ADF.USIM/5F70/4F31': 'EF.SLL',
|
||||||
|
# 4.4.7 / 4.4.9 / 4.4.10
|
||||||
|
'ADF.USIM/5F80': 'DF.BCAST',
|
||||||
|
'ADF.USIM/5FA0': 'DF.ACDC',
|
||||||
|
'ADF.USIM/5FA0/4F01': 'EF.ACDC_LIST',
|
||||||
|
'ADF.USIM/5FB0': 'DF.TV',
|
||||||
|
# 4.6.6 DF.A2X (DF.TELECOM)
|
||||||
|
'MF/7F10/5F3F': 'DF.A2X',
|
||||||
|
'MF/7F10/5F3F/4F01': 'EF.AST',
|
||||||
|
'MF/7F10/5F3F/4F02': 'EF.A2X_CONFIG',
|
||||||
|
}
|
||||||
|
for path, name in expected.items():
|
||||||
|
self.assertIn(path, by_path)
|
||||||
|
self.assertEqual(by_path[path]['name'], name)
|
||||||
|
self.assertEqual(by_path[path]['root'], path.split('/')[0])
|
||||||
|
self.assertEqual(by_path[path]['kind'],
|
||||||
|
'df' if name.startswith('DF.') else 'ef')
|
||||||
|
# The same FID under another DF stays a distinct entry (name, path).
|
||||||
|
self.assertEqual(by_path['ADF.USIM/5F3A/4F30']['name'], 'EF.PBR')
|
||||||
|
self.assertEqual(by_path['MF/7F10/5F3A/4F30']['name'], 'EF.PBR')
|
||||||
|
# The ADF.USIM files are mirrored under the non-IMSI USIM root.
|
||||||
|
for path, name in expected.items():
|
||||||
|
if not path.startswith('ADF.USIM/'):
|
||||||
|
continue
|
||||||
|
mirror = 'ADF.USIM-NON-IMSI' + path[len('ADF.USIM'):]
|
||||||
|
self.assertIn(mirror, by_path)
|
||||||
|
self.assertEqual(by_path[mirror]['name'], name)
|
||||||
|
self.assertEqual(by_path[mirror]['root'], 'ADF.USIM-NON-IMSI')
|
||||||
|
# (name, root) resolves each SUPPLEMENT file to exactly one path.
|
||||||
|
for path, name in expected.items():
|
||||||
|
root = path.split('/')[0]
|
||||||
|
self.assertEqual([f['path'] for f in files
|
||||||
|
if f['name'] == name and f['root'] == root], [path])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user