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()