diff --git a/frontend/index.html b/frontend/index.html
index 280fecd..92d0d2a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1868,7 +1868,7 @@ function onFilterChange() {
}
// ──────────────────── Versioning ────────────────────
-const PWA_VERSION = '1.13.0';
+const PWA_VERSION = '1.14.0';
const MIN_SERVER_VERSION = '1.0.0';
function parseVersion(v) {
diff --git a/frontend/sw.js b/frontend/sw.js
index f3c8a33..1bc7832 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simtrace-analyser-v16';
+const CACHE = 'simtrace-analyser-v17';
const PRECACHE = [
'index.html',
diff --git a/pyproject.toml b/pyproject.toml
index 6223d36..c1207db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "simtrace2-pysniff"
-version = "1.13.0"
+version = "1.14.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 6c98ded..83c795f 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.13.0'
+__version__ = '1.14.0'
__all__ = [
'SniffSession',
'DeviceDisconnected',
diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py
index a41c0cd..7158be3 100644
--- a/simtrace2_pysniff/server/decode.py
+++ b/simtrace2_pysniff/server/decode.py
@@ -1085,6 +1085,169 @@ def _idle_text_qualifier(qualifier):
COMMAND_QUALIFIERS[0x28] = _idle_text_qualifier
+# ──────────────────── §8.6 bitmask qualifier decoders ────────────────────
+# Each decoder lists the *deviations from default*; an all-default (0x00)
+# qualifier stays hidden, a non-zero qualifier with only RFU bits set
+# falls back to raw hex.
+
+def _bitmask(fn):
+ def decoder(q):
+ val = fn(q)
+ parts = [val] if isinstance(val, str) else [p for p in val if p]
+ if parts:
+ return ', '.join(parts)
+ return None if q == 0 else f'0x{q:02X}'
+ return decoder
+
+
+def _alphabet_bit(q):
+ return 'UCS2 alphabet requested' if q & 0x02 else None
+
+
+def _help_bit(q):
+ return 'help information available' if q & 0x80 else None
+
+
+@_bitmask
+def _send_sm_qualifier(q):
+ return ['SMS packing by terminal required'] if q & 0x01 else []
+
+
+@_bitmask
+def _play_tone_qualifier(q):
+ return ['vibrate alert if available'] if q & 0x01 else []
+
+
+@_bitmask
+def _display_text_qualifier(q):
+ return [('high priority' if q & 0x01 else None),
+ ('wait for user to clear message' if q & 0x80 else None)]
+
+
+@_bitmask
+def _get_inkey_qualifier(q):
+ return [
+ 'alphabet set requested' if q & 0x01 else None,
+ _alphabet_bit(q),
+ 'Yes/No response requested' if q & 0x04 else None,
+ 'immediate digit response requested' if q & 0x08 else None,
+ _help_bit(q),
+ ]
+
+
+@_bitmask
+def _get_input_qualifier(q):
+ return [
+ 'alphabet set requested' if q & 0x01 else None,
+ _alphabet_bit(q),
+ 'input shall not be revealed' if q & 0x04 else None,
+ 'SMS packed format requested' if q & 0x08 else None,
+ _help_bit(q),
+ ]
+
+
+@_bitmask
+def _select_item_qualifier(q):
+ pres = []
+ if q & 0x01:
+ pres.append('navigation options presentation'
+ if q & 0x02 else 'data values presentation')
+ return pres + ['selection using soft key preferred' if q & 0x04 else None,
+ _help_bit(q)]
+
+
+@_bitmask
+def _set_up_menu_qualifier(q):
+ return ['selection using soft key preferred' if q & 0x01 else None,
+ _help_bit(q)]
+
+
+@_bitmask
+def _timer_mgmt_qualifier(q):
+ op = (q >> 1) & 0x03
+ return {1: 'deactivate timer', 2: 'get current timer value'}.get(op, [])
+
+
+@_bitmask
+def _language_notification_qualifier(q):
+ return ['specific language notification'] if q & 0x01 else []
+
+
+@_bitmask
+def _open_channel_qualifier(q):
+ # Coding depends on the bearer; bits decoded per the packet-data /
+ # local bearer definition (TS 102 223 §8.6).
+ parts = [p for p in [
+ 'immediate link establishment' if q & 0x01 else None,
+ 'automatic reconnection' if q & 0x02 else None,
+ 'background mode link establishment' if q & 0x04 else None,
+ 'DNS server address(es) requested' if q & 0x08 else None] if p]
+ return [p + ' (bearer-dependent)' for p in parts]
+
+
+@_bitmask
+def _close_channel_qualifier(q):
+ # Packet data service: b1 = reuse Network Access Name indication;
+ # other bearers code b1 differently — hence the note.
+ if q & 0x01:
+ return ['next OPEN CHANNEL reuses same NAA/bearer (bearer-dependent)']
+ return []
+
+
+@_bitmask
+def _send_data_qualifier(q):
+ return ['send data immediately'] if q & 0x01 else []
+
+
+@_bitmask
+def _declare_service_qualifier(q):
+ return ['delete service from terminal database'] if q & 0x01 else []
+
+
+@_bitmask
+def _display_mms_qualifier(q):
+ return [('high priority' if q & 0x01 else None),
+ ('wait for user to clear message' if q & 0x80 else None)]
+
+
+COMMAND_QUALIFIERS.update({
+ 0x10: {0x00: 'set up call, but only if not currently busy',
+ 0x01: 'set up call, not busy, with redial',
+ 0x02: 'set up call, holding all other calls',
+ 0x03: 'set up call, holding others, with redial',
+ 0x04: 'set up call, disconnecting all other calls (if any)',
+ 0x05: 'set up call, disconnecting others, with redial'},
+ 0x13: _send_sm_qualifier, # SEND SHORT MESSAGE
+ 0x15: {0x00: 'launch browser if not already launched',
+ 0x02: 'use existing browser (no secured session)',
+ 0x03: 'close browser session and launch new'}, # LAUNCH BROWSER
+ 0x20: _play_tone_qualifier, # PLAY TONE
+ 0x21: _display_text_qualifier, # DISPLAY TEXT
+ 0x22: _get_inkey_qualifier, # GET INKEY
+ 0x23: _get_input_qualifier, # GET INPUT
+ 0x24: _select_item_qualifier, # SELECT ITEM
+ 0x25: _set_up_menu_qualifier, # SET UP MENU
+ 0x27: _timer_mgmt_qualifier, # TIMER MANAGEMENT
+ 0x33: {0x00: 'Card reader status',
+ 0x01: 'Card reader identifier'}, # GET READER STATUS
+ 0x35: _language_notification_qualifier, # LANGUAGE NOTIFICATION
+ 0x40: _open_channel_qualifier, # OPEN CHANNEL
+ 0x41: _close_channel_qualifier, # CLOSE CHANNEL
+ 0x43: _send_data_qualifier, # SEND DATA
+ 0x47: _declare_service_qualifier, # DECLARE SERVICE
+ 0x50: {0x00: 'draw separator between adjoining frames',
+ 0x01: 'no separator between frames'}, # SET FRAMES
+ 0x62: _display_mms_qualifier, # DISPLAY MULTIMEDIA MESSAGE
+ 0x73: {0x00: 'end encapsulated command session',
+ 0x01: 'request Master SA setup',
+ 0x02: 'request Connection SA setup',
+ 0x03: 'request Secure Channel Start',
+ 0x04: 'close Master/Connection SA, keep session'}, # eCAT
+ 0x79: {0x00: 'Proactive Session Request',
+ 0x01: 'UICC Platform Reset'}, # LSI COMMAND
+})
+
+
def _command_qualifier(cmd_type, qualifier):
"""Decode a Command Qualifier (Command Details byte 3) into a string.
diff --git a/tests/test_decode.py b/tests/test_decode.py
index 2e107fa..16f5d63 100644
--- a/tests/test_decode.py
+++ b/tests/test_decode.py
@@ -1779,6 +1779,67 @@ class TestEnvelopeDecoders(unittest.TestCase):
{'src': 'Network', 'dst': 'UICC'})
+class TestQualifiers(unittest.TestCase):
+ """Phase 3: §8.6 qualifier decoding for the remaining commands."""
+
+ def _fetch_qual(self, cmd_type, qual):
+ inner_hex = '810301%02x%02x820281828d00' % (cmd_type, qual)
+ inner = bytes.fromhex(inner_hex)
+ body = b'\xd0' + bytes([len(inner)]) + inner
+ r = decode_message(
+ bytes.fromhex('80120000%02x' % len(body)) + body + b'\x90\x00')
+ return r['cmd'].get('qualifier')
+
+ def test_set_up_call_value_table(self):
+ self.assertEqual(self._fetch_qual(0x10, 0x04),
+ '0x04 set up call, disconnecting all other calls (if any)')
+
+ def test_display_text_bits(self):
+ self.assertEqual(self._fetch_qual(0x21, 0x81),
+ 'high priority, wait for user to clear message')
+ self.assertIsNone(self._fetch_qual(0x21, 0x00))
+
+ def test_get_inkey_bits(self):
+ self.assertEqual(self._fetch_qual(0x22, 0x0B),
+ 'alphabet set requested, UCS2 alphabet requested, '
+ 'immediate digit response requested')
+
+ def test_get_input_bits(self):
+ self.assertEqual(self._fetch_qual(0x23, 0x04),
+ 'input shall not be revealed')
+
+ def test_select_item_presentation_and_help(self):
+ self.assertEqual(self._fetch_qual(0x24, 0x05),
+ 'data values presentation, '
+ 'selection using soft key preferred')
+
+ def test_timer_management_ops(self):
+ self.assertEqual(self._fetch_qual(0x27, 0x02), 'deactivate timer')
+ self.assertEqual(self._fetch_qual(0x27, 0x04),
+ 'get current timer value')
+ self.assertIsNone(self._fetch_qual(0x27, 0x00)) # start = default
+
+ def test_send_sm_packing_bit(self):
+ self.assertEqual(self._fetch_qual(0x13, 0x01),
+ 'SMS packing by terminal required')
+
+ def test_open_channel_bearer_dependent(self):
+ self.assertIn('immediate link establishment',
+ self._fetch_qual(0x40, 0x01))
+ self.assertIn('bearer-dependent', self._fetch_qual(0x40, 0x01))
+
+ def test_launch_browser_value_table(self):
+ self.assertEqual(self._fetch_qual(0x15, 0x03),
+ "0x03 close browser session and launch new")
+
+ def test_lsi_command_values(self):
+ self.assertEqual(self._fetch_qual(0x79, 0x01), '0x01 UICC Platform Reset')
+
+ def test_rfu_byte_commands_stay_raw(self):
+ self.assertEqual(self._fetch_qual(0x02, 0xFF), '0xFF') # MORE TIME
+ self.assertIsNone(self._fetch_qual(0x02, 0x00))
+
+
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 4bc2e8e..d42cde1 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.13.0')
+ self.assertEqual(__version__, '1.14.0')
def test_status_includes_version(self):
import os