diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 38e648e..5f1e7ac 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -504,6 +504,8 @@
  • Location Status — выпадающий список: Normal / Limited / No service (тег 9B)
  • Access Technology Change — 13 типов RAT (тег BF)
  • Network Rejection — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля местоположения (MCC, MNC, LAC, RAC, TAC), технология доступа и единый выпадающий список из 53 кодов причин (EMM, GMM, 5GMM и LU)
  • +
  • События звонка — MT call (transaction identifier, номер вызывающего + TON → Address, subaddress, IMS URI, media type; источник network), Call connected и Call disconnected (transaction identifier, источник near/far, media type и причина — отсутствует, нулевая форма radio link timeout или свои байты)
  • +
  • Browser termination (причина user / error), (I-)WLAN access status (нет покрытия / покрытие / соединение) и CSG cell selection (статус + механизм выбора, access technology, CSG ID, HNB name, PLMN ID; источник network)
  • Data Connection Status Change (0x1D) — статус (successful / rejected / dropped-deactivated), тип соединения (PDP / PDN / PDU), необязательная причина (E/5G)SM, transaction identifier и location status, а также необязательные date-time, access technology, APN, тип PDP/PDN/PDU и location information (MCC/MNC + LAC + Cell ID) — TS 131 111 §7.5.25
  • Poll Interval Negotiation (0x1C) — предлагает интервал опроса в виде Duration (единица + значение); после отправки показывается ответ UICC (принято / отклонено / изменено + длительность), а значение изменено становится интервалом фонового опроса (TS 102 223 §7.5.22). Форма появляется, когда карта подписана на это событие.
  • diff --git a/frontend/help.html b/frontend/help.html index e980793..f7d6ed0 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -503,6 +503,8 @@
  • Location Status — dropdown: Normal / Limited / No service (tag 9B)
  • Access Technology Change — 13 RAT types (tag BF)
  • Network Rejection — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes
  • +
  • Call events — MT call (transaction identifier, calling party number + TON → Address, subaddress, IMS URI, media type; source network), Call connected and Call disconnected (transaction identifier, near/far-end source, media type, and a cause — absent, the zero-length radio-link-timeout form, or custom bytes)
  • +
  • Browser termination (user / error cause), (I-)WLAN access status (no coverage / coverage / connection on-going) and CSG cell selection (status + selection mechanism, access technology, CSG ID, HNB name, PLMN ID; source network)
  • Data Connection Status Change (0x1D) — status (successful / rejected / dropped-deactivated), connection type (PDP / PDN / PDU), optional (E/5G)SM cause, transaction identifier and location status, plus optional date-time, access technology, APN, PDP/PDN/PDU type and location information (MCC/MNC + LAC + Cell ID) — TS 131 111 §7.5.25
  • Poll Interval Negotiation (0x1C) — proposes a poll interval as a Duration (unit + value); after sending, the UICC's answer is shown (accepted / rejected / modified + duration), and a modified duration becomes the background poll interval (TS 102 223 §7.5.22). The form appears when the card subscribed to this event.
  • diff --git a/frontend/index.html b/frontend/index.html index 3fc13d7..24359e6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13049,6 +13049,27 @@ function eventDateTimeTlv(d) { hx(semi(d.getSeconds())) + hx(tz); } +// EFADN number coding (TS 31.102 / TS 24.008 10.5.4.7): digit pairs in +// swapped semi-octets, an odd number of digits padded with 'F'. +function encBcdDigits(digits) { + const d = String(digits || '').replace(/\D/g, ''); + let out = ''; + for (let i = 0; i < d.length; i += 2) { + out += ((i + 1 < d.length ? d[i + 1] : 'F') + d[i]).toUpperCase(); + } + return out; +} + +// Address object (TS 102 223 8.1): TON/NPI byte + the number in the EFADN +// coding. NPI = ISDN/telephony (1), TON per the form (0 = unknown). +function eventAddressTlv(number, ton) { + const bcd = encBcdDigits(number); + if (!bcd) return ''; + const tonNpi = (0x80 | ((parseInt(ton, 10) & 0x07) << 4) | 0x01); + const value = tonNpi.toString(16).padStart(2, '0') + bcd; + return '86' + (value.length / 2).toString(16).padStart(2, '0') + value; +} + 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'}, @@ -13056,9 +13077,70 @@ const ACCESSTECH = [ ]; const EVENT_FORMS = { - 0x00: { title: 'MT call', note: 'not_yet' }, - 0x01: { title: 'Call connected', note: 'not_yet' }, - 0x02: { title: 'Call disconnected', note: 'not_yet' }, + 0x00: { title: 'MT call', src: '83', + fields: [ + { id: 'ti', label: 'Transaction identifier', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, e.g. 00' }, + { id: 'number', label: 'Calling party number (optional)', type: 'text', pattern: '\\d{1,20}', placeholder: 'digits, e.g. 79001234567' }, + { id: 'ton', label: 'Type of number', type: 'select', + opts: [{v:0,l:'Unknown'},{v:1,l:'International'},{v:2,l:'National'},{v:3,l:'Network specific'}] }, + { id: 'sub', label: 'Subaddress (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,40}', placeholder: 'e.g. 1234' }, + { id: 'ims_uri', label: 'IMS URI (optional)', type: 'text', placeholder: 'e.g. sip:user@example.com' }, + { 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'}] }, + ], + build(v) { + let d = '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2); + d += eventAddressTlv(v.number, v.ton); + if (v.sub) { + const h = v.sub.replace(/[^0-9a-fA-F]/g, ''); + d += '88' + (h.length / 2).toString(16).padStart(2, '0') + h; + } + if (v.ims_uri) { + const b = new TextEncoder().encode(v.ims_uri).slice(0, 100); + d += 'B1' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b)); + } + if (v.media !== '' && v.media !== undefined) + d += 'FE01' + ((parseInt(v.media) || 0) & 0xFF).toString(16).padStart(2, '0'); + return d.toUpperCase(); + } + }, + 0x01: { title: 'Call connected', srcField: 'src', + fields: [ + { id: 'src', label: 'Source', type: 'select', + opts: [{v:'82',l:'terminal \u2014 near end (MT call)'},{v:'83',l:'network \u2014 far end (MO call)'}] }, + { id: 'ti', label: 'Transaction identifier', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, e.g. 00' }, + { 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'}] }, + ], + build(v) { + let d = '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2); + if (v.media !== '' && v.media !== undefined) + d += 'FE01' + ((parseInt(v.media) || 0) & 0xFF).toString(16).padStart(2, '0'); + return d.toUpperCase(); + } + }, + 0x02: { title: 'Call disconnected', srcField: 'src', + fields: [ + { id: 'src', label: 'Source', type: 'select', + opts: [{v:'82',l:'terminal \u2014 near end (terminal-initiated)'},{v:'83',l:'network \u2014 far end (network-initiated)'}] }, + { id: 'ti', label: 'Transaction identifier', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, e.g. 00' }, + { id: 'cause_mode', label: 'Cause', type: 'select', onchange: 'updateCallCauseFields()', + opts: [{v:'',l:'\u2014 not present \u2014'},{v:'rlt',l:'radio link timeout (zero length)'},{v:'custom',l:'custom (hex)'}] }, + { 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'}] }, + ], + build(v) { + let d = '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2); + const cause = (v.cause || '').replace(/[^0-9a-fA-F]/g, ''); + if (v.cause_mode === 'rlt') d += '9A00'; + else if (v.cause_mode === 'custom' && cause) + 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'); + return d.toUpperCase(); + } + }, 0x03: { title: 'Location status', fields: [ { id: 'status', label: 'Service Status', type: 'select', @@ -13081,7 +13163,13 @@ const EVENT_FORMS = { ], build(v) { const b = new TextEncoder().encode(v.lang||'en'); return b.length.toString(16).padStart(2,'0') + bytesToHex(Array.from(b)); } }, - 0x08: { title: 'Browser termination', note: 'not_yet' }, + 0x08: { title: 'Browser termination', + fields: [ + { id: 'cause', label: 'Browser termination cause', type: 'select', + opts: [{v:0,l:'00 \u2014 user termination'},{v:1,l:'01 \u2014 error termination'}] }, + ], + build(v) { return 'B401' + ((parseInt(v.cause) || 0) & 0xFF).toString(16).padStart(2, '0'); } + }, 0x09: null, 0x0A: { title: 'Channel status', fields: [ @@ -13114,7 +13202,15 @@ const EVENT_FORMS = { 0x0E: null, 0x0F: null, 0x10: null, - 0x11: { title: 'I-WLAN Access Status', note: 'not_yet' }, + 0x11: { title: 'I-WLAN Access Status', + fields: [ + { id: 'status', label: 'Access status', type: 'select', + opts: [{v:0,l:'00 \u2014 no current (I-)WLAN coverage'}, + {v:1,l:'01 \u2014 coverage available, no current connection'}, + {v:2,l:'02 \u2014 coverage available, connection on-going'}] }, + ], + build(v) { return 'CB01' + ((parseInt(v.status) || 0) & 0xFF).toString(16).padStart(2, '0'); } + }, 0x12: { title: 'Network Rejection', fields: [ { id: 'reg_type', label: 'Registration type', type: 'select', onchange: 'updateNetRejectionFields()', @@ -13166,7 +13262,40 @@ const EVENT_FORMS = { opts: [{v:0,l:'GSM'},{v:3,l:'UTRAN'},{v:8,l:'E-UTRAN'},{v:10,l:'NG-RAN'}] }], build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); } }, - 0x15: { title: 'CSG cell selection', note: 'not_yet' }, + 0x15: { title: 'CSG cell selection', src: '83', + fields: [ + { id: 'status', label: 'CSG cell selection status', type: 'select', + opts: [{v:0,l:'00 \u2014 not camped on an Allowed / Operator CSG cell'}, + {v:1,l:'01 \u2014 camped on an Allowed / Operator CSG cell'}] }, + { id: 'mech', label: 'Selection mechanism', type: 'select', + opts: [{v:'',l:'\u2014 none \u2014'},{v:'manual',l:'manual CSG selection'}, + {v:'other',l:'another CSG selection type'}] }, + { id: 'tech', label: 'Access Technology (optional)', type: 'select', + opts: [{v:'',l:'\u2014 not present \u2014'}].concat(ACCESSTECH) }, + { id: 'csg', label: 'CSG ID (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,16}', placeholder: 'e.g. 00000001' }, + { id: 'hnb', label: 'HNB name (optional)', type: 'text', placeholder: 'e.g. MyCSG' }, + { id: 'mcc', label: 'MCC (PLMN ID, optional)', type: 'text', pattern: '\\d{3}', placeholder: 'e.g. 250' }, + { id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01' }, + ], + build(v) { + let d = ''; + if (v.tech !== '' && v.tech !== undefined) + d += 'BF01' + ((parseInt(v.tech) || 0) & 0xFF).toString(16).padStart(2, '0'); + const mech = v.mech === 'manual' ? 0x40 : (v.mech === 'other' ? 0x80 : 0x00); + d += 'D502' + ((parseInt(v.status) || 0) & 0xFF).toString(16).padStart(2, '0') + + (0x01 | mech).toString(16).padStart(2, '0'); + if (v.csg) { + const h = v.csg.replace(/[^0-9a-fA-F]/g, ''); + d += 'D6' + (h.length / 2).toString(16).padStart(2, '0') + h; + } + if (v.hnb) { + const b = new TextEncoder().encode(v.hnb).slice(0, 60); + d += 'D7' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b)); + } + if (v.mcc) d += '8903' + encPlmn(v.mcc, v.mnc || '01'); + return d.toUpperCase(); + } + }, 0x16: null, 0x17: null, 0x1C: { title: 'Poll interval negotiation', @@ -13690,10 +13819,23 @@ function pysimEventSendForm(eventType) { body.innerHTML = html; btn.classList.remove('hidden'); if (eventType === 0x12) updateNetRejectionFields(); + if (eventType === 0x02) updateCallCauseFields(); } modal.classList.remove('hidden'); } +// The call-disconnected form's Cause bytes are shown only for a custom cause +// (the radio-link-timeout form is the zero-length object). +function updateCallCauseFields() { + const sel = document.getElementById('ev-f-cause_mode'); + if (!sel) return; + const g = sel.value; + document.querySelectorAll('#event-send-body .event-field').forEach(div => { + const groups = div.getAttribute('data-groups') || ''; + div.style.display = (groups === '' || groups.indexOf(g) >= 0) ? '' : 'none'; + }); +} + function updateNetRejectionFields() { const sel = document.getElementById('ev-f-reg_type'); if (!sel) return; @@ -13715,6 +13857,9 @@ async function pysimEventSend() { const cfg = EVENT_FORMS[_eventFormType]; let eventData = null; + // The device-identities source (TS 102 223 8.7): a fixed per-event value + // or a form field (the call events differ near/far end). + let eventSrc = (cfg && cfg.src) || null; if (cfg && cfg.build) { const values = {}; @@ -13733,6 +13878,7 @@ async function pysimEventSend() { if (!valid) return; try { eventData = cfg.build(values); + if (cfg.srcField && values[cfg.srcField]) eventSrc = values[cfg.srcField]; } catch (e) { err.textContent = 'Error: ' + e.message; err.classList.remove('hidden'); @@ -13743,7 +13889,7 @@ async function pysimEventSend() { btn.disabled = true; btn.textContent = t('Sending...'); try { - const resp = await pysimFetch('/api/event-send', { event_type: _eventFormType, event_data: eventData }); + const resp = await pysimFetch('/api/event-send', { event_type: _eventFormType, event_data: eventData, event_src: eventSrc }); if (resp.sw === '9000') { result.textContent = (eventData ? t('Event data sent.') : t('Event sent.')) + ' SW: ' + resp.sw; if (resp.negotiation) { diff --git a/frontend/sw.js b/frontend/sw.js index 7886a98..0c4de4e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v347'; +const CACHE = 'simple-v348'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/event_forms.test.js b/frontend/tests/event_forms.test.js index 1fc4ecf..aa68d95 100644 --- a/frontend/tests/event_forms.test.js +++ b/frontend/tests/event_forms.test.js @@ -149,3 +149,40 @@ test('the data connection event can carry the current date-time', () => { assert.strictEqual(build({ status: '0', type: '0', ti: '00', loc_status: '0' }), '9D0100AA01001C01009B0100'); }); + +test('encBcdDigits and eventAddressTlv build the EFADN number coding', () => { + assert.strictEqual(encBcdDigits('12345'), '2143F5'); + assert.strictEqual(encBcdDigits('79001234567'), '9700214365F7'); + // international number -> TON/NPI 0x91, length = 1 + 6 bytes + assert.strictEqual(eventAddressTlv('79001234567', '1'), '8607919700214365F7'); + assert.strictEqual(eventAddressTlv('', '1'), ''); +}); + +test('the call events build their 31.111 object sets', () => { + // MT call: source network, TI + Address + Subaddress + IMS URI + Media Type + assert.strictEqual(EVENT_FORMS[0x00].src, '83'); + assert.strictEqual(EVENT_FORMS[0x00].build({ ti: '85', number: '79001234567', ton: '1', + sub: '12', ims_uri: 'sip:a@b', media: '1' }), + '1C01858607919700214365F7880112B1077369703A614062FE0101'); + // call connected: source is the form's field, TI + optional Media Type + assert.strictEqual(EVENT_FORMS[0x01].srcField, 'src'); + assert.strictEqual(EVENT_FORMS[0x01].build({ ti: '00', media: '0' }), '1C0100FE0100'); + assert.strictEqual(EVENT_FORMS[0x01].build({ ti: '00', media: '' }), '1C0100'); + // call disconnected: radio link timeout = zero-length cause, custom = bytes + assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: 'rlt', media: '' }), '1C01009A00'); + assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: 'custom', cause: '10', media: '' }), '1C01009A0110'); +}); + +test('the browser, WLAN and CSG events build their object sets', () => { + assert.strictEqual(EVENT_FORMS[0x08].build({ cause: '1' }), 'B40101'); + assert.strictEqual(EVENT_FORMS[0x11].build({ status: '2' }), 'CB0102'); + // CSG: source network, access tech + status/mechanism + ID + name + PLMN + assert.strictEqual(EVENT_FORMS[0x15].src, '83'); + assert.strictEqual(EVENT_FORMS[0x15].build({ status: '1', mech: 'manual', tech: '8', + csg: '00000001', hnb: 'MyCSG', mcc: '250', mnc: '01' }), + 'BF0108D5020141D60400000001D7054D79435347890352F010'); +}); + +test('every event type has a parameter form now', () => { + assert.ok(!html.includes("note: 'not_yet'"), 'no event may stay unimplemented'); +}); diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 6617f37..b221a87 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -4276,12 +4276,20 @@ def _verify_adm(scc, app, adm_hex): 'error': 'Security status not satisfied' if sw == '6982' else 'Error'} -def _send_event_download(scc, event_type, event_data=None, drain=True): +def _send_event_download(scc, event_type, event_data=None, drain=True, src=None): """Send ENVELOPE(Event Download) for the given event type. - Builds: CLA C2 0000 Lc D6 [len] (99 01 [type] 82 02 82 81 [extra])""" + Builds: CLA C2 0000 Lc D6 [len] (99 01 [type] 82 02 81 [extra]). + `src` is the source device identity (TS 102 223 8.7: '82' terminal, + '83' network); the default is the terminal.""" + src_byte = 0x82 + if src: + try: + src_byte = int(str(src), 16) & 0xFF + except (TypeError, ValueError): + src_byte = 0x82 inner = bytearray() inner.extend([0x99, 0x01, event_type]) - inner.extend([0x82, 0x02, 0x82, 0x81]) + inner.extend([0x82, 0x02, src_byte, 0x81]) if event_data: inner.extend(event_data) if len(inner) < 0x80: @@ -6849,8 +6857,9 @@ class PysimHandler(BaseHTTPRequestHandler): self._send_json({'error': 'event_type is required'}, 400) return event_data = bytes.fromhex(event_data_hex) if event_data_hex else None + event_src = body.get('event_src') try: - data, sw = _send_event_download(scc, event_type, event_data) + data, sw = _send_event_download(scc, event_type, event_data, src=event_src) try: ev_type = int(event_type) except (TypeError, ValueError): diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 5f4e653..5d5b1c6 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -789,7 +789,7 @@ class TestProactiveDecode(unittest.TestCase): class TestEventDownload(unittest.TestCase): """ENVELOPE (EVENT DOWNLOAD) assembly, TS 102 223 7.5.11.""" - def _send(self, event_type, event_data): + def _send(self, event_type, event_data, src=None): import pysim_simple_server.server as srv calls = [] @@ -802,7 +802,7 @@ class TestEventDownload(unittest.TestCase): cat_cla = '80' _tp = Tp() - data, sw = srv._send_event_download(Scc(), event_type, event_data) + data, sw = srv._send_event_download(Scc(), event_type, event_data, src=src) return calls[0], sw def test_channel_status_event(self): @@ -817,6 +817,15 @@ class TestEventDownload(unittest.TestCase): self.assertEqual(sw, '9000') self.assertEqual(apdu, '80c2000009d60799010582028281') + def test_event_source_device_identity_can_be_overridden(self): + # MT call / CSG cell selection come from the network (TS 102 223 8.7) + apdu, sw = self._send(0x00, None, src='83') + self.assertEqual(sw, '9000') + self.assertEqual(apdu, '80c2000009d60799010082028381') + # an invalid source falls back to the terminal + apdu, sw = self._send(0x00, None, src='zz') + self.assertEqual(apdu, '80c2000009d60799010082028281') + def test_event_with_long_data_uses_the_ber_long_form(self): # 128+ bytes of event data: the D6 length becomes '81' (the # 7.5.x tables allow 1 or 2 length bytes; a raw byte above 0x7F would