From 582c42c5cdc9f80e9aaefff33c6f28ee47f78d32 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, 20 Aug 2026 21:48:28 +0300 Subject: [PATCH] Decode SCP80 Response Packet (PoR) in GET RESPONSE after SMS-PP DOWNLOAD --- frontend/index.html | 12 +++++++++- simtrace2_pysniff/server/decode.py | 20 +++++++++++++---- tests/test_decode.py | 35 +++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index caa99fe..e40522f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -95,7 +95,7 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(- .trace-topbar select { padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); } .trace-topbar button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; } .trace-topbar button:hover { background: var(--accent-hover); } -.trace-topbar button.stop { background: var(--bg-input); } +.trace-topbar button.stop { background: var(--bg-input); color: var(--text); } .trace-topbar button.stop:hover { background: var(--bg-panel); } .trace-topbar button.connect { background: var(--warn); color: #0f172a; } .trace-topbar button.connect:hover { background: var(--warn-hover); } @@ -591,6 +591,16 @@ function responseRows(resp) { rows.push([k, v]); } } + if (resp.rpl !== undefined) { + rows.push(['Response packet', 'Response Packet Length ' + resp.rpl + ' · Response Header Length ' + resp.rhl]); + rows.push(['TAR', '0x' + resp.tar]); + rows.push(['CNTR', '0x' + resp.cntr]); + rows.push(['PCNTR', resp.pcntr]); + const st = resp.status || {}; + rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]); + if (resp.rc_cc_ds) rows.push(['RC/CC/DS', resp.rc_cc_ds]); + if (resp.data) rows.push(['Response data', resp.data]); + } if (!rows.length) return ''; return `Response` + diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index 62433bd..37992bd 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -956,15 +956,18 @@ COMMAND_QUALIFIERS = { def _command_qualifier(cmd_type, qualifier): """Decode a Command Qualifier (Command Details byte 3) into a string. - Returns None when no meaningful qualifier is present, a decoded name - when a table exists (including a table's own '00' entry), or a raw - '0xXX' for a non-zero qualifier without a table. + Returns None when no meaningful qualifier is present, the raw '0xXX' + for a non-zero qualifier without a table, or '0xXX ' when a + table entry exists. """ if qualifier is None: return None table = COMMAND_QUALIFIERS.get(cmd_type) if table: - return table.get(qualifier, f'0x{qualifier:02X}') + name = table.get(qualifier) + if name is None: + return f'0x{qualifier:02X}' + return f'0x{qualifier:02X} {name}' if qualifier == 0x00: return None return f'0x{qualifier:02X}' @@ -3031,6 +3034,9 @@ def _build_summary(result): fd = response.get('file_descriptor') if fd: parts.append(_fcp_summary(result['response_for'], fd, response)) + elif response.get('tar'): + st = response.get('status') or {} + parts.append(f"{st.get('name') or ('0x' + st.get('code', ''))} · TAR {response['tar']}") else: parts.append('response for ' + result['response_for']) if response and response.get('name'): @@ -3236,6 +3242,12 @@ def decode_message(raw_data, prev=None): 'ef': KNOWN_FIDS.get(fid, fid.upper()), 'record_numbers': list(remaining[:cmd_body_len]), } + elif prev_ins == 0xC2: # ENVELOPE → Response Packet (PoR) + data = remaining[:cmd_body_len] + # RPI mapping precedes the packet: UDHL='02', IEIa='71', + # IEIDLa='00' (TS 31.115 §4.4). + if data[:3] == b'\x02\x71\x00': + result['response'] = _decode_response_packet(data[3:]) else: response = _decode_response_for(prev_ins, remaining[:cmd_body_len]) if response: diff --git a/tests/test_decode.py b/tests/test_decode.py index 13c15d9..811c0e4 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -73,7 +73,7 @@ class TestCatDecoding(unittest.TestCase): '801200000BD0098103012600820281829000')) self.assertEqual(r['cat_command'], 'PROVIDE LOCAL INFORMATION') self.assertEqual(r['cmd']['qualifier'], - 'Location Info (MCC, MNC, LAC/TAC, Cell ID)') + '0x00 Location Info (MCC, MNC, LAC/TAC, Cell ID)') def test_envelope_event_download(self): r = decode_message(bytes.fromhex( @@ -186,20 +186,20 @@ class TestPliQualifier(unittest.TestCase): r = decode_message(bytes.fromhex( '8014000016810301260102028281030106130952F0991EC57A68009F')) self.assertEqual(r['response_to'], 'PROVIDE LOCAL INFORMATION') - self.assertEqual(r['response']['qualifier'], 'IMEI') + self.assertEqual(r['response']['qualifier'], '0x01 IMEI') def test_fetch_pli_location(self): r = decode_message(bytes.fromhex( '801200000BD0098103012600820281829000')) self.assertEqual(r['cat_command'], 'PROVIDE LOCAL INFORMATION') self.assertEqual(r['cmd']['qualifier'], - 'Location Info (MCC, MNC, LAC/TAC, Cell ID)') + '0x00 Location Info (MCC, MNC, LAC/TAC, Cell ID)') def test_fetch_pli_date_time(self): r = decode_message(bytes.fromhex( '801200000BD0098103012603820281829000')) self.assertEqual(r['cat_command'], 'PROVIDE LOCAL INFORMATION') - self.assertEqual(r['cmd']['qualifier'], 'Date, time and time zone') + self.assertEqual(r['cmd']['qualifier'], '0x03 Date, time and time zone') def test_non_pli_no_qualifier(self): r = decode_message(bytes.fromhex( @@ -222,6 +222,29 @@ class TestGetResponseContext(unittest.TestCase): r = decode_message(self.GET_RESPONSE, prev=None) self.assertIsNone(r['response_for']) + def test_get_response_por(self): + # GET RESPONSE after ENVELOPE SMS-PP DOWNLOAD → Response Packet (PoR). + r = decode_message( + bytes.fromhex('00C0000024') + + bytes.fromhex('027100001F0AB0000100000000C100000290000080041C043004400438044FD83CDF37FF') + + bytes.fromhex('9000'), + prev={'ins': 0xC2, 'ins_name': 'ENVELOPE', 'sw1': '61'}) + self.assertEqual(r['response_for'], 'ENVELOPE') + self.assertEqual(r['response']['rpl'], 31) + self.assertEqual(r['response']['rhl'], 10) + self.assertEqual(r['response']['tar'], 'B00001') + self.assertEqual(r['response']['cntr'], '00000000C1') + self.assertEqual(r['response']['status'], {'code': '00', 'name': 'PoR OK'}) + self.assertEqual(r['response']['data'], '0290000080041C043004400438044FD83CDF37FF') + self.assertIn('PoR OK · TAR B00001', r['summary']) + + def test_get_response_non_por_not_parsed(self): + # A GET RESPONSE after ENVELOPE that is not a PoR must stay undecoded. + r = decode_message(bytes.fromhex('00C0000004') + bytes.fromhex('AABBCCDD') + bytes.fromhex('9000'), + prev={'ins': 0xC2, 'ins_name': 'ENVELOPE', 'sw1': '61'}) + self.assertEqual(r['response_for'], 'ENVELOPE') + self.assertNotIn('response', r) + class TestFcpResponse(unittest.TestCase): GET_RESPONSE = bytes.fromhex( @@ -1512,13 +1535,13 @@ class TestRefresh(unittest.TestCase): def test_uicc_reset(self): r = decode_message(bytes.fromhex('801200000bd0098103010104820281829000')) self.assertEqual(r['cat_command'], 'REFRESH') - self.assertEqual(r['cmd']['qualifier'], 'UICC Reset') + self.assertEqual(r['cmd']['qualifier'], '0x04 UICC Reset') self.assertNotIn('file_list', r['cmd']) self.assertNotIn('aid', r['cmd']) def test_file_change_notification(self): r = decode_message(bytes.fromhex('8012000011d00f81030101018202818292046f076f209000')) - self.assertEqual(r['cmd']['qualifier'], 'File Change Notification') + self.assertEqual(r['cmd']['qualifier'], '0x01 File Change Notification') self.assertEqual(r['cmd']['file_list'], ['6F07', '6F20'])