diff --git a/wireshark/README.md b/wireshark/README.md new file mode 100644 index 0000000..d22ac70 --- /dev/null +++ b/wireshark/README.md @@ -0,0 +1,116 @@ +# dpl wireshark dissector + +lua dissector for DPL (Digital Peripheral Link), the byte-level protocol on the UART link between an Iridium ISU and attached peripherals (handsets, SIM readers, controllers), plus the firmware-upgrade transport. + +## framing (Layer 2) + +``` ++--------+--------+---------------------+---------+------+ +| STX(1) | LEN(1) | PAYLOAD (LEN bytes) | XOR(1) | ETX | ++--------+--------+---------------------+---------+------+ + +XOR = STX ^ LEN ^ payload[0] ^ ... ^ payload[LEN-1] +ETX = 0x03 (always) +``` + +no sync word, no CRC. integrity is the one-byte xor. terminator is always `0x03`. + +## STX values + +`STX` is the Start-of-I-Frame character. besides being the frame delimiter it also selects which logical device / protocol family the frame is addressed to. + +| STX | Name | Direction | Purpose | +|---|---|---|---| +| 0x01 | DLOG | ISU ↔ host | Data Logger | +| 0x02 | TPI | ISU ↔ host | Test/Production Interface | +| 0x04 | reserved | — | special use | +| 0x05 | firmware upgrade transport | ISU ↔ host | | +| 0x07 | STDIO | ISU → host | text | +| 0x81 … 0x87 | IP1 … IP7 | ISU ↔ peripheral | Intelligent Peripheral addresses | + +ACK (0x06) and NAK (0x15) are single-byte control characters outside the I-frame; they don't carry a length byte and are not dissected as separate frames. + +only frames with STX in this table are accepted by the heuristic. + +## firmware upgrade opcodes (STX=0x05) + +the first payload byte is the opcode. known values: + +| opcode | name | +|---|---| +| 0x80 | PING | +| 0x83 | GETID | +| 0x85 | WRITEBUFF | +| 0x86 | READBUFF | +| 0x89 | CLEARBUFF | +| 0x8a | ERASECHIP | +| 0x8c | ERASESECT | +| 0x8f | WRITESECT | +| 0xc0 | READSECT | +| 0xc6 | BOOT | + +arg layout after the opcode byte is opcode-specific and not tabulated here. + +## Layer 3 — PMH (for STX=0x81 … 0x87) + +for frames addressed to an Intelligent Peripheral the payload starts with a 5-byte **Peripheral Message Header** followed by primitive-specific Layer 3 data: + +``` +byte 0 : addr(hi nybble) | sequence(lo nybble) ; 0xE=ISU, 0x1..0x7=IP +byte 1 : Interface ID ; protocol family +byte 2 : Primitive ID ; command / indication +byte 3 : Destination sub-address +byte 4 : Source sub-address +byte 5…: Layer 3 data +``` + +the dissector decodes the header and names the primitive where possible. + +### Interface IDs + +| id | family | +|---|---| +| 0x14 | SEEM (SIM External Module) | +| 0x94 | Audio | +| 0x98 | SIM (external card reader) | +| 0xA7 | DPL control / MMI / TEST (includes INIT/RTI) | +| 0xA8 | MMI / Call Control / Audio | + +### Recognised primitives + +the dissector names every (iface, prim) combination it knows. it does **not** currently decode primitive payload fields — only the header. Layer 3 data is shown as raw bytes with a label; extend per-primitive decoders as captures require them. + +representative primitives: + +- **0xA7 (discovery / MMI / TEST):** `INIT` (0x12), `RTI` (0x13), `ECHO` (0x14), `DEBUG` (0xFF), `HSCTRL`/`HSLCD`/`HSKPD` (0x15/0x16/0x17), `ip_change_pin_req` (0x02), `ip_man_reg_req` (0x06), `ip_ss_status_ind` (0x0C), and related `_cnf`s. +- **0xA8 (MMI/CC/Audio):** `ip_call_start_req` (0x05), `ip_call_accept_req` (0x20), `ip_call_release_req` (0x21), `ip_call_status_ind` (0x27), `ip_class_ind` (0x29), `ip_call_dtmf_req` (0x35), `ip_gen_imei_req/_cnf` (0x36/0x37), `ip_gen_pin_stat_req/_cnf` (0x38/0x39), `ip_gen_pin_set_req/_cnf` (0x3A/0x3B), `ip_signal_level_req/_cnf` (0x04/0x2D), `ip_mute_req/_ind` (0x22/0x08), `ip_sidetone_req/_cnf` (0x41/0x42), `ip_audio_routing_req` (0x40), and more. +- **0x14 (SEEM):** `seem_activate_ind`/`_cnf` (0x03/0x02), `seem_status_cnf` (0x0F), the PIN `_cnf` family (0x11, 0x13, 0x15, 0x17), `seem_unblocking_cnf` (0x19), `ip_get_info_element_cnf` (0x0C). +- **0x94 (Audio):** `ip_key_feedback_ind` (0x0E). +- **0x98 (SIM):** `sim_activate_req`/`_cnf` (0x00/0x20), `sim_deactivate_req/_cnf` (0x06/0x21), `sim_instruction_req/_cnf` (0x03/0x23) (GSM 11.11 passthrough), `sim_card_detect_ind` (0x40), `sim_switch_act_volt_req` (0x01). + +see `dpl.lua` for the full table. + +## install + +drop `dpl.lua` into your Wireshark personal Lua plugins dir. on linux/macOS that's usually `~/.local/lib/wireshark/plugins/` or `~/.config/wireshark/plugins/`. check `Help -> About Wireshark -> Folders` if unsure. reload plugins (`Analyze -> Reload Lua Plugins`) or restart. + +the dissector registers a heuristic on `tcp`, `udp`, `usb.bulk`, `usb.interrupt` that only fires when length + xor + terminator all match and STX is one we know. anything marginal stays as plain `Data` so you can still use `Decode As -> DPL` manually if you want to force it. + +## generating test frames + +`dpl_encode.py` builds DPL frames and wraps them in UDP over the BSD null link-type so Wireshark can pick them up. + +``` +python3 dpl_encode.py # regenerate tests/sample.pcap +python3 dpl_encode.py --hex 05 80 # print hex for one frame +``` + +the module exports two helpers: `encode(stx, payload)` wraps a full DPL frame, and `pmh(addr, seq, iface, prim, dest, src, data)` builds a Peripheral Message Header + L3 data (use as the `payload` argument to `encode` for STX[IPn] frames). + +verify: + +``` +tshark -r tests/sample.pcap -X lua_script:dpl.lua -O dpl +``` + +`tests/sample.pcap` covers every known firmware-upgrade opcode on STX=0x05, an STDIO (STX=0x07) and a DLOG (STX=0x01) opaque payload, PMH-bearing STX=0x81 frames (`INIT`, `RTI`, `ip_call_start_req`, `ip_call_status_ind`, `sim_instruction_req`), a STX=0x82 (IP2) frame carrying a SEEM primitive, a STX=0x81 frame with a too-short payload (triggers the "payload too short for PMH" expert hint), and one deliberately-corrupted xor to confirm the heuristic doesn't accept bad frames. diff --git a/wireshark/dpl.lua b/wireshark/dpl.lua new file mode 100644 index 0000000..8ce0fc1 --- /dev/null +++ b/wireshark/dpl.lua @@ -0,0 +1,300 @@ +-- DPL dissector for Wireshark +-- +-- Usage: +-- 1. Drop this file into your Wireshark personal Lua plugins directory +-- (Help -> About Wireshark -> Folders -> Personal Lua Plugins). +-- 2. Reload Lua plugins (Analyze -> Reload Lua Plugins) or restart Wireshark. +-- 3. Right-click a packet -> "Decode As..." -> pick "DPL". +-- +-- Heuristic activation is also registered on a few common transports. +-- Accepts a single DPL frame per dissected PDU. + +local dpl = Proto("dpl", "DPL") + +-- Layer 2 start characters. +local stx_names = { + [0x01] = "DLOG", + [0x02] = "TPI", + [0x04] = "reserved", + [0x05] = "firmware upgrade transport", + [0x07] = "STDIO", + [0x81] = "IP1", + [0x82] = "IP2", + [0x83] = "IP3", + [0x84] = "IP4", + [0x85] = "IP5", + [0x86] = "IP6", + [0x87] = "IP7", +} + +local function is_ip_stx(b) return b >= 0x81 and b <= 0x87 end + +-- Firmware upgrade opcodes (STX=0x05). +local fw_opcode_names = { + [0x80] = "PING", + [0x83] = "GETID", + [0x85] = "WRITEBUFF", + [0x86] = "READBUFF", + [0x89] = "CLEARBUFF", + [0x8a] = "ERASECHIP", + [0x8c] = "ERASESECT", + [0x8f] = "WRITESECT", + [0xc0] = "READSECT", + [0xc6] = "BOOT", +} + +-- Layer 3 Interface IDs. +local iface_names = { + [0x14] = "SEEM", -- SIM External Module + [0x94] = "Audio", + [0x98] = "SIM", + [0xa7] = "DPL/MMI/TEST", + [0xa8] = "MMI/CC/Audio", +} + +-- Primitive tables, keyed by (Interface ID, Prim ID) → name. +local prim_names = { + [0xa7] = { + [0x00] = "ip_dtmf_rec_ind", + [0x01] = "ip_mute_dtmf_fbk_req", + [0x02] = "ip_change_pin_req", + [0x03] = "ip_change_pin_cnf", + [0x04] = "ip_change_pin_state_req", + [0x05] = "ip_change_pin_state_cnf", + [0x06] = "ip_man_reg_req", + [0x07] = "ip_man_reg_cnf", + [0x0c] = "ip_ss_status_ind", + [0x12] = "INIT", + [0x13] = "RTI", + [0x14] = "ECHO", + [0x15] = "HSCTRL", + [0x16] = "HSLCD", + [0x17] = "HSKPD", + [0xff] = "DEBUG", + }, + [0xa8] = { + [0x03] = "ip_time_charge_req", + [0x04] = "ip_signal_level_req", + [0x05] = "ip_call_start_req", + [0x06] = "ip_get_info_element_req", + [0x08] = "ip_mute_ind", + [0x20] = "ip_call_accept_req", + [0x21] = "ip_call_release_req", + [0x22] = "ip_mute_req", + [0x23] = "ip_step_volume_level_req", + [0x25] = "ip_indr_ctrl_state_ind", + [0x26] = "ip_pd_usage_ind", + [0x27] = "ip_call_status_ind", + [0x28] = "ip_audio_status_ind", + [0x29] = "ip_class_ind", + [0x2c] = "ip_time_charge_cnf", + [0x2d] = "ip_signal_level_cnf", + [0x2e] = "ip_call_start_cnf", + [0x2f] = "ip_stop_req", + [0x30] = "ip_stop_cnf", + [0x31] = "ip_abbr_dial_tbl_ind", + [0x32] = "ip_step_volume_level_cnf", + [0x33] = "ip_mmi_display_update_ind", + [0x35] = "ip_call_dtmf_req", + [0x36] = "ip_gen_imei_req", + [0x37] = "ip_gen_imei_cnf", + [0x38] = "ip_gen_pin_stat_req", + [0x39] = "ip_gen_pin_stat_cnf", + [0x3a] = "ip_gen_pin_set_req", + [0x3b] = "ip_gen_pin_set_cnf", + [0x40] = "ip_audio_routing_req", + [0x41] = "ip_sidetone_req", + [0x42] = "ip_sidetone_cnf", + }, + [0x14] = { + [0x02] = "seem_activate_cnf", + [0x03] = "seem_activate_ind", + [0x06] = "seem_deactivate_ind", + [0x0c] = "ip_get_info_element_cnf", + [0x0f] = "seem_status_cnf", + [0x11] = "seem_pin_change_cnf", + [0x13] = "seem_pin_disable_cnf", + [0x15] = "seem_pin_enable_cnf", + [0x17] = "seem_pin_verify_cnf", + [0x19] = "seem_unblocking_cnf", + }, + [0x94] = { + [0x0e] = "ip_key_feedback_ind", + }, + [0x98] = { + [0x00] = "sim_activate_req", + [0x01] = "sim_switch_act_volt_req", + [0x03] = "sim_instruction_req", + [0x06] = "sim_deactivate_req", + [0x20] = "sim_activate_cnf", + [0x21] = "sim_deactivate_cnf", + [0x23] = "sim_instruction_cnf", + [0x40] = "sim_card_detect_ind", + }, +} + +local function prim_name(iface, prim) + local t = prim_names[iface] + if t == nil then return nil end + return t[prim] +end + +local function addr_name(a) + if a == 0xe then return "ISU" end + if a >= 0x1 and a <= 0x7 then return "IP" .. a end + return string.format("0x%x", a) +end + +-- Protocol fields +local f_stx = ProtoField.uint8 ("dpl.stx", "STX", base.HEX, stx_names) +local f_len = ProtoField.uint8 ("dpl.len", "Length", base.DEC) +local f_payload = ProtoField.bytes ("dpl.payload", "Payload") +local f_fw_opcode = ProtoField.uint8 ("dpl.fw.opcode", "Firmware opcode",base.HEX, fw_opcode_names) +local f_xor = ProtoField.uint8 ("dpl.xor", "XOR", base.HEX) +local f_xor_ok = ProtoField.bool ("dpl.xor_ok", "XOR valid") +local f_eom = ProtoField.uint8 ("dpl.eom", "End-of-message", base.HEX) + +-- PMH (Peripheral Message Header) — only present for STX[IPn] +local f_pmh_addr = ProtoField.uint8 ("dpl.pmh.addr", "Address", base.HEX, nil, 0xf0) +local f_pmh_seq = ProtoField.uint8 ("dpl.pmh.seq", "Sequence", base.DEC, nil, 0x0f) +local f_pmh_iface = ProtoField.uint8 ("dpl.pmh.iface", "Interface ID", base.HEX, iface_names) +local f_pmh_prim = ProtoField.uint8 ("dpl.pmh.prim", "Primitive ID", base.HEX) +local f_pmh_dest = ProtoField.uint8 ("dpl.pmh.dest", "Dest sub-addr", base.HEX) +local f_pmh_src = ProtoField.uint8 ("dpl.pmh.src", "Src sub-addr", base.HEX) +local f_pmh_data = ProtoField.bytes ("dpl.pmh.data", "L3 Data") + +-- Generated / resolved fields +local f_pmh_prim_name = ProtoField.string("dpl.pmh.prim_name", "Primitive name") + +dpl.fields = { + f_stx, f_len, f_payload, f_fw_opcode, f_xor, f_xor_ok, f_eom, + f_pmh_addr, f_pmh_seq, f_pmh_iface, f_pmh_prim, + f_pmh_dest, f_pmh_src, f_pmh_data, f_pmh_prim_name, +} + +local ef_bad_xor = ProtoExpert.new("dpl.bad_xor.expert", "DPL XOR mismatch", + expert.group.CHECKSUM, expert.severity.WARN) +local ef_bad_eom = ProtoExpert.new("dpl.bad_eom.expert", "DPL terminator is not 0x03", + expert.group.MALFORMED, expert.severity.WARN) +local ef_short_ip = ProtoExpert.new("dpl.short_ip.expert", + "STX[IPn] payload too short for PMH (<5 bytes)", + expert.group.MALFORMED, expert.severity.WARN) +dpl.experts = { ef_bad_xor, ef_bad_eom, ef_short_ip } + +local function compute_xor(tvb, first, last) + local x = 0 + for i = first, last do + x = bit.bxor(x, tvb(i, 1):uint()) + end + return x +end + +-- Returns: ok, len, expected_xor, actual_xor, eom_byte +-- ok=false means the buffer cannot be a DPL frame at all. +local function validate(tvb) + if tvb:len() < 4 then return false end + local plen = tvb(1, 1):uint() + local expected_size = 1 + 1 + plen + 1 + 1 + if tvb:len() ~= expected_size then return false end + local eom = tvb(tvb:len() - 1, 1):uint() + local expected_xor = compute_xor(tvb, 0, 1 + plen) + local actual_xor = tvb(2 + plen, 1):uint() + return true, plen, expected_xor, actual_xor, eom +end + +-- Dissect the 5-byte PMH + optional L3 data, starting at offset `off`. +-- Returns an info string describing the primitive for the column display. +local function dissect_pmh(tvb, off, plen, subtree) + local pmh = subtree:add(dpl, tvb(off, plen), "Peripheral Message Header") + + local addr_byte = tvb(off, 1):uint() + local addr = bit.rshift(addr_byte, 4) + local seq = bit.band(addr_byte, 0x0f) + pmh:add(f_pmh_addr, tvb(off, 1)):append_text(string.format(" (%s)", addr_name(addr))) + pmh:add(f_pmh_seq, tvb(off, 1)) + + local iface = tvb(off + 1, 1):uint() + local prim = tvb(off + 2, 1):uint() + pmh:add(f_pmh_iface, tvb(off + 1, 1)) + local prim_item = pmh:add(f_pmh_prim, tvb(off + 2, 1)) + local pname = prim_name(iface, prim) + if pname then + prim_item:append_text(string.format(" (%s)", pname)) + pmh:add(f_pmh_prim_name, tvb(off + 2, 1), pname):set_generated() + end + + pmh:add(f_pmh_dest, tvb(off + 3, 1)) + pmh:add(f_pmh_src, tvb(off + 4, 1)) + + if plen > 5 then + subtree:add(f_pmh_data, tvb(off + 5, plen - 5)) + end + + local iface_label = iface_names[iface] or string.format("iface=0x%02x", iface) + local prim_label = pname or string.format("prim=0x%02x", prim) + return string.format("%s %s seq=%d", iface_label, prim_label, seq) +end + +function dpl.dissector(tvb, pinfo, tree) + local ok, plen, expected_xor, actual_xor, eom = validate(tvb) + if not ok then return 0 end + + pinfo.cols.protocol = "DPL" + + local subtree = tree:add(dpl, tvb(), "DPL") + subtree:add(f_stx, tvb(0, 1)) + subtree:add(f_len, tvb(1, 1)) + + local stx = tvb(0, 1):uint() + local info = string.format("stx=0x%02x len=%d", stx, plen) + + if plen > 0 then + local payload_tvb = tvb(2, plen) + subtree:add(f_payload, payload_tvb) + + if stx == 0x05 then + local op = tvb(2, 1):uint() + subtree:add(f_fw_opcode, tvb(2, 1)) + info = string.format("stx=0x%02x fw_op=%s len=%d", + stx, fw_opcode_names[op] or string.format("0x%02x", op), plen) + elseif is_ip_stx(stx) then + if plen >= 5 then + local pmh_info = dissect_pmh(tvb, 2, plen, subtree) + info = string.format("%s %s", stx_names[stx] or string.format("0x%02x", stx), pmh_info) + else + subtree:add_proto_expert_info(ef_short_ip) + end + end + end + + local xor_item = subtree:add(f_xor, tvb(2 + plen, 1)) + subtree:add(f_xor_ok, expected_xor == actual_xor):set_generated() + if expected_xor ~= actual_xor then + xor_item:add_proto_expert_info(ef_bad_xor, + string.format("expected 0x%02x, got 0x%02x", expected_xor, actual_xor)) + end + + local eom_item = subtree:add(f_eom, tvb(tvb:len() - 1, 1)) + if eom ~= 0x03 then + eom_item:add_proto_expert_info(ef_bad_eom) + end + + pinfo.cols.info = info + return tvb:len() +end + +local function dpl_heur(tvb, pinfo, tree) + local ok, _, expected_xor, actual_xor, eom = validate(tvb) + if not ok then return false end + if eom ~= 0x03 then return false end + if expected_xor ~= actual_xor then return false end + local stx = tvb(0, 1):uint() + if stx_names[stx] == nil then return false end + dpl.dissector(tvb, pinfo, tree) + return true +end + +dpl:register_heuristic("tcp", dpl_heur) +dpl:register_heuristic("udp", dpl_heur) +dpl:register_heuristic("usb.bulk", dpl_heur) +dpl:register_heuristic("usb.interrupt", dpl_heur) diff --git a/wireshark/dpl_encode.py b/wireshark/dpl_encode.py new file mode 100644 index 0000000..a16ab1c --- /dev/null +++ b/wireshark/dpl_encode.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +# Encode DPL frames and emit a pcap for dissector testing. +# +# Usage: +# python3 dpl_encode.py # regenerate tests/sample.pcap +# python3 dpl_encode.py --hex 05 80 # print hex for a single frame +# +# No third-party deps. Frames are wrapped in UDP/IP over the BSD null +# loopback link type; Wireshark's heuristic dissector lookup handles the rest. + +import argparse +import os +import struct +import sys +import time + + +def encode(stx: int, payload: bytes = b"") -> bytes: + """Return a full DPL frame: STX, LEN, payload, XOR, ETX (0x03).""" + if not 0 <= stx <= 0xff: + raise ValueError("STX out of range") + if len(payload) > 0xff: + raise ValueError("payload too long for 1-byte length field") + header = bytes([stx, len(payload)]) + body = header + payload + x = 0 + for b in body: + x ^= b + return body + bytes([x, 0x03]) + + +def pmh( + addr: int, + seq: int, + iface: int, + prim: int, + dest: int = 0x00, + src: int = 0x00, + data: bytes = b"", +) -> bytes: + """Build a Peripheral Message Header + Layer 3 data payload. + + Used as the payload of an STX[IPn] frame. + + addr: 4-bit destination address (0xE=ISU, 0x1..0x7=IP). + seq: 4-bit sequence number. + """ + if not 0 <= addr <= 0xf: + raise ValueError("addr is a nybble") + if not 0 <= seq <= 0xf: + raise ValueError("seq is a nybble") + for name, v in (("iface", iface), ("prim", prim), ("dest", dest), ("src", src)): + if not 0 <= v <= 0xff: + raise ValueError(f"{name} out of range") + return bytes([(addr << 4) | seq, iface, prim, dest, src]) + data + + +def wrap_udp(dpl_frame: bytes, sport: int = 4000, dport: int = 4001) -> bytes: + """Wrap a DPL frame as UDP/IPv4 over loopback (BSD null encap).""" + udp_len = 8 + len(dpl_frame) + udp = struct.pack("!HHHH", sport, dport, udp_len, 0) + dpl_frame + + ip_total = 20 + udp_len + ip_hdr = struct.pack( + "!BBHHHBBH4s4s", + 0x45, 0x00, ip_total, + 0x0000, 0x0000, + 64, 17, 0x0000, + b"\x7f\x00\x00\x01", + b"\x7f\x00\x00\x01", + ) + ip_chk = _ip_checksum(ip_hdr) + ip_hdr = ip_hdr[:10] + struct.pack("!H", ip_chk) + ip_hdr[12:] + + loopback_family = struct.pack(" int: + s = 0 + for i in range(0, len(hdr), 2): + s += (hdr[i] << 8) | hdr[i + 1] + while s >> 16: + s = (s & 0xffff) + (s >> 16) + return (~s) & 0xffff + + +def write_pcap(path: str, frames: list[bytes]) -> None: + """Write pcap file in LINKTYPE_NULL (BSD loopback) format.""" + with open(path, "wb") as f: + f.write(struct.pack("=IHHIIII", + 0xa1b2c3d4, + 2, 4, + 0, 0, + 65535, + 0)) # link_type 0 = LINKTYPE_NULL + ts = int(time.time()) + for i, frame in enumerate(frames): + pkt = wrap_udp(frame) + f.write(struct.pack("=IIII", ts, i, len(pkt), len(pkt))) + f.write(pkt) + + +def sample_frames() -> list[bytes]: + frames = [] + + # STX=0x05: firmware upgrade transport + frames.append(encode(0x05, bytes([0x80]))) # PING + frames.append(encode(0x05, bytes([0x83]))) # GETID + frames.append(encode(0x05, bytes([0x86, 0x00, 0x00, 0x00]))) # READBUFF + frames.append(encode(0x05, bytes([0xc0, 0x12, 0x34]))) # READSECT + frames.append(encode(0x05, bytes([0xc6]))) # BOOT + + # STX=0x07: STDIO + frames.append(encode(0x07, b"ready\r\n")) + + # STX=0x01: DLOG, opaque payload + frames.append(encode(0x01, bytes([0xde, 0xad, 0xbe, 0xef]))) + + # STX=0x81 (IP1) with PMH: INIT + # ISU -> IP1, seq=0, iface=0xA7, prim=0x12, data=protocol version byte + frames.append(encode(0x81, pmh(0x1, 0, 0xa7, 0x12, 0x00, 0x00, bytes([0x02])))) + + # STX=0x81 with PMH: RTI (Response To INIT) — 16 bytes + # IP1 -> ISU, seq=0, iface=0xA7, prim=0x13 + # serial(6) + device_emulation_group(4 LE) + requested_events_group(4 LE) + rsvd(2) + rti_data = ( + bytes([0x80, 0x32, 0xe0, 0x00, 0x00, 0x01]) + + b"\x80\x00\x00\x00" + + b"\xc8\x00\x00\x00" + + b"\x00\x00" + ) + frames.append(encode(0x81, pmh(0xe, 0, 0xa7, 0x13, 0x00, 0x00, rti_data))) + + # STX=0x81 with PMH: ip_call_start_req (keypad dial) + # IP1 -> ISU, seq=1, iface=0xA8, prim=0x05, dest=0x17, src=0x00 + call_start = ( + bytes([0x00, 0x00, 0x91, 0x00]) + + b"+14805551212\x00" + ) + frames.append(encode(0x81, pmh(0xe, 1, 0xa8, 0x05, 0x17, 0x00, call_start))) + + # STX=0x81 with PMH: ip_call_status_ind + # ISU -> IP1, seq=2, iface=0xA8, prim=0x27 + frames.append(encode(0x81, pmh(0x1, 2, 0xa8, 0x27, 0x00, 0x00, bytes([0x02, 0x01, 0x00])))) + + # STX=0x82 (IP2) with PMH: seem_status_cnf + # ISU -> IP2, seq=0, iface=0x14, prim=0x0F + frames.append(encode(0x82, pmh(0x2, 0, 0x14, 0x0f, 0x00, 0x00, bytes([0x06])))) + + # STX=0x81 with PMH: sim_instruction_req (GSM 11.11 passthrough) + # IP1 -> ISU, seq=3, iface=0x98, prim=0x03 + # minimal GSM 11.11 header: CLA=A0 INS=C0 P1=00 P2=00 P3=16 + direction=0 + sim_instr = bytes([0xa0, 0xc0, 0x00, 0x00, 0x16, 0x00]) + frames.append(encode(0x81, pmh(0xe, 3, 0x98, 0x03, 0xfa, 0x00, sim_instr))) + + # STX=0x81 too short for PMH (should trigger expert info) + frames.append(encode(0x81, b"AT\r")) + + # Malformed: bad XOR + bad = bytearray(encode(0x05, bytes([0x80, 0x11]))) + bad[-2] ^= 0xff + frames.append(bytes(bad)) + + return frames + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--hex", nargs="+", metavar="BYTE", + help="print hex for one DPL frame: first byte is STX, " + "rest is payload. Example: --hex 05 80") + ap.add_argument("-o", "--out", default=None, + help="output pcap path (default: tests/sample.pcap next to this script)") + args = ap.parse_args() + + if args.hex: + vals = [int(x, 16) for x in args.hex] + frame = encode(vals[0], bytes(vals[1:])) + print(frame.hex()) + return 0 + + out = args.out + if out is None: + here = os.path.dirname(os.path.abspath(__file__)) + out = os.path.join(here, "tests", "sample.pcap") + os.makedirs(os.path.dirname(out), exist_ok=True) + write_pcap(out, sample_frames()) + print("wrote", out, file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wireshark/tests/sample.pcap b/wireshark/tests/sample.pcap new file mode 100644 index 0000000..b41ff15 Binary files /dev/null and b/wireshark/tests/sample.pcap differ