35 Commits
Author SHA1 Message Date
catarrh 215d0a0ba0 frontend: fix equipped-card preset auto-selection race
release / release (push) Waiting to run
cardsAutoSelectByIccid latched its 'already handled' guard even when no
matching preset was found, so a card equipped before /api/presets had
loaded (the connect-time race with the status/availability polls) never
got auto-selected: navigating to SCP80/RAM showed no preset until the card
was re-inserted.  The guard is now latched only after a successful
selection, and cardsFetch re-runs the auto-selection once the store
arrives, so the card's preset is picked as soon as both resources are
available.

Bumps VERSION to 3.40.3 and the SW cache to simple-v488.
2026-10-27 18:58:18 +03:00
catarrh 8f63368008 docs: move Prerequisites before Quick start
Prerequisites is step 0 for the install, so it now leads the docs as a
top-level section ahead of Quick start (removed from the Server section).
2026-10-27 14:56:32 +03:00
catarrh 29d762a662 docs: quick start guides only the server install
Drop the 'Frontend only (client-side tools)' alternative — the quick
start is the default path and that installs the server.
2026-10-27 14:53:10 +03:00
catarrh eeea7ca509 docs: drop the SMPP bridge mentions (not installed, not used) 2026-10-27 14:50:58 +03:00
catarrh ad4289eabe docs: clarify the Windows prerequisite, drop the redundant PSK-TLS note
The Windows bullet now states the pyscard wheel situation plainly (3.13
= wheels, no compile; 3.14 = build from source) without repeating the
SCP81 TLS-PSK 3.13+ requirement already given in the Python bullet.
2026-10-27 14:50:03 +03:00
catarrh 04859051d3 docs: say 'frontend' instead of PWA where the app's PWA-ness is irrelevant
PWA is kept only where it matters: the first occurrence (unpacked as
'Progressive Web App') and the '## PWA' install/offline section.  The
RU text declines 'фронтенд' correctly.
2026-10-27 11:08:08 +03:00
catarrh 2154912d2b docs: drop the CORS mention from Quick start
The UI and API are served from one origin by default, so CORS is a
non-issue — the quick start no longer calls it out.  Not re-added
elsewhere (kept unobtrusive).
2026-10-27 11:01:05 +03:00
catarrh 6750f72cd5 card reader: move Services pill next to File manager 2026-10-26 22:49:47 +03:00
catarrh 465a43eef2 docs: link the project website alongside the Telegram channel
release / release (push) Waiting to run
2026-10-26 22:11:38 +03:00
catarrh fdbff77188 release: 3.40.2 — file-filter reset (✕) button
A red ✕ next to the filter selector appears whenever the filter is not
"All" and resets it to "All" in one click (pysimFsFilterClear).  Bump
VERSION/pyproject/PWA header to 3.40.2 and the SW cache to simple-v487.
2026-10-26 21:01:29 +03:00
catarrh 1f8fa54c49 release: 3.40.1 — filter uses per-spec service names
release / release (push) Waiting to run
Drop the invented "SMS-related" / "CB-related" filter categories: those
files are service-declared and are already reached through their
per-service options (per-spec service names).  Bump VERSION/pyproject/
PWA header to 3.40.1 and the SW cache to simple-v486.
2026-10-26 19:41:32 +03:00
catarrh 98926833f7 release: 3.40.0 — file-tree filter
release / release (push) Canceled after 0s
Bump VERSION/pyproject/PWA header to 3.40.0 and the SW cache to
simple-v485 (index.html changed).  CHANGELOG [3.40.0] documents the
file-tree filter (checkbox multiselect, path-exact DF hiding, expand/
collapse all, auto-expand, and the "To EF filter" cross-link).
2026-10-26 15:20:08 +03:00
catarrh 4fd2efe77c file manager: style expand/collapse as buttons like the All filter
Give Expand all / Collapse all the same bordered button style as the
filter button in its default 'All' state.  Frontend 1163 tests green.
2026-10-26 15:10:22 +03:00
catarrh 4a4232de5c file manager: expand/collapse all + auto-expand on filter
- "Expand all" / "Collapse all" links in the sort row: expand-all walks
  the directory tree loading children on demand (skipping DFs the probe
  found absent), collapse-all resets every directory.
- Selecting any filter but "All" auto-expands the tree so the matched
  files are visible (pysimFsAutoExpandForFilter, wired into the two
  filter setters).
- i18n EN/RU for the two links.

Frontend 1163 tests green (+2).  (Version/sw bump still deferred.)
2026-10-26 15:05:49 +03:00
catarrh cc7c23a9c7 file manager: blue filter button when a filter is active
A non-'All' selection turns the collapsed filter button blue (like the
active sort pill) so it stands out; 'All' keeps the default style.
Frontend 1161 tests green.
2026-10-26 14:53:36 +03:00
catarrh 8fdf1ddff9 file manager: hide directories outside the filter's file paths
When a filter is active (not "All"), only the matching files, their
ancestor directories and the MF root stay; every other directory is
hidden.  The ancestors come from the standard file list (name -> paths):
pysimFsFilterMatchingPaths unions the registry paths of every matching
name, and pysimFsFilterShow keeps a directory iff it matches itself
(e.g. DF.GSM for svc:GPRS) or its canonical path prefixes a matching
path.  Custom files contribute their own paths; the not-loaded window
keeps directories rather than hiding everything.

Frontend 1160 tests green (+8).  (Version/sw bump still deferred.)
2026-10-26 14:49:44 +03:00
catarrh e2d5d720f7 file manager: move the filter to its own row under the sort pills
Filter names (the per-service options) are long, so the label + dropdown
now live on a dedicated row below Sort.  Frontend 1152 tests green.
2026-10-26 14:37:37 +03:00
catarrh 627198e57b service editors: compact 'To EF filter' glyph with tooltip
The per-service cross-link is now a single compact glyph (U+25BE) with
'To EF filter' as the title/aria-label, instead of a text button that
shifted the grid.  Frontend 1152 tests green.
2026-10-26 14:17:18 +03:00
catarrh 77fbed80ce service editors: "To EF filter" per-service cross-link
In the SST/UST/IST/EST decoded editors, each service row backed by files
gains a small "To EF filter" action that sets the file-tree filter to
exactly that service's option:

- The four service-table fields carry a `table` key (sst/ust/est/ist);
  servicesFeatureByNumber() inverts SERVICES_CROSS_REF into per-table
  number->feature maps (lazy, cached).
- pysimFsFilterBtnHtml(table, n) renders the button only for file-backed
  features; pysimFsSetFilterToService(table, n) sets pysimFsFilter to
  ['svc:<feature>'], persists, and re-renders the dropdown + tree.
- i18n: "To EF filter" (EN/RU).  No CSS rebuild needed.

Frontend 1152 tests green (+6).  (Version/sw bump still deferred.)
2026-10-26 14:06:06 +03:00
catarrh d1067e3e72 file manager: filter as a collapsed checkbox dropdown
Replace the <select multiple size=4> filter with a single-row button that
expands into a scrollable checkbox menu (All + Custom files, then the
Identifiers/PLMN/Services headings with the categories and per-service
options), mirroring simtrace-analyser's command filter.

- pysimFsFilterSummary/Label, pysimFsFilterToggle, pysimFsFilterSetEntry
  (all-exclusive, empty -> all) and a rewritten pysimFsFilterRender;
  pysimFsSetFilter is gone.
- Closes on outside click and Escape.  i18n: "Selected: {n}".
- No CSS rebuild needed (swapped to utility classes already in the
  prebuilt style.css).

Frontend 1146 tests green.  (Version/sw bump still deferred.)
2026-10-26 13:55:48 +03:00
catarrh 01e50d841f file manager: implement the tree filter (categories + per-service)
Fill in the scaffolded file-tree filter:

- FS_FILTER_GROUPS: Identifiers, PLMN selection, Provider names,
  WLAN/I-WLAN, SMS-related and CB-related (cell broadcast), each with
  its exact file list; plus one option per file-backed feature of
  SERVICES_FS_MAP (svc:<feature>).
- "All" is the default and exclusive; a top-level "Custom files"
  option matches the injected custom-file nodes by their flag.
- fsFilterIndexBuild inverts name -> criterion set; pysimFsFilterMatch
  matches by name (and custom by flag); pysimFsFilterShow keeps a
  directory while it has a matching (loaded) descendant, and keeps
  never-expanded directories.  The dropdown renders All / Custom files
  and three optgroups (Identifiers / PLMN / Services).
- i18n EN/RU for the new labels.

Frontend 1145 tests green (fs_filter.test.js 19).
2026-10-26 13:34:54 +03:00
catarrh 3878156646 file manager: scaffold the tree filter (multiselect)
Add a Filter multiselect next to the sort pills in the file tree toolbar.
The option list is large and undecided, so FS_FILTER_OPTIONS is empty and
the per-node match is a no-op for now; the mechanism is wired end to end:

- FS_FILTER_OPTIONS const (empty), pysimFsFilter state persisted to
  simple_fs_filter, pysimFsSetFilter / pysimFsFilterMatch /
  pysimFsFilterRender, and the render loop consults pysimFsFilterMatch.
- The dropdown re-renders on language switch; "Filter:" and
  "(no filters yet)" are translated (EN/RU).

Frontend 1132 tests green (6 new scaffold pins).
2026-10-26 13:03:22 +03:00
catarrh 105efe62da release: 3.39.0 — decoded-editing batch
release / release (push) Canceled after 0s
Bump VERSION/pyproject/PWA header to 3.39.0 and the SW cache to
simple-v484 (index.html + help changed).  CHANGELOG [3.39.0] folds the
unreleased 3.38.2 start-script fix and documents the decoded-editing
batch: DF-context resolution, EF.ECC/EF.AD split, path-based selection,
EF.SST tri-state editor, the Services editor + path-exact FS check, and
the UST 147-150 completion.
2026-10-26 00:11:26 +03:00
catarrh 4835bc8585 Services editor: throbber while the filesystem check runs
Show the established animate-spin throbber next to the Check filesystem
button while the scan (tree load + probe-all + report) is in flight, and
hide it when it settles.  Frontend 1126 tests green.
2026-10-26 00:07:54 +03:00
catarrh b173e73437 Services editor: path-exact filesystem check
Rework the FS check from FID-level to path-exact:

- SERVICES_FS_MAP now maps a feature to symbolic roles (registry names),
  not FIDs; the (name, root) -> path index resolves each role to its
  unambiguous canonical path (same-FID files in other DFs - EF.SAI vs
  EF.PBR, EF.AST - resolve separately).
- Per-tree declaration: SST -> MF, UST/EST -> ADF.USIM, IST -> ADF.ISIM,
  with a DF.TELECOM fallback for USIM/ISIM roles whose DFs live under
  DF.TELECOM (MMS storage, MCPTT, V2X, A2X), except the GSM-application
  mapped copies (ADN/EXT1/ECCP).
- The report carries the path (and declaring tree) for missing and
  present-but-undeclared, plus an honest "unchecked" bucket for roles
  with no path yet (DF.GRAPHICS/IMG, SIM DF.SoLSA/MExE copies,
  EF.3GPPPSDATAOFFservicelist).
- efDfFromPath maps A000000087100B -> ADF.USIM-NON-IMSI.

Frontend 1126 + Python 1091 tests green.  (Version/sw bump still
deferred for the decoded-editing batch.)
2026-10-26 00:01:23 +03:00
catarrh a1892094f4 uicc_files: supplement optional files missing from pySim's model
Add a supplement to the cardless registry generator for the 13 files the
Services editor's FS check references but pySim does not emit (optional,
service-gated): EF.LRPLMNSI/IAL/IPD/3GPPPSDATAOFF/TVCONFIG, DF.MExE +
EF.MExE-ST/ORPK/ARPK/TPRPK, DF.SoLSA + EF.SAI/EF.SLL, DF.BCAST,
DF.ACDC + EF.ACDC_LIST, DF.TV, and DF.A2X + EF.AST/EF.A2X_CONFIG (under
DF.TELECOM).  Emitted with distinct (name, path) so same-FID files in
other DFs (EF.SAI vs EF.PBR, EF.AST) stay separate; ADF.USIM rows are
mirrored to ADF.USIM-NON-IMSI.  490 -> 527 entries.

Regenerated the asset; test_uicc_files.py pins the 20 new paths, the
FID-collision cases and (name, root) uniqueness.  1091 Python tests.
2026-10-26 00:01:23 +03:00
catarrh 12ff552df8 Services editor: filesystem check
Add a "Check filesystem" action to the Services pill:

- SERVICES_FS_MAP: the feature -> FID list transcribed from
  docs/services-editor.md §A (127 file-backed + 34 capability features,
  157 distinct FIDs; a completeness/data test pins it).
- servicesFsReport: pure two-direction check - declared features whose
  mapped files are absent, and present files whose features are
  undeclared.  FID-level (not DF-exact) for this first pass; documented.
- The Check runs the tree probe (once per session) and renders the
  missing/extra lists or the "matches" line, with an FID-level note.

Also fix a doc typo (eCall Data's EF.FDN was written as 6F3A = EF.ADN).
Frontend 1120 + Python 1090 tests green.  (Version/sw bump still
deferred for the decoded-editing batch.)
2026-10-25 22:22:33 +03:00
catarrh e10228c37c Services editor (core): cross-file service tables
Add a Services pill to the Card reader tab that shows the card's service
tables side by side:

- SERVICES_CROSS_REF: the 161-row feature cross-reference (SST/UST/EST/IST
  numbers + class) transcribed from docs/services-editor.md §A; a
  completeness test pins every non-RFU service number to exactly one row.
- Reads the four files (DF.GSM/6F38, ADF.USIM/6F38, ADF.USIM/6F56,
  ADF.ISIM/6F07) via /api/read, cached per card session; a column renders
  only for a present file (no ISIM -> no IST column).
- SST cell is on/allocated-off only (never de-allocates); UST/EST/IST are
  checkboxes; per-file Save buttons write via /api/write, preserving
  unmodelled bits and reporting each result.

Frontend 1105 + Python 1090 tests green.  (Version/sw bump deferred;
FS-check is the next step.)
2026-10-25 22:06:31 +03:00
catarrh fc27feba8e EF.SST: tri-state editor (deleted / allocated-off / on)
Give EF.SST its own per-file editor for the 2-bit SIM service table
(TS 51.011 10.3.7: allocated + activated per service):

- efEncSst encodes the pair layout, preserving unmodelled pairs and the
  file length; validates service number, fit and state.
- A new `bits2` field kind renders a three-state select per service and
  collects {n, name, allocated, activated}; registered as EF_SST_FIELDS
  with the encoder (df '7F20'), and SST leaves EF_READONLY_NAMES.
- i18n: Deleted/Off/On + the state-error and hint strings (EN/RU).

Docs/help updated: SST is no longer read-only.  Frontend 1093 + Python
1090 tests green.  (Version/sw bump still deferred for the unreleased
decoded-editing batch.)
2026-10-25 21:43:07 +03:00
catarrh 5ebcd0773d file manager: path-based selection + ADF AID normalization
Make the DF context from the resolver actually reach the live views:

- pysimFsSelected now stores the canonical path (pysimFsNodePath), not
  the node name; pysimFsRenderNode emits the path in the click/toggle
  handlers; every consumer resolves via pysimFsFindNodeByPath.  The
  name-based pysimFsFindNode is removed - two same-named files under
  different DFs (EF.ECC/EF.AD, DF.PHONEBOOK copies) are now distinct.
- efDfFromPath maps ADF-rooted parent segments by RID (A0000000871002
  -> ADF.USIM, A0000000871004 -> ADF.ISIM), so snapshot/profile paths
  rooted by AID resolve to the same DF key as the named form.
- efDecEcc shares efEccCategory with efDecEccUsim (TS 24.008
  Table 10.5.135d), so the SIM and USIM category displays can't drift.

Frontend 1087 + Python 1090 tests green.  (Version/sw bump deferred -
  this is part of the unreleased decoded-editing batch.)
2026-10-25 21:01:11 +03:00
catarrh 8e0a97f025 EF resolver: resolve by (DF, FID); split EF.ECC and EF.AD per DF
The decoder/encoder lookup now takes an optional DF context and resolves
DF-first, then name, then FID (the spec keys files by FID within their
DF; the name is a display label).  efDfFromPath derives the parent DF
segment of a canonical path.

EF.ECC is split into its two DF-specific decoders: DF.GSM (7F20)
transparent 3n (existing) and ADF.USIM linear-fixed X+4 - the new
efDecEccUsim reads the 3-byte Emergency Call Code, the Annex A alpha and
the Emergency Service Category (decoded per TS 24.008 Table 10.5.135d).
EF.AD is registered as DF-keyed entries (7F20 / ADF.USIM / ADF.ISIM),
reusing the existing encoder.

The df is threaded through the file-manager decoded-view/edit/save and
the snapshot/compare decode.  No ECC editor yet (read-only).

Frontend 1081 + Python 1090 tests green.
2026-10-25 12:40:51 +03:00
catarrh 6d762b9d9c ust: complete the USIM service table (147-150) + Services editor data fixes
The PWA's EF_UST_SERVICES stopped at 146; TS 31.102 §4.2.8 pins 147-150
(5MBS UE pre-configuration, Operator Controlled Signal Threshold per
Access Technology, A2X, IMS Data Channel Indication).  Add them and fix
the two test fixtures that assumed the 146-service table.

Resolve the three Services-editor FS mappings against the spec:
- eCall Data (89) -> EF.SDN 6F49 (+ EF.FDN 6F3A)
- eCall Data over IMS (112) -> EF.SDNURI 6FEF (+ EF.FDNURI 6FED)
- EARFCN list for MTC/NB-IOT (121) -> EF.EARFCNList 6FFD; "Extended
  EARFCN support" (100) is a capability, not a file.

Record the FID-first rule: the resolver and FS check key by (DF, FID),
the symbolic name is only a label; and clarify that the FS check is
card-only (independent of the cardless uicc_files.json list).
2026-10-25 12:10:14 +03:00
catarrh 1d5a049ea1 docs: Services editor data tables (draft)
Feature cross-reference (SST/UST/IST/EST) and feature->filesystem
mapping for the planned cross-file Services editor.  Reconciliation
exact: 160 feature rows, every service number exactly once.

Flags for review: UST list is 4 short of the spec (147-150 missing
from EF_UST_SERVICES), GSM Access gates DF.GSM-ACCESS 5F3B (not DF.GSM),
CCP/CCP2/ECCP equivalence, and three unresolved file mappings.
2026-10-25 11:52:28 +03:00
catarrh 504bf1d66e docs: SIM/USIM format divergence + cross-file Services editor design
- correct finding 12 (EF.LP is 1-byte; EF.PL is 2-byte like EF.LI)
- name-collision class now names EF.ECC and EF.AD
- split EF.LP/EF.PL and EF.AD (SIM/USIM) survey rows
- add §2.4 SIM/USIM divergence table (groups A/B/C)
- add §6 Services editor + §6.1 cross-application candidates
2026-10-25 11:36:12 +03:00
catarrh 62c5b4e1a9 start: name the requested --http-host/--http-port in the opening line
The first line was hardcoded to http://127.0.0.1:8080 while the flags
were passed through, so ./start.sh --http-host 10.0.28.161 printed
127.0.0.1 although the server bound 10.0.28.161 (its own listening banner
named it).  Both start scripts now parse --http-host/--http-port (space
and = forms, the last value wins like argparse) and echo the effective
address; start.bat parses with shift (%* is unaffected, so the server
still receives every argument).

Bump 3.38.2 (server, pyproject, PWA header, sw simple-v483) and add the
CHANGELOG entry.

Tests: tests/test_pcsc_startup.py pins the parsing/echo (2 new).
1090 Python + 1075 frontend.
2026-10-25 03:25:22 +03:00
28 changed files with 4985 additions and 211 deletions

No files matched your search

+102
View File
@@ -9,6 +9,108 @@ every change carries a descriptive commit message.
## [Unreleased]
## [3.40.3] — 2026-10-27
### Fixed
- **Equipped card not always auto-selected in SCP80/RAM.** The ICCID-based
preset auto-selection latched its "already handled" guard even when no
matching preset was found, so a card equipped before `/api/presets` had
loaded (the connect-time race) never got auto-selected — navigating to
SCP80/RAM showed no preset until the card was re-inserted. The guard is
now latched only after a successful selection, and `cardsFetch` re-runs
the auto-selection once the store arrives.
## [3.40.2] — 2026-10-26
### Added
- **File filter: reset (✕) button.** A red ✕ to the right of the filter
selector appears whenever the filter is not "All" and resets it to "All"
in one click.
## [3.40.1] — 2026-10-26
### Changed
- **File filter: drop the "SMS-related" / "CB-related" categories.** Those
files are service-declared and already reachable through their per-service
filter options, which carry the per-spec service names ("Short Message
Storage (SMS)", "SMS Parameters (SMSP)", "Short Message Status Reports
(SMSR)", "Cell Broadcast Message Identifier (CBMI)", "Cell Broadcast
Message Identifier Ranges (CBMIR)").
## [3.40.0] — 2026-10-26
### Added
- **File-tree filter** (the file manager gains a **Filter** dropdown, next to
Sort). A collapsed checkbox multiselect with: **All** (default), **Custom
files**, **Identifiers** (ICCID/IMSI/MSISDN/IMPI/IMPU/SUPI_NAI/GID1/GID2),
**PLMN selection** / **Provider names** / **WLAN/I-WLAN** / **SMS-related** /
**CB-related** (cell broadcast), and one option per file-backed service of
the cross-reference. When a filter is active, directories that are not on
the path of the selected files are hidden; the button turns blue to stand
out.
- **Expand all / Collapse all** buttons beside the sort pills; **Expand all**
walks the tree loading children on demand and skips directories the probe
found absent, and selecting any filter other than "All" auto-expands the
tree so the matched files are visible.
- **"To EF filter"** cross-link: each file-backed service row in the
SST/UST/IST/EST decoded editors carries a compact glyph that narrows the
file-tree filter to exactly that service's files.
## [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
+24 -26
View File
@@ -2,22 +2,27 @@
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
📢 Project news and updates: [Telegram channel](https://t.me/youeyeseesee)
📢 Project news and updates: [website](https://atroshin.ru) · [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 frontend (PWA — a Progressive Web App) plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the frontend). 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);
- **OTA lab** — SCP80 secured packets (TS 102 225) and Java Card RAM installation, plus the terminal side of HTTP OTA (SCP81): BIP channel emulation, a PSK TLS administration server and APDU script execution;
- **Phone/CAT simulator** — SIM Toolkit menu browser, proactive command / event handling, TERMINAL PROFILE editing and the C-AT dialogue log;
- **Card profiler** — filesystem capture (profiles and immutable snapshots with timings), exact snapshot comparison and FCP/FCI decoding.
**Demo:** [simple.atroshin.ru](https://simple.atroshin.ru) — the PWA alone, for experimenting. Install the server (below) for card-reader functions.
**Demo:** [simple.atroshin.ru](https://simple.atroshin.ru) — the frontend alone, for experimenting. Install the server (below) for card-reader functions.
## 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 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).
- **Windows** — use **Python 3.13 (strongly recommended)**: `pyscard` ships precompiled wheels for it, so nothing needs compiling; on **3.14** the `pyscard` and some pysim dependency wheels are missing and build from source (needs MSVC C++ Build Tools).
## Quick start
**PWA only (client-side tools):** open `frontend/index.html` in any browser, or serve `frontend/` with any static server. No Python required.
**Full (PWA + card server)** — two ways to get it:
**Install the server** — two ways to get it:
**1. Download the release package (recommended).** From the
[Releases](https://github.com/anttro/simple/releases) page take the latest package
@@ -25,7 +30,7 @@ SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus
```sh
./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server
./start.sh # or start.bat — starts the server (it serves the PWA too)
./start.sh # or start.bat — starts the server (it serves the frontend too)
```
The package is built from the tagged source — no git history, no development files.
@@ -47,7 +52,7 @@ cd simple # `git pull` still updates it
./start.sh # or start.bat
```
Then open http://127.0.0.1:8080 — the UI and API share one origin, so no CORS or browser-permission setup is needed. The server starts fine with an empty reader (no card is a normal state): insert a card and it is initialized automatically (auto-equip), or press **Equip card** in the UI.
Then open http://127.0.0.1:8080 — the server starts fine with an empty reader (no card is a normal state): insert a card and it is initialized automatically (auto-equip), or press **Equip card** in the UI.
## Build
@@ -557,7 +562,7 @@ probe is refused when SPI1 has no counter check or the named preset does not def
## Cards
Stores saved card configurations (presets) in a **server-side store** — `~/.pysim-simple-server/card_presets.json` (`--card-presets` overrides it). A preset holds the cryptographic keys, SPI settings, TARs and the replay counters for SCP80 operations, the optional **ADM** key, plus the **PSK identity / PSK key** pair used by the SCP81 HTTP OTA listener. A preset carries **several keysets** — a keyset's number is the `b8..b5` nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (`01`–`0F`; A.2 rejects a mismatch), `00` (no security) is not a preset keyset, and each keyset has its own counter on the card (Annex A.1) — the server owns those counters, persists every consumed increment and appends it to an audit trail (`card_presets.json.audit.jsonl`). The list is loaded when the PWA connects; JSON export/import (which also accepts the old flat presets) is the migration path from the browser-local store. Cards is a **top-level tab**. The form groups the fields into two bordered blocks — **SCP80 (GSM 03.48, ETSI TS 102 225)** and **SCP81 (HTTP OTA)** — with the optional **ADM** field in the top row; the keysets are edited as rows (one per number, **Add keyset** / **Remove keyset**). When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches **no** preset — or the selected preset belongs to another card — all three SCP80 views show an amber warning above the preset selector. The header shows gray **SCP80** / **SCP81** markers and a key glyph on the **ADM** badge when the preset matching the equipped card's ICCID has those settings filled in.
Stores saved card configurations (presets) in a **server-side store** — `~/.pysim-simple-server/card_presets.json` (`--card-presets` overrides it). A preset holds the cryptographic keys, SPI settings, TARs and the replay counters for SCP80 operations, the optional **ADM** key, plus the **PSK identity / PSK key** pair used by the SCP81 HTTP OTA listener. A preset carries **several keysets** — a keyset's number is the `b8..b5` nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (`01`–`0F`; A.2 rejects a mismatch), `00` (no security) is not a preset keyset, and each keyset has its own counter on the card (Annex A.1) — the server owns those counters, persists every consumed increment and appends it to an audit trail (`card_presets.json.audit.jsonl`). The list is loaded when the frontend connects; JSON export/import (which also accepts the old flat presets) is the migration path from the browser-local store. Cards is a **top-level tab**. The form groups the fields into two bordered blocks — **SCP80 (GSM 03.48, ETSI TS 102 225)** and **SCP81 (HTTP OTA)** — with the optional **ADM** field in the top row; the keysets are edited as rows (one per number, **Add keyset** / **Remove keyset**). When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches **no** preset — or the selected preset belongs to another card — all three SCP80 views show an amber warning above the preset selector. The header shows gray **SCP80** / **SCP81** markers and a key glyph on the **ADM** badge when the preset matching the equipped card's ICCID has those settings filled in.
| Field | Description |
|---|---|
@@ -588,7 +593,7 @@ The **SCP81** column shows whether the preset supplies a usable PSK pair: **✓*
Connects to the bundled [`pysim-simple-server`](pysim_simple_server/) for live card operations.
> **Browser restriction:** when the PWA is served from a public HTTPS host, reaching the local server (`http://127.0.0.1:8080`) requires two things: the server must send `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 does this automatically), and the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to `127.0.0.1` is blocked before any preflight is sent.
> **Browser restriction:** when the frontend is served from a public HTTPS host, reaching the local server (`http://127.0.0.1:8080`) requires two things: the server must send `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 does this automatically), and the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to `127.0.0.1` is blocked before any preflight is sent.
### File Browser
@@ -743,21 +748,14 @@ The interface is in English with Russian support. The language is detected from
## Server (pysim-simple-server)
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the SIMple PWA (from `frontend/`) and a JSON API under `/api/*`.
### 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 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).
- **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.
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the SIMple frontend (from `frontend/`) and a JSON API under `/api/*`.
### Scripts
| Script | What it does |
|--------|-------------|
| `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 frontend + 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`. |
`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).
@@ -773,8 +771,8 @@ pysim-simple-server --http-port 8080
```
On Windows use `setup.bat` instead: it installs pysim's dependencies from
`requirements-pysim.txt` and pysim itself with `--no-deps`, so the SMPP bridge (and
its MSVC build chain) is not pulled in.
`requirements-pysim.txt` and pysim itself with `--no-deps`, so pysim's optional
dependencies are not pulled in.
### CLI options
@@ -782,7 +780,7 @@ its MSVC build chain) is not pulled in.
|--------|-------------|
| `--http-host` | Bind address (default: `127.0.0.1`) |
| `--http-port` | TCP port (default: `8080`) |
| `--web-dir` | Directory with PWA static files (default: `<repo>/frontend`) |
| `--web-dir` | Directory with frontend static files (default: `<repo>/frontend`) |
| `-p` / `--pcsc-device` | PC/SC reader slot number |
| `-d` / `--device` | Serial device path |
| `--no-card-init` | Skip card init to preserve the CAT session (no file manager) |
@@ -824,13 +822,13 @@ See [docs/api.md](docs/api.md) for the full endpoint reference.
## Version compatibility
| PWA (SIMple) | Server | Status |
| frontend (SIMple) | Server | Status |
|-------------|--------|--------|
| any | same major | ✅ Compatible |
| any | older major | ❌ Outdated — update server |
| any | newer major | ⚠️ Server newer — update PWA |
| any | newer major | ⚠️ Server newer — update frontend |
The PWA checks the server version on connect via `GET /api/version` and compares the major version (e.g. a 3.x PWA with a 3.x server; a 2.x server is flagged as outdated).
The frontend checks the server version on connect via `GET /api/version` and compares the major version (e.g. a 3.x frontend with a 3.x server; a 2.x server is flagged as outdated).
## License
@@ -843,5 +841,5 @@ pySim itself is not included in this repository or in the release artifacts:
`setup.sh` / `setup.bat` install it from its upstream git repository.
Bundled third-party components (the MCC/MNC operator list and the AES/DES
bundles in the PWA) keep their own MIT licenses — see
bundles in the frontend) keep their own MIT licenses — see
[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
+25 -27
View File
@@ -2,22 +2,27 @@
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
📢 Новости и обновления проекта: [Telegram-канал](https://t.me/youeyeseesee)
📢 Новости и обновления проекта: [сайт](https://atroshin.ru) · [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 — Progressive Web App) и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт фронтенд). Закрывает весь цикл лабораторной работы:
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
- **OTA-лаборатория** — защищённые пакеты SCP80 (TS 102 225) и установка Java Card RAM, а также терминальная сторона HTTP OTA (SCP81): эмуляция BIP-каналов, PSK-TLS-сервер администрирования и выполнение скриптов APDU;
- **Симулятор** — браузер меню SIM Toolkit, обработка proactive-команд и событий, редактирование TERMINAL PROFILE, тест-скрипты карты и журнал C-AT-диалога;
- **Профайлер карты** — захват файловой системы (профили и неизменяемые снимки с таймингами), точное сравнение снимков и декодер FCP/FCI.
**Демо:** [simple.atroshin.ru](https://simple.atroshin.ru) — только PWA, для экспериментов. Для функций картридера установите сервер (ниже).
**Демо:** [simple.atroshin.ru](https://simple.atroshin.ru) — только фронтенд, для экспериментов. Для функций картридера установите сервер (ниже).
## Требования
- **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`).
- **Windows** — используйте **Python 3.13 (настоятельно рекомендуется)**: для него `pyscard` поставляет готовые wheel, так что ничего компилировать не нужно; на **3.14** wheel для `pyscard` и части зависимостей pysim отсутствуют, и они собираются из исходников (нужны MSVC C++ Build Tools).
## Быстрый старт
**Только PWA (клиентские функции):** откройте `frontend/index.html` в любом браузере или раздайте `frontend/` любым статическим сервером. Python не нужен.
**Полная установка (PWA + сервер карт)** — два способа:
**Установка сервера** — два способа:
**1. Скачать релизный пакет (рекомендуется).** На странице
[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет
@@ -25,7 +30,7 @@ SIMple — инструментарий для специалистов по UIC
```sh
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
./start.sh # или start.bat — запускает сервер (он же раздаёт PWA)
./start.sh # или start.bat — запускает сервер (он же раздаёт фронтенд)
```
Пакет собран из помеченного тегом исходного кода — без истории git и без файлов для
@@ -47,7 +52,7 @@ cd simple # `git pull` всё равно
./start.sh # или start.bat
```
Затем откройте http://127.0.0.1:8080 — интерфейс и API на одном origin, поэтому CORS и разрешения браузера не нужны. Сервер запускается и с пустым картридером (отсутствие карты — нормальное состояние): вставьте карту, и она инициализируется автоматически (auto-equip), либо нажмите **Equip card** в интерфейсе.
Затем откройте http://127.0.0.1:8080 — сервер запускается и с пустым картридером (отсутствие карты — нормальное состояние): вставьте карту, и она инициализируется автоматически (auto-equip), либо нажмите **Equip card** в интерфейсе.
## Сборка
@@ -532,7 +537,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
## Карты
Хранит сохранённые конфигурации карт (пресеты) в **серверном хранилище** — `~/.pysim-simple-server/card_presets.json` (переопределяется `--card-presets`). Пресет содержит криптографические ключи, настройки SPI, TAR-ы и счётчики повторов для SCP80-операций, необязательный ключ **ADM**, а также пару **PSK identity / PSK key** для слушателя SCP81 HTTP OTA. Пресет содержит **несколько keysets** — номер keyset задаётся нибблом `b8..b5` его KIc/KID (TS 102 225 §5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (`01`–`0F`; A.2 отвергает несовпадение), `00` («без защиты») не является keyset пресета, и у каждого keyset на карте свой счётчик (Annex A.1) — счётчиками владеет сервер: он сохраняет каждый израсходованный инкремент и пишет его в журнал аудита (`card_presets.json.audit.jsonl`). Список загружается при подключении PWA; экспорт/импорт JSON (принимает и старые плоские пресеты) — путь миграции из браузерного хранилища. «Карты» — **верхнеуровневая вкладка**. Поля формы сгруппированы в два блока с рамкой — **SCP80 (GSM 03.48, ETSI TS 102 225)** и **SCP81 (HTTP OTA)**; необязательное поле **ADM** находится в верхней строке, а keysets редактируются строками (по одной на номер, **Добавить keyset** / **Удалить keyset**). При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх видах SCP80; если подключённая карта не совпадает ни с одним пресетом — или выбранный пресет принадлежит другой карте — все три вида SCP80 показывают жёлтое предупреждение над выбором пресета. В шапке отображаются серые маркеры **SCP80** / **SCP81** и значок ключа на бейдже **ADM**, когда в пресете с ICCID подключённой карты эти поля заполнены.
Хранит сохранённые конфигурации карт (пресеты) в **серверном хранилище** — `~/.pysim-simple-server/card_presets.json` (переопределяется `--card-presets`). Пресет содержит криптографические ключи, настройки SPI, TAR-ы и счётчики повторов для SCP80-операций, необязательный ключ **ADM**, а также пару **PSK identity / PSK key** для слушателя SCP81 HTTP OTA. Пресет содержит **несколько keysets** — номер keyset задаётся нибблом `b8..b5` его KIc/KID (TS 102 225 §5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (`01`–`0F`; A.2 отвергает несовпадение), `00` («без защиты») не является keyset пресета, и у каждого keyset на карте свой счётчик (Annex A.1) — счётчиками владеет сервер: он сохраняет каждый израсходованный инкремент и пишет его в журнал аудита (`card_presets.json.audit.jsonl`). Список загружается при подключении фронтенда; экспорт/импорт JSON (принимает и старые плоские пресеты) — путь миграции из браузерного хранилища. «Карты» — **верхнеуровневая вкладка**. Поля формы сгруппированы в два блока с рамкой — **SCP80 (GSM 03.48, ETSI TS 102 225)** и **SCP81 (HTTP OTA)**; необязательное поле **ADM** находится в верхней строке, а keysets редактируются строками (по одной на номер, **Добавить keyset** / **Удалить keyset**). При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх видах SCP80; если подключённая карта не совпадает ни с одним пресетом — или выбранный пресет принадлежит другой карте — все три вида SCP80 показывают жёлтое предупреждение над выбором пресета. В шапке отображаются серые маркеры **SCP80** / **SCP81** и значок ключа на бейдже **ADM**, когда в пресете с ICCID подключённой карты эти поля заполнены.
| Поле | Описание |
|---|---|
@@ -563,7 +568,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Подключение к встроенному [`pysim-simple-server`](pysim_simple_server/) для работы с картой.
> **Ограничение браузера:** если PWA раздаётся с публичного HTTPS-хоста, для доступа к локальному серверу (`http://127.0.0.1:8080`) нужны два условия: сервер должен отправлять `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 делает это автоматически), и браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к `127.0.0.1` блокируется ещё до отправки preflight.
> **Ограничение браузера:** если фронтенд раздаётся с публичного HTTPS-хоста, для доступа к локальному серверу (`http://127.0.0.1:8080`) нужны два условия: сервер должен отправлять `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 делает это автоматически), и браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к `127.0.0.1` блокируется ещё до отправки preflight.
### Файловый менеджер
@@ -715,33 +720,26 @@ SIMple — Progressive Web App. Можно установить для offline-
## Совместимость версий
| PWA (SIMple) | Сервер | Статус |
| фронтенд (SIMple) | Сервер | Статус |
|---|---|---|
| любая | та же мажорная | ✅ Совместимы |
| любая | старее мажорная | ❌ Сервер устарел — обновите сервер |
| любая | новее мажорная | ⚠️ Сервер новее — обновите PWA |
| любая | новее мажорная | ⚠️ Сервер новее — обновите фронтенд |
PWA проверяет версию сервера при подключении через `GET /api/version` и сравнивает мажорную версию (например, PWA 3.x с сервером 3.x; сервер 2.x помечается как устаревший).
фронтенд проверяет версию сервера при подключении через `GET /api/version` и сравнивает мажорную версию (например, фронтенд 3.x с сервером 3.x; сервер 2.x помечается как устаревший).
---
## Сервер (pysim-simple-server)
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как PWA (из `frontend/`), так и JSON API по `/api/*`.
### Требования
- **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`).
- **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 сознательно не ставится.
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как фронтенд (из `frontend/`), так и JSON API по `/api/*`.
### Скрипты
| Скрипт | Назначение |
|--------|-------------|
| `setup.sh` / `setup.bat` | Создаёт `.venv/` (или исправляет неполный), ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт фронтенд + API на `:8080`). |
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
`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-ридер не найден, сервер сообщает об этом и завершается — подключите ридер и перезапустите (ридер без карты — по-прежнему нормальный старт без карты).
@@ -757,8 +755,8 @@ pysim-simple-server --http-port 8080
```
В Windows используйте `setup.bat`: он ставит зависимости pysim из
`requirements-pysim.txt`, а сам pysim — с `--no-deps`, поэтому SMPP-мост (и его сборка
через MSVC) не подтягивается.
`requirements-pysim.txt`, а сам pysim — с `--no-deps`, поэтому необязательные
зависимости pysim не подтягиваются.
### Параметры CLI
@@ -766,7 +764,7 @@ pysim-simple-server --http-port 8080
|----------|-------------|
| `--http-host` | Адрес привязки (по умолчанию `127.0.0.1`) |
| `--http-port` | Порт (по умолчанию `8080`) |
| `--web-dir` | Каталог со статикой PWA (по умолчанию `<repo>/frontend`) |
| `--web-dir` | Каталог со статикой фронтенда (по умолчанию `<repo>/frontend`) |
| `-p` / `--pcsc-device` | Номер слота PC/SC |
| `-d` / `--device` | Путь к serial-устройству |
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
@@ -816,5 +814,5 @@ pysim-simple-server --http-port 8080
Сам pySim не входит ни в этот репозиторий, ни в релизные артефакты: `setup.sh` /
`setup.bat` устанавливают его из его upstream git-репозитория.
Встроенные сторонние компоненты (список операторов MCC/MNC и AES/DES-бандлы в
PWA) сохраняют свои лицензии MIT — см. [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
Встроенные сторонние компоненты (список операторов MCC/MNC и AES/DES-бандлы во
фронтенде) сохраняют свои лицензии MIT — см. [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
+133 -16
View File
@@ -64,10 +64,11 @@ editing candidates.
| EF.IMSI | 6F07 | TS 31.102 4.2.2 | 9 B | length byte + parity/type nibble ('9' odd / '1' even) + BCD | S3 | **3.34** |
| EF.IST | 6F07 | TS 31.102 4.2.2 (SAIP) | 1–2 B | service bits | S6 | **3.34** |
| EF.UST | 6F38 | TS 31.102 4.2.8 | X ≥ 1 | service bits | S6 | **3.34** |
| EF.SST | 6F38 | TS 51.011 10.3.7 | X ≥ 1 | **2 bits/service** (allocated + activated) | S6 (pending) | decoder fixed; editor later |
| EF.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 (3 services) | S6 | **3.34** |
| EF.LI | 6F05 | TS 31.102 4.2.1 | 2n | ISO 639 letters (2 B each) | S6 | 3.35 |
| EF.LP / EF.PL | 6F05 / 2F05 | TS 51.011 10.3.1 / 10.1.2 | 1–n | **1 byte per language** (DCS language coding) | S6 | 3.35 |
| EF.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.PLMNsel | 6F30 | TS 51.011 10.3.4 | 3n (n ≥ 8) | PLMN list | S4 | **3.34** |
| EF.FPLMN | 6F7B | TS 31.102 4.2.16 | 3n (n ≥ 4) | PLMN list (FFFFFF holes anywhere) | S4 | **3.34** |
@@ -82,7 +83,8 @@ editing candidates.
| EF.ACC | 6F78 | TS 31.102 4.2.15 | 2 B | class bits (b8…b1 = 15…8 / 7…0; ACC 10 written 0) | S6 | **3.34** |
| EF.PHASE | 6FAE | TS 51.011 10.3.19 | 1 B | phase id (00/02/03) | S1 | **3.34** |
| EF.HPPLMN | 6F31 | TS 31.102 4.2.6 | 1 B | search-period multiplier | S1 | **3.34** |
| EF.AD | 6FAD | TS 31.102 4.2.18 | 4+X | op-mode byte enum, info, MNC length (may be 0 or absent) | S1 | **3.34** |
| EF.AD (SIM) | 6FAD | TS 51.011 10.3.18 | 3+X | op-mode byte + additional info (no MNC length) | S1 | **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.CBMI | 6F45 | TS 31.102 4.2.14 | 2n | CB message ids | S6 | **3.34** |
| EF.CBMID | 6F48 | TS 31.102 4.2.20 | 2n | CB message ids | S6 | **3.34** |
| EF.CBMIR | 6F50 | TS 31.102 4.2.22 | 4n | CB id ranges | S6 | **3.34** |
@@ -126,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
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)
1. **EF.BDN is X+15, not X+14.** The shared ADN codec assumes the last two
@@ -133,10 +157,16 @@ beyond S9/S10.
(`X+15`, TS 31.102 4.2.44). The read-only decoded view currently
mis-parses BDN records, and the v3.33.0 encoder would corrupt them. The
codec must take the layout (ADN vs BDN) and preserve the CMI pointer.
2. **EF.ECC has two layouts.** SIM: transparent 3n (TS 51.011 10.3.27);
USIM: linear fixed X+4 (TS 31.102 4.2.21). The decoder implements the SIM
layout only; on a USIM the alpha bytes are read as more codes. A
structure-aware decode is needed before an ECC editor.
2. **EF.ECC is two different files, not one file with two layouts.** They
share the FID `6FB7` **and the name `EF.ECC`**, but live in different DFs
under different specs: DF.GSM — transparent 3n, code(2 BCD) + category
(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
case. DIR/ARR/PBR nest TLVs and cross-reference other files; they need
either a tree editor or a structured read-only view first.
@@ -150,7 +180,13 @@ beyond S9/S10.
6. **FID collisions across DFs** (e.g. 6F02 = EF.IMPI in ISIM and EF.OCST in
USIM) make the fid-only decoder fallback unsafe; the name-based lookup is
the primary one and encoders should not be fid-only where a collision is
possible.
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;
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
@@ -158,8 +194,8 @@ beyond S9/S10.
8. **EF.SST is a 2-bit table** (TS 51.011 10.3.7): 4 services per byte, first
bit of the pair = allocated, second = activated. The shared 1-bit
UST/EST codec was used for it, so SIM service tables were both misread and
miswritten; the decoder is fixed in v3.34.0, editing waits for an
allocated/activated UI.
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.
@@ -168,9 +204,11 @@ beyond S9/S10.
or absent (3-byte SIM form). The decoder and encoder follow this now.
11. **The IMSI parity/type nibble** (TS 24.008 10.5.1.4) is 9 for an odd digit
count and 1 for an even one; the first encoder always wrote 9.
12. **SIM EF.LP/EF.PL use the 1-byte DCS language coding**, unlike the USIM
EF.LI's 2-byte ISO 639 letters — the shared decoder is wrong for the SIM
files (queued with the language work).
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
@@ -190,7 +228,7 @@ beyond S9/S10.
(filterable bit grid), ACC, PHASE, HPPLMN, AD, the PLMN-only lists
(PLMNsel/FPLMN/EHPLMN), ICCID/IMSI (behind a confirmation) and
CBMI/CBMID/CBMIR. Decoder fixes: ACC class numbering, the AD operation
mode / MNC-length rules, the SST 2-bit layout (read-only for now) and the
mode / MNC-length rules, the SST 2-bit layout and the
IMSI parity nibble.
- **v3.35.0 (shipped)** — EF.SMSP as the first S10 dedicated builder: the full
parameter form (alpha; TP-DA and TS-SCA with their own length conventions
@@ -201,8 +239,10 @@ beyond S9/S10.
- **v3.36.0** — LI/LP/PL (the SIM files need the 1-byte DCS language coding
and a name-dependent decoder), GID1/GID2 (the clause states no coding —
verify TS 31.101 first), SPDI (the A3 > 80 wrapper), OPL (PNN record
select), ECC (SIM and USIM — needs a structure-aware decode), SUUME,
IMPI/DOMAIN/IMPU, ACL; EXT1–EXT5 as a read-only structured view.
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.
@@ -222,3 +262,80 @@ beyond S9/S10.
- **No capability by accident**: a file without an encoder must never show
the editor (gating test), and a schema/label drift guard keeps the editors
and decoders aligned.
## 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).
+447
View File
@@ -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.
+1 -1
View File
@@ -409,7 +409,7 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Читать сырые данные / Декодировать</strong> — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); таблица декодируется в браузере из тех же сырых байт (без серверного декодирования). Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл. «Декодировать» недоступна, если для файла нет клиентского декодера (проверка по имени/FID до чтения).</li>
<li><strong>Редактировать сырые данные</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.</li>
<li><strong>Редактировать декодированные</strong> — редактирование файла в его собственной декодированной форме; кодирование выполняется в браузере, запись идёт обычным путём обновления. Кодировщики покрывают <strong>EF.SPN</strong>, <strong>EF.PNN</strong>, списки PLMN (<strong>PLMNsel / FPLMN / EHPLMN</strong> и списки с технологиями доступа <strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), файлы номеров (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>), таблицы сервисов (<strong>UST / IST / EST</strong>; <strong>SST</strong> остаётся только для чтения до появления редактора allocated/activated), <strong>ACC</strong>, <strong>PHASE</strong>, <strong>HPPLMN</strong>, <strong>AD</strong>, <strong>ICCID / IMSI</strong> (с подтверждением — запись меняет идентичность карты) списки cell broadcast (<strong>CBMI / CBMID / CBMIR</strong>) и <strong>EF.SMSP</strong> (полная форма SMS-параметров: alpha, TP-DA и TS-SCA с их правилами адресации и TON, TP-PID, TP-DCS и относительный TP-VP; у каждого параметра — флажок <em>«отсутствует (действует значение ME по умолчанию)»</em> и живые подсказки по DCS и сроку действия). Для transparent-файла — одна форма (поля SPN, строки PLMN с добавлением/удалением, элементы PNN с добавлением/удалением); для record-файла — по форме на запись с флажком выбора, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. <strong>Сохранить</strong> проверяет все выбранные записи до записи; для файлов без кодировщика кнопка недоступна с подсказкой. Указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется.</li>
<li><strong>Редактировать декодированные</strong> — редактирование файла в его собственной декодированной форме; кодирование выполняется в браузере, запись идёт обычным путём обновления. Кодировщики покрывают <strong>EF.SPN</strong>, <strong>EF.PNN</strong>, списки PLMN (<strong>PLMNsel / FPLMN / EHPLMN</strong> и списки с технологиями доступа <strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), файлы номеров (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>), таблицы сервисов (<strong>UST / IST / EST</strong> и <strong>SST</strong> — SIM-таблица, редактируется по каждой службе как удалена / выделена-выключена / включена), <strong>ACC</strong>, <strong>PHASE</strong>, <strong>HPPLMN</strong>, <strong>AD</strong>, <strong>ICCID / IMSI</strong> (с подтверждением — запись меняет идентичность карты) списки cell broadcast (<strong>CBMI / CBMID / CBMIR</strong>) и <strong>EF.SMSP</strong> (полная форма SMS-параметров: alpha, TP-DA и TS-SCA с их правилами адресации и TON, TP-PID, TP-DCS и относительный TP-VP; у каждого параметра — флажок <em>«отсутствует (действует значение ME по умолчанию)»</em> и живые подсказки по DCS и сроку действия). Для transparent-файла — одна форма (поля SPN, строки PLMN с добавлением/удалением, элементы PNN с добавлением/удалением); для record-файла — по форме на запись с флажком выбора, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. <strong>Сохранить</strong> проверяет все выбранные записи до записи; для файлов без кодировщика кнопка недоступна с подсказкой. Указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется.</li>
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
<li><strong>ADM</strong> — файлы, требующие администраторский PIN, возвращают <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка <strong>«Проверить ADM»</strong>, а значок в заголовке (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) становится кликабельным. Каждый неверный ключ расходует попытку: остаток показывается, а повторная попытка требует подтверждения. Заблокированный ADM здесь не восстановить — нужен ключ разблокировки карты.</li>
</ul>
+1 -1
View File
@@ -408,7 +408,7 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the decoded table is built in the browser from the same raw bytes (no server-side decode). The highlighted pill is the current view; clicking either pill (re-)reads the file. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
<li><strong>Edit decoded</strong> — edit the file through its own decoded form, encoded in the browser and written with the normal update path. The encoders cover <strong>EF.SPN</strong>, <strong>EF.PNN</strong>, the PLMN lists (<strong>PLMNsel / FPLMN / EHPLMN</strong> and the access-technology lists <strong>PLMNwAcT / OPLMNwAcT / HPLMNwAcT</strong>), the dialling-number files (<strong>ADN / FDN / SDN / MSISDN / LND / BDN</strong>), the service tables (<strong>UST / IST / EST</strong>; <strong>SST</strong> stays read-only until the SIM allocated/activated editor exists), <strong>ACC</strong>, <strong>PHASE</strong>, <strong>HPPLMN</strong>, <strong>AD</strong>, <strong>ICCID / IMSI</strong> (behind a confirmation — writing them changes the card identity) the cell-broadcast lists (<strong>CBMI / CBMID / CBMIR</strong>) and <strong>EF.SMSP</strong> (the full SMS-parameter form: alpha, TP-DA and TS-SCA with their addressing rules and TON, TP-PID, TP-DCS and the relative TP-VP, each with an <em>absent (the ME default applies)</em> toggle and live DCS/validity hints). A transparent file gets one form (SPN fields, PLMN rows with add/remove, PNN items with add/remove); a record file gets one form per record with a selection checkbox that is auto-checked when a field changes, so only the edited records are written. <strong>Save</strong> validates every selected record before writing anything; files without an encoder keep the button disabled with a tooltip. Extension pointers (EF.EXT1–EF.EXT5) and the BDN comparison-method byte are preserved, and editing a number that continues in an extension file is refused.</li>
<li><strong>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>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card&rsquo;s unblock key.</li>
</ul>
+1769 -91
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v482';
const CACHE = 'simple-v488';
const URLS = [
'index.html',
'help.html',
+31
View File
@@ -146,6 +146,30 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', ()
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
});
test('a miss is not latched, so the store arriving later still auto-selects', () => {
// The card equips before /api/presets fills cards[] (the connect-time
// race): the auto-selection must not latch the ICCID on a miss, or the
// later cardsFetch would be a no-op and the preset would stay unselected
// until the card is re-inserted.
const els = fakeDoc();
const applied = [];
globalThis.cardsApply = idx => applied.push(idx);
globalThis.cards = []; // presets not loaded yet
globalThis._ramCardIdx = null;
globalThis._cardsAutoIccid = null;
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), -1);
assert.strictEqual(globalThis._cardsAutoIccid, null,
'a missed auto-selection must not latch the ICCID');
// the presets arrive: the retry (from cardsFetch) finds and selects the card
globalThis.cards = [{ name: 'A', iccid: RAW_HEX }];
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
assert.strictEqual(els['sp-card-sel'].value, '0');
assert.strictEqual(els['ram-card-sel'].value, '0');
assert.strictEqual(els['tar-card-sel'].value, '0');
assert.deepStrictEqual(applied, ['0']);
assert.strictEqual(globalThis._ramCardIdx, 0);
});
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
globalThis.cards = [
{ name: 'A', iccid: '1111111111111111111' },
@@ -201,6 +225,13 @@ test('the card-state update wires the ICCID into the preset selection', () => {
assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')"));
});
test('cardsFetch retries the auto-selection once the presets load', () => {
// The equip may be reported before /api/presets resolves, so the fetch
// must re-run the auto-selection (a no-op once the card is selected).
assert.ok(html.includes('cardsAutoSelectByIccid(_pysimCardIccid);'),
'cardsFetch must re-run the auto-selection after loading the store');
});
test('cardsRender highlights the equipped card preset', () => {
globalThis.t = s => s;
const els = { 'cards-tbody': { innerHTML: '' } };
+159 -5
View File
@@ -26,13 +26,13 @@ const FNS = [
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecSst', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact',
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecEccUsim', 'efEccCategory', 'efDecOpl', 'efDecAd', 'efDecAcl',
'efSmspDaNumber', 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
'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',
'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml',
'pysimFsDecodedHtml', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects',
'pysimFsDecodedHtml', 'pysimFsNodePath', 'pysimFsFindNodeByPath', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects',
'profilerAspectSummary', 'profilerMatchedRecordsText', 'profilerRawDataCheck', 'profilerCustomNameForPath',
'pysimCustomNormPath',
];
@@ -45,9 +45,11 @@ for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) {
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_PBR_KINDS']) {
code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
}
code += html.match(/const EF_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_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';
eval(code);
@@ -106,7 +108,7 @@ test('EF decoders match the pinned spec / pySim test vectors', () => {
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [15] });
assert.deepStrictEqual(efDecAcc(efBytes('0001')), { access_classes: [0] });
assert.deepStrictEqual(efDecAcc(efBytes('8001')), { access_classes: [15, 0] });
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: 'police' });
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
// EF.FPLMN: a FFFFFF gap in any position is skipped, not a terminator
@@ -357,6 +359,106 @@ test('decoder registry resolves by name first, then FID; unknown returns 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', () => {
const t = efDecodeFile('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' });
assert.strictEqual(typeof t.fn, 'function');
@@ -398,6 +500,58 @@ test('efFlatten skips the all-FF empty marker', () => {
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)', () => {
assert.deepStrictEqual(efDiffData({ empty: true }, { full: 'Miran' }),
[['Full name', 'Empty (all FF)', 'Miran']]);
+106 -8
View File
@@ -51,13 +51,13 @@ const FNS = [
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
'efFindEncoder', 'efHasEncoder', 'gsm7Decode', 'gsm7TextToSeptets',
'gsm7Encode', 'efEncError', 'efDecModelValues',
'efDecServiceTable', 'efDecSst', 'efDecAcc', 'efDecPhase', 'efDecHpplmn', 'efDecAd', 'efDecCbmi', 'efDecCbmir', 'efDecIccid', 'efDecImsi', 'efDecPlmnList', 'efDecFplmn', 'efEncServiceTable', 'efEncAcc', 'efPhaseValue', 'efPhaseModel', 'efEncPhase', 'efEncHpplmn', 'efAdOpValue', 'efAdModel', 'efEncAd', 'efEncPlmnOnly', 'efEncIccid', 'efEncImsi', 'efCbmiModel', 'efEncCbmi', 'efCbmirModel', 'efEncCbmir', 'efSmspAddressModel', 'efSmspModel', 'efSmspTonOptions', 'efSmspParamRow', 'efSmspHtml', 'efSmspCollect', 'pysimFsSmspToggle', 'pysimFsSmspHexInput', 'efSmspHexByte', 'efSmspAddressBytes', 'efSmspAddrUnchanged', 'efSmspByteUnchanged', 'efEncSmsp', 'smspT', 'smspDcsHint', 'smspVpHint', 'smspDurationText', 'smspPluralIndex', 'smspCount', 'efSmspDaNumber',
'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 = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', '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);
}
eval(code);
@@ -218,6 +218,43 @@ test('EF.SPN and the ADN family share the encoder registry with the PLMN 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', () => {
globalThis.t = k => (k === 'Select at least one access technology' ? 'Выберите технологию' : k);
try {
@@ -573,7 +610,7 @@ test('F8: the ADN-family entries name their extension EF and use it in refusals'
// Wave 2 fixtures.
const UST_ONE = '00000010'; // service 29
const UST_MULTI = '010204'; // services 1, 10, 19
const UST_UNMODELLED = '000000' + '10' + '00'.repeat(14) + 'FC'; // 29 + bits 147-152
const 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
@@ -596,10 +633,13 @@ test('service tables: encode/decode round-trips and unmodelled bits', () => {
const services = efDecServiceTable(efBytes(hex), table).services;
assert.strictEqual(entry.enc({ services: services }, hex), hex, name + ' ' + hex);
}
// EF.SST has no editor (its 2-bit SIM layout needs a dedicated UI)
assert.strictEqual(efFindEncoder('EF.SST', '6f38'), null);
assert.strictEqual(efHasEncoder('EF.SST', '6f38'), false);
// 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)' },
@@ -615,20 +655,78 @@ test('service tables: encode/decode round-trips and unmodelled bits', () => {
assert.strictEqual(entry.enc({ services: unmodelled }, UST_UNMODELLED), UST_UNMODELLED);
// unchecking everything clears the table bits but keeps the unmodelled byte
assert.strictEqual(entry.enc({ services: [] }, UST_UNMODELLED),
'000000' + '00' + '00'.repeat(14) + 'FC');
'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: 147, name: 'RFU' }] },
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] });
+147 -19
View File
@@ -48,22 +48,24 @@ const FNS = [
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
'efFindEncoder', 'efHasEncoder', 'efEncError',
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
'servicesFeatureByNumber', 'pysimFsFilterBtnHtml',
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
'efDecRecordsHtml', 'efDecEditHtml',
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
'efBitsSelected', 'pysimFsDecBitsFilter', 'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
'pysimFsDecRemoveItem', 'pysimFsFindNode',
'efBitsSelected', 'pysimFsDecBitsFilter', 'pysimFsDecBits2Filter', 'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem',
'pysimFsDecRemoveItem', 'pysimFsFindNodeByPath', 'pysimCustomNormPath', 'pysimFsNodePath', 'pysimFsFallbackNode', 'efFidFromPath', 'efDfFromPath',
'pysimFsHasDecoder', 'pysimFsHasEncoder', 'pysimFsDecSave', 'pysimFsDecEdit',
'pysimFsPillsEnabled', 'pysimFsSyncContentVisibility', 'gsm7Decode',
'gsm7TextToSeptets', 'gsm7Encode',
'efDecServiceTable', 'efDecSst', 'efDecAcc', 'efDecPhase', 'efDecHpplmn', 'efDecAd', 'efDecCbmi', 'efDecCbmir', 'efDecIccid', 'efDecImsi', 'efDecPlmnList', 'efDecFplmn', 'efEncServiceTable', 'efEncAcc', 'efPhaseValue', 'efPhaseModel', 'efEncPhase', 'efEncHpplmn', 'efAdOpValue', 'efAdModel', 'efEncAd', 'efEncPlmnOnly', 'efEncIccid', 'efEncImsi', 'efCbmiModel', 'efEncCbmi', 'efCbmirModel', 'efEncCbmir', 'efSmspAddressModel', 'efSmspModel', 'efSmspTonOptions', 'efSmspParamRow', 'efSmspHtml', 'efSmspCollect', 'pysimFsSmspToggle', 'pysimFsSmspHexInput', 'efSmspHexByte', 'efSmspAddressBytes', 'efSmspAddrUnchanged', 'efSmspByteUnchanged', 'efEncSmsp', 'smspT', 'smspDcsHint', 'smspVpHint', 'smspDurationText', 'smspPluralIndex', 'smspCount', 'efSmspDaNumber',
'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'
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n';
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\nvar _servicesFeatureByNumber = null;\n';
for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', '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',
'SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) {
code += extractConst(html, c);
}
eval(code);
@@ -253,24 +255,50 @@ test('changing a record input checks its block', () => {
assert.strictEqual(cb.checked, true);
});
test('pysimFsHasEncoder resolves the selected file by name or FID', () => {
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SPN', fid: '6f46' }, { name: 'EF.ALIAS', fid: '6f3a' }] };
pysimFsSelected = 'EF.SPN';
test('pysimFsHasEncoder resolves the selected file by its path or FID', () => {
pysimFsTreeRoot = { name: 'MF', children: [] };
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);
pysimFsSelected = 'EF.ALIAS';
pysimFsSelected = 'MF/6F3A';
assert.strictEqual(pysimFsHasEncoder(), true);
// a bare FID selection still resolves through the FID fallback
pysimFsSelected = '6F46';
assert.strictEqual(pysimFsHasEncoder(), true);
pysimFsSelected = 'EF.LOCI';
assert.strictEqual(pysimFsHasEncoder(), false);
// the SIM service table shares 6F38 with UST but has no editor
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SST', fid: '6f38' }] };
pysimFsSelected = 'EF.SST';
pysimFsSelected = 'MF/6F7E';
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;
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', () => {
const btn = /<button[^>]*id="pysim-fs-edit-dec-btn"[^>]*>/.exec(html);
assert.ok(btn, 'Edit decoded button');
@@ -634,8 +662,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 () => {
pysimFsSelected = 'EF.SPN';
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.SPN', fid: '6f46' }] };
pysimFsSelected = 'MF/6F46';
pysimFsTreeRoot = { name: 'MF', children: [] };
pysimFsTreeRoot.children.push({ name: 'EF.SPN', fid: '6f46', parent: pysimFsTreeRoot });
const fakeEl = () => {
const classes = new Set();
return {
@@ -668,7 +697,7 @@ test('F12: a selection change during the read abandons the decoded edit', async
let resets = 0;
globalThis.pysimFsResetEdit = () => { resets++; };
const pending = pysimFsDecEdit();
pysimFsSelected = 'EF.ADN';
pysimFsSelected = 'MF/6F3A';
release();
await pending;
assert.strictEqual(resets, 1);
@@ -689,8 +718,8 @@ test('the bits field renders a filtered checkbox grid and collects {n,name}', ()
assert.match(out, /class="pysim-dec-bits /);
assert.match(out, /pysim-dec-bits-filter/);
assert.match(out, /data-bit="29" data-bit-name="Data download via SMS-CB" checked/);
// the whole UST table is rendered (146 services)
assert.strictEqual((out.match(/data-bit="/g) || []).length, 146);
// 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);
@@ -712,6 +741,20 @@ test('the bits field renders a filtered checkbox grid and collects {n,name}', ()
] });
});
test('the service grids link file-backed rows to the file tree filter', () => {
const ust = efDecEditHtml(efDecEditModel('EF.UST', '6f38', { kind: 'transparent', data: UST_ONE }), UST_ONE);
// service 2 = FDN (files), service 99 = URI support by UICC (capability)
assert.match(ust, /pysimFsSetFilterToService\('ust', 2\)/);
assert.ok(!/pysimFsSetFilterToService\('ust', 99\)/.test(ust));
const sst = efDecEditHtml(efDecEditModel('EF.SST', '6f38', { kind: 'transparent', data: '4C' }, '7F20'), '4C');
// service 4 = SMS (files), service 1 = CHV1 disable function (capability)
assert.match(sst, /pysimFsSetFilterToService\('sst', 4\)/);
assert.ok(!/pysimFsSetFilterToService\('sst', 1\)/.test(sst));
// EF.ACC's bits grid is not a service table - no cross-link at all
const acc = efDecEditHtml(efDecEditModel('EF.ACC', '6f78', { kind: 'transparent', data: ACC_ONE }), ACC_ONE);
assert.ok(!/pysimFsSetFilterToService/.test(acc));
});
test('the bits filter hides the non-matching rows', () => {
const rows = [
{ textContent: '1 Local Phone Book', style: {} },
@@ -724,6 +767,91 @@ test('the bits filter hides the non-matching rows', () => {
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' });
+12 -5
View File
@@ -29,10 +29,12 @@ let code = 'var pysimFsDecodedMode = false;\n'
+ 'var pysimFsSelected = null;\n'
+ 'var pysimFsTreeRoot = null;\n';
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNode', 'pysimFsHasDecoder',
'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNodeByPath', 'pysimCustomNormPath',
'pysimFsNodePath', 'efFidFromPath', 'efDfFromPath', 'pysimFsHasDecoder',
'pysimFsSyncContentVisibility']) {
code += extractFunc(html, fn) + '\n';
}
code += html.match(/const EF_ADF_RIDS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
eval(code);
let events = [];
@@ -83,8 +85,9 @@ function setup(opts) {
pysimFsDecodedMode = !!opts.decoded;
pysimFsEditMode = false;
pysimFsEditData = null;
pysimFsSelected = 'EF.IMSI';
pysimFsTreeRoot = { name: 'MF', fid: '3f00', children: [{ name: 'EF.IMSI', fid: '6f07' }] };
pysimFsSelected = 'MF/6F07';
pysimFsTreeRoot = { name: 'MF', fid: '3f00', children: [] };
pysimFsTreeRoot.children.push({ name: 'EF.IMSI', fid: '6f07', parent: pysimFsTreeRoot });
globalThis.efFindDecoder = (name, fid) => {
const n = (name || '').toUpperCase();
const f = (fid || '').toLowerCase();
@@ -205,12 +208,16 @@ test('Read decoded is ignored when the file has no decoder', () => {
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();
assert.strictEqual(pysimFsHasDecoder(), true, 'EF.IMSI is in the tree');
// alias name: the decoder is matched by the FID fallback
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);
// unknown file -> no decoder
globalThis.efFindDecoder = () => null;
+665
View File
@@ -0,0 +1,665 @@
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';
}
function extractStrConst(src, name) {
const m = new RegExp("const " + name + " = '([^']*)';").exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + " = '" + m[1] + "';\n";
}
// module state the extracted functions close over
let pysimFsFilter = new Set(['all']);
let _fsFilterIndex = null;
// The shipped standard list stands in for the fetched one (uicc_files.json is
// what uiccStdFilesLoad caches; the filter reads its entries synchronously).
const stdAsset = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'uicc_files.json'), 'utf8'));
let _uiccStdFiles = stdAsset.files;
let _fsFilterPathIndex = null;
let _fsFilterPathIndexSrc = null;
let _fsFilterPaths = null;
let _fsFilterPathsKey = null;
let pysimCustomFiles = [];
const _store = {};
let _treeRenders = 0;
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
let pysimFsLoadChildren = async node => { if (!node.children) node.children = []; };
globalThis.localStorage = {
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
setItem: (k, v) => { _store[k] = String(v); },
removeItem: k => { delete _store[k]; },
};
globalThis.esc = s => String(s);
globalThis.t = k => k;
globalThis.pysimFsRenderTree = () => { _treeRenders++; };
// The filter's two DOM elements: the collapsed row's button and the hidden
// menu it expands into. The menu only needs a settable innerHTML (the
// render never reads the DOM back) and a classList for the open/closed state.
function fakeBtn() {
const classes = new Set();
return {
textContent: '',
attrs: {},
classes,
classList: {
contains: c => classes.has(c),
toggle(c, force) {
const on = force === undefined ? !classes.has(c) : !!force;
if (on) classes.add(c); else classes.delete(c);
return on;
},
},
setAttribute(k, v) { this.attrs[k] = String(v); },
getAttribute(k) { return this.attrs[k]; },
};
}
function fakeMenu() {
const classes = new Set(['hidden']);
return {
_html: '',
classes,
classList: {
contains: c => classes.has(c),
toggle(c, force) {
const on = force === undefined ? !classes.has(c) : !!force;
if (on) classes.add(c); else classes.delete(c);
return on;
},
},
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = v; },
};
}
const dom = { menu: null, btn: null, clear: null };
globalThis.document = {
getElementById: id => (id === 'pysim-fs-filter-menu' ? dom.menu
: (id === 'pysim-fs-filter-btn' ? dom.btn
: (id === 'pysim-fs-filter-clear' ? dom.clear : null))),
};
let code = '';
for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex',
'fsFilterPathIndexBuild', 'fsFilterPathIndex', 'fsFilterCustomPaths',
'fsFilterPathsKeyOf', 'pysimFsFilterPathsInvalidate', 'pysimFsFilterMatchingPaths',
'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterLabel',
'pysimFsFilterSummary', 'pysimFsFilterRender', 'pysimFsFilterToggle',
'pysimFsFilterSetEntry', 'pysimFsFilterClear', 'pysimFsFilterRestore', 'pysimFsNodePath',
'pysimFsExpandAll', 'pysimFsCollapseAll', 'pysimFsAutoExpandForFilter']) {
code += extractFunc(html, f) + '\n';
}
for (const c of ['FS_FILTER_GROUPS', 'FS_FILTER_GROUP_ORDER', 'SERVICES_FS_MAP']) code += extractConst(html, c);
code += extractStrConst(html, 'FS_FILTER_ALL');
code += extractStrConst(html, 'FS_FILTER_CUSTOM');
eval(code);
function withFilter(ids, fn) {
const saved = pysimFsFilter;
pysimFsFilter = new Set(ids);
try { return fn(); } finally { pysimFsFilter = saved; }
}
// The checkbox rows the render wrote: [{id, checked}] in document order.
function renderedBoxes() {
const out = [];
for (const m of (dom.menu ? dom.menu.innerHTML : '').matchAll(
/<input type="checkbox" data-filter="([^"]*)"( checked)?/g)) {
out.push({ id: m[1], checked: !!m[2] });
}
return out;
}
const box = id => renderedBoxes().find(b => b.id === id);
const fileBackedFeatures = () => Object.keys(SERVICES_FS_MAP).filter(n => SERVICES_FS_MAP[n].length);
test('the file tree has a collapsed filter dropdown next to the sort pills', () => {
assert.match(html, /id="pysim-fs-filter-btn"[^>]*aria-haspopup="true"[^>]*aria-expanded="false"[^>]*onclick="pysimFsFilterToggle\(\)"/);
assert.match(html, /id="pysim-fs-filter-menu"[^>]*role="group"[^>]*class="hidden absolute/);
assert.match(html, /data-l10n="Filter:"/);
// the multiselect (and its onchange handler) is gone
assert.ok(!/id="pysim-fs-filter"/.test(html));
assert.ok(!/pysimFsSetFilter\(/.test(html));
});
test('the ✕ next to the selector resets a non-All filter to All', () => {
// markup: a hidden red ✕ that clears the filter
assert.match(html, /id="pysim-fs-filter-clear"[^>]*onclick="pysimFsFilterClear\(\)"/);
assert.match(html, /id="pysim-fs-filter-clear"[^>]*data-l10n-title="Reset filter"/);
// the clear handler resets to All
const fn = extractFunc(html, 'pysimFsFilterClear');
assert.ok(fn.indexOf('pysimFsFilterSetEntry(FS_FILTER_ALL, true)') > 0, fn);
// the render shows it only when a non-All filter is active
dom.menu = fakeMenu();
dom.btn = fakeBtn();
dom.clear = fakeBtn();
withFilter(['plmn-selection'], () => {
pysimFsFilterRender();
assert.strictEqual(dom.clear.classes.has('hidden'), false);
assert.strictEqual(dom.clear.attrs['aria-label'], 'Reset filter');
});
withFilter(['all'], () => {
pysimFsFilterRender();
assert.strictEqual(dom.clear.classes.has('hidden'), true);
});
// the Russian entry exists
const dict = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/)[1];
assert.ok(dict.includes("'Reset filter':"), 'missing Russian entry');
});
test('an outside click or Escape closes the menu', () => {
const click = html.match(/document\.addEventListener\('click', e => \{([\s\S]{0,600}?)\n\}\);/);
assert.ok(click, 'no document click listener');
assert.match(click[1], /#pysim-fs-filter-btn/);
assert.match(click[1], /#pysim-fs-filter-menu/);
assert.match(click[1], /pysimFsFilterToggle\(false\)/);
const key = html.match(/document\.addEventListener\('keydown', e => \{([\s\S]{0,300}?)\n\}\);/);
assert.ok(key, 'no document keydown listener');
assert.match(key[1], /Escape/);
assert.match(key[1], /pysimFsFilterToggle\(false\)/);
});
test('the four category groups name their files', () => {
assert.deepStrictEqual(FS_FILTER_GROUPS.map(g => g.id),
['identifiers', 'plmn-selection', 'provider-names', 'wlan']);
const files = id => new Set(FS_FILTER_GROUPS.find(g => g.id === id).files);
assert.deepStrictEqual([...files('identifiers')],
['EF.ICCID', 'EF.IMSI', 'EF.MSISDN', 'EF.IMPI', 'EF.IMPU', 'EF.SUPI_NAI', 'EF.GID1', 'EF.GID2']);
assert.deepStrictEqual([...files('plmn-selection')],
['EF.PLMNsel', 'EF.PLMNwAcT', 'EF.OPLMNwAcT', 'EF.HPLMNwAcT', 'EF.EHPLMN', 'EF.FPLMN',
'EF.HPPLMN', 'EF.LRPLMNSI', 'EF.EHPLMNPI', 'EF.MCHPPLMN']);
assert.deepStrictEqual([...files('provider-names')],
['EF.SPN', 'EF.PNN', 'EF.OPL', 'EF.OPL5G', 'EF.SPDI', 'EF.SPNI', 'EF.PNNI', 'EF.CNL']);
assert.deepStrictEqual([...files('wlan')],
['EF.UPLMNWLAN', 'EF.OPLMNWLAN', 'EF.WRI', 'EF.HWSIDL', 'EF.WEHPLMNPI', 'EF.WHPI',
'EF.WLRPLMN', 'EF.HPLMNDAI']);
assert.strictEqual(FS_FILTER_ALL, 'all');
assert.strictEqual(FS_FILTER_CUSTOM, 'custom');
});
test('the per-service options come from the file-backed features only', () => {
const services = fsFilterServiceOptions();
const features = fileBackedFeatures();
assert.ok(features.length > 50, 'expected the cross-reference to be available');
assert.strictEqual(services.length, features.length);
assert.ok(services.every(o => o.id === 'svc:' + o.label));
const spn = services.find(o => o.id === 'svc:Service Provider Name (SPN)');
assert.deepStrictEqual(spn.files, ['EF.SPN']);
// capability/RFU rows carry no files, so they are not offered
assert.ok(!services.some(o => o.id === 'svc:Proactive SIM' || o.id === 'svc:CHV1 disable function'));
});
test('the inverted index keys the uppercased names and merges memberships', () => {
const idx = fsFilterIndexBuild(FS_FILTER_GROUPS, fsFilterServiceOptions());
const ids = name => Array.from(idx.get(name.toUpperCase()) || []);
assert.ok(ids('EF.ICCID').includes('identifiers'));
assert.ok(ids('EF.PLMNwAcT').includes('plmn-selection'));
assert.ok(ids('EF.SPN').includes('provider-names'));
assert.ok(ids('EF.SPN').includes('svc:Service Provider Name (SPN)'));
// SMS/CB files are covered by their per-service options, not a category
assert.ok(ids('EF.SMS').includes('svc:Short Message Storage (SMS)'));
assert.ok(ids('EF.CBMI').includes('svc:Cell Broadcast Message Identifier (CBMI)'));
// EF.PSISMSC is neither a category nor a file-backed service, so nothing
// maps it (pySim models no such file, and no service lists it either)
assert.ok(!idx.has('EF.PSISMSC'));
assert.ok(!fileBackedFeatures().some(n => (SERVICES_FS_MAP[n] || []).includes('EF.PSISMSC')));
});
test('a file matches the selected categories, case-insensitively', () => {
withFilter(['identifiers'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', fid: '2FE2' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'ef.iccid', fid: '2FE2' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN', fid: '6F46' }), false);
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', fid: '7F20', isDir: true }), false);
});
withFilter(['svc:Service Provider Name (SPN)'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.PNN' }), false);
});
withFilter(['identifiers', 'svc:Short Message Status Reports (SMSR)'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.IMSI' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SMSR' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN' }), false);
});
withFilter(['svc:Short Message Storage (SMS)'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ECC' }), false);
});
});
test('an empty filter and "all" show everything', () => {
withFilter([], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', isDir: true }), true);
assert.strictEqual(pysimFsFilterMatch({ name: undefined }), true);
});
withFilter(['all'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ZZZ' }), true);
});
// a stale multi-selection that still names all also shows everything
withFilter(['all', 'identifiers'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ECC' }), true);
});
});
test('a capability-only service selection matches nothing', () => {
withFilter(['svc:Proactive SIM'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), false);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.UST' }), false);
});
});
test('"custom files" matches only the injected custom nodes', () => {
withFilter(['custom'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN', custom: false }), false);
assert.strictEqual(pysimFsFilterMatch({ name: 'MyCustomEF', custom: true }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', custom: true }), true);
});
withFilter(['custom', 'identifiers'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', custom: false }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'MyCustomEF', custom: true }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN', custom: false }), false);
});
});
// A small tree with real parent links (pysimFsNodePath walks them): MF with
// DF.TELECOM (EF.MSISDN), DF.GSM (EF.SPN, EF.ECC) and ADF.USIM (DF.WLAN).
function fsTree() {
const root = { name: 'MF', fid: '3F00', isDir: true, children: [], parent: null, expanded: true };
const add = (parent, name, fid, isDir) => {
const n = { name, fid, isDir: !!isDir, children: isDir ? [] : null, parent, expanded: true };
parent.children.push(n);
return n;
};
const telecom = add(root, 'DF.TELECOM', '7F10', true);
const msisdn = add(telecom, 'EF.MSISDN', '6F40', false);
const gsm = add(root, 'DF.GSM', '7F20', true);
const spn = add(gsm, 'EF.SPN', '6F46', false);
const ecc = add(gsm, 'EF.ECC', '6FB7', false);
const usim = add(root, 'ADF.USIM', '7FFF', true);
const wlan = add(usim, 'DF.WLAN', '5F40', true);
return { root, telecom, msisdn, gsm, spn, ecc, usim, wlan };
}
test('a directory shows only on the path of a file the filter selects', () => {
const t = fsTree();
withFilter(['provider-names'], () => {
// the root always stays; DF.GSM holds EF.SPN (MF/7F20/6F46)
assert.strictEqual(pysimFsFilterShow(t.root), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
// ... and ADF.USIM holds its own EF.SPN copy
assert.strictEqual(pysimFsFilterShow(t.usim), true);
// neither DF.TELECOM nor DF.WLAN holds a provider-name file
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
// files keep their name rule
assert.strictEqual(pysimFsFilterShow(t.spn), true);
assert.strictEqual(pysimFsFilterShow(t.ecc), false);
});
withFilter(['identifiers'], () => {
// EF.MSISDN is in DF.TELECOM, EF.IMSI/EF.GID1/2 in DF.GSM
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
assert.strictEqual(pysimFsFilterShow(t.msisdn), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.usim), true); // EF.IMSI/EF.MSISDN copies
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
});
withFilter(['svc:Cell Broadcast Message Identifier (CBMI)'], () => {
assert.strictEqual(pysimFsFilterShow(t.gsm), true); // EF.CBMI
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
});
});
test('a directory named by the selection itself shows', () => {
const t = fsTree();
// the GPRS feature names DF.GSM (its SST bit covers the DF, not a file)
withFilter(['svc:GPRS'], () => {
assert.strictEqual(pysimFsFilterMatch(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
});
});
test('"all" and an empty selection show every node', () => {
const t = fsTree();
for (const ids of [['all'], []]) {
withFilter(ids, () => {
assert.strictEqual(pysimFsFilterShow(t.root), true);
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
assert.strictEqual(pysimFsFilterShow(t.wlan), true);
assert.strictEqual(pysimFsFilterShow(t.ecc), true);
});
}
});
test('directories are kept until the standard list is available', () => {
const t = fsTree();
const saved = _uiccStdFiles;
_uiccStdFiles = null;
try {
withFilter(['provider-names'], () => {
assert.strictEqual(pysimFsFilterMatchingPaths(), null);
// the ancestors cannot be judged, so nothing vanishes
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
assert.strictEqual(pysimFsFilterShow(t.wlan), true);
// files still obey the name rule
assert.strictEqual(pysimFsFilterShow(t.ecc), false);
});
} finally {
_uiccStdFiles = saved;
}
});
test('the standard list gives a name every tree it lives in', () => {
const idx = fsFilterPathIndexBuild(_uiccStdFiles);
assert.deepStrictEqual(idx.get('EF.SPN').slice().sort(),
['ADF.USIM-NON-IMSI/6F46', 'ADF.USIM/6F46', 'MF/7F20/6F46']);
assert.deepStrictEqual(idx.get('EF.ICCID'), ['MF/2FE2']);
// the index is cached for the loaded list
assert.strictEqual(fsFilterPathIndex(), fsFilterPathIndex());
});
test('the selection resolves to the paths of its files', () => {
withFilter(['svc:Cell Broadcast Message Identifier (CBMI)'], () => {
const paths = pysimFsFilterMatchingPaths();
assert.ok(paths instanceof Set, 'expected a Set');
assert.deepStrictEqual(Array.from(paths).sort(), [
'ADF.USIM-NON-IMSI/6F45', 'ADF.USIM/6F45', 'MF/7F20/6F45',
]);
// cached for the same selection
assert.strictEqual(pysimFsFilterMatchingPaths(), paths);
});
withFilter(['custom'], () => {
assert.deepStrictEqual(Array.from(pysimFsFilterMatchingPaths()), []);
});
});
test('"custom files" keeps the directories that hold them', () => {
const t = fsTree();
const custom = { name: 'EF.MYFILE', fid: '6F99', isDir: false, children: null, parent: t.gsm, custom: true };
t.gsm.children.push(custom);
pysimCustomFiles = [{ path: 'MF/7F20/6F99', name: 'EF.MYFILE', kind: 'ef' }];
try {
withFilter(['custom'], () => {
// custom files are outside the standard list: their own paths are
// the ancestry, so the holding DF stays
assert.strictEqual(pysimFsFilterShow(custom), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.root), true);
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
});
} finally {
pysimCustomFiles = [];
}
});
test('the menu lists all, custom, the three headings and the services', () => {
pysimFsFilter = new Set(['all']);
dom.menu = fakeMenu();
dom.btn = fakeBtn();
pysimFsFilterRender();
const out = dom.menu.innerHTML;
assert.match(out, /^<label[^>]*><input type="checkbox" data-filter="all" checked/);
assert.match(out, /data-filter="custom"/);
assert.match(out, /<div class="[^"]*">Identifiers<\/div>/);
assert.match(out, /<div class="[^"]*">PLMN<\/div>/);
assert.match(out, /<div class="[^"]*">Services<\/div>/);
// the headings come in the declared order
assert.ok(out.indexOf('>Identifiers</div>') < out.indexOf('>PLMN</div>'));
assert.ok(out.indexOf('>PLMN</div>') < out.indexOf('>Services</div>'));
// the categories sit under their headings
assert.ok(out.indexOf('data-filter="provider-names"') < out.indexOf('>PLMN</div>'));
assert.ok(out.indexOf('data-filter="plmn-selection"') > out.indexOf('>PLMN</div>'));
// the per-service options sit under the Services heading
assert.ok(out.indexOf('data-filter="svc:') > out.indexOf('>Services</div>'));
const boxes = renderedBoxes();
assert.strictEqual(boxes.length, 2 + FS_FILTER_GROUPS.length + fsFilterServiceOptions().length);
assert.strictEqual(boxes[0].id, 'all');
assert.strictEqual(boxes[0].checked, true);
assert.strictEqual(box('custom').checked, false);
assert.ok(boxes.some(b => b.id === 'svc:Service Provider Name (SPN)'));
assert.ok(!boxes.some(b => b.id === 'svc:Proactive SIM'));
// the row shows the summary, and the checkbox calls the setter directly
assert.strictEqual(dom.btn.textContent, 'All');
assert.match(out, /onchange="pysimFsFilterSetEntry\(this\.dataset\.filter, this\.checked\)"/);
});
test('the button turns blue when a filter is active and back for all', () => {
dom.menu = fakeMenu();
dom.btn = fakeBtn();
pysimFsFilter = new Set(['identifiers']);
pysimFsFilterRender();
assert.strictEqual(dom.btn.classes.has('bg-blue-600'), true);
assert.strictEqual(dom.btn.classes.has('text-white'), true);
assert.strictEqual(dom.btn.classes.has('text-gray-700'), false);
pysimFsFilter = new Set(['all']);
pysimFsFilterRender();
assert.strictEqual(dom.btn.classes.has('bg-blue-600'), false);
assert.strictEqual(dom.btn.classes.has('text-white'), false);
assert.strictEqual(dom.btn.classes.has('text-gray-700'), true);
});
test('expand all and collapse all walk the directory tree, skipping absent DFs', async () => {
const telecom = { name: 'DF.TELECOM', fid: '7F10', isDir: true, exists: true, expanded: false,
parent: null, children: [
{ name: 'DF.PHONEBOOK', fid: '5F3A', isDir: true, exists: true, expanded: false, parent: null, children: null },
] };
const gsm = { name: 'DF.GSM', fid: '7F20', isDir: true, exists: false, expanded: false, parent: null, children: null };
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, exists: true, expanded: true, parent: null,
children: [gsm, telecom] };
let loads = 0;
pysimFsLoadChildren = async node => { if (!node.children) { node.children = []; } loads++; };
try {
_treeRenders = 0;
await pysimFsExpandAll();
assert.strictEqual(pysimFsTreeRoot.expanded, true);
assert.strictEqual(gsm.expanded, false, 'an absent DF is not expanded');
assert.strictEqual(telecom.expanded, true);
assert.strictEqual(telecom.children[0].expanded, true);
assert.ok(loads > 0, 'lazy children are loaded on demand');
assert.strictEqual(_treeRenders, 1);
pysimFsCollapseAll();
assert.strictEqual(pysimFsTreeRoot.expanded, false);
assert.strictEqual(telecom.expanded, false);
assert.strictEqual(telecom.children[0].expanded, false);
} finally {
pysimFsTreeRoot = null;
}
});
test('auto-expand fires only for a non-"all" filter', () => {
// expand-all (with a null tree) still re-renders once; pin the branch
pysimFsFilter = new Set(['all']);
_treeRenders = 0;
pysimFsAutoExpandForFilter();
assert.strictEqual(_treeRenders, 0, '"all" does not expand');
pysimFsFilter = new Set(['identifiers']);
pysimFsAutoExpandForFilter();
assert.strictEqual(_treeRenders, 1, 'a category expands the tree');
});
test('the menu marks the saved selection', () => {
pysimFsFilter = new Set(['svc:Short Message Storage (SMS)', 'identifiers']);
dom.menu = fakeMenu();
dom.btn = fakeBtn();
pysimFsFilterRender();
assert.deepStrictEqual(renderedBoxes().filter(b => b.checked).map(b => b.id),
['identifiers', 'svc:Short Message Storage (SMS)']);
assert.strictEqual(box('all').checked, false);
assert.strictEqual(dom.btn.textContent, 'Selected: 2');
});
test('the button summary names the single entry or counts the selection', () => {
const summary = ids => withFilter(ids, () => pysimFsFilterSummary());
assert.strictEqual(summary(['all']), 'All');
// an empty selection also means everything
assert.strictEqual(summary([]), 'All');
assert.strictEqual(summary(['all', 'plmn-selection']), 'All');
assert.strictEqual(summary(['custom']), 'Custom files');
assert.strictEqual(summary(['plmn-selection']), 'PLMN selection');
assert.strictEqual(summary(['svc:Service Provider Name (SPN)']), 'Service Provider Name (SPN)');
assert.strictEqual(summary(['plmn-selection', 'wlan']), 'Selected: 2');
// an unknown id falls back to itself
assert.strictEqual(summary(['providers']), 'providers');
});
test('the menu toggles open and closed', () => {
dom.menu = fakeMenu();
dom.btn = fakeBtn();
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
pysimFsFilterToggle();
assert.strictEqual(dom.menu.classList.contains('hidden'), false);
assert.strictEqual(dom.btn.getAttribute('aria-expanded'), 'true');
pysimFsFilterToggle();
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
assert.strictEqual(dom.btn.getAttribute('aria-expanded'), 'false');
// the forced forms (the outside click / Escape handlers pass false)
pysimFsFilterToggle(true);
assert.strictEqual(dom.menu.classList.contains('hidden'), false);
pysimFsFilterToggle(false);
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
});
test('the checkbox rows keep "all" exclusive and persist the selection', () => {
dom.menu = fakeMenu();
dom.btn = fakeBtn();
delete _store.simple_fs_filter;
_treeRenders = 0;
pysimFsFilter = new Set(['all']);
pysimFsFilterSetEntry('all', true);
assert.deepStrictEqual([...pysimFsFilter], ['all']);
assert.strictEqual(_store.simple_fs_filter, '["all"]');
assert.strictEqual(_treeRenders, 1);
// choosing a category drops "all"
pysimFsFilterSetEntry('provider-names', true);
assert.deepStrictEqual([...pysimFsFilter], ['provider-names']);
assert.strictEqual(_store.simple_fs_filter, '["provider-names"]');
assert.strictEqual(_treeRenders, 3); // render + the auto-expand's render
assert.strictEqual(box('provider-names').checked, true);
assert.strictEqual(box('all').checked, false);
assert.strictEqual(dom.btn.textContent, 'Provider names');
// several categories stay selected
pysimFsFilterSetEntry('identifiers', true);
assert.deepStrictEqual([...pysimFsFilter], ['provider-names', 'identifiers']);
// unchecking one keeps the other
pysimFsFilterSetEntry('provider-names', false);
assert.deepStrictEqual([...pysimFsFilter], ['identifiers']);
// unchecking the last one restores the default
pysimFsFilterSetEntry('identifiers', false);
assert.deepStrictEqual([...pysimFsFilter], ['all']);
assert.strictEqual(_store.simple_fs_filter, '["all"]');
assert.strictEqual(box('all').checked, true);
// "all" is exclusive even when another entry is selected
pysimFsFilter = new Set(['provider-names']);
pysimFsFilterSetEntry('all', true);
assert.deepStrictEqual([...pysimFsFilter], ['all']);
});
test('the saved selection is restored, defaulting to "all"', () => {
delete _store.simple_fs_filter;
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
_store.simple_fs_filter = JSON.stringify(['providers', 'svc:Service Provider Name (SPN)']);
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['providers', 'svc:Service Provider Name (SPN)']);
// `all` is exclusive; an empty or broken save falls back to it
_store.simple_fs_filter = JSON.stringify(['all', 'plmn-selection']);
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
_store.simple_fs_filter = '[]';
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
_store.simple_fs_filter = '{not json';
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
});
test('the render consults the filter when walking children', () => {
assert.match(html, /if \(!pysimFsFilterShow\(child\)\) continue;/);
assert.ok(!/pysimFsFilterMatch\(child\)/.test(html));
});
test('the directory rule uses the registry paths, not the loaded children', () => {
const fn = extractFunc(html, 'pysimFsFilterShow');
assert.ok(!fn.includes('node.children'), fn);
assert.ok(fn.indexOf('pysimFsFilterMatchingPaths()') > 0, fn);
assert.ok(fn.indexOf("path === 'MF'") > 0, fn);
// the standard list is read synchronously from the cached array
assert.match(html, /let _uiccStdFiles = null;/);
assert.ok(extractFunc(html, 'pysimFsFilterMatchingPaths').indexOf('_uiccStdFiles') > 0);
});
test('every filter change drops the path cache before the tree redraws', () => {
for (const name of ['pysimFsFilterSetEntry', 'pysimFsSetFilterToService', 'pysimFsFilterRestore']) {
const fn = extractFunc(html, name);
const at = fn.indexOf('pysimFsFilterPathsInvalidate()');
assert.ok(at > 0, name + ' does not invalidate the path cache');
const render = fn.indexOf('pysimFsRenderTree()');
if (render > 0) assert.ok(at < render, name + ': the cache is dropped before the redraw');
}
// the list arriving redraws a tree that was filtered while it loaded
const load = extractFunc(html, 'uiccStdFilesLoad');
assert.ok(load.indexOf('pysimFsRenderTree()') > 0, load);
});
test('the filter state is persisted under simple_fs_filter', () => {
const fn = extractFunc(html, 'pysimFsFilterSetEntry');
assert.ok(fn.indexOf("localStorage.setItem('simple_fs_filter'") > 0, fn);
assert.ok(fn.indexOf('pysimFsRenderTree()') > 0, fn);
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
});
test('the dropdown is re-rendered on language switch', () => {
const fn = extractFunc(html, 'refreshDynamicI18n');
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
});
test('the filter labels have Russian entries', () => {
const dict = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/)[1];
for (const key of ['All', 'Custom files', 'Identifiers', 'PLMN', 'Services', 'PLMN selection',
'Provider names', 'WLAN / I-WLAN', 'Selected: {n}']) {
assert.ok(dict.includes("'" + key + "':"), 'missing Russian entry: ' + key);
}
});
+16 -2
View File
@@ -24,6 +24,9 @@ function extractFunc(src, name) {
let code = 'var pysimFsSelected = null;\n'
+ 'var pysimFsDecodedMode = false;\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, 'pysimFsRead') + '\n';
eval(code);
@@ -53,7 +56,7 @@ function setup() {
};
globalThis.t = s => s;
globalThis.esc = s => s;
globalThis.pysimFsFindNode = () => ({ name: 'EF.IMSI', fid: '6f07' });
globalThis.pysimFsFindNodeByPath = () => ({ name: 'EF.IMSI', fid: '6f07' });
globalThis.pysimFsSelectBody = () => ({});
globalThis.pysimFsShowError = (el, sw, err) => {
errorShown = { sw, err };
@@ -63,7 +66,7 @@ function setup() {
if (fetchResult instanceof Error) throw fetchResult;
return fetchResult;
};
pysimFsSelected = 'EF.IMSI';
pysimFsSelected = 'ADF.USIM/6F07';
pysimFsDecodedMode = false;
pysimFsTreeRoot = { name: 'MF' };
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');
});
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 () => {
setup();
fetchResult = { success: false, sw: '6982', error: 'Security status not satisfied' };
+28
View File
@@ -22,7 +22,11 @@ function extractFunc(src, name) {
}
let code = 'var pysimFsSort = "fid";\n';
code += 'var pysimFsFilter = new Set();\n';
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
code += extractFunc(html, 'pysimFsNodePath') + '\n';
code += extractFunc(html, 'pysimFsFilterMatch') + '\n';
code += extractFunc(html, 'pysimFsFilterShow') + '\n';
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);
@@ -65,3 +69,27 @@ test('non-existing directory hides previously loaded children', () => {
assert.ok(!out.includes('(empty)'), 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\)/);
});
+134
View File
@@ -0,0 +1,134 @@
// The service-table rows' cross-link to the file-tree filter: a table row
// that is backed by files gets a "To EF filter" button that narrows the tree
// to exactly that service's files.
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';
}
// module state the extracted functions close over
let pysimFsFilter = new Set(['all']);
let _servicesFeatureByNumber = null;
// the tree filter's matching-path cache (pysimFsSetFilterToService drops it)
let _fsFilterPaths = null;
let _fsFilterPathsKey = null;
const _store = {};
let _renders = 0;
let _treeRenders = 0;
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
globalThis.localStorage = {
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
setItem: (k, v) => { _store[k] = String(v); },
removeItem: k => { delete _store[k]; },
};
globalThis.esc = s => String(s);
globalThis.t = k => k;
globalThis.pysimFsFilterRender = () => { _renders++; };
globalThis.pysimFsRenderTree = () => { _treeRenders++; };
let code = '';
for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService',
'pysimFsFilterPathsInvalidate', 'pysimFsExpandAll', 'pysimFsCollapseAll',
'pysimFsAutoExpandForFilter']) {
code += extractFunc(html, f) + '\n';
}
for (const c of ['SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) code += extractConst(html, c);
code += "var FS_FILTER_ALL = 'all';\n";
eval(code);
test('servicesFeatureByNumber inverts the cross-reference per file table', () => {
const map = servicesFeatureByNumber();
assert.strictEqual(map.ust[2], 'Fixed Dialling Numbers (FDN)');
assert.strictEqual(map.sst[2], 'Local Phone Book (ADN)');
assert.strictEqual(map.est[1], 'Fixed Dialling Numbers (FDN)');
assert.strictEqual(map.ist[1], 'P-CSCF address');
// the same number names a different feature in another table
assert.strictEqual(map.ust[1], 'Local Phone Book (ADN)');
assert.strictEqual(map.sst[1], 'CHV1 disable function');
// a capability-only number maps to a feature too
assert.strictEqual(map.ust[99], 'URI support by UICC');
// the map is built once
assert.strictEqual(servicesFeatureByNumber(), map);
});
test('pysimFsFilterBtnHtml links a file-backed service row only', () => {
const out = pysimFsFilterBtnHtml('ust', 2);
assert.match(out, /^<button type="button" class="ml-1 px-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"/);
assert.ok(out.indexOf("pysimFsSetFilterToService('ust', 2)") > 0, out);
assert.ok(out.indexOf('To EF filter') > 0, out);
// OCI is file-backed
assert.notStrictEqual(pysimFsFilterBtnHtml('ust', 8), '');
// a capability-only service, a missing table and an unmodelled number
assert.strictEqual(pysimFsFilterBtnHtml('ust', 99), '');
assert.strictEqual(pysimFsFilterBtnHtml(undefined, 1), '');
assert.strictEqual(pysimFsFilterBtnHtml('sst', 99), '');
});
test('pysimFsSetFilterToService narrows the tree to that service', () => {
delete _store.simple_fs_filter;
_renders = 0;
_treeRenders = 0;
pysimFsFilter = new Set(['all']);
pysimFsSetFilterToService('ust', 2);
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
assert.strictEqual(_store.simple_fs_filter, JSON.stringify(['svc:Fixed Dialling Numbers (FDN)']));
assert.strictEqual(_renders, 1);
assert.strictEqual(_treeRenders, 2); // render + the auto-expand's render
// a capability-only service changes nothing
pysimFsSetFilterToService('ust', 99);
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
assert.strictEqual(_renders, 1);
assert.strictEqual(_treeRenders, 2);
});
test('the four service-table fields carry their table, EF.ACC carries none', () => {
assert.match(html, /const EF_UST_FIELDS = \[\{[^\n]*table: 'ust'/);
assert.match(html, /const EF_IST_FIELDS = \[\{[^\n]*table: 'ist'/);
assert.match(html, /const EF_EST_FIELDS = \[\{[^\n]*table: 'est'/);
assert.match(html, /const EF_SST_FIELDS = \[\{[\s\S]{0,200}?table: 'sst'/);
const acc = /const EF_ACC_FIELDS = [^\n]*/.exec(html);
assert.ok(acc, 'EF_ACC_FIELDS not found');
assert.ok(acc[0].indexOf('table') < 0, acc[0]);
});
test('both service-grid renderers append the cross-link after their control', () => {
const fn = extractFunc(html, 'efDecFieldHtml');
assert.strictEqual((fn.match(/pysimFsFilterBtnHtml\(f\.table, n\)/g) || []).length, 2, fn);
});
+2 -1
View File
@@ -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('pysim-fs-filename'));
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()'));
});
+702
View File
@@ -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;/);
});
+297 -1
View File
@@ -1,5 +1,5 @@
{
"generated": "2026-09-23",
"generated": "2026-10-25",
"files": [
{
"path": "MF/2F00",
@@ -161,6 +161,30 @@
"kind": "ef",
"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",
"name": "EF.ADN",
@@ -1030,6 +1054,46 @@
"kind": "ef",
"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",
"name": "DF.WLAN",
@@ -1182,6 +1246,38 @@
"kind": "ef",
"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",
"name": "DF.ProSe",
@@ -1302,6 +1398,30 @@
"kind": "ef",
"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",
"name": "DF.5GS",
@@ -2262,6 +2382,14 @@
"kind": "ef",
"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",
"name": "EF.NAFKCA",
@@ -2358,6 +2486,14 @@
"kind": "ef",
"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",
"name": "EF.IPS",
@@ -2366,6 +2502,14 @@
"kind": "ef",
"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",
"name": "EF.ePDGId",
@@ -2414,6 +2558,22 @@
"kind": "ef",
"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",
"name": "EF.XCAPConfigData",
@@ -2527,6 +2687,46 @@
"kind": "ef",
"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",
"name": "DF.WLAN",
@@ -2679,6 +2879,38 @@
"kind": "ef",
"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",
"name": "DF.ProSe",
@@ -2799,6 +3031,30 @@
"kind": "ef",
"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",
"name": "DF.5GS",
@@ -3751,6 +4007,14 @@
"kind": "ef",
"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",
"name": "EF.NAFKCA",
@@ -3847,6 +4111,14 @@
"kind": "ef",
"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",
"name": "EF.IPS",
@@ -3855,6 +4127,14 @@
"kind": "ef",
"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",
"name": "EF.ePDGId",
@@ -3903,6 +4183,22 @@
"kind": "ef",
"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",
"name": "EF.XCAPConfigData",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.38.1"
version = "3.40.3"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9"
+1 -1
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.38.1'
VERSION = '3.40.3'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
+63
View File
@@ -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
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.
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
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
# 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):
"""Depth-first walk of a DF/ADF subtree (children is a fid -> CardFile dict)."""
for fid, child in sorted(node.children.items()):
@@ -116,6 +177,8 @@ def build():
seen.add(key)
out.append(_entry(key, adf.name, adf.name, '', 'adf', adf.name, app.aid))
_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
# 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']))
+29 -3
View File
@@ -12,15 +12,41 @@ set VENV_DIR=%~dp0.venv
set LOG=%~dp0simple_lastrun.log
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" (
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.
"%VENV_DIR%\Scripts\pysim-simple-server.exe" --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
goto :eof
)
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.
python -m pysim_simple_server --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
goto :eof
@@ -28,7 +54,7 @@ if exist "%~dp0pysim_simple_server\__main__.py" (
where pysim-simple-server >nul 2>&1
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.
pysim-simple-server --http-port 8080 -p 0 %* 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOG%'"
goto :eof
+20 -2
View File
@@ -10,6 +10,24 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
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
# cards) - it is gitignored and must not be committed.
LOG_FILE="$SCRIPT_DIR/simple_lastrun.log"
@@ -73,7 +91,7 @@ if [ -f "$VENV_DIR/bin/pysim-simple-server" ]; then
elif command -v pysim-simple-server &> /dev/null; then
SERVER="pysim-simple-server"
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)."
cd "$SCRIPT_DIR" && python3 -m pysim_simple_server --http-port 8080 $READER_ARGS "$@"
exit $?
@@ -84,7 +102,7 @@ else
exit 1
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 "Press Ctrl+C to stop."
echo "Extra arguments are passed to the server (e.g. ./start.sh --gsmtap)."
+15
View File
@@ -150,6 +150,21 @@ class SourcePins(unittest.TestCase):
# 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()
+54
View File
@@ -72,6 +72,60 @@ class TestUiccFilesAsset(unittest.TestCase):
self.assertRegex(f['fid'], r'^[0-9A-F]{4}$')
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__':
unittest.main()