From 69abbe2fa5458f5e37effae88e5ea8746484facf 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: Thu, 1 Oct 2026 22:57:01 +0300 Subject: [PATCH] feat: the conditional event objects are complete The six forms that still missed conditional/optional objects now carry them: - Location status (0x03): Location Information (MCC/MNC + LAC + Cell ID) - included only for Normal service, as TS 102 223 8.19 requires. - Channel status (0x0A): Bearer Description (B5, type + parameters) and Other address (BE, IPv4 dotted / IPv6 hex / the empty "dynamic address" form); new eventIpBytes() helper. - Call disconnected (0x02): IMS call disconnection cause (D5, SIP/Q.850 + 2-byte cause). - Network rejection (0x12): Extended information (F2, CAG ID / NID / RedCap). - CAG cell selection (0x1E): CAG Human-readable network name list (D7, 80-tagged names). - Slices status change (0x1F): Allowed slices with S-NSSAI mapping (F7), Allowed slices (F8), Rejected slices with mapping (D7) and Rejected slices (B1) beside the served list. Every implemented event form now carries all the objects of its spec table. Tests: the new build vectors. Help EN/RU and both AGENTS files; sw simple-v350. --- frontend/help-ru.html | 10 ++-- frontend/help.html | 10 ++-- frontend/index.html | 89 ++++++++++++++++++++++++++++-- frontend/sw.js | 2 +- frontend/tests/event_forms.test.js | 33 +++++++++++ 5 files changed, 129 insertions(+), 15 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 0a406bf..c881cf5 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -501,13 +501,13 @@

События, которые отслеживает карта. У каждого события есть кнопка Отправить, открывающая форму, специфичную для типа события:

diff --git a/frontend/help.html b/frontend/help.html index fc9b519..cdc4108 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -500,13 +500,13 @@

The events the card monitors. Each event has a Send button that opens a form specific to the event type:

diff --git a/frontend/index.html b/frontend/index.html index 9990188..aeda54b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13070,6 +13070,17 @@ function eventAddressTlv(number, ton) { return '86' + (value.length / 2).toString(16).padStart(2, '0') + value; } +// Other address value (TS 102 223 8.58): a dotted IPv4 or hex address +// bytes; '' when the text is not an even-length hex string. +function eventIpBytes(text, type) { + const s = String(text || '').trim(); + if (type === '21' && /^\d{1,3}(\.\d{1,3}){3}$/.test(s)) { + return s.split('.').map(n => (parseInt(n, 10) & 0xFF).toString(16).padStart(2, '0')).join('').toUpperCase(); + } + const h = s.replace(/[^0-9a-fA-F]/g, ''); + return h.length % 2 ? '' : h.toUpperCase(); +} + const ACCESSTECH = [ {v:0,l:'GSM'},{v:1,l:'TIA/EIA-553-A'},{v:2,l:'TIA/EIA-136-270'},{v:3,l:'UTRAN'}, {v:4,l:'TETRA'},{v:5,l:'TIA/EIA-95-B'},{v:6,l:'CDMA2000 1x'},{v:7,l:'CDMA2000 HRPD'}, @@ -13129,6 +13140,9 @@ const EVENT_FORMS = { { id: 'cause', label: 'Cause bytes (hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,60}', placeholder: 'e.g. 10', g: 'custom' }, { id: 'media', label: 'Media Type (optional)', type: 'select', opts: [{v:'',l:'\u2014 not present \u2014'},{v:0,l:'00 \u2014 voice'},{v:1,l:'01 \u2014 video'}] }, + { id: 'ims_proto', label: 'IMS disconnection cause (optional)', type: 'select', + opts: [{v:'',l:'\u2014 not present \u2014'},{v:1,l:'01 \u2014 SIP'},{v:2,l:'02 \u2014 Q.850'}] }, + { id: 'ims_cause', label: 'Cause value', type: 'number', min: '0', max: '65535', placeholder: '0\u201365535, e.g. 603' }, ], build(v) { let d = '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2); @@ -13138,15 +13152,29 @@ const EVENT_FORMS = { d += '9A' + (cause.length / 2).toString(16).padStart(2, '0') + cause; if (v.media !== '' && v.media !== undefined) d += 'FE01' + ((parseInt(v.media) || 0) & 0xFF).toString(16).padStart(2, '0'); + if (v.ims_proto !== '' && v.ims_proto !== undefined && v.ims_cause !== '' && v.ims_cause !== undefined) + d += 'D503' + ((parseInt(v.ims_proto) || 0) & 0xFF).toString(16).padStart(2, '0') + + ((parseInt(v.ims_cause) || 0) & 0xFFFF).toString(16).padStart(4, '0'); return d.toUpperCase(); } }, 0x03: { title: 'Location status', fields: [ { id: 'status', label: 'Service Status', type: 'select', - opts: [{v:0,l:'Normal service'},{v:1,l:'Limited service'},{v:2,l:'No service'}] } + opts: [{v:0,l:'Normal service'},{v:1,l:'Limited service'},{v:2,l:'No service'}] }, + { id: 'mcc', label: 'MCC (Location Information)', type: 'text', pattern: '\\d{3}', placeholder: 'sent for Normal service' }, + { id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01' }, + { id: 'lac', label: 'LAC', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 00FF' }, + { id: 'cell', label: 'Cell ID', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 0001' }, ], - build(v) { return '9B01' + (v.status|0).toString(16).padStart(2,'0'); } + build(v) { + let d = '9B01' + (v.status|0).toString(16).padStart(2,'0'); + // 8.19: the location information is only included for Normal service + if ((parseInt(v.status) || 0) === 0 && v.mcc) + d += '9307' + encPlmn(v.mcc, v.mnc || '01') + + (v.lac || '0000').toUpperCase() + (v.cell || '0001').toUpperCase(); + return d.toUpperCase(); + } }, 0x04: null, 0x05: null, @@ -13205,10 +13233,34 @@ const EVENT_FORMS = { { id: 'info', label: 'Channel info', type: 'select', opts: [{v:0x00,l:'No further info can be given'}, {v:0x05,l:'Link dropped (network failure or user cancellation)'}] }, + { id: 'bearer', label: 'Bearer Description (optional)', type: 'select', + opts: [{v:'',l:'\u2014 not present \u2014'}, + {v:0x03,l:'03 \u2014 default bearer for the requested transport layer'}, + {v:0x04,l:'04 \u2014 local link technology independent'}, + {v:0x05,l:'05 \u2014 Bluetooth'},{v:0x06,l:'06 \u2014 IrDA'}, + {v:0x07,l:'07 \u2014 RS232'},{v:0x10,l:'10 \u2014 USB'}] }, + { id: 'bearer_params', label: 'Bearer parameters (hex, optional)', type: 'text', pattern: '[0-9A-Fa-f]{2,40}', placeholder: 'bearer specific' }, + { id: 'addr_type', label: 'Other address \u2014 local address (optional)', type: 'select', + opts: [{v:'',l:'\u2014 not present \u2014'},{v:'21',l:'21 \u2014 IPv4 address'}, + {v:'57',l:'57 \u2014 IPv6 address'},{v:'00',l:'empty (request a dynamic address)'}] }, + { id: 'addr', label: 'Address', type: 'text', placeholder: 'dotted IPv4 (192.168.1.2) or hex' }, ], build(v) { const st = (parseInt(v.state) || 0) | (parseInt(v.channel) || 0); - return 'B802' + st.toString(16).padStart(2,'0') + (parseInt(v.info) || 0).toString(16).padStart(2,'0'); + let d = 'B802' + st.toString(16).padStart(2,'0') + ((parseInt(v.info) || 0) & 0xFF).toString(16).padStart(2,'0'); + if (v.bearer !== '' && v.bearer !== undefined) { + const params = (v.bearer_params || '').replace(/[^0-9a-fA-F]/g, ''); + d += 'B5' + (1 + params.length / 2).toString(16).padStart(2, '0') + + ((parseInt(v.bearer) || 0) & 0xFF).toString(16).padStart(2, '0') + params; + } + if (v.addr_type !== '' && v.addr_type !== undefined) { + if (v.addr_type === '00') d += 'BE00'; + else { + const h = eventIpBytes(v.addr, v.addr_type); + if (h) d += 'BE' + (1 + h.length / 2).toString(16).padStart(2, '0') + v.addr_type + h; + } + } + return d.toUpperCase(); } }, 0x0B: { title: 'Access Technology Change', @@ -13296,7 +13348,10 @@ const EVENT_FORMS = { { id: 'access_tech', label: 'Access Technology', type: 'select', opts: [{v:0x00,l:'GSM'},{v:0x03,l:'UTRAN'},{v:0x08,l:'E-UTRAN'},{v:0x0A,l:'NG-RAN'},{v:0x0B,l:'Satellite NG-RAN'},{v:0x0C,l:'Satellite E-UTRAN'}], g: 'lu,gprs,eps' }, { id: 'cause', label: 'Rejection cause code', type: 'select', opts: REJECTION_CAUSES, g: 'lu,gprs,eps' }, - { id: 'ext_cause', label: 'Extended EMM cause (optional)', type: 'number', min: '0', max: '255', placeholder: '0–255', g: 'eps' }, + { id: 'ext_cause', label: 'Extended EMM cause (optional)', type: 'number', min: '0', max: '255', placeholder: '0\u2013255', g: 'eps' }, + { id: 'ext_info_type', label: 'Extended information (optional)', type: 'select', + opts: [{v:'',l:'\u2014 not present \u2014'},{v:1,l:'01 \u2014 CAG ID'},{v:2,l:'02 \u2014 NID'},{v:3,l:'03 \u2014 RedCap'}] }, + { id: 'ext_info', label: 'Extended identification (hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,20}', placeholder: 'CAG ID: 4 bytes' }, ], build(v) { const rt = parseInt(v.reg_type) || 0; @@ -13316,6 +13371,11 @@ const EVENT_FORMS = { d += '5701' + (parseInt(v.ext_cause)|0).toString(16).padStart(2,'0'); } } + if (v.ext_info_type !== '' && v.ext_info_type !== undefined) { + const h = (v.ext_info || '').replace(/[^0-9a-fA-F]/g, ''); + d += 'F2' + (1 + h.length / 2).toString(16).padStart(2, '0') + + ((parseInt(v.ext_info_type) || 0) & 0xFF).toString(16).padStart(2, '0') + h; + } return d.toUpperCase(); } }, @@ -13470,6 +13530,7 @@ const EVENT_FORMS = { { id: 'tech', label: 'Access Technology (optional)', type: 'select', opts: [{v:'',l:'\u2014 not present \u2014'}].concat(ACCESSTECH) }, { id: 'list', label: 'CAG information list (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'CAG identity list' }, + { id: 'hrnn', label: 'CAG HRNN list (optional)', type: 'text', placeholder: 'one or more names, comma-separated' }, ], build(v) { let d = ''; @@ -13482,6 +13543,14 @@ const EVENT_FORMS = { const h = v.list.replace(/[^0-9a-fA-F]/g, ''); d += 'D6' + (h.length / 2).toString(16).padStart(2, '0') + h; } + if (v.hrnn) { + let inner = ''; + String(v.hrnn).split(/[\s,;]+/).filter(Boolean).forEach(n => { + const b = new TextEncoder().encode(n).slice(0, 60); + inner += '80' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b)); + }); + if (inner) d += 'D7' + (inner.length / 2).toString(16).padStart(2, '0') + inner; + } return d.toUpperCase(); } }, @@ -13493,19 +13562,31 @@ const EVENT_FORMS = { {v:0x82,l:'82 \u2014 partially rejected'}] }, { id: 'tech', label: 'Access Technology (optional)', type: 'select', opts: [{v:'',l:'\u2014 not present \u2014'}].concat(ACCESSTECH) }, + { id: 'allowed_map', label: 'Allowed slices with S-NSSAI mapping (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'TS 24.501 S-NSSAI-with-mapping elements' }, { id: 'served', label: 'Served S-NSSAIs (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{8,80}', placeholder: '4 bytes per S-NSSAI (SST + SD)' }, + { id: 'allowed', label: 'Allowed slices (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{8,80}', placeholder: '4 bytes per S-NSSAI (SST + SD)' }, + { id: 'rejected_map', label: 'Rejected slices with S-NSSAI mapping (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'TS 24.501 elements + rejection cause' }, + { id: 'rejected', label: 'Rejected slices (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'TS 24.501 rejected S-NSSAI elements' }, ], build(v) { let d = ''; if (v.tech !== '' && v.tech !== undefined) d += 'BF01' + ((parseInt(v.tech) || 0) & 0xFF).toString(16).padStart(2, '0'); d += 'D501' + ((parseInt(v.status) || 1) & 0xFF).toString(16).padStart(2, '0'); + const list = (val, tag) => { + const h = (val || '').replace(/[^0-9a-fA-F]/g, ''); + if (h) d += tag + (h.length / 2).toString(16).padStart(2, '0') + h; + }; + list(v.allowed_map, 'F7'); const h = (v.served || '').replace(/[^0-9a-fA-F]/g, ''); if (h) { const n = Math.floor(h.length / 8); d += 'D6' + (1 + n * 4).toString(16).padStart(2, '0') + n.toString(16).padStart(2, '0') + h.slice(0, n * 8); } + list(v.allowed, 'F8'); + list(v.rejected_map, 'D7'); + list(v.rejected, 'B1'); return d.toUpperCase(); } }, diff --git a/frontend/sw.js b/frontend/sw.js index 5fc904d..ec862c0 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v349'; +const CACHE = 'simple-v350'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/event_forms.test.js b/frontend/tests/event_forms.test.js index f322a92..10e4332 100644 --- a/frontend/tests/event_forms.test.js +++ b/frontend/tests/event_forms.test.js @@ -216,3 +216,36 @@ test('the IMS, CAG and slices events build their object sets', () => { assert.strictEqual(EVENT_FORMS[0x1F].build({ status: '1', tech: '8', served: '01020304' }), 'BF0108D50101D6050101020304'); }); + +test('the location-status event can carry the location information', () => { + const build = EVENT_FORMS[0x03].build; + // Normal service: the 8.19 object follows the status + assert.strictEqual(build({ status: '0', mcc: '250', mnc: '01', lac: '00FF', cell: '0001' }), + '9B0100930752F01000FF0001'); + // Limited / No service: no location information + assert.strictEqual(build({ status: '1', mcc: '250' }), '9B0101'); + assert.strictEqual(build({ status: '0' }), '9B0100'); +}); + +test('the channel-status event can carry the bearer and the local address', () => { + const build = EVENT_FORMS[0x0A].build; + assert.strictEqual(build({ channel: '1', state: '128', info: '0' }), 'B8028100'); + assert.strictEqual(build({ channel: '1', state: '128', info: '0', bearer: '5', addr_type: '21', addr: '192.168.1.2' }), + 'B8028100B50105BE0521C0A80102'); + assert.strictEqual(build({ channel: '1', state: '128', info: '0', addr_type: '00' }), 'B8028100BE00'); +}); + +test('the disconnected/rejection/CAG/slices events carry their extra objects', () => { + // IMS call disconnection cause: protocol + 2-byte cause (603 -> 025B) + assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: '', media: '', ims_proto: '1', ims_cause: '603' }), + '1C0100D50301025B'); + // Extended information (CAG ID, 4 bytes) + assert.ok(EVENT_FORMS[0x12].build({ reg_type: '9', access_tech: '8', cause: '2', ext_info_type: '1', ext_info: '00000001' }) + .endsWith('F2050100000001')); + // CAG HRNN list (80-tagged names) + assert.ok(EVENT_FORMS[0x1E].build({ status: '1', list: '', hrnn: 'A,B' }).endsWith('D706800141800142')); + // allowed/served/rejected slice lists, with and without mapping + assert.ok(EVENT_FORMS[0x1F].build({ status: '1', served: '', allowed_map: '01020304', allowed: '01020304', + rejected_map: '01020304', rejected: '01020304' }) + .endsWith('F70401020304F80401020304D70401020304B10401020304')); +});