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