fix: emit wire-shaped TPDUs in the GSMTAP stream (v3.1.1)
The GSMTAP stream sent logical APDUs, so a receiver (SIMtrace Analyser,
Wireshark) flagged almost every packet: case-4 commands carried their Le
byte ('length_mismatch: excessive') and responses were headerless packets
decoded as bogus commands ('truncated'). The ATR also arrived in the
middle of the stream.
- gsmtap.py: the tracer now emits wire-shaped TPDUs, matching a hardware
sniffer capture: case 4 -> the command without Le plus the 61XX 'bytes
available' SW (derived from the real response length), then the data as
a GET RESPONSE TPDU (00C00000<len> + data + SW, chunked at 255); case
1/2/3 -> one packet cmd + data + SW; unparseable APDUs fall back to raw
command/response packets.
- No ATR/VCC/RST/PPS events at all (no line-level access over PC/SC);
the ATR sending helper and the server-side plumbing were removed.
- Verified by replaying the bad capture's exchanges through the new
tracer: 116 packets, zero decoder warnings (previously nearly all).
- tests: the per-case wire forms, chunking, fallbacks; README/AGENTS
document the wire shape and its limits; version 3.1.1, sw simple-v236.
This commit is contained in:
@@ -685,7 +685,7 @@ pysim-simple-server --http-port 8080
|
||||
| `-d` / `--device` | Serial device path |
|
||||
| `--no-card-init` | Skip card init to preserve the CAT session (no file manager) |
|
||||
| `--apdu-trace` | Log APDU-level traces to stderr |
|
||||
| `--gsmtap [HOST[:PORT]]` | Stream every APDU (and the card ATR at equip) as GSMTAP-SIM UDP packets for Wireshark / SIMtrace Analyser (`--capture gsmtap`); default target `127.0.0.1:4729`. Combines with `--apdu-trace` |
|
||||
| `--gsmtap [HOST[:PORT]]` | Stream the card APDUs as GSMTAP-SIM UDP packets for Wireshark / SIMtrace Analyser (`--capture gsmtap`); default target `127.0.0.1:4729`. APDUs only (no ATR), emitted in wire shape (case-4 Le stripped, data as a GET RESPONSE TPDU, case-2/3 responses merged into the command packet). Combines with `--apdu-trace` |
|
||||
| `--log-requests` | Log request/response payloads to stderr |
|
||||
| `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit |
|
||||
| `--terminal-profile` | TERMINAL PROFILE payload hex (default: 33-byte real-handset profile that advertises BIP events/commands; the live card ignores HTTP OTA without it) |
|
||||
|
||||
+1
-1
@@ -668,7 +668,7 @@ pysim-simple-server --http-port 8080
|
||||
| `-d` / `--device` | Путь к serial-устройству |
|
||||
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
||||
| `--apdu-trace` | Лог APDU-трафика в stderr |
|
||||
| `--gsmtap [HOST[:PORT]]` | Поток всех APDU (и ATR карты при equip) в виде GSMTAP-SIM UDP-пакетов для Wireshark / SIMtrace Analyser (`--capture gsmtap`); цель по умолчанию `127.0.0.1:4729`. Совместимо с `--apdu-trace` |
|
||||
| `--gsmtap [HOST[:PORT]]` | Поток APDU карты в виде GSMTAP-SIM UDP-пакетов для Wireshark / SIMtrace Analyser (`--capture gsmtap`); цель по умолчанию `127.0.0.1:4729`. Только APDU (без ATR), в «проводной» форме (у case-4 убирается Le, данные — как GET RESPONSE TPDU, ответы case-2/3 объединяются с командой). Совместимо с `--apdu-trace` |
|
||||
| `--log-requests` | Лог запросов/ответов в stderr |
|
||||
| `--sms-oa` / `--sms-sm-sc` | Адрес отправителя SMS-DELIVER / SM-SC для PoR-in-submit |
|
||||
| `--terminal-profile` | Hex TERMINAL PROFILE (по умолчанию — 33-байтовый профиль реального телефона с BIP-событиями/командами; без него живая карта не запускает HTTP OTA) |
|
||||
|
||||
+1
-1
@@ -1436,7 +1436,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// by the server version check in pysimConnect().
|
||||
const SIMPLE_VERSION = '3.1.0';
|
||||
const SIMPLE_VERSION = '3.1.1';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v235';
|
||||
const CACHE = 'simple-v236';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.1.0"
|
||||
version = "3.1.1"
|
||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||
requires-python = ">=3.8"
|
||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||
|
||||
@@ -14,7 +14,7 @@ from pySim.cards import UiccCardBase
|
||||
from .shell import load_pysim_app
|
||||
from . import fastinit
|
||||
from . import gsmtap
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _send_gsmtap_atr, _LineFilter
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _LineFilter
|
||||
|
||||
|
||||
_server_start = 0
|
||||
@@ -267,10 +267,6 @@ def main():
|
||||
server.card_present = card is not None
|
||||
server.card_session = 1 if card is not None else 0
|
||||
server.iccid = iccid
|
||||
server.gsmtap = gsmtap_sender
|
||||
# Stream the ATR so a GSMTAP receiver (SIMtrace Analyser, Wireshark) has
|
||||
# the session context before the first APDU of this session.
|
||||
_send_gsmtap_atr(server)
|
||||
# Network state monitor: install the state read during the startup init
|
||||
# (right after the ICCID, before the TERMINAL PROFILE). No readable
|
||||
# ICCID means the card is considered unusable - give up.
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
# coding=utf-8
|
||||
"""GSMTAP-SIM UDP sender for live APDU capture.
|
||||
|
||||
Streams every APDU the server sends/receives as GSMTAP-SIM packets, so a
|
||||
Streams the card APDUs the server sends/receives as GSMTAP-SIM packets, so a
|
||||
GSMTAP receiver (SIMtrace Analyser ``--capture gsmtap``, Wireshark, or
|
||||
simtrace2-sniff) can follow the card dialogue live. Enabled with
|
||||
``--gsmtap [HOST[:PORT]]`` only (default ``127.0.0.1:4729``) - there is no
|
||||
UI or API for it.
|
||||
``--gsmtap [HOST[:PORT]]`` only (default ``127.0.0.1:4729``) - there is no UI
|
||||
or API for it.
|
||||
|
||||
The packet format is the one shared by libosmocore's ``gsmtap.h``,
|
||||
simtrace2-sniff, sigrok-iso7816-stream and the SIMtrace Analyser: a 16-byte
|
||||
big-endian header (version 2, ``hdr_len`` 4, type 0x04 = SIM, sub_type,
|
||||
``res`` flags) followed by the raw APDU/TPDU bytes. A response is sent as
|
||||
``data + SW1SW2`` (the wire form); the receiver infers the direction from
|
||||
the ISO 7816 case, exactly like a sniffer capture.
|
||||
big-endian header (version 2, ``hdr_len`` 4, type 0x04 = SIM, sub_type 0x00 =
|
||||
APDU, ``res`` flags) followed by the raw TPDU bytes.
|
||||
|
||||
The tracer emits **wire-shaped TPDUs**, not logical APDUs, because that is
|
||||
what a receiver pairs and decodes:
|
||||
|
||||
* case 4 (command with data + Le): the command without its Le byte plus the
|
||||
"61XX bytes available" status word, then the response data as a GET
|
||||
RESPONSE TPDU (``00C00000<len> + data + SW``);
|
||||
* case 2 / 1 / 3: one packet with the command, the response data and the SW;
|
||||
* unparseable APDUs fall back to a raw command packet and a raw response one.
|
||||
|
||||
The APDU data is always the real one - only the T=0 framing (which the PC/SC
|
||||
reader hides) is reconstructed, so the ``61XX`` length is the final response
|
||||
length and internal retries are not visible. No ATR/VCC/RST/PPS events are
|
||||
sent (the server has no line-level access).
|
||||
|
||||
Sending is fire-and-forget on a non-blocking socket: a missing listener must
|
||||
never affect card I/O.
|
||||
@@ -22,13 +34,14 @@ import socket
|
||||
import struct
|
||||
|
||||
from pySim.transport import ApduTracer
|
||||
from pySim.utils import h2b, parse_command_apdu
|
||||
|
||||
GSMTAP_VERSION = 0x02
|
||||
GSMTAP_HDR_LEN = 4 # in 32-bit words (16 bytes)
|
||||
GSMTAP_TYPE_SIM = 0x04
|
||||
|
||||
GSMTAP_SIM_APDU = 0x00
|
||||
GSMTAP_SIM_ATR = 0x01
|
||||
GSMTAP_SIM_ATR = 0x01 # not sent; kept for reference
|
||||
|
||||
GSMTAP_UDP_PORT = 4729
|
||||
DEFAULT_TARGET = '127.0.0.1:%d' % GSMTAP_UDP_PORT
|
||||
@@ -36,6 +49,8 @@ DEFAULT_TARGET = '127.0.0.1:%d' % GSMTAP_UDP_PORT
|
||||
_HDR_FMT = '!BBBBHBBIBBBB' # 16 bytes, big-endian
|
||||
_HDR_SIZE = struct.calcsize(_HDR_FMT)
|
||||
|
||||
_GET_RESPONSE_CHUNK = 255 # max GET RESPONSE payload per TPDU
|
||||
|
||||
|
||||
def build_packet(sub_type, data, flags=0, slot_nr=0):
|
||||
"""Build a complete GSMTAP-SIM packet (header + payload) as bytes."""
|
||||
@@ -91,9 +106,6 @@ class GsmtapSender:
|
||||
def send_apdu(self, data, slot_nr=0):
|
||||
self.send(GSMTAP_SIM_APDU, data, slot_nr=slot_nr)
|
||||
|
||||
def send_atr(self, data, slot_nr=0):
|
||||
self.send(GSMTAP_SIM_ATR, data, slot_nr=slot_nr)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._sock.close()
|
||||
@@ -101,12 +113,20 @@ class GsmtapSender:
|
||||
pass
|
||||
|
||||
|
||||
class GsmtapApduTracer(ApduTracer):
|
||||
"""pySim APDU tracer that streams every APDU as a GSMTAP-SIM packet.
|
||||
def _apdu_case(cmd):
|
||||
"""ISO 7816-3 case of a command APDU, or None when unparseable."""
|
||||
try:
|
||||
case, _lc, _le, _data = parse_command_apdu(h2b(cmd))
|
||||
return case
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
Commands are sent as-is; a response is sent as ``data + SW1SW2`` so the
|
||||
receiver sees the same wire TPDU a hardware sniffer would capture.
|
||||
Malformed hex never raises into pySim's transport.
|
||||
|
||||
class GsmtapApduTracer(ApduTracer):
|
||||
"""pySim APDU tracer streaming wire-shaped TPDUs as GSMTAP-SIM packets.
|
||||
|
||||
See the module docstring for the emitted forms. Malformed hex never
|
||||
raises into pySim's transport.
|
||||
"""
|
||||
|
||||
def __init__(self, sender):
|
||||
@@ -114,21 +134,47 @@ class GsmtapApduTracer(ApduTracer):
|
||||
self.sender = sender
|
||||
|
||||
def trace_command(self, cmd):
|
||||
if not cmd:
|
||||
return
|
||||
try:
|
||||
self.sender.send_apdu(bytes.fromhex(cmd))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
"""Nothing is sent here: the wire packets need the response."""
|
||||
|
||||
def trace_response(self, cmd, sw, resp):
|
||||
data = (resp or '') + (sw or '')
|
||||
if not data:
|
||||
return
|
||||
try:
|
||||
self.sender.send_apdu(bytes.fromhex(data))
|
||||
raw = bytes.fromhex(cmd or '')
|
||||
resp_bytes = bytes.fromhex(resp or '')
|
||||
sw_bytes = bytes.fromhex(sw or '')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return
|
||||
|
||||
case = _apdu_case(cmd)
|
||||
if case is None or not raw:
|
||||
# Unparseable APDU: keep the raw command / raw response form.
|
||||
if raw:
|
||||
self.sender.send_apdu(raw)
|
||||
if resp_bytes or sw_bytes:
|
||||
self.sender.send_apdu(resp_bytes + sw_bytes)
|
||||
return
|
||||
|
||||
if case == 4 and len(raw) > 1:
|
||||
# The wire TPDU carries no Le byte for case 4.
|
||||
body = raw[:-1]
|
||||
if not resp_bytes:
|
||||
self.sender.send_apdu(body + sw_bytes)
|
||||
return
|
||||
size = len(resp_bytes)
|
||||
# T=0: "61XX bytes available", then the data via GET RESPONSE.
|
||||
self.sender.send_apdu(body + bytes([0x61, min(size, 0xFF)]))
|
||||
offset = 0
|
||||
while offset < size:
|
||||
chunk = min(size - offset, _GET_RESPONSE_CHUNK)
|
||||
last = offset + chunk >= size
|
||||
self.sender.send_apdu(
|
||||
bytes([0x00, 0xC0, 0x00, 0x00, chunk])
|
||||
+ resp_bytes[offset:offset + chunk]
|
||||
+ (sw_bytes if last else b''))
|
||||
offset += chunk
|
||||
return
|
||||
|
||||
# case 1/2/3: the command TPDU and its response share one packet.
|
||||
self.sender.send_apdu(raw + resp_bytes + sw_bytes)
|
||||
|
||||
|
||||
class FanoutApduTracer(ApduTracer):
|
||||
|
||||
@@ -28,7 +28,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.1.0'
|
||||
VERSION = '3.1.1'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -2532,34 +2532,10 @@ def _handle_card_disconnect():
|
||||
_reset_proactive_log()
|
||||
|
||||
|
||||
def _send_gsmtap_atr(server):
|
||||
"""Stream the card's ATR as a GSMTAP-SIM packet (--gsmtap only).
|
||||
|
||||
Sent at every equip so a GSMTAP receiver (SIMtrace Analyser, Wireshark)
|
||||
has the session context before the first APDU of the new session."""
|
||||
sender = getattr(server, 'gsmtap', None)
|
||||
if sender is None:
|
||||
return
|
||||
rs = getattr(server, 'rs', None)
|
||||
atr = None
|
||||
if rs is not None:
|
||||
try:
|
||||
atr = (rs.identity or {}).get('ATR')
|
||||
except Exception:
|
||||
atr = None
|
||||
if not atr:
|
||||
return
|
||||
try:
|
||||
sender.send_atr(bytes.fromhex(atr))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def _apply_equipped_card(server):
|
||||
"""Common post-equip state refresh + TERMINAL PROFILE, shared by the
|
||||
/api/command equip branch and the auto-equip worker."""
|
||||
global _CARD_CONNECTED
|
||||
_send_gsmtap_atr(server)
|
||||
server.stk_pending = None
|
||||
server.menu_active = False
|
||||
_cancel_menu_timeout()
|
||||
|
||||
+57
-29
@@ -7,6 +7,9 @@ import unittest
|
||||
|
||||
from pysim_simple_server import gsmtap
|
||||
|
||||
FCI = ('62278202782183023F00A50A8001718302C0F28701018A01058B032F0601'
|
||||
'C60990014083010183010A')
|
||||
|
||||
|
||||
class PacketTests(unittest.TestCase):
|
||||
def test_header_and_payload(self):
|
||||
@@ -23,12 +26,6 @@ class PacketTests(unittest.TestCase):
|
||||
self.assertEqual((antenna, slot, res), (0, 0, 0))
|
||||
self.assertEqual(pkt[16:], apdu)
|
||||
|
||||
def test_atr_subtype_and_slot(self):
|
||||
pkt = gsmtap.build_packet(gsmtap.GSMTAP_SIM_ATR, b'\x3b\x00', slot_nr=2)
|
||||
self.assertEqual(pkt[12], gsmtap.GSMTAP_SIM_ATR) # sub_type
|
||||
self.assertEqual(pkt[14], 2) # sub_slot
|
||||
self.assertEqual(pkt[16:], b'\x3b\x00')
|
||||
|
||||
def test_parse_target_defaults_and_overrides(self):
|
||||
self.assertEqual(gsmtap.parse_target(None), ('127.0.0.1', 4729))
|
||||
self.assertEqual(gsmtap.parse_target(''), ('127.0.0.1', 4729))
|
||||
@@ -39,7 +36,7 @@ class PacketTests(unittest.TestCase):
|
||||
|
||||
|
||||
class SenderTests(unittest.TestCase):
|
||||
def test_send_apdu_and_atr_reach_a_udp_listener(self):
|
||||
def test_send_apdu_reaches_a_udp_listener(self):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
sock.settimeout(2.0)
|
||||
@@ -47,16 +44,12 @@ class SenderTests(unittest.TestCase):
|
||||
sender = gsmtap.GsmtapSender(host, port)
|
||||
try:
|
||||
sender.send_apdu(bytes.fromhex('00A40004023F00'))
|
||||
sender.send_atr(bytes.fromhex('3B00'))
|
||||
first, _addr = sock.recvfrom(2048)
|
||||
second, _addr = sock.recvfrom(2048)
|
||||
data, _addr = sock.recvfrom(2048)
|
||||
finally:
|
||||
sender.close()
|
||||
sock.close()
|
||||
self.assertEqual(first[12], gsmtap.GSMTAP_SIM_APDU) # sub_type
|
||||
self.assertEqual(first[16:], bytes.fromhex('00A40004023F00'))
|
||||
self.assertEqual(second[12], gsmtap.GSMTAP_SIM_ATR)
|
||||
self.assertEqual(second[16:], bytes.fromhex('3B00'))
|
||||
self.assertEqual(data[12], gsmtap.GSMTAP_SIM_APDU) # sub_type
|
||||
self.assertEqual(data[16:], bytes.fromhex('00A40004023F00'))
|
||||
|
||||
def test_send_never_raises_without_a_listener(self):
|
||||
sender = gsmtap.GsmtapSender('127.0.0.1', 1) # nothing listening
|
||||
@@ -75,26 +68,61 @@ class Recorder:
|
||||
|
||||
|
||||
class TracerTests(unittest.TestCase):
|
||||
def test_command_and_response_are_wire_shaped(self):
|
||||
def trace(self, cmd, sw='9000', resp=''):
|
||||
rec = Recorder()
|
||||
tracer = gsmtap.GsmtapApduTracer(rec)
|
||||
tracer.trace_command('00A40004023F00')
|
||||
tracer.trace_response('00A40004023F00', '9000', '622982027821')
|
||||
self.assertEqual(rec.apdus, [
|
||||
bytes.fromhex('00A40004023F00'),
|
||||
bytes.fromhex('6229820278219000'), # data + SW1SW2
|
||||
gsmtap.GsmtapApduTracer(rec).trace_response(cmd, sw, resp)
|
||||
return rec.apdus
|
||||
|
||||
def test_case4_data_becomes_command_plus_get_response(self):
|
||||
# A case-4 command goes on the wire without its Le byte and the data
|
||||
# follows via GET RESPONSE (T=0).
|
||||
self.assertEqual(self.trace('00A40004023F0000', resp=FCI), [
|
||||
bytes.fromhex('00A40004023F00') + bytes([0x61, len(FCI) // 2]),
|
||||
bytes.fromhex('00C00000') + bytes([len(FCI) // 2])
|
||||
+ bytes.fromhex(FCI) + bytes.fromhex('9000'),
|
||||
])
|
||||
|
||||
def test_response_without_data_is_the_sw(self):
|
||||
rec = Recorder()
|
||||
gsmtap.GsmtapApduTracer(rec).trace_response('00B000000A', '6A82', '')
|
||||
self.assertEqual(rec.apdus, [bytes.fromhex('6A82')])
|
||||
def test_case4_error_is_command_plus_sw(self):
|
||||
self.assertEqual(
|
||||
self.trace('00A4040410A0000005591010FFFFFFFF890000010000', sw='6A82'),
|
||||
[bytes.fromhex('00A4040410A0000005591010FFFFFFFF8900000100' + '6A82')])
|
||||
|
||||
def test_malformed_hex_never_raises(self):
|
||||
def test_case2_merges_the_response_into_the_command_packet(self):
|
||||
self.assertEqual(self.trace('00B000000A', resp='980711090000640070F2'),
|
||||
[bytes.fromhex('00B000000A980711090000640070F29000')])
|
||||
|
||||
def test_case2_error_keeps_the_status_word(self):
|
||||
self.assertEqual(self.trace('80F2000C00', sw='6A82'),
|
||||
[bytes.fromhex('80F2000C006A82')])
|
||||
|
||||
def test_case3_merges_the_status_word(self):
|
||||
tp = '8010000022' + 'FF' * 34
|
||||
self.assertEqual(self.trace(tp, sw='9130'),
|
||||
[bytes.fromhex(tp + '9130')])
|
||||
|
||||
def test_long_case4_response_is_chunked(self):
|
||||
data = 'AA' * 300
|
||||
packets = self.trace('00A40004023F0000', resp=data)
|
||||
self.assertEqual(len(packets), 3) # command + 2 GET RESPONSEs
|
||||
self.assertEqual(packets[0],
|
||||
bytes.fromhex('00A40004023F00') + bytes([0x61, 255]))
|
||||
self.assertEqual(packets[1][:5], bytes([0x00, 0xC0, 0x00, 0x00, 255]))
|
||||
self.assertEqual(packets[1][5:], bytes.fromhex(data[:255 * 2]))
|
||||
self.assertEqual(packets[2][:5], bytes([0x00, 0xC0, 0x00, 0x00, 45]))
|
||||
self.assertEqual(packets[2][5:],
|
||||
bytes.fromhex(data[255 * 2:]) + bytes.fromhex('9000'))
|
||||
|
||||
def test_unparseable_apdu_falls_back_to_raw_packets(self):
|
||||
self.assertEqual(self.trace('00A4', resp='AA'),
|
||||
[bytes.fromhex('00A4'), bytes.fromhex('AA9000')])
|
||||
|
||||
def test_malformed_hex_sends_nothing(self):
|
||||
self.assertEqual(self.trace('zz', resp='AA'), [])
|
||||
self.assertEqual(self.trace('', resp=''), [])
|
||||
|
||||
def test_trace_command_sends_nothing(self):
|
||||
rec = Recorder()
|
||||
tracer = gsmtap.GsmtapApduTracer(rec)
|
||||
tracer.trace_command('not-hex')
|
||||
tracer.trace_response('00A4', None, None)
|
||||
gsmtap.GsmtapApduTracer(rec).trace_command('00A40004023F0000')
|
||||
self.assertEqual(rec.apdus, [])
|
||||
|
||||
def test_fanout_forwards_every_callback(self):
|
||||
|
||||
Reference in New Issue
Block a user