From fac6bd09af3bdb2d8aafa37efad33bdf4ac52a8f 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: Sat, 22 Aug 2026 22:17:27 +0300 Subject: [PATCH] Decode SET UP IDLE MODE TEXT command data; bump to 1.11.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS 102 223 §6.6.13 support for the SET UP IDLE MODE TEXT (0x28) proactive command: - Command qualifier: COMMAND_QUALIFIERS entries may now be callables for bitmask-style qualifiers; _idle_text_qualifier decodes bit 1 as "icon self-explanatory", hides 0x00, falls back to raw hex on RFU bits. - Icon Identifier TLV (0x1E) parsed into icon_id (image record number in EF_IMG, 0 = none); rendered as an "Icon" detail row in the PWA. - Zero-length Text String TLV now yields text='' ("remove idle mode text") instead of being silently skipped. Tests: +6 TestIdleModeText cases (8-bit/UCS-2 text, icon TLV, qualifier variants, empty text) — 285 total. SW cache bumped to simtrace-analyser-v14. --- frontend/index.html | 3 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- simtrace2_pysniff/__init__.py | 2 +- simtrace2_pysniff/server/decode.py | 24 +++++++++++++-- tests/test_decode.py | 47 ++++++++++++++++++++++++++++++ tests/test_pcap_import.py | 2 +- 7 files changed, 75 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 6e4d003..ee77229 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -667,6 +667,7 @@ function cmdRows(cmd) { if (cmd.aid) rows.push(['AID', cmd.aid]); if (cmd.title) rows.push(['Title', cmd.title]); if (cmd.text !== undefined) rows.push(['Text', cmd.text]); + if (cmd.icon_id !== undefined) rows.push(['Icon', cmd.icon_id ? 'record #' + cmd.icon_id : 'none']); if (cmd.items) { for (const it of cmd.items) { rows.push(['Item ' + (it.id !== undefined ? '0x' + hx(it.id, 2) : '?'), it.text]); @@ -1851,7 +1852,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.10.0'; +const PWA_VERSION = '1.11.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 009df1a..f6116bd 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v13'; +const CACHE = 'simtrace-analyser-v14'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 5b9899e..cb62ba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.10.0" +version = "1.11.0" description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "GPL-2.0-or-later"} diff --git a/simtrace2_pysniff/__init__.py b/simtrace2_pysniff/__init__.py index 1494d69..9222449 100644 --- a/simtrace2_pysniff/__init__.py +++ b/simtrace2_pysniff/__init__.py @@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver from .pcap import PcapWriter from .dump import FileDumper, format_message -__version__ = '1.10.0' +__version__ = '1.11.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index cf5f14a..ecf42f9 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -1047,13 +1047,28 @@ PLI_QUALIFIERS = { # TS 102 223 §8.6 — Command Qualifier tables per Type of Command. # Only commands with a known qualifier coding are listed; others fall back -# to a raw '0xXX' in _command_qualifier. +# to a raw '0xXX' in _command_qualifier. An entry may also be a callable +# for bitmask-style qualifiers (returns the decoded string or None). COMMAND_QUALIFIERS = { 0x01: REFRESH_MODES, # REFRESH 0x26: PLI_QUALIFIERS, # PROVIDE LOCAL INFORMATION } +def _idle_text_qualifier(qualifier): + """SET UP IDLE MODE TEXT (TS 102 223 §6.6.13): bit 1 = icon + self-explanatory flag, all other bits RFU.""" + if qualifier & ~0x01: + return f'0x{qualifier:02X}' + if qualifier & 0x01: + return 'icon self-explanatory' + return None + + +# TS 102 223 §6.6.13 — SET UP IDLE MODE TEXT +COMMAND_QUALIFIERS[0x28] = _idle_text_qualifier + + def _command_qualifier(cmd_type, qualifier): """Decode a Command Qualifier (Command Details byte 3) into a string. @@ -1064,6 +1079,8 @@ def _command_qualifier(cmd_type, qualifier): if qualifier is None: return None table = COMMAND_QUALIFIERS.get(cmd_type) + if callable(table): + return table(qualifier) if table: name = table.get(qualifier) if name is None: @@ -2235,6 +2252,7 @@ _P_ITEM = 0x0F _P_FILE_LIST = 0x12 _P_RESPONSE_LEN = 0x11 _P_EVENT_LIST = 0x19 +_P_ICON_ID = 0x1E _P_AID = 0x2F @@ -2268,7 +2286,7 @@ def _decode_proactive(body): qualifier = value[2] elif base == _P_ALPHA_ID and value: result['title'] = _decode_annex_a(value) - elif base == _P_TEXT_STRING and value: + elif base == _P_TEXT_STRING: result['text'] = _decode_dcs_text(value) elif base == _P_ITEM and len(value) >= 2: items.append({'id': value[0], 'text': _decode_annex_a(value[1:])}) @@ -2282,6 +2300,8 @@ def _decode_proactive(body): result['events'] = [EVENT_TYPES.get(e, f'0x{e:02X}') for e in value] elif base == _P_ADDRESS and value: result['address'] = _decode_bcd_address(value) + elif base == _P_ICON_ID and value: + result['icon_id'] = value[0] elif base == _P_FILE_LIST and value: file_list = [value[i:i + 2].hex().upper() for i in range(0, len(value), 2)] elif base == _P_AID and value: diff --git a/tests/test_decode.py b/tests/test_decode.py index 20d4cc9..3f48a62 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1545,6 +1545,53 @@ class TestRefresh(unittest.TestCase): self.assertEqual(r['cmd']['file_list'], ['6F07', '6F20']) +class TestIdleModeText(unittest.TestCase): + """SET UP IDLE MODE TEXT (TS 102 223 §6.6.13) proactive command.""" + + def _fetch(self, inner_hex): + # FETCH response body: D0 BER-TLV wrapping the proactive command + inner = bytes.fromhex(inner_hex) + body = b'\xd0' + bytes([len(inner)]) + inner + return decode_message( + bytes.fromhex('80120000%02x' % len(body)) + body + b'\x90\x00') + + def test_8bit_text_icon_qualifier(self): + # "Hello" 8-bit + icon record 5 + qualifier bit 1 (self-explanatory) + r = self._fetch( + '810301280182028182' # cmd details (qual 0x01) + device ids + '8d060448656c6c6f' # Text String: DCS 0x04 (8-bit), "Hello" + '1e0105') # Icon Identifier: record 5 + self.assertEqual(r['cat_command'], 'SET UP IDLE MODE TEXT') + self.assertEqual(r['cmd']['text'], 'Hello') + self.assertEqual(r['cmd']['qualifier'], 'icon self-explanatory') + self.assertEqual(r['cmd']['icon_id'], 5) + + def test_ucs2_text(self): + # "Привет" UCS-2 (DCS 0x08) + r = self._fetch( + '810301280082028182' + '8d0d08041f04400438043204350442') + self.assertEqual(r['cat_command'], 'SET UP IDLE MODE TEXT') + self.assertEqual(r['cmd']['text'], 'Привет') + + def test_qualifier_zero_hidden(self): + r = self._fetch('8103012800820281828d060448656c6c6f') + self.assertNotIn('qualifier', r['cmd']) + + def test_qualifier_rfu_bits_raw(self): + r = self._fetch('8103012802820281828d060448656c6c6f') + self.assertEqual(r['cmd']['qualifier'], '0x02') + + def test_empty_text_removes_idle_text(self): + # Zero-length Text String instructs the ME to remove the idle text + r = self._fetch('8103012800820281828d00') + self.assertEqual(r['cmd']['text'], '') + + def test_no_icon_tlv(self): + r = self._fetch('8103012800820281828d060448656c6c6f') + self.assertNotIn('icon_id', r['cmd']) + + class TestSwUiccSpecific(unittest.TestCase): """TS 102 221 UICC-specific SWs (tables 10.7-10.15).""" diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index fc89dc5..cb5c6fc 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -197,7 +197,7 @@ class TestImportEndpoint(unittest.TestCase): class TestVersion(unittest.TestCase): def test_package_version(self): from simtrace2_pysniff import __version__ - self.assertEqual(__version__, '1.10.0') + self.assertEqual(__version__, '1.11.0') def test_status_includes_version(self): import os