TERMINAL PROFILE: device characteristics in list + detail views
- decode.py: _decode_terminal_profile (TS 102 223 §5.2, b1 = LSB per the spec tables): class (declared ND/NK at byte 14 b6/b7, strict likely/ handset rules), generation (positive-only 3G/HSDPA, 4G/E-UTRAN from byte 17 — never a 2G claim, real devices under-declare), BIP (byte 12 b1, byte 13 bearers/channels, byte 17 transports), standards family (byte 18 IMEISV/ESN), UI facts (soft keys/screen/markup), era band - list summary replaces raw hex: e.g. '3G/4G · BIP', 'modem · BIP', 'no BIP', fallback '<n>-byte profile' - detail view: tpRows() panel with class/reason, radio bits, BIP, standards, UI, profile length - body row label for TERMINAL PROFILE: 'TLV data' -> 'Profile' - tests: TERMINAL_PROFILE.md §10 regression anchors + synthetic declared-modem/handset vectors - Bump 1.22.21, SW cache v52->v53
This commit is contained in:
1 parent
2c373f50b7
commit
8441b56e99
7 files changed
+261
-6
No files matched your search
+45
-1
@@ -1128,6 +1128,49 @@ const API = {
|
|||||||
function el(id) { return document.getElementById(id); }
|
function el(id) { return document.getElementById(id); }
|
||||||
function $on(id, ev, fn) { el(id).addEventListener(ev, fn); }
|
function $on(id, ev, fn) { el(id).addEventListener(ev, fn); }
|
||||||
|
|
||||||
|
function tpRows(tp) {
|
||||||
|
if (!tp) return '';
|
||||||
|
const rows = [];
|
||||||
|
if (tp.device_class === 'modem') {
|
||||||
|
rows.push(['Device class', 'headless/modem — ' + (tp.class_reason || '')]);
|
||||||
|
} else if (tp.device_class === 'handset') {
|
||||||
|
rows.push(['Device class', 'handset — ' + (tp.class_reason || '')]);
|
||||||
|
} else {
|
||||||
|
rows.push(['Device class', 'not determined']);
|
||||||
|
}
|
||||||
|
const gen = [];
|
||||||
|
if (tp.hsdpa) gen.push('HSDPA (3G)');
|
||||||
|
if (tp.eutran) gen.push('E-UTRAN (4G)');
|
||||||
|
rows.push(['Radio', gen.length
|
||||||
|
? 'declared: ' + gen.join(', ')
|
||||||
|
: 'no 3G/4G capability declared (under-declaration is common)']);
|
||||||
|
if (tp.utran_ps) rows.push(['UTRAN PS ext.', 'declared']);
|
||||||
|
if (tp.bip === true) {
|
||||||
|
let v = 'capable';
|
||||||
|
if (tp.bip_channels != null) {
|
||||||
|
v += ' — ' + tp.bip_channels + ' channel' + (tp.bip_channels === 1 ? '' : 's');
|
||||||
|
}
|
||||||
|
if (tp.bip_bearers && tp.bip_bearers.length) v += ' · ' + tp.bip_bearers.join(', ');
|
||||||
|
rows.push(['BIP', v]);
|
||||||
|
if (tp.bip_transports && tp.bip_transports.length) {
|
||||||
|
rows.push(['BIP transports', tp.bip_transports.join(', ')]);
|
||||||
|
}
|
||||||
|
} else if (tp.bip === false) {
|
||||||
|
rows.push(['BIP', 'not advertised (no OPEN CHANNEL)']);
|
||||||
|
}
|
||||||
|
if (tp.standards) rows.push(['Standards', tp.standards]);
|
||||||
|
const ui = [];
|
||||||
|
if (tp.soft_keys != null) ui.push('soft keys ' + tp.soft_keys);
|
||||||
|
if (tp.screen_h != null || tp.screen_w != null) {
|
||||||
|
ui.push('screen ' + (tp.screen_h || 0) + '×' + (tp.screen_w || 0) + ' chars');
|
||||||
|
}
|
||||||
|
if (tp.markup && tp.markup.length) ui.push('markup ' + tp.markup.join('/'));
|
||||||
|
if (ui.length) rows.push(['UI', ui.join(' · ')]);
|
||||||
|
rows.push(['Profile', tp.length + ' bytes — ' + (tp.era || '')]);
|
||||||
|
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">Terminal profile</td></tr>` +
|
||||||
|
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function responseRows(resp) {
|
function responseRows(resp) {
|
||||||
if (!resp) return '';
|
if (!resp) return '';
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@@ -1694,6 +1737,7 @@ function showDetail(msg, entry) {
|
|||||||
${body}
|
${body}
|
||||||
${cmdRows(decoded.cmd)}
|
${cmdRows(decoded.cmd)}
|
||||||
${responseRows(decoded.response)}
|
${responseRows(decoded.response)}
|
||||||
|
${tpRows(decoded.tp)}
|
||||||
${fileRows(decoded.file)}
|
${fileRows(decoded.file)}
|
||||||
${sw}
|
${sw}
|
||||||
${decoded.errors ? `<tr><td class="label">Errors</td><td class="value" style="color:var(--error)">${decoded.errors.join(', ')}</td></tr>` : ''}
|
${decoded.errors ? `<tr><td class="label">Errors</td><td class="value" style="color:var(--error)">${decoded.errors.join(', ')}</td></tr>` : ''}
|
||||||
@@ -2637,7 +2681,7 @@ function onFilterChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── Versioning ────────────────────
|
// ──────────────────── Versioning ────────────────────
|
||||||
const PWA_VERSION = '1.22.20';
|
const PWA_VERSION = '1.22.21';
|
||||||
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-v52';
|
const CACHE = 'simtrace-analyser-v53';
|
||||||
|
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'index.html',
|
'index.html',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "simtrace2-pysniff"
|
name = "simtrace2-pysniff"
|
||||||
version = "1.22.20"
|
version = "1.22.21"
|
||||||
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
|
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
|
||||||
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"}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver
|
|||||||
from .pcap import PcapWriter
|
from .pcap import PcapWriter
|
||||||
from .dump import FileDumper, format_message
|
from .dump import FileDumper, format_message
|
||||||
|
|
||||||
__version__ = '1.22.20'
|
__version__ = '1.22.21'
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'SniffSession',
|
'SniffSession',
|
||||||
'DeviceDisconnected',
|
'DeviceDisconnected',
|
||||||
|
|||||||
@@ -885,7 +885,7 @@ APDU_SPEC = {
|
|||||||
0x10: {
|
0x10: {
|
||||||
'name': 'TERMINAL PROFILE',
|
'name': 'TERMINAL PROFILE',
|
||||||
'p1p2': {'unused': True},
|
'p1p2': {'unused': True},
|
||||||
'body': {'label': 'TLV data'},
|
'body': {'label': 'Profile'},
|
||||||
},
|
},
|
||||||
0x76: {
|
0x76: {
|
||||||
'name': 'SUSPEND UICC',
|
'name': 'SUSPEND UICC',
|
||||||
@@ -2731,6 +2731,136 @@ def _decode_proactive(body):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────── TERMINAL PROFILE device characteristics ────────────────────
|
||||||
|
|
||||||
|
def _decode_terminal_profile(data):
|
||||||
|
"""Decode a TERMINAL PROFILE (TS 102 223 §5.2) into device facts.
|
||||||
|
|
||||||
|
Bit numbering per the spec tables: b1 = LSB = first row, so a facility
|
||||||
|
at bit b(n) has mask ``1 << (n - 1)``. Only *declared* capabilities are
|
||||||
|
reported — a clear bit is never proof of absence (real 3G devices such
|
||||||
|
as the Huawei E173 and Nokia E52 leave the HSDPA bit clear), so no
|
||||||
|
"2G" claim is derived from a zero byte 17.
|
||||||
|
|
||||||
|
Inference rules and their caveats: projects/docs/TERMINAL_PROFILE.md.
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
n = len(data)
|
||||||
|
|
||||||
|
def byte(num):
|
||||||
|
return data[num - 1] if n >= num else None
|
||||||
|
|
||||||
|
b4, b11, b12, b13 = byte(4), byte(11), byte(12), byte(13)
|
||||||
|
b14, b15, b17, b18 = byte(14), byte(15), byte(17), byte(18)
|
||||||
|
b21, b22 = byte(21), byte(22)
|
||||||
|
|
||||||
|
result = {'length': n}
|
||||||
|
|
||||||
|
# --- BIP: byte 12 b1 OPEN CHANNEL; byte 13 bearers + channel count ---
|
||||||
|
if b12 is not None:
|
||||||
|
result['bip'] = bool(b12 & 0x01)
|
||||||
|
if b13 is not None:
|
||||||
|
result['bip_channels'] = (b13 >> 5) & 0x07
|
||||||
|
result['bip_bearers'] = [name for mask, name in (
|
||||||
|
(0x01, 'CSD'), (0x02, 'GPRS'), (0x04, 'Bluetooth'),
|
||||||
|
(0x08, 'IrDA'), (0x10, 'RS232')) if b13 & mask]
|
||||||
|
|
||||||
|
# --- Radio generation: byte 17 b8 HSDPA (3G), b7 E-UTRAN (4G) ---
|
||||||
|
if b17 is not None:
|
||||||
|
hsdpa = bool(b17 & 0x80)
|
||||||
|
eutran = bool(b17 & 0x40)
|
||||||
|
result['hsdpa'] = hsdpa
|
||||||
|
result['eutran'] = eutran
|
||||||
|
if hsdpa and eutran:
|
||||||
|
result['generation'] = '3G/4G'
|
||||||
|
elif eutran:
|
||||||
|
result['generation'] = '4G'
|
||||||
|
elif hsdpa:
|
||||||
|
result['generation'] = '3G'
|
||||||
|
result['bip_transports'] = [name for mask, name in (
|
||||||
|
(0x01, 'TCP client, remote'), (0x02, 'UDP client, remote'),
|
||||||
|
(0x04, 'TCP server'), (0x08, 'TCP client, local'),
|
||||||
|
(0x10, 'UDP client, local'), (0x20, 'direct channel'))
|
||||||
|
if b17 & mask]
|
||||||
|
if b22 is not None:
|
||||||
|
result['utran_ps'] = bool(b22 & 0x01)
|
||||||
|
|
||||||
|
# --- Standards family: byte 18 b7 IMEISV (3GPP), b5 ESN (CDMA) ---
|
||||||
|
if b18 is not None:
|
||||||
|
imeisv = bool(b18 & 0x40)
|
||||||
|
esn = bool(b18 & 0x10)
|
||||||
|
result['imeisv'] = imeisv
|
||||||
|
result['esn'] = esn
|
||||||
|
if imeisv and esn:
|
||||||
|
result['standards'] = '3GPP + CDMA'
|
||||||
|
elif imeisv:
|
||||||
|
result['standards'] = '3GPP'
|
||||||
|
elif esn:
|
||||||
|
result['standards'] = 'CDMA (3GPP2)'
|
||||||
|
|
||||||
|
# --- UI facts: byte 11 soft keys; bytes 14/15 screen; byte 21 markup ---
|
||||||
|
if b11 is not None:
|
||||||
|
result['soft_keys'] = b11
|
||||||
|
if b14 is not None:
|
||||||
|
result['screen_h'] = b14 & 0x1F
|
||||||
|
if b15 is not None:
|
||||||
|
result['screen_w'] = b15 & 0x7F
|
||||||
|
if b21 is not None:
|
||||||
|
result['markup'] = [name for mask, name in (
|
||||||
|
(0x01, 'WML'), (0x02, 'XHTML'), (0x04, 'HTML'), (0x08, 'CHTML'))
|
||||||
|
if b21 & mask]
|
||||||
|
|
||||||
|
# --- Device class (strict rules, TERMINAL_PROFILE.md §4) ---
|
||||||
|
no_display = bool(b14 & 0x20) if b14 is not None else False # byte 14 b6
|
||||||
|
no_keypad = bool(b14 & 0x40) if b14 is not None else False # byte 14 b7
|
||||||
|
if no_display or no_keypad:
|
||||||
|
result['device_class'] = 'modem'
|
||||||
|
reasons = [name for name, set_ in (
|
||||||
|
('no display', no_display), ('no keypad', no_keypad)) if set_]
|
||||||
|
result['class_reason'] = 'declared: ' + ', '.join(reasons)
|
||||||
|
else:
|
||||||
|
screen_present = bool((result.get('screen_h') or 0)
|
||||||
|
or (result.get('screen_w') or 0))
|
||||||
|
soft_keys = result.get('soft_keys')
|
||||||
|
menus = True
|
||||||
|
if b4 is not None:
|
||||||
|
menus = bool(b4 & 0x01) or bool(b4 & 0x20) # SELECT ITEM / SET UP MENU
|
||||||
|
if soft_keys == 0 and not screen_present and not menus:
|
||||||
|
result['device_class'] = 'modem'
|
||||||
|
result['class_reason'] = 'likely: no soft keys, no screen size, no menus'
|
||||||
|
elif screen_present and soft_keys not in (None, 0):
|
||||||
|
result['device_class'] = 'handset'
|
||||||
|
result['class_reason'] = (f"screen {result.get('screen_h') or 0}"
|
||||||
|
f"\u00d7{result.get('screen_w') or 0}"
|
||||||
|
f", soft keys {soft_keys}")
|
||||||
|
|
||||||
|
# --- Era from the profile length (soft signal, TERMINAL_PROFILE.md §3) ---
|
||||||
|
if n <= 9:
|
||||||
|
result['era'] = 'early/minimal profile'
|
||||||
|
elif n <= 20:
|
||||||
|
result['era'] = 'classic 2G/3G-era profile'
|
||||||
|
elif n <= 28:
|
||||||
|
result['era'] = 'transitional-era profile'
|
||||||
|
elif n <= 32:
|
||||||
|
result['era'] = 'smartphone/BIP-era profile'
|
||||||
|
else:
|
||||||
|
result['era'] = 'modern LTE/5G-era profile'
|
||||||
|
|
||||||
|
# --- Concise APDU-list description (replaces the raw hex) ---
|
||||||
|
parts = []
|
||||||
|
if result.get('device_class'):
|
||||||
|
parts.append(result['device_class'])
|
||||||
|
if result.get('generation'):
|
||||||
|
parts.append(result['generation'])
|
||||||
|
if result.get('bip') is True:
|
||||||
|
parts.append('BIP')
|
||||||
|
elif result.get('bip') is False:
|
||||||
|
parts.append('no BIP')
|
||||||
|
result['summary'] = ' \u00b7 '.join(parts) if parts else f'{n}-byte profile'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────── Decode entry point ────────────────────
|
# ──────────────────── Decode entry point ────────────────────
|
||||||
|
|
||||||
# ──────────────────── File data decoders (READ/UPDATE) ────────────────────
|
# ──────────────────── File data decoders (READ/UPDATE) ────────────────────
|
||||||
@@ -3728,6 +3858,10 @@ def _build_summary(result):
|
|||||||
txt += f' \u2014 {brief}'
|
txt += f' \u2014 {brief}'
|
||||||
parts.append(txt)
|
parts.append(txt)
|
||||||
|
|
||||||
|
tp = result.get('tp')
|
||||||
|
if tp and tp.get('summary'):
|
||||||
|
parts.append(tp['summary'])
|
||||||
|
|
||||||
cmd = result.get('cmd')
|
cmd = result.get('cmd')
|
||||||
if cmd:
|
if cmd:
|
||||||
if cmd.get('context'):
|
if cmd.get('context'):
|
||||||
@@ -3914,6 +4048,10 @@ def decode_message(raw_data, prev=None):
|
|||||||
fcp = _decode_fcp(body)
|
fcp = _decode_fcp(body)
|
||||||
if fcp:
|
if fcp:
|
||||||
result['response'] = fcp
|
result['response'] = fcp
|
||||||
|
elif ins == 0x10: # TERMINAL PROFILE → device characteristics
|
||||||
|
tp = _decode_terminal_profile(body)
|
||||||
|
if tp:
|
||||||
|
result['tp'] = tp
|
||||||
|
|
||||||
# File data decode for READ/UPDATE using the current selection.
|
# File data decode for READ/UPDATE using the current selection.
|
||||||
p1p2 = result.get('p1p2') or {}
|
p1p2 = result.get('p1p2') or {}
|
||||||
|
|||||||
@@ -1006,6 +1006,79 @@ class TestPacketSummaries(unittest.TestCase):
|
|||||||
self.assertIsNone(self._dec('010100', 'vcc').get('summary'))
|
self.assertIsNone(self._dec('010100', 'vcc').get('summary'))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalProfile(unittest.TestCase):
|
||||||
|
"""TERMINAL PROFILE device characteristics (regression anchors from
|
||||||
|
projects/docs/TERMINAL_PROFILE.md §10)."""
|
||||||
|
|
||||||
|
def _dec(self, profile_hex):
|
||||||
|
data = bytes.fromhex(profile_hex)
|
||||||
|
raw = '80100000%02x' % len(data) + profile_hex + '9000'
|
||||||
|
return decode_message(bytes.fromhex(raw))
|
||||||
|
|
||||||
|
def test_xiaomi_mi_a1(self):
|
||||||
|
r = self._dec('FFFFFFFF7F9F00DFFF03021FE2000000C3FB0007041178'
|
||||||
|
'00710100000038428003')
|
||||||
|
tp = r['tp']
|
||||||
|
self.assertEqual(r['body']['label'], 'Profile')
|
||||||
|
self.assertTrue(tp['bip'])
|
||||||
|
self.assertEqual(tp['bip_channels'], 7)
|
||||||
|
self.assertEqual(tp['bip_bearers'], ['GPRS'])
|
||||||
|
self.assertEqual(tp['bip_transports'],
|
||||||
|
['TCP client, remote', 'UDP client, remote'])
|
||||||
|
self.assertTrue(tp['hsdpa'])
|
||||||
|
self.assertTrue(tp['eutran'])
|
||||||
|
self.assertEqual(tp['generation'], '3G/4G')
|
||||||
|
self.assertTrue(tp['utran_ps'])
|
||||||
|
self.assertEqual(tp['standards'], '3GPP + CDMA')
|
||||||
|
self.assertEqual(tp['summary'], '3G/4G \u00b7 BIP')
|
||||||
|
self.assertEqual(r['summary'], '3G/4G \u00b7 BIP')
|
||||||
|
|
||||||
|
def test_quectel_gsm_module(self):
|
||||||
|
r = self._dec('FFFFFFFF7F1F007FFF00001F230811060700')
|
||||||
|
tp = r['tp']
|
||||||
|
self.assertTrue(tp['bip'])
|
||||||
|
self.assertEqual(tp['bip_channels'], 1)
|
||||||
|
self.assertEqual(tp['bip_bearers'], ['CSD', 'GPRS'])
|
||||||
|
self.assertNotIn('generation', tp)
|
||||||
|
self.assertEqual(tp['summary'], 'BIP')
|
||||||
|
|
||||||
|
def test_huawei_e173_underdeclared(self):
|
||||||
|
# The E173 is a 3G modem yet leaves E-UTRAN/HSDPA clear — the TP
|
||||||
|
# must not be read as "2G".
|
||||||
|
r = self._dec('2965D7E11F90001FD50000000000000000E00000')
|
||||||
|
tp = r['tp']
|
||||||
|
self.assertIs(tp['bip'], False)
|
||||||
|
self.assertNotIn('generation', tp)
|
||||||
|
self.assertEqual(tp['standards'], '3GPP')
|
||||||
|
self.assertEqual(tp['summary'], 'no BIP')
|
||||||
|
|
||||||
|
def test_nokia_7210(self):
|
||||||
|
r = self._dec('FFFFFFFF7F3F00DFFF00001FE28A0D0203290000020040')
|
||||||
|
tp = r['tp']
|
||||||
|
self.assertTrue(tp['bip'])
|
||||||
|
self.assertEqual(tp['bip_channels'], 7)
|
||||||
|
self.assertEqual(tp['screen_h'], 10)
|
||||||
|
self.assertEqual(tp['screen_w'], 13)
|
||||||
|
self.assertNotIn('device_class', tp)
|
||||||
|
self.assertEqual(tp['summary'], 'BIP')
|
||||||
|
|
||||||
|
def test_declared_headless_modem(self):
|
||||||
|
# byte 14 b6 (0x20) = no display capability (class ND).
|
||||||
|
r = self._dec('FFFFFFFF7F9F00DFFF03021F00' + '20' + '00' * 6)
|
||||||
|
tp = r['tp']
|
||||||
|
self.assertEqual(tp['device_class'], 'modem')
|
||||||
|
self.assertIn('declared', tp['class_reason'])
|
||||||
|
self.assertIn('no display', tp['class_reason'])
|
||||||
|
self.assertEqual(tp['summary'], 'modem \u00b7 BIP')
|
||||||
|
|
||||||
|
def test_handset_class(self):
|
||||||
|
r = self._dec('FFFFFFFF7F9F00DFFF03021FE208110000')
|
||||||
|
tp = r['tp']
|
||||||
|
self.assertEqual(tp['device_class'], 'handset')
|
||||||
|
self.assertIn('screen 8\u00d717', tp['class_reason'])
|
||||||
|
self.assertEqual(tp['summary'], 'handset \u00b7 BIP')
|
||||||
|
|
||||||
|
|
||||||
class TestSummary(unittest.TestCase):
|
class TestSummary(unittest.TestCase):
|
||||||
def test_select_path_from_mf(self):
|
def test_select_path_from_mf(self):
|
||||||
r = decode_message(bytes.fromhex('a0a40804022fe29000'))
|
r = decode_message(bytes.fromhex('a0a40804022fe29000'))
|
||||||
|
|||||||
@@ -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__, '1.22.20')
|
self.assertEqual(__version__, '1.22.21')
|
||||||
|
|
||||||
def test_status_includes_version(self):
|
def test_status_includes_version(self):
|
||||||
import os
|
import os
|
||||||
|
|||||||
Reference in new issue
Block a user