diff --git a/docs/services-editor.md b/docs/services-editor.md
index f7db781..6d5dcaa 100644
--- a/docs/services-editor.md
+++ b/docs/services-editor.md
@@ -6,6 +6,11 @@
> `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`. §B (the
+> filesystem check) is **not** implemented yet.
**Counts:** SST **59** / UST **150** / IST **22** / EST **3** services →
**164 feature rows** (10 SIM-only, 85 USIM-only, 5 ISIM-only, 61 shared,
diff --git a/frontend/index.html b/frontend/index.html
index 043649b..a26b0ac 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1106,6 +1106,7 @@
File manager
pySim command line
Raw APDU
+ Services
@@ -1166,6 +1167,17 @@
+
+
+ Services
+ Refresh
+
+
+
+
+
+
+
@@ -15799,12 +15811,13 @@ function pysimSwitchSubtab(name) {
btn.classList.toggle('text-gray-700', !isActive);
btn.classList.toggle('dark:text-slate-300', !isActive);
});
- ['cmd', 'apdu', 'files'].forEach(s => {
+ ['cmd', 'apdu', 'files', 'services'].forEach(s => {
document.getElementById('pysim-sub-' + s).classList.toggle('hidden', s !== name);
});
pysimHelpAnchor = {cmd:'pysim-cmdline', apdu:'raw-apdu', files:'file-manager'}[name] || 'card-reader';
setHelpAnchor(pysimHelpAnchor);
if (name === 'files') pysimFsFitTree();
+ if (name === 'services') pysimServicesOpen();
navRecord();
}
@@ -18207,6 +18220,7 @@ async function pysimResetCardData(refreshStatus) {
stkMenuClose();
stkMenuStack = [];
pysimFsRefresh();
+ pysimServicesReset();
stkCheckMenu();
pysimEventsRender();
pysimProactiveLogRender();
@@ -23084,6 +23098,506 @@ const EF_EST_SERVICES = {1: "Fixed Dialling Numbers (FDN)", 2: "Barred Dialling
// TS 31.102 §4.4.2.1 EF_PBR primitive tags (the phonebook kinds).
const EF_PBR_KINDS = { 0xC0: 'EF.ADN', 0xC1: 'EF.IAP', 0xC2: 'EF.EXT1', 0xC3: 'EF.SNE', 0xC4: 'EF.ANR', 0xC5: 'EF.PBC', 0xC6: 'EF.GRP', 0xC7: 'EF.AAS', 0xC8: 'EF.GAS', 0xC9: 'EF.UID', 0xCA: 'EF.EMAIL', 0xCB: 'EF.CCP1', 0xCC: 'EF.PURI' };
+// ===== cross-file Services editor (EF.SST / EF.UST / EF.EST / EF.IST) =====
+// The four service tables number the same feature differently (and some
+// features exist in one application only), so the editor is driven by a
+// curated feature cross-reference rather than by service number:
+// EF.SST DF.GSM 6F38 2 bits/service (allocated + activated), TS 51.011 10.3.7
+// EF.UST ADF.USIM 6F38 1 bit/service, TS 31.102 4.2.8
+// EF.EST ADF.USIM 6F56 the enabled subset of UST, TS 31.102 4.2.47
+// EF.IST ADF.ISIM 6F07 1 bit/service, TS 31.103 4.2.7
+// The table is a transcription of docs/services-editor.md §A.1-§A.3 (the
+// RFU/reserved slots of §A.4 are not features), and its completeness - every
+// non-RFU service number of every file appears in exactly one row - is pinned
+// by frontend/tests/services.test.js. cls: 'shared' | 'sim' | 'usim' | 'isim'.
+const SERVICES_CROSS_REF = [
+ { f: 'CHV1 disable function', sst: 1, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'Local Phone Book (ADN)', sst: 2, ust: 1, est: null, ist: null, cls: 'shared' },
+ { f: 'Fixed Dialling Numbers (FDN)', sst: 3, ust: 2, est: 1, ist: null, cls: 'shared' },
+ { f: 'Short Message Storage (SMS)', sst: 4, ust: 10, est: null, ist: 6, cls: 'shared' },
+ { f: 'Advice of Charge (AoC)', sst: 5, ust: 13, est: null, ist: null, cls: 'shared' },
+ { f: 'Capability Configuration Parameters (CCP)', sst: 6, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'Capability Configuration Parameters 2 (CCP2)', sst: null, ust: 14, est: null, ist: null, cls: 'usim' },
+ { f: 'PLMN selector', sst: 7, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'MSISDN', sst: 9, ust: 21, est: null, ist: null, cls: 'shared' },
+ { f: 'Extension 1', sst: 10, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'Extension 2', sst: 11, ust: 3, est: null, ist: null, cls: 'shared' },
+ { f: 'SMS Parameters (SMSP)', sst: 12, ust: 12, est: null, ist: null, cls: 'shared' },
+ { f: 'Last Number Dialled (LND)', sst: 13, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'Cell Broadcast Message Identifier (CBMI)', sst: 14, ust: 15, est: null, ist: null, cls: 'shared' },
+ { f: 'Group Identifier Level 1 (GID1)', sst: 15, ust: 17, est: null, ist: null, cls: 'shared' },
+ { f: 'Group Identifier Level 2 (GID2)', sst: 16, ust: 18, est: null, ist: null, cls: 'shared' },
+ { f: 'Service Provider Name (SPN)', sst: 17, ust: 19, est: null, ist: null, cls: 'shared' },
+ { f: 'Service Dialling Numbers (SDN)', sst: 18, ust: 4, est: null, ist: null, cls: 'shared' },
+ { f: 'Extension 3', sst: 19, ust: 5, est: null, ist: null, cls: 'shared' },
+ { f: 'VGCS Group Identifier List', sst: 21, ust: 57, est: null, ist: null, cls: 'shared' },
+ { f: 'VBS Group Identifier List', sst: 22, ust: 58, est: null, ist: null, cls: 'shared' },
+ { f: 'enhanced Multi-Level Precedence and Pre-emption (eMLPP)', sst: 23, ust: 24, est: null, ist: null, cls: 'shared' },
+ { f: 'Automatic Answer for eMLPP (AAeM)', sst: 24, ust: 25, est: null, ist: null, cls: 'shared' },
+ { f: 'Data download via SMS-CB', sst: 25, ust: 29, est: null, ist: null, cls: 'shared' },
+ { f: 'Data download via SMS-PP', sst: 26, ust: 28, est: null, ist: null, cls: 'shared' },
+ { f: 'Menu selection', sst: 27, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'Call control (by SIM/USIM)', sst: 28, ust: 30, est: null, ist: null, cls: 'shared' },
+ { f: 'Proactive SIM', sst: 29, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'Cell Broadcast Message Identifier Ranges (CBMIR)', sst: 30, ust: 16, est: null, ist: null, cls: 'shared' },
+ { f: 'Barred Dialling Numbers (BDN)', sst: 31, ust: 6, est: 2, ist: null, cls: 'shared' },
+ { f: 'Extension 4', sst: 32, ust: 7, est: null, ist: null, cls: 'shared' },
+ { f: 'De-personalisation Control Keys (DCK)', sst: 33, ust: 36, est: null, ist: null, cls: 'shared' },
+ { f: 'Co-operative Network List (CNL)', sst: 34, ust: 37, est: null, ist: null, cls: 'shared' },
+ { f: 'Short Message Status Reports (SMSR)', sst: 35, ust: 11, est: null, ist: 7, cls: 'shared' },
+ { f: 'Network\'s indication of alerting in the MS (NIA)', sst: 36, ust: 56, est: null, ist: null, cls: 'shared' },
+ { f: 'MO-SMS control (by SIM/USIM)', sst: 37, ust: 31, est: null, ist: null, cls: 'shared' },
+ { f: 'GPRS', sst: 38, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'Image (IMG)', sst: 39, ust: 22, est: null, ist: null, cls: 'shared' },
+ { f: 'SoLSA', sst: 40, ust: 23, est: null, ist: null, cls: 'shared' },
+ { f: 'USSD string data object supported in Call Control', sst: 41, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'RUN AT COMMAND command', sst: 42, ust: 32, est: null, ist: null, cls: 'shared' },
+ { f: 'User controlled PLMN selector with Access Technology', sst: 43, ust: 20, est: null, ist: null, cls: 'shared' },
+ { f: 'Operator controlled PLMN selector with Access Technology', sst: 44, ust: 42, est: null, ist: null, cls: 'shared' },
+ { f: 'HPLMN selector with Access Technology', sst: 45, ust: 43, est: null, ist: null, cls: 'shared' },
+ { f: 'CPBCCH Information', sst: 46, ust: 39, est: null, ist: null, cls: 'shared' },
+ { f: 'Investigation Scan', sst: 47, ust: 40, est: null, ist: null, cls: 'shared' },
+ { f: 'Extended Capability Configuration Parameters (ECCP)', sst: 48, ust: null, est: null, ist: null, cls: 'sim' },
+ { f: 'MExE', sst: 49, ust: 41, est: null, ist: null, cls: 'shared' },
+ { f: 'PLMN Network Name (PNN)', sst: 51, ust: 45, est: null, ist: null, cls: 'shared' },
+ { f: 'Operator PLMN List (OPL)', sst: 52, ust: 46, est: null, ist: null, cls: 'shared' },
+ { f: 'Mailbox Dialling Numbers (MBDN)', sst: 53, ust: 47, est: null, ist: null, cls: 'shared' },
+ { f: 'Message Waiting Indication Status (MWIS)', sst: 54, ust: 48, est: null, ist: null, cls: 'shared' },
+ { f: 'Call Forwarding Indication Status (CFIS)', sst: 55, ust: 49, est: null, ist: null, cls: 'shared' },
+ { f: 'Service Provider Display Information (SPDI)', sst: 56, ust: 51, est: null, ist: null, cls: 'shared' },
+ { f: 'Multimedia Messaging Service (MMS)', sst: 57, ust: 52, est: null, ist: null, cls: 'shared' },
+ { f: 'Extension 8', sst: 58, ust: 53, est: null, ist: null, cls: 'shared' },
+ { f: 'MMS User Connectivity Parameters', sst: 59, ust: 55, est: null, ist: null, cls: 'shared' },
+ { f: 'Outgoing Call Information (OCI and OCT)', sst: null, ust: 8, est: null, ist: null, cls: 'usim' },
+ { f: 'Incoming Call Information (ICI and ICT)', sst: null, ust: 9, est: null, ist: null, cls: 'usim' },
+ { f: 'GSM Access', sst: null, ust: 27, est: null, ist: null, cls: 'usim' },
+ { f: 'Enabled Services Table', sst: null, ust: 34, est: null, ist: null, cls: 'usim' },
+ { f: 'APN Control List (ACL)', sst: null, ust: 35, est: 3, ist: null, cls: 'usim' },
+ { f: 'GSM security context', sst: null, ust: 38, est: null, ist: null, cls: 'usim' },
+ { f: 'Extension 5', sst: null, ust: 44, est: null, ist: null, cls: 'usim' },
+ { f: 'Call control on GPRS by USIM', sst: null, ust: 54, est: null, ist: null, cls: 'usim' },
+ { f: 'Pseudonym', sst: null, ust: 59, est: null, ist: null, cls: 'usim' },
+ { f: 'User Controlled PLMN selector for I-WLAN access', sst: null, ust: 60, est: null, ist: null, cls: 'usim' },
+ { f: 'Operator Controlled PLMN selector for I-WLAN access', sst: null, ust: 61, est: null, ist: null, cls: 'usim' },
+ { f: 'User controlled WSID list', sst: null, ust: 62, est: null, ist: null, cls: 'usim' },
+ { f: 'Operator controlled WSID list', sst: null, ust: 63, est: null, ist: null, cls: 'usim' },
+ { f: 'VGCS security', sst: null, ust: 64, est: null, ist: null, cls: 'usim' },
+ { f: 'VBS security', sst: null, ust: 65, est: null, ist: null, cls: 'usim' },
+ { f: 'WLAN Reauthentication Identity', sst: null, ust: 66, est: null, ist: null, cls: 'usim' },
+ { f: 'Multimedia Messages Storage', sst: null, ust: 67, est: null, ist: null, cls: 'usim' },
+ { f: 'Generic Bootstrapping Architecture (GBA)', sst: null, ust: 68, est: null, ist: 2, cls: 'shared' },
+ { f: 'MBMS security', sst: null, ust: 69, est: null, ist: null, cls: 'usim' },
+ { f: 'Data download via USSD and USSD application mode', sst: null, ust: 70, est: null, ist: null, cls: 'usim' },
+ { f: 'Equivalent HPLMN', sst: null, ust: 71, est: null, ist: null, cls: 'usim' },
+ { f: 'Additional TERMINAL PROFILE after UICC activation', sst: null, ust: 72, est: null, ist: null, cls: 'usim' },
+ { f: 'Equivalent HPLMN Presentation Indication', sst: null, ust: 73, est: null, ist: null, cls: 'usim' },
+ { f: 'Last RPLMN Selection Indication', sst: null, ust: 74, est: null, ist: null, cls: 'usim' },
+ { f: 'OMA BCAST Smart Card Profile', sst: null, ust: 75, est: null, ist: null, cls: 'usim' },
+ { f: 'GBA-based Local Key Establishment Mechanism', sst: null, ust: 76, est: null, ist: 4, cls: 'shared' },
+ { f: 'Terminal Applications', sst: null, ust: 77, est: null, ist: null, cls: 'usim' },
+ { f: 'Service Provider Name Icon', sst: null, ust: 78, est: null, ist: null, cls: 'usim' },
+ { f: 'PLMN Network Name Icon', sst: null, ust: 79, est: null, ist: null, cls: 'usim' },
+ { f: 'Connectivity Parameters for USIM IP connections', sst: null, ust: 80, est: null, ist: null, cls: 'usim' },
+ { f: 'Home I-WLAN Specific Identifier List', sst: null, ust: 81, est: null, ist: null, cls: 'usim' },
+ { f: 'I-WLAN Equivalent HPLMN Presentation Indication', sst: null, ust: 82, est: null, ist: null, cls: 'usim' },
+ { f: 'I-WLAN HPLMN Priority Indication', sst: null, ust: 83, est: null, ist: null, cls: 'usim' },
+ { f: 'I-WLAN Last Registered PLMN', sst: null, ust: 84, est: null, ist: null, cls: 'usim' },
+ { f: 'EPS Mobility Management Information', sst: null, ust: 85, est: null, ist: null, cls: 'usim' },
+ { f: 'Allowed CSG Lists and corresponding indications', sst: null, ust: 86, est: null, ist: null, cls: 'usim' },
+ { f: 'Call control on EPS PDN connection by USIM', sst: null, ust: 87, est: null, ist: null, cls: 'usim' },
+ { f: 'HPLMN Direct Access', sst: null, ust: 88, est: null, ist: null, cls: 'usim' },
+ { f: 'eCall Data', sst: null, ust: 89, est: null, ist: null, cls: 'usim' },
+ { f: 'Operator CSG Lists and corresponding indications', sst: null, ust: 90, est: null, ist: null, cls: 'usim' },
+ { f: 'Support for SM-over-IP', sst: null, ust: 91, est: null, ist: 8, cls: 'shared' },
+ { f: 'Support of CSG Display Control', sst: null, ust: 92, est: null, ist: null, cls: 'usim' },
+ { f: 'Communication Control for IMS', sst: null, ust: 93, est: null, ist: 9, cls: 'shared' },
+ { f: 'Extended Terminal Applications', sst: null, ust: 94, est: null, ist: null, cls: 'usim' },
+ { f: 'Support of UICC access to IMS', sst: null, ust: 95, est: null, ist: 10, cls: 'shared' },
+ { f: 'Non-Access Stratum configuration by USIM', sst: null, ust: 96, est: null, ist: null, cls: 'usim' },
+ { f: 'PWS configuration by USIM', sst: null, ust: 97, est: null, ist: null, cls: 'usim' },
+ { f: 'URI support by UICC', sst: null, ust: 99, est: null, ist: 11, cls: 'shared' },
+ { f: 'Extended EARFCN support', sst: null, ust: 100, est: null, ist: null, cls: 'usim' },
+ { f: 'ProSe', sst: null, ust: 101, est: null, ist: null, cls: 'usim' },
+ { f: 'USAT Application Pairing', sst: null, ust: 102, est: null, ist: null, cls: 'usim' },
+ { f: 'Media Type support', sst: null, ust: 103, est: null, ist: 12, cls: 'shared' },
+ { f: 'IMS call disconnection cause', sst: null, ust: 104, est: null, ist: 13, cls: 'shared' },
+ { f: 'URI support for MO SHORT MESSAGE CONTROL', sst: null, ust: 105, est: null, ist: 14, cls: 'shared' },
+ { f: 'ePDG configuration Information support', sst: null, ust: 106, est: null, ist: null, cls: 'usim' },
+ { f: 'ePDG configuration Information configured', sst: null, ust: 107, est: null, ist: null, cls: 'usim' },
+ { f: 'ACDC support', sst: null, ust: 108, est: null, ist: null, cls: 'usim' },
+ { f: 'MCPTT', sst: null, ust: 109, est: null, ist: 15, cls: 'shared' },
+ { f: 'ePDG configuration Information for Emergency Service support', sst: null, ust: 110, est: null, ist: null, cls: 'usim' },
+ { f: 'ePDG configuration Information for Emergency Service configured', sst: null, ust: 111, est: null, ist: null, cls: 'usim' },
+ { f: 'eCall Data over IMS', sst: null, ust: 112, est: null, ist: null, cls: 'usim' },
+ { f: 'URI support for SMS-PP DOWNLOAD', sst: null, ust: 113, est: null, ist: 16, cls: 'shared' },
+ { f: 'From Preferred', sst: null, ust: 114, est: null, ist: 17, cls: 'shared' },
+ { f: 'IMS configuration data', sst: null, ust: 115, est: null, ist: 18, cls: 'shared' },
+ { f: 'TV configuration', sst: null, ust: 116, est: null, ist: null, cls: 'usim' },
+ { f: '3GPP PS Data Off', sst: null, ust: 117, est: null, ist: null, cls: 'usim' },
+ { f: '3GPP PS Data Off Service List', sst: null, ust: 118, est: null, ist: null, cls: 'usim' },
+ { f: 'V2X', sst: null, ust: 119, est: null, ist: null, cls: 'usim' },
+ { f: 'XCAP Configuration Data', sst: null, ust: 120, est: null, ist: 19, cls: 'shared' },
+ { f: 'EARFCN list for MTC/NB-IOT UEs', sst: null, ust: 121, est: null, ist: null, cls: 'usim' },
+ { f: '5GS Mobility Management Information', sst: null, ust: 122, est: null, ist: null, cls: 'usim' },
+ { f: '5G Security Parameters', sst: null, ust: 123, est: null, ist: null, cls: 'usim' },
+ { f: 'Subscription identifier privacy support', sst: null, ust: 124, est: null, ist: null, cls: 'usim' },
+ { f: 'SUCI calculation by the USIM', sst: null, ust: 125, est: null, ist: null, cls: 'usim' },
+ { f: 'UAC Access Identities support', sst: null, ust: 126, est: null, ist: null, cls: 'usim' },
+ { f: 'Expect control plane-based Steering of Roaming information during initial registration in VPLMN', sst: null, ust: 127, est: null, ist: null, cls: 'usim' },
+ { f: 'Call control on PDU Session by USIM', sst: null, ust: 128, est: null, ist: null, cls: 'usim' },
+ { f: '5GS Operator PLMN List', sst: null, ust: 129, est: null, ist: null, cls: 'usim' },
+ { f: 'Support for SUPI of type NSI or GLI or GCI', sst: null, ust: 130, est: null, ist: null, cls: 'usim' },
+ { f: '3GPP PS Data Off separate Home and Roaming lists', sst: null, ust: 131, est: null, ist: null, cls: 'usim' },
+ { f: 'Support for URSP by USIM', sst: null, ust: 132, est: null, ist: null, cls: 'usim' },
+ { f: '5G Security Parameters extended', sst: null, ust: 133, est: null, ist: null, cls: 'usim' },
+ { f: 'MuD and MiD configuration data', sst: null, ust: 134, est: null, ist: 21, cls: 'shared' },
+ { f: 'Support for Trusted non-3GPP access networks by USIM', sst: null, ust: 135, est: null, ist: null, cls: 'usim' },
+ { f: 'Support for multiple records of NAS security context storage for multiple registration', sst: null, ust: 136, est: null, ist: null, cls: 'usim' },
+ { f: 'Pre-configured CAG information list', sst: null, ust: 137, est: null, ist: null, cls: 'usim' },
+ { f: 'SOR-CMCI storage in USIM', sst: null, ust: 138, est: null, ist: null, cls: 'usim' },
+ { f: '5G ProSe', sst: null, ust: 139, est: null, ist: null, cls: 'usim' },
+ { f: 'Storage of disaster roaming information in USIM', sst: null, ust: 140, est: null, ist: null, cls: 'usim' },
+ { f: 'Pre-configured eDRX parameters', sst: null, ust: 141, est: null, ist: null, cls: 'usim' },
+ { f: '5G NSWO support', sst: null, ust: 142, est: null, ist: null, cls: 'usim' },
+ { f: 'PWS configuration for SNPN in USIM', sst: null, ust: 143, est: null, ist: null, cls: 'usim' },
+ { f: 'Multiplier Coefficient for Higher Priority PLMN search via NG-RAN satellite access', sst: null, ust: 144, est: null, ist: null, cls: 'usim' },
+ { f: 'K_AUSF derivation configuration', sst: null, ust: 145, est: null, ist: null, cls: 'usim' },
+ { f: 'Network Identifier for SNPN (NID)', sst: null, ust: 146, est: null, ist: null, cls: 'usim' },
+ { f: '5MBS UE pre-configuration', sst: null, ust: 147, est: null, ist: null, cls: 'usim' },
+ { f: 'Operator Controlled Signal Threshold per Access Technology', sst: null, ust: 148, est: null, ist: null, cls: 'usim' },
+ { f: 'A2X', sst: null, ust: 149, est: null, ist: null, cls: 'usim' },
+ { f: 'IMS Data Channel Indication', sst: null, ust: 150, est: null, ist: null, cls: 'usim' },
+ { f: 'P-CSCF address', sst: null, ust: null, est: null, ist: 1, cls: 'isim' },
+ { f: 'HTTP Digest', sst: null, ust: null, est: null, ist: 3, cls: 'isim' },
+ { f: 'Support of P-CSCF discovery for IMS Local Break Out', sst: null, ust: null, est: null, ist: 5, cls: 'isim' },
+ { f: 'WebRTC URI', sst: null, ust: null, est: null, ist: 20, cls: 'isim' },
+ { f: 'IMS Data Channel indication', sst: null, ust: null, est: null, ist: 22, cls: 'isim' },
+];
+
+// The four files in the editor's column order, each addressed by its
+// canonical (DF, FID) path: the symbolic name alone is ambiguous across
+// applications (6F38 is EF.SST under DF.GSM and EF.UST under ADF.USIM, 6F07
+// is EF.IST under ADF.ISIM and EF.IMSI elsewhere) - docs/decoded-editing.md
+// finding 6.
+const SERVICES_FILES = [
+ { key: 'sst', label: 'EF.SST', path: 'DF.GSM/6F38' },
+ { key: 'ust', label: 'EF.UST', path: 'ADF.USIM/6F38' },
+ { key: 'est', label: 'EF.EST', path: 'ADF.USIM/6F56' },
+ { key: 'ist', label: 'EF.IST', path: 'ADF.ISIM/6F07' },
+];
+
+const SERVICES_TABLES = { ust: EF_UST_SERVICES, est: EF_EST_SERVICES, ist: EF_IST_SERVICES };
+
+function servicesDecode(key, bytes) {
+ if (key === 'sst') return efDecSst(bytes);
+ return efDecServiceTable(bytes, SERVICES_TABLES[key]);
+}
+
+// Service numbers of a file that the cross-reference does not model: the
+// spec's RFU/reserved slots (SST 8/20/50, UST 26/33/50/98). Their bits are
+// copied from the file's own bytes on save, so the editor never rewrites a
+// service it does not show (the per-file editors own those slots).
+function servicesUnmodelled(key) {
+ const table = key === 'sst' ? EF_SST_SERVICES : (SERVICES_TABLES[key] || {});
+ const modelled = {};
+ for (const f of SERVICES_CROSS_REF) if (f[key] !== null && f[key] !== undefined) modelled[f[key]] = true;
+ return Object.keys(table).map(Number).filter(n => !modelled[n]);
+}
+
+// Per-feature edit state from the four decoded tables. EF.SST is tri-state on
+// the card; the Services editor collapses it to 'on' (allocated and activated)
+// and 'off' (allocated-off *and* deleted render alike), because it never
+// de-allocates - explicit deletion is the per-file EF.SST editor's job. A
+// null means the feature has no number in that file (or the file is absent).
+function servicesSstState(dec, n) {
+ if (!dec || n === null || n === undefined) return null;
+ for (const s of (dec.services || [])) {
+ if (Number(s.n) !== Number(n)) continue;
+ return (s.allocated && s.activated) ? 'on' : 'off';
+ }
+ return 'off'; // not in the decoded list: the card has the service deleted
+}
+
+function servicesBitState(dec, n) {
+ if (!dec || n === null || n === undefined) return null;
+ for (const s of (dec.services || [])) if (Number(s.n) === Number(n)) return true;
+ return false;
+}
+
+function servicesFeatureState(feature, sstDec, ustDec, estDec, istDec) {
+ return {
+ sst: servicesSstState(sstDec, feature.sst),
+ ust: servicesBitState(ustDec, feature.ust),
+ est: servicesBitState(estDec, feature.est),
+ ist: servicesBitState(istDec, feature.ist),
+ };
+}
+
+// The card's allocation bit for an SST service, read straight from the file
+// bytes (TS 51.011 10.3.7: service n -> byte (n-1)>>2, pair start
+// 2*((n-1)&3), the first bit of the pair allocated).
+function servicesSstAllocated(origHex, n) {
+ const b = efBytes(origHex || '');
+ const idx = (n - 1) >> 2;
+ if (n < 1 || idx >= b.length) return false;
+ return !!(b[idx] & (1 << (2 * ((n - 1) & 3))));
+}
+
+// Original pairs of the unmodelled SST slots, so a save reproduces them.
+function servicesSstPreserved(origHex) {
+ const b = efBytes(origHex || '');
+ const out = [];
+ for (const n of servicesUnmodelled('sst')) {
+ const idx = (n - 1) >> 2;
+ if (idx >= b.length) continue;
+ const allocated = !!(b[idx] & (1 << (2 * ((n - 1) & 3))));
+ const activated = !!(b[idx] & (1 << (2 * ((n - 1) & 3) + 1)));
+ if (allocated || activated) out.push({ n: n, allocated: allocated, activated: activated });
+ }
+ return out;
+}
+
+// Original bits of the unmodelled 1-bit slots, so a save reproduces them.
+function servicesBitsPreserved(key, origHex) {
+ const b = efBytes(origHex || '');
+ const out = [];
+ for (const n of servicesUnmodelled(key)) {
+ const idx = (n - 1) >> 3;
+ if (idx >= b.length) continue;
+ if (b[idx] & (1 << ((n - 1) & 7))) out.push({ n: n });
+ }
+ return out;
+}
+
+// efEncSst() entries from the form's per-row state: 'on' allocates and
+// activates; 'off' keeps the allocation the card had (allocated-off) and
+// leaves an already-deleted pair deleted - the Services editor only ever sets
+// on or allocated-off, so a save never de-allocates a service.
+function servicesSstValues(values, origHex) {
+ const out = [];
+ SERVICES_CROSS_REF.forEach((f, i) => {
+ if (f.sst === null || f.sst === undefined) return;
+ const v = values && values[i] ? values[i].sst : undefined;
+ if (v === 'on') out.push({ n: f.sst, allocated: true, activated: true });
+ else if (servicesSstAllocated(origHex, f.sst)) out.push({ n: f.sst, allocated: true, activated: false });
+ });
+ return { services: out.concat(servicesSstPreserved(origHex)) };
+}
+
+// efEncServiceTable() entries (UST / EST / IST) from the form's per-row state.
+function servicesTableValues(values, key, origHex) {
+ const out = [];
+ SERVICES_CROSS_REF.forEach((f, i) => {
+ const n = f[key];
+ if (n === null || n === undefined) return;
+ if (values && values[i] && values[i][key]) out.push({ n: n });
+ });
+ return { services: out.concat(servicesBitsPreserved(key, origHex)) };
+}
+
+// New bytes for one file: the form's state over the file's own original bytes
+// (the encoders keep every bit they do not model and the file length).
+function servicesFileBytes(key, values, files) {
+ const f = files && files[key];
+ if (!f || !f.present) return null;
+ if (key === 'sst') return efEncSst(servicesSstValues(values, f.hex), f.hex);
+ const table = SERVICES_TABLES[key];
+ if (!table) return null;
+ return efEncServiceTable(servicesTableValues(values, key, f.hex), f.hex, table);
+}
+
+// Presence + contents cached per card session, like the EF.ARR copies
+// (pysimArrEnsure): the four files are read on open, again on Refresh and
+// after a card-session change.
+let _pysimServicesCache = null;
+let _pysimServicesBusy = false;
+
+async function pysimServicesReadFile(spec) {
+ try {
+ const data = await pysimFetch('/api/read', { path: spec.path, mode: 'raw' });
+ if (!data || data.success !== true || typeof data.data !== 'string') {
+ return { present: false, error: (data && (data.error || data.sw)) || '' };
+ }
+ const hex = String(data.data).replace(/[^0-9a-fA-F]/g, '').toUpperCase();
+ if (!hex) return { present: false, error: '' };
+ return { present: true, hex: hex, dec: servicesDecode(spec.key, efBytes(hex)) };
+ } catch (e) {
+ return { present: false, error: (e && e.message) ? e.message : String(e) };
+ }
+}
+
+async function pysimServicesEnsure(force) {
+ if (!force && _pysimServicesCache && _pysimServicesCache.session === _pysimCardSession) return _pysimServicesCache;
+ const files = {};
+ for (const spec of SERVICES_FILES) files[spec.key] = await pysimServicesReadFile(spec);
+ _pysimServicesCache = { session: _pysimCardSession, files: files };
+ return _pysimServicesCache;
+}
+
+async function pysimServicesOpen(force) {
+ const grid = document.getElementById('pysim-services-grid');
+ if (!grid) return;
+ const statusEl = document.getElementById('pysim-services-status');
+ if (statusEl) statusEl.textContent = t('Loading...');
+ grid.innerHTML = '';
+ let cache;
+ try {
+ cache = await pysimServicesEnsure(!!force);
+ } catch (e) {
+ if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
+ return;
+ }
+ if (statusEl) statusEl.textContent = '';
+ pysimServicesRender();
+}
+
+// A card-session change drops the cached reads and the rendered grid.
+function pysimServicesReset() {
+ _pysimServicesCache = null;
+ pysimServicesRender();
+ if (isViewVisible('pysim-sub-services')) pysimServicesOpen();
+}
+
+// The form's per-row state: { : { sst: 'on'|'off', ust: bool, ... } }
+function pysimServicesCollect() {
+ const values = {};
+ const grid = document.getElementById('pysim-services-grid');
+ if (!grid || !grid.querySelectorAll) return values;
+ grid.querySelectorAll('[data-svc]').forEach(el => {
+ const row = Number(el.getAttribute('data-row'));
+ const key = el.getAttribute('data-svc');
+ if (!Number.isInteger(row) || !key) return;
+ if (!values[row]) values[row] = {};
+ values[row][key] = el.type === 'checkbox' ? !!el.checked : String(el.value);
+ });
+ return values;
+}
+
+function pysimServicesColumns(files) {
+ return SERVICES_FILES.filter(spec => files && files[spec.key] && files[spec.key].present);
+}
+
+// Table HTML for the present files' columns. `current` is the live form state
+// (a re-render must not discard pending edits); without it the cells start
+// from the card's decode.
+function pysimServicesGridHtml(files, current) {
+ const cols = pysimServicesColumns(files);
+ if (!cols.length) {
+ return '' + esc(t('No service table found on this card')) + '
';
+ }
+ const dec = key => (files[key] || {}).dec;
+ const sstDec = dec('sst'), ustDec = dec('ust'), estDec = dec('est'), istDec = dec('ist');
+ const inputCls = 'border border-gray-300 dark:border-slate-600 rounded px-1 py-0.5 text-xs dark:bg-slate-800';
+ let html = '';
+}
+
+// One explicit Save per present file: there is no atomic cross-file write.
+function pysimServicesSaveRowHtml(files) {
+ return pysimServicesColumns(files).map(spec =>
+ '' +
+ '' +
+ esc(t('Save to') + ' ' + spec.label) + ' ' +
+ ' ' +
+ '
').join('');
+}
+
+function pysimServicesRender() {
+ const grid = document.getElementById('pysim-services-grid');
+ const noteEl = document.getElementById('pysim-services-note');
+ const saveEl = document.getElementById('pysim-services-save');
+ if (!grid) return;
+ const cache = _pysimServicesCache;
+ // A re-render (language switch, refresh) keeps what is on screen.
+ const current = grid.querySelector('[data-svc]') ? pysimServicesCollect() : {};
+ if (!cache || !cache.files) {
+ grid.innerHTML = '';
+ if (noteEl) noteEl.innerHTML = '';
+ if (saveEl) saveEl.innerHTML = '';
+ return;
+ }
+ const notes = SERVICES_FILES.filter(spec => !(cache.files[spec.key] && cache.files[spec.key].present))
+ .map(spec => {
+ const err = cache.files[spec.key] && cache.files[spec.key].error;
+ return '' + esc(spec.label) + ': ' + esc(err || t('Not found on the card')) + '
';
+ });
+ if (noteEl) noteEl.innerHTML = notes.join('');
+ grid.innerHTML = pysimServicesGridHtml(cache.files, current);
+ if (saveEl) saveEl.innerHTML = pysimServicesSaveRowHtml(cache.files);
+ pysimApplyAvailability();
+}
+
+async function pysimServicesSave(key) {
+ if (_pysimServicesBusy) return;
+ const cache = _pysimServicesCache;
+ const spec = SERVICES_FILES.find(s => s.key === key);
+ const statusEl = document.getElementById('pysim-services-save-status-' + key);
+ if (!cache || !cache.files || !spec || !cache.files[key] || !cache.files[key].present) return;
+ let hex;
+ try {
+ hex = servicesFileBytes(key, pysimServicesCollect(), cache.files);
+ } catch (e) {
+ if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
+ return;
+ }
+ if (!hex) return;
+ if (String(hex).toUpperCase() === String(cache.files[key].hex).toUpperCase()) {
+ if (statusEl) statusEl.textContent = t('No change');
+ return;
+ }
+ _pysimServicesBusy = true;
+ if (statusEl) statusEl.textContent = t('Saving...');
+ try {
+ const data = await pysimFetch('/api/write', { path: spec.path, data: hex });
+ if (!data || data.success !== true) {
+ if (statusEl) statusEl.textContent = t('Error') + ': ' + ((data && (data.error || data.sw)) || '');
+ return;
+ }
+ if (statusEl) statusEl.textContent = 'SW: ' + data.sw + ' OK';
+ // keep the cache in step with the card without re-rendering (that would
+ // drop the other files' pending edits)
+ const fresh = await pysimServicesReadFile(spec);
+ if (fresh.present) cache.files[key] = fresh;
+ } catch (e) {
+ if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
+ } finally {
+ _pysimServicesBusy = false;
+ }
+}
+
// ===== EF content decoders (client-side, offline) =====
// Decode the contents of common EFs for the file manager, the snapshot view,
// profile-check results and snapshot comparison. Ported/adapted from
@@ -26703,6 +27217,17 @@ const LANG_RU = {
'Sort:': 'Сортировка:',
'Name': 'Имя',
'Probe all files': 'Проверить все файлы',
+ 'Feature': 'Функция',
+ 'SST': 'SST',
+ 'UST': 'UST',
+ 'EST': 'EST',
+ 'IST': 'IST',
+ 'on': 'включён',
+ 'allocated-off': 'выделен, выключен',
+ 'Save to': 'Сохранить в',
+ 'No change': 'Без изменений',
+ 'Not found on the card': 'не найдено на карте',
+ 'No service table found on this card': 'На этой карте не найдено ни одной таблицы сервисов',
'Stop': 'Остановить',
'Probing:': 'Проверка:',
'discovering': 'поиск файлов',
@@ -27723,6 +28248,8 @@ function refreshDynamicI18n() {
if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions();
pysimUpdateCardActionLabels();
pysimUpdateStateIndicator();
+ // the Services grid is built with t() strings at render time
+ if (isViewVisible('pysim-sub-services')) pysimServicesRender();
// the counter field's placeholder carries a t() string (auto / авто)
spCntrPlaceholder();
if (isViewVisible('tab-cards')) {
diff --git a/frontend/tests/services.test.js b/frontend/tests/services.test.js
new file mode 100644
index 0000000..f9c9036
--- /dev/null
+++ b/frontend/tests/services.test.js
@@ -0,0 +1,304 @@
+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',
+];
+
+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_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: { : [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('[^<]* '));
+ const adnRow = out.split(' r.includes('Local Phone Book (ADN)'));
+ assert.ok(adnRow.includes('checked'), adnRow);
+ assert.ok(adnRow.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(' 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(' 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(' 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);
+});