diff --git a/frontend/index.html b/frontend/index.html index 92666a9..27d059d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1284,7 +1284,8 @@ function cmdRows(cmd) { 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]); + const next = it.next_action ? ' \u2192 ' + it.next_action : ''; + rows.push(['Item ' + (it.id !== undefined ? '0x' + hx(it.id, 2) : '?'), it.text + next]); } } if (cmd.duration) rows.push(['Duration', cmd.duration.value + ' ' + cmd.duration.unit]); @@ -2706,7 +2707,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.1.1'; +const PWA_VERSION = '2.1.2'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 49585dd..b685f68 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v56'; +const CACHE = 'simtrace-analyser-v57'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 1939d7d..0056278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace-analyser" -version = "2.1.1" +version = "2.1.2" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "GPL-2.0-or-later"} diff --git a/simtrace_analyser/decode.py b/simtrace_analyser/decode.py index c1ef11e..29b15d2 100644 --- a/simtrace_analyser/decode.py +++ b/simtrace_analyser/decode.py @@ -2620,6 +2620,7 @@ _P_TEXT_STRING = 0x0D _P_ITEM = 0x0F _P_FILE_LIST = 0x12 _P_RESPONSE_LEN = 0x11 +_P_NEXT_ACTION = 0x18 _P_EVENT_LIST = 0x19 _P_ICON_ID = 0x1E _P_AID = 0x2F @@ -2666,6 +2667,8 @@ def _decode_proactive(body): file_list = [] aid = None raw_tlv = [] + next_actions = None + next_actions_hex = None text_seen = False for tag, _length, value in inner: base = tag & 0x7F @@ -2704,6 +2707,11 @@ def _decode_proactive(body): result['text'] = _decode_dcs_text(value) result['text_note'] = "text string with non-standard tag '10'" text_seen = True + elif base == _P_NEXT_ACTION and value: + # TS 102 223 §8.24 — one byte per item (§9.4 coding); '00' and + # reserved values mean "no next action" and decode to None. + next_actions = [CAT_COMMAND_TYPES.get(b) for b in value] + next_actions_hex = value.hex().upper() 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: @@ -2713,6 +2721,15 @@ def _decode_proactive(body): else: raw_tlv.append({'tag': f'{base:02X}', 'value': value.hex().upper()}) + if next_actions is not None: + if len(next_actions) == len(items): + for item, action in zip(items, next_actions): + if action: + item['next_action'] = action + else: + # §8.24 requires X = number of items; keep unexpected lists raw. + raw_tlv.append({'tag': '18', 'value': next_actions_hex}) + if cmd_type is None: return None if 0xF0 <= cmd_type <= 0xFE: # TS 102 223 §9.4 — reserved for proprietary use diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py index 6d05c04..a8e3d0e 100644 --- a/simtrace_analyser/version.py +++ b/simtrace_analyser/version.py @@ -1,3 +1,3 @@ """Single source of truth for the SIMtrace Analyser version.""" -__version__ = '2.1.1' +__version__ = '2.1.2' diff --git a/tests/test_decode.py b/tests/test_decode.py index 2853dd0..b622151 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1890,6 +1890,49 @@ class TestRefresh(unittest.TestCase): '0x08 Steering of Roaming for I-WLAN') +class TestItemsNextActionIndicator(unittest.TestCase): + """Items Next Action Indicator (TS 102 223 §8.24, tag '18') — real + SET UP MENU samples from the local trace sessions (47/53/57).""" + + def test_second_item_display_text(self): + # session 53: 2 items, item 2 → DISPLAY TEXT ('21'); '00' = no action. + r = decode_message(bytes.fromhex( + '8012000040D03E810301250082028182850B416C6661204D6F62696C' + '658F16808112089DB0C1C2C0BEB9BAB82F53657474696E67738F0A81' + '416C66612D494D4549180200219000')) + items = r['cmd']['items'] + self.assertNotIn('next_action', items[0]) + self.assertEqual(items[1]['next_action'], 'DISPLAY TEXT') + # Detail-only: the APDU-list summary stays unchanged. + self.assertEqual(r['summary'], 'Alfa Mobile, 2 items') + + def test_second_item_select_item(self): + # session 57: 2 items, item 2 → SELECT ITEM ('24'). + r = decode_message(bytes.fromhex( + '8012000044D042810301250082028182050B416C6661204D6F62696C' + '658F16808112089DB0C1C2C0BEB9BAB82F53657474696E67738F0E81' + '416C66612053494D2074657374180200249000')) + self.assertEqual(r['cmd']['items'][1]['next_action'], 'SELECT ITEM') + + def test_last_item_of_nine(self): + # session 47: 9 items, only the last has SELECT ITEM ('24'). + r = decode_message(bytes.fromhex( + '80120000E6D081E3810301250082028182051580004D0065006700610046006F006E00500052004F8F100180041C0435043304300424043E043D8F1802800420043004370432043B043504470435043D0438044F8F0C03800421043F043E044004428F100480041D043E0432043E0441044204388F10058004240438043D0430043D0441044B8F1006800421043F044004300432043A04308F100780041E043104490435043D043804358F188080041A0430043B043504390434043E0441043A043E043F8F1A8180041C043E04310438043B044C043D044B043900200049004418090000000000000000249000')) + items = r['cmd']['items'] + self.assertEqual(len(items), 9) + self.assertNotIn('next_action', items[0]) + self.assertEqual(items[-1]['next_action'], 'SELECT ITEM') + + def test_length_mismatch_kept_raw(self): + # §8.24 requires X = number of items; a mismatched list stays raw. + r = decode_message(bytes.fromhex( + '8012000014D0128103012500820281828F03014142180224249000')) + items = r['cmd']['items'] + self.assertEqual(len(items), 1) + self.assertNotIn('next_action', items[0]) + self.assertEqual(r['cmd']['raw_tlv'], [{'tag': '18', 'value': '2424'}]) + + class TestNoReservedLabels(unittest.TestCase): # ETSI 'Reserved for GSM/3GPP' values must be resolved via the sibling # spec or left unnamed — never surfaced as a 'Reserved ...' label. diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 890504b..ea37b35 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase): class TestVersion(unittest.TestCase): def test_package_version(self): from simtrace2_pysniff import __version__ - self.assertEqual(__version__, '2.1.1') + self.assertEqual(__version__, '2.1.2') def test_status_includes_version(self): import os