Decode the Items Next Action Indicator (TS 102 223 §8.24)
- tag '18' (TS 101 220: "18 only"): one byte per item, §9.4 coding; '00' and reserved values mean "no next action" (decoded to None) - items[] now carry a per-item next_action; a list whose length differs from the item count is preserved raw (spec requires X = item count) - detail view renders "Item 0xNN: text → COMMAND"; the APDU-list summary is unchanged — next actions are detail-only, as requested - tests: 3 real SET UP MENU samples (local sessions 47/53/57) + a length-mismatch tolerance case - v2.1.2; SW cache simtrace-analyser-v57
This commit is contained in:
1 parent
d4a378695c
commit
a50a93a238
7 files changed
+67
-6
No files matched your search
+3
-2
@@ -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.icon_id !== undefined) rows.push(['Icon', cmd.icon_id ? 'record #' + cmd.icon_id : 'none']);
|
||||||
if (cmd.items) {
|
if (cmd.items) {
|
||||||
for (const it of 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]);
|
if (cmd.duration) rows.push(['Duration', cmd.duration.value + ' ' + cmd.duration.unit]);
|
||||||
@@ -2706,7 +2707,7 @@ function onFilterChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── Versioning ────────────────────
|
// ──────────────────── Versioning ────────────────────
|
||||||
const PWA_VERSION = '2.1.1';
|
const PWA_VERSION = '2.1.2';
|
||||||
const MIN_SERVER_VERSION = '1.0.0';
|
const MIN_SERVER_VERSION = '1.0.0';
|
||||||
|
|
||||||
function parseVersion(v) {
|
function parseVersion(v) {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simtrace-analyser-v56';
|
const CACHE = 'simtrace-analyser-v57';
|
||||||
|
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'index.html',
|
'index.html',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "simtrace-analyser"
|
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)"
|
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
|
||||||
readme = {file = "README.md", content-type = "text/markdown"}
|
readme = {file = "README.md", content-type = "text/markdown"}
|
||||||
license = {text = "GPL-2.0-or-later"}
|
license = {text = "GPL-2.0-or-later"}
|
||||||
|
|||||||
@@ -2620,6 +2620,7 @@ _P_TEXT_STRING = 0x0D
|
|||||||
_P_ITEM = 0x0F
|
_P_ITEM = 0x0F
|
||||||
_P_FILE_LIST = 0x12
|
_P_FILE_LIST = 0x12
|
||||||
_P_RESPONSE_LEN = 0x11
|
_P_RESPONSE_LEN = 0x11
|
||||||
|
_P_NEXT_ACTION = 0x18
|
||||||
_P_EVENT_LIST = 0x19
|
_P_EVENT_LIST = 0x19
|
||||||
_P_ICON_ID = 0x1E
|
_P_ICON_ID = 0x1E
|
||||||
_P_AID = 0x2F
|
_P_AID = 0x2F
|
||||||
@@ -2666,6 +2667,8 @@ def _decode_proactive(body):
|
|||||||
file_list = []
|
file_list = []
|
||||||
aid = None
|
aid = None
|
||||||
raw_tlv = []
|
raw_tlv = []
|
||||||
|
next_actions = None
|
||||||
|
next_actions_hex = None
|
||||||
text_seen = False
|
text_seen = False
|
||||||
for tag, _length, value in inner:
|
for tag, _length, value in inner:
|
||||||
base = tag & 0x7F
|
base = tag & 0x7F
|
||||||
@@ -2704,6 +2707,11 @@ def _decode_proactive(body):
|
|||||||
result['text'] = _decode_dcs_text(value)
|
result['text'] = _decode_dcs_text(value)
|
||||||
result['text_note'] = "text string with non-standard tag '10'"
|
result['text_note'] = "text string with non-standard tag '10'"
|
||||||
text_seen = True
|
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:
|
elif base == _P_FILE_LIST and value:
|
||||||
file_list = [value[i:i + 2].hex().upper() for i in range(0, len(value), 2)]
|
file_list = [value[i:i + 2].hex().upper() for i in range(0, len(value), 2)]
|
||||||
elif base == _P_AID and value:
|
elif base == _P_AID and value:
|
||||||
@@ -2713,6 +2721,15 @@ def _decode_proactive(body):
|
|||||||
else:
|
else:
|
||||||
raw_tlv.append({'tag': f'{base:02X}', 'value': value.hex().upper()})
|
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:
|
if cmd_type is None:
|
||||||
return None
|
return None
|
||||||
if 0xF0 <= cmd_type <= 0xFE: # TS 102 223 §9.4 — reserved for proprietary use
|
if 0xF0 <= cmd_type <= 0xFE: # TS 102 223 §9.4 — reserved for proprietary use
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the SIMtrace Analyser version."""
|
"""Single source of truth for the SIMtrace Analyser version."""
|
||||||
|
|
||||||
__version__ = '2.1.1'
|
__version__ = '2.1.2'
|
||||||
@@ -1890,6 +1890,49 @@ class TestRefresh(unittest.TestCase):
|
|||||||
'0x08 Steering of Roaming for I-WLAN')
|
'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):
|
class TestNoReservedLabels(unittest.TestCase):
|
||||||
# ETSI 'Reserved for GSM/3GPP' values must be resolved via the sibling
|
# ETSI 'Reserved for GSM/3GPP' values must be resolved via the sibling
|
||||||
# spec or left unnamed — never surfaced as a 'Reserved ...' label.
|
# spec or left unnamed — never surfaced as a 'Reserved ...' label.
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
|
|||||||
class TestVersion(unittest.TestCase):
|
class TestVersion(unittest.TestCase):
|
||||||
def test_package_version(self):
|
def test_package_version(self):
|
||||||
from simtrace2_pysniff import __version__
|
from simtrace2_pysniff import __version__
|
||||||
self.assertEqual(__version__, '2.1.1')
|
self.assertEqual(__version__, '2.1.2')
|
||||||
|
|
||||||
def test_status_includes_version(self):
|
def test_status_includes_version(self):
|
||||||
import os
|
import os
|
||||||
|
|||||||
Reference in new issue
Block a user