From 5dd056beb81f745f51dbf295c4633879c1275224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 2 Oct 2026 08:57:02 +0300 Subject: [PATCH] catrelease: the CAT item introduction-release table (release guard, stage 1) The table behind the release-aware command/parameter filter: which proactive command, event, data object and PLI qualifier appeared in which release, seeded from the curated reference in the workspace docs/uicc/ETSI-3GPP-Releases.md (3GPP TS 31.111 Annex W, ETSI TS 102 223 Annex V, ETSI TS 101 220 Annex Q; the version major is the release). - pysim_simple_server/catrelease.py: CAT_RELEASE (events/commands/objects/pli), release_label() - everything below Rel-6 collapses to "pre-Rel-6" - and item_release(); an item absent from the table is one the histories do not pin down and is never filtered. - The PWA mirrors it as CAT_RELEASE + catReleaseLabel() next to the other release table (ETSI_TOOLKIT_RELEASE); no behaviour change yet - stage 2 (the card-release selector and REL-x badges) and stage 3 (the filtering of the event list, PLI and push builders) consume it. - Drift guards: tests/test_catrelease.py (module <-> PWA mirror, event-list coverage through 0x1F minus the Void 0x1A, helper vectors) and frontend/tests/cat_release.test.js (the PWA side). - sw cache simple-v360. --- frontend/index.html | 97 +++++++++++++++++++++++ frontend/sw.js | 2 +- frontend/tests/cat_release.test.js | 79 +++++++++++++++++++ pysim_simple_server/catrelease.py | 119 +++++++++++++++++++++++++++++ tests/test_catrelease.py | 73 ++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/cat_release.test.js create mode 100644 pysim_simple_server/catrelease.py create mode 100644 tests/test_catrelease.py diff --git a/frontend/index.html b/frontend/index.html index 621a148..347d440 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9016,6 +9016,103 @@ const ETSI_TOOLKIT_RELEASE = { '1.12': 'REL-15/16/17', '1.13': 'REL-17', '1.14': 'REL-17/18', }; +// CAT/USAT item introduction releases (the release guard): which proactive +// command, event, data object and PLI qualifier appeared in which release. +// Curated from the change histories of the three pinned specs (3GPP TS 31.111 +// Annex W, ETSI TS 102 223 Annex V, ETSI TS 101 220 Annex Q); the reference +// with the per-row sources lives in the workspace at +// docs/uicc/ETSI-3GPP-Releases.md, the Python mirror in +// pysim_simple_server/catrelease.py (drift-guarded by tests/test_catrelease.py). +// Release 4 is the specification baseline; an absent item means the histories +// do not pin it down - never filter on it. Releases below 6 display as +// "pre-Rel-6" (catReleaseLabel). +const CAT_RELEASE = { + events: { + 0x00: 4, 0x01: 4, 0x02: 4, 0x03: 4, 0x04: 4, 0x05: 4, 0x06: 4, 0x07: 4, + 0x08: 4, 0x09: 4, 0x0A: 4, 0x0B: 4, 0x0C: 4, 0x0D: 4, 0x0E: 4, 0x0F: 4, + 0x10: 6, 0x11: 7, 0x12: 8, 0x13: 9, 0x14: 12, 0x15: 9, 0x16: 9, 0x17: 10, + 0x18: 10, 0x19: 10, 0x1B: 11, 0x1C: 12, 0x1D: 14, 0x1E: 17, 0x1F: 16, + }, + commands: { + 'REFRESH': 4, 'MORE TIME': 4, + 'POLL INTERVAL': 4, 'POLLING OFF': 4, + 'SET UP EVENT LIST': 4, 'SET UP CALL': 4, + 'SEND SS': 4, 'SEND USSD': 4, + 'SEND SHORT MESSAGE': 4, 'SEND DTMF': 4, + 'LAUNCH BROWSER': 4, 'PLAY TONE': 4, + 'DISPLAY TEXT': 4, 'GET INKEY': 4, + 'GET INPUT': 4, 'SELECT ITEM': 4, + 'SET UP MENU': 4, 'PROVIDE LOCAL INFORMATION': 4, + 'TIMER MANAGEMENT': 4, 'SET UP IDLE MODE TEXT': 4, + 'RUN AT COMMAND': 4, 'LANGUAGE NOTIFICATION': 4, + 'OPEN CHANNEL': 4, 'CLOSE CHANNEL': 4, + 'SEND DATA': 4, 'RECEIVE DATA': 4, + 'GET CHANNEL STATUS': 4, 'SERVICE SEARCH': 4, + 'GET SERVICE INFORMATION': 4, 'DECLARE SERVICE': 4, + 'SET FRAMES': 6, 'GET FRAMES STATUS': 6, + 'RETRIEVE MULTIMEDIA MESSAGE': 7, 'SUBMIT MULTIMEDIA MESSAGE': 7, + 'DISPLAY MULTIMEDIA MESSAGE': 7, 'GEOGRAPHICAL LOCATION REQUEST': 8, + 'ACTIVATE': 13, 'CONTACTLESS STATE CHANGED': 9, + 'COMMAND CONTAINER': 10, 'ENCAPSULATED SESSION CONTROL': 11, + 'LSI COMMAND': 17, + }, + objects: { + '06/86 Address': 4, + '08/88 Subaddress': 4, + '1C/9C Transaction identifier': 4, + '1A/9A Cause': 4, + '34/B4 Browser termination cause': 4, + '35/B5 Bearer description': 4, + '37/B7 Channel data length': 4, + '38/B8 Channel status': 4, + '3E/BE Other address (local / destination)': 4, + '40/C0 Display parameters': 4, + '64/E4 Browsing status': 4, + '65/E5 Network search mode': 4, + '67/E7 Frames Information': 6, + '4B/CB (I-)WLAN Access Status': 7, + '55/D5 CSG cell selection status': 9, + '56/D6 CSG ID': 9, + '57/D7 HNB name': 9, + '31/B1 IMS URI': 10, + '77/F7 IARI': 10, + '77/F7 IMPU list': 11, + '78/F8 IMS status code': 11, + '7E/FE Media Type': 12, + '09/89 PLMN ID': 4, + '3F/BF Access technology': 4, + 'B4 Supported Radio Access Technologies': 13, + '9D Data connection status': 14, + 'AA Data connection type': 14, + 'AE (E/5G)SM cause': 14, + 'C7 Network Access Name': 14, + '0B/8B PDP/PDN/PDU type': 14, + '26/A6 Date-Time and Time zone': 4, + '13/93 Location Information': 4, + '55/D5 CAG cell selection status': 17, + '56/D6 CAG information list': 17, + '57/D7 CAG Human-readable network name list': 17, + '55/D5 Slices status': 16, + '56/D6 Slices information (served)': 16, + '78/F8 Allowed slices information': 16, + '77/F7 Allowed slices with S-NSSAI mapping': 18, + 'D7 Rejected slices with S-NSSAI mapping': 18, + 'B1 Rejected slices information': 18, + }, + pli: { + '00': 4, '01': 4, '02': 4, '03': 4, '04': 4, '06': 4, '07': 4, '08': 6, + '09': 4, '0A': 4, '0D': 8, '0E': 12, '0F': 10, '10': 10, '11': 9, '12': 10, + '13': 10, '14': 13, '15': 16, '16': 17, '17': 18, '1A': 13, + }, +}; + +// The display label of an introduction release: everything below Rel-6 +// collapses to "pre-Rel-6"; 0/undefined yields ''. +function catReleaseLabel(rel) { + if (!rel) return ''; + return rel < 6 ? 'pre-Rel-6' : 'Rel-' + rel; +} + // Normalise an AID (strip spacing, uppercase) and reject malformed input; // '' when it cannot be an AID (5-16 bytes). function jcAidNorm(aid) { diff --git a/frontend/sw.js b/frontend/sw.js index 1e148a9..d633f12 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v359'; +const CACHE = 'simple-v360'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/cat_release.test.js b/frontend/tests/cat_release.test.js new file mode 100644 index 0000000..890500a --- /dev/null +++ b/frontend/tests/cat_release.test.js @@ -0,0 +1,79 @@ +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 extractBlock(startMarker, endMarker) { + const start = html.indexOf(startMarker); + const end = html.indexOf(endMarker, start); + if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker); + return html.slice(start, end); +} + +function extractFunc(src, name) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return src.slice(m.index, i + 1); +} + +// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval. +eval(extractBlock('const CAT_RELEASE = {', '// The display label of an introduction release') + .replace(/^const /gm, 'var ')); +eval(extractFunc(html, 'catReleaseLabel')); +eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var ')); + +test('CAT_RELEASE carries the introduction releases of the event list', () => { + assert.strictEqual(CAT_RELEASE.events[0x10], 6); + assert.strictEqual(CAT_RELEASE.events[0x12], 8); + assert.strictEqual(CAT_RELEASE.events[0x1D], 14); + assert.strictEqual(CAT_RELEASE.events[0x1E], 17); + assert.strictEqual(CAT_RELEASE.events[0x1F], 16); + // the Void 0x1A is not an event and the reserved 0x20+ codes are not + // introduced by any release + assert.strictEqual(CAT_RELEASE.events[0x1A], undefined); + assert.strictEqual(CAT_RELEASE.events[0x20], undefined); +}); + +test('every named event through 0x1F has an introduction release', () => { + const named = Object.keys(EVENT_NAMES).map(Number).filter(c => c <= 0x1F && c !== 0x1A); + for (const code of named) { + assert.strictEqual(typeof CAT_RELEASE.events[code], 'number', '0x' + code.toString(16)); + } + assert.strictEqual(Object.keys(CAT_RELEASE.events).length, named.length); +}); + +test('CAT_RELEASE carries commands, objects and PLI qualifiers', () => { + assert.strictEqual(CAT_RELEASE.commands['OPEN CHANNEL'], 4); + assert.strictEqual(CAT_RELEASE.commands['SET FRAMES'], 6); + assert.strictEqual(CAT_RELEASE.commands['GEOGRAPHICAL LOCATION REQUEST'], 8); + assert.strictEqual(CAT_RELEASE.commands['ACTIVATE'], 13); + assert.strictEqual(CAT_RELEASE.commands['LSI COMMAND'], 17); + assert.strictEqual(CAT_RELEASE.objects['67/E7 Frames Information'], 6); + assert.strictEqual(CAT_RELEASE.objects['9D Data connection status'], 14); + assert.strictEqual(CAT_RELEASE.objects['55/D5 CAG cell selection status'], 17); + assert.strictEqual(CAT_RELEASE.pli['08'], 6); + assert.strictEqual(CAT_RELEASE.pli['14'], 13); + assert.strictEqual(CAT_RELEASE.pli['15'], 16); + assert.strictEqual(CAT_RELEASE.pli['16'], 17); +}); + +test('catReleaseLabel collapses everything below Rel-6', () => { + assert.strictEqual(catReleaseLabel(0), ''); + assert.strictEqual(catReleaseLabel(undefined), ''); + assert.strictEqual(catReleaseLabel(4), 'pre-Rel-6'); + assert.strictEqual(catReleaseLabel(5), 'pre-Rel-6'); + assert.strictEqual(catReleaseLabel(6), 'Rel-6'); + assert.strictEqual(catReleaseLabel(18), 'Rel-18'); +}); diff --git a/pysim_simple_server/catrelease.py b/pysim_simple_server/catrelease.py new file mode 100644 index 0000000..270f350 --- /dev/null +++ b/pysim_simple_server/catrelease.py @@ -0,0 +1,119 @@ +"""CAT/USAT item introduction releases (Stage 1 of the release guard). + +Which proactive command, event, data object and PROVIDE LOCAL INFORMATION +qualifier appeared in which release, curated from the change histories of the +three pinned specifications: + +* 3GPP TS 31.111 (USAT) V18.12.0, Annex W; +* ETSI TS 102 223 (CAT) V18.3.0, Annex V; +* ETSI TS 101 220 (TLVs/TARs) V18.3.0, Annex Q. + +The version major equals the release (V7.x = Rel-7 ... V18.x = Rel-18). The +human-readable reference with the per-row sources lives in the workspace at +``docs/uicc/ETSI-3GPP-Releases.md``. Release 4 is the specification baseline +(an item the histories never name); an item absent from this table is one the +histories do not pin down - never filter on it. + +The PWA mirrors this table (``CAT_RELEASE`` in frontend/index.html); the drift +guard is tests/test_catrelease.py. Releases below 6 display as "pre-Rel-6" +(``release_label``). +""" + +# Keyed by: events - the event code (TS 102 223 8.25 / TS 31.111 7.5); +# commands - the proactive command name; objects - 'tag name' as in the +# reference document; pli - the PROVIDE LOCAL INFORMATION qualifier byte. +CAT_RELEASE = { + 'events': { + 0x00: 4, 0x01: 4, 0x02: 4, 0x03: 4, 0x04: 4, 0x05: 4, 0x06: 4, 0x07: 4, + 0x08: 4, 0x09: 4, 0x0A: 4, 0x0B: 4, 0x0C: 4, 0x0D: 4, 0x0E: 4, 0x0F: 4, + 0x10: 6, 0x11: 7, 0x12: 8, 0x13: 9, 0x14: 12, 0x15: 9, 0x16: 9, 0x17: 10, + 0x18: 10, 0x19: 10, 0x1B: 11, 0x1C: 12, 0x1D: 14, 0x1E: 17, 0x1F: 16, + }, + 'commands': { + 'REFRESH': 4, 'MORE TIME': 4, + 'POLL INTERVAL': 4, 'POLLING OFF': 4, + 'SET UP EVENT LIST': 4, 'SET UP CALL': 4, + 'SEND SS': 4, 'SEND USSD': 4, + 'SEND SHORT MESSAGE': 4, 'SEND DTMF': 4, + 'LAUNCH BROWSER': 4, 'PLAY TONE': 4, + 'DISPLAY TEXT': 4, 'GET INKEY': 4, + 'GET INPUT': 4, 'SELECT ITEM': 4, + 'SET UP MENU': 4, 'PROVIDE LOCAL INFORMATION': 4, + 'TIMER MANAGEMENT': 4, 'SET UP IDLE MODE TEXT': 4, + 'RUN AT COMMAND': 4, 'LANGUAGE NOTIFICATION': 4, + 'OPEN CHANNEL': 4, 'CLOSE CHANNEL': 4, + 'SEND DATA': 4, 'RECEIVE DATA': 4, + 'GET CHANNEL STATUS': 4, 'SERVICE SEARCH': 4, + 'GET SERVICE INFORMATION': 4, 'DECLARE SERVICE': 4, + 'SET FRAMES': 6, 'GET FRAMES STATUS': 6, + 'RETRIEVE MULTIMEDIA MESSAGE': 7, 'SUBMIT MULTIMEDIA MESSAGE': 7, + 'DISPLAY MULTIMEDIA MESSAGE': 7, 'GEOGRAPHICAL LOCATION REQUEST': 8, + 'ACTIVATE': 13, 'CONTACTLESS STATE CHANGED': 9, + 'COMMAND CONTAINER': 10, 'ENCAPSULATED SESSION CONTROL': 11, + 'LSI COMMAND': 17, + }, + 'objects': { + '06/86 Address': 4, + '08/88 Subaddress': 4, + '1C/9C Transaction identifier': 4, + '1A/9A Cause': 4, + '34/B4 Browser termination cause': 4, + '35/B5 Bearer description': 4, + '37/B7 Channel data length': 4, + '38/B8 Channel status': 4, + '3E/BE Other address (local / destination)': 4, + '40/C0 Display parameters': 4, + '64/E4 Browsing status': 4, + '65/E5 Network search mode': 4, + '67/E7 Frames Information': 6, + '4B/CB (I-)WLAN Access Status': 7, + '55/D5 CSG cell selection status': 9, + '56/D6 CSG ID': 9, + '57/D7 HNB name': 9, + '31/B1 IMS URI': 10, + '77/F7 IARI': 10, + '77/F7 IMPU list': 11, + '78/F8 IMS status code': 11, + '7E/FE Media Type': 12, + '09/89 PLMN ID': 4, + '3F/BF Access technology': 4, + 'B4 Supported Radio Access Technologies': 13, + '9D Data connection status': 14, + 'AA Data connection type': 14, + 'AE (E/5G)SM cause': 14, + 'C7 Network Access Name': 14, + '0B/8B PDP/PDN/PDU type': 14, + '26/A6 Date-Time and Time zone': 4, + '13/93 Location Information': 4, + '55/D5 CAG cell selection status': 17, + '56/D6 CAG information list': 17, + '57/D7 CAG Human-readable network name list': 17, + '55/D5 Slices status': 16, + '56/D6 Slices information (served)': 16, + '78/F8 Allowed slices information': 16, + '77/F7 Allowed slices with S-NSSAI mapping': 18, + 'D7 Rejected slices with S-NSSAI mapping': 18, + 'B1 Rejected slices information': 18, + }, + 'pli': { + '00': 4, '01': 4, '02': 4, '03': 4, '04': 4, '06': 4, '07': 4, '08': 6, + '09': 4, '0A': 4, '0D': 8, '0E': 12, '0F': 10, '10': 10, '11': 9, '12': 10, + '13': 10, '14': 13, '15': 16, '16': 17, '17': 18, '1A': 13, + }, +} + + +def release_label(rel): + """The display label of an introduction release. + + Everything below Rel-6 collapses to "pre-Rel-6" (the UI convention); + 0/None yields '' (unknown - the item is never filtered). + """ + if not rel: + return '' + return 'pre-Rel-6' if rel < 6 else 'Rel-%d' % rel + + +def item_release(kind, key, default=None): + """The introduction release of an item, or `default` when unknown.""" + return CAT_RELEASE.get(kind, {}).get(key, default) diff --git a/tests/test_catrelease.py b/tests/test_catrelease.py new file mode 100644 index 0000000..6830a73 --- /dev/null +++ b/tests/test_catrelease.py @@ -0,0 +1,73 @@ +"""Tests for the CAT item introduction-release table (catrelease) and its PWA mirror.""" + +import pathlib +import re +import unittest + +from pysim_simple_server import catrelease + +HTML = pathlib.Path(__file__).resolve().parents[1].joinpath('frontend', 'index.html') + + +def _pwa_block(): + html = HTML.read_text(encoding='utf-8') + m = re.search(r"const CAT_RELEASE = \{(.*?)\n\};", html, re.S) + assert m is not None, 'CAT_RELEASE not found in index.html' + return m.group(1), html + + +class TestCatReleaseTable(unittest.TestCase): + + def test_pwa_mirror_matches_the_module(self): + # the PWA's release guard uses the same table (drift guard, like + # ETSI_TOOLKIT_RELEASE in test_cap_memory) + block, _ = _pwa_block() + ev = re.search(r"events:\s*\{(.*?)\}", block, re.S) + self.assertIsNotNone(ev, 'events section not found') + self.assertEqual( + {int(k, 16): int(v) + for k, v in re.findall(r"(0x[0-9A-Fa-f]{2}):\s*(\d+)", ev.group(1))}, + catrelease.CAT_RELEASE['events']) + for section in ('commands', 'objects', 'pli'): + m = re.search(r"%s:\s*\{(.*?)\}" % section, block, re.S) + self.assertIsNotNone(m, section) + self.assertEqual( + {k: int(v) for k, v in re.findall(r"'([^']+)':\s*(\d+)", m.group(1))}, + catrelease.CAT_RELEASE[section], section) + + def test_events_cover_the_pwa_event_list(self): + # every event the UI names through 0x1F except the Void 0x1A carries + # an introduction release; the reserved 0x20+ codes do not + _, html = _pwa_block() + names = re.search(r"const EVENT_NAMES = \{(.*?)\};", html, re.S) + self.assertIsNotNone(names, 'EVENT_NAMES not found in index.html') + codes = {int(k, 16) for k in re.findall(r"(0x[0-9A-Fa-f]{2}):", names.group(1))} + self.assertEqual(set(catrelease.CAT_RELEASE['events']), + {c for c in codes if c <= 0x1F} - {0x1A}) + + def test_release_label_collapses_below_rel6(self): + self.assertEqual(catrelease.release_label(4), 'pre-Rel-6') + self.assertEqual(catrelease.release_label(5), 'pre-Rel-6') + self.assertEqual(catrelease.release_label(6), 'Rel-6') + self.assertEqual(catrelease.release_label(18), 'Rel-18') + # unknown never gets a label - it must not be filtered + self.assertEqual(catrelease.release_label(None), '') + self.assertEqual(catrelease.release_label(0), '') + + def test_item_release_lookup(self): + self.assertEqual(catrelease.item_release('events', 0x1D), 14) + self.assertEqual(catrelease.item_release('commands', 'LSI COMMAND'), 17) + self.assertIsNone(catrelease.item_release('events', 0x1A)) + self.assertIsNone(catrelease.item_release('nope', 'x')) + + def test_table_sanity(self): + for section, table in catrelease.CAT_RELEASE.items(): + self.assertTrue(table, section) + for key, rel in table.items(): + self.assertIsInstance(rel, int, (section, key)) + self.assertTrue(4 <= rel <= 18, (section, key, rel)) + self.assertTrue(all(0 <= c <= 0x1F for c in catrelease.CAT_RELEASE['events'])) + + +if __name__ == '__main__': + unittest.main()