From 91d1e47ebef574280b5cc67675eb973e69e2686b Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Sun, 23 Feb 2025 15:12:07 +0100 Subject: [PATCH 01/20] [parser] improve IAQ detection/handling --- bitsparser.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index a5c241e..06858be 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -328,8 +328,13 @@ def __init__(self,msg): return if "msgtype" not in self.__dict__ and (not args.freqclass or self.frequency < f_duplex) and self.uplink: - if len(data)>=2*26 and len(data)<2*50: + plen = 26 + if len(data) >= 2*plen and len(data) <= 2*(plen+4): # Decoder adds at least 3 symbols self.msgtype="AQ" + elif len(data) >= 2*plen and len(data) <= 2*(54): # Longer only if valid BPSK + sym = [['0', 'e', 'e', '1'][int(data[2*x])*2 + int(data[2*x+1])] for x in range(26)] + if 'e' not in sym: + self.msgtype = "AQ" if "msgtype" not in self.__dict__: if args.harder: @@ -738,12 +743,17 @@ def __init__(self,imsg): for x in range(0,len(bits)-1,2): self.sym.append(imap[int(bits[x+0])*2 + int(bits[x+1])]) - if 'e' in self.sym: + if 'e' in self.sym[:12]: raise ParserError("IAQ content not BPSK") self.rid=int(self.sym[4]+self.sym[6]+self.sym[8]+self.sym[10]+self.sym[5]+self.sym[7]+self.sym[9]+self.sym[11],2) self.val=bytes([int("".join(self.sym[:4]),2),int("".join(self.sym[4:12]),2)]) - self.ridcrc=int("".join(self.sym[12:]),2) + + if 'e' in self.sym[12:]: + self._new_error("IAQ crc not BPSK") + self.ridcrc = "".join(self.sym[12:]) + else: + self.ridcrc = int("".join(self.sym[12:]), 2) self.crcval=iaq_crc16( bytes(self.val)) >>2 @@ -756,6 +766,8 @@ def pretty(self): st+= " " + "Rid:%03d"%self.rid if self.ridcrc==self.crcval: st+= " " + "CRC:OK" + elif type(self.ridcrc) is str: + st += " " + "CRC:no[%s/%s]"%(self.ridcrc, '{0:08b}'.format(self.crcval)) else: st+= " " + "CRC:no[%04x]"%self.ridcrc From c0c92c6b5a6a3be46f008d0eb232ae6a4ea5162d Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Sun, 23 Feb 2025 22:48:58 +0100 Subject: [PATCH 02/20] [parser] itl: another special message type --- bitsparser.py | 6 +++--- itl.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index 06858be..5f1b070 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -846,11 +846,11 @@ def __init__(self,imsg): if x in itl.MAP_PRS: self.msg.append(itl.MAP_PRS[x]) else: - if i==0 or self.msg[0] != 108: # special message does not contain normal PRS + if i == 0 or self.msg[0] not in (108, 109): # special message does not contain normal PRS raise ParserError("ITL V2 PRS Q#%d unknown"%i) self.msg.append(x) - if self.msg[0] != 108: + if self.msg[0] not in (108, 109): #sanity check the PRS sequence order sanity = "".join([str(itl.MAP_PRS_TYPE[x]) for x in self.q]) if self.plane%2 == 0: @@ -878,7 +878,7 @@ def __init__(self,imsg): cat=None for qidx in range(len(self.q)): mindist=999 - if qidx > 0 and self.msg[0] == 108: + if qidx > 0 and self.msg[0] in (108, 109): self.msg[qidx]=self.q[qidx] next if self.q[qidx] in itl.MAP_PRS: diff --git a/itl.py b/itl.py index 2eb0e88..e9be293 100644 --- a/itl.py +++ b/itl.py @@ -707,8 +707,8 @@ def map_sat(num, version): return ("S%02d"%(num-84), "N%02d"%(2)) elif num >=96 and num <=107: return ("R%02d"%(((num-96)%3)+1), "N%02d"%(((num-96)//3)+3)) - elif num == 108: - return ("---", "SSS") + elif num >= 108 and num <= 109: + return ("---", "SS%d"%(num-108)) elif num == 111: return ("---", "N%02d"%(8)) else: From e1ef06d71e977a2840db85a1c1047fe341d238b0 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Sat, 22 Feb 2025 11:40:37 +0100 Subject: [PATCH 03/20] [reassembler] sbd DL header has variable length --- iridiumtk/reassembler/ida.py | 4 +--- iridiumtk/reassembler/sbd.py | 6 ++++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/iridiumtk/reassembler/ida.py b/iridiumtk/reassembler/ida.py index ab8eee3..b06d0e8 100755 --- a/iridiumtk/reassembler/ida.py +++ b/iridiumtk/reassembler/ida.py @@ -807,12 +807,10 @@ def consume(self,q): # 5: number of messages waiting to be delivered / backlog # 6+7: "ack request" (answer: <50:xx:xx>) -- only for <26> - if data[0]==0x26: -# prehdr=data[:7] + if data[0] == 0x26 or (data[0] == 0x20 and data[7] == 0x10): prehdr="<%02x MTMSN=(%02x%02x) msgct:%d backlog=%d mid=(%02x:%02x)>"%(data[0],data[1],data[2],data[3],data[4],data[5],data[6]) data=data[7:] elif data[0]==0x20: -# prehdr=data[:5] prehdr="<%02x MTMSN=(%02x%02x) msgct:%d backlog=%d>"%(data[0],data[1],data[2],data[3],data[4]) data=data[5:] else: diff --git a/iridiumtk/reassembler/sbd.py b/iridiumtk/reassembler/sbd.py index 9ec6f8f..fd8aa21 100755 --- a/iridiumtk/reassembler/sbd.py +++ b/iridiumtk/reassembler/sbd.py @@ -97,6 +97,12 @@ def process_l2(self,q): elif data[0]==0x20: prehdr=data[:5] data=data[5:] + # sometimes it seems to be 7 bytes long + if len(data) >= 2 and data[2] == 0x10: + # first DL Message will have 0x01 in data[2], so + # there should be no danger of misclassification + prehdr += data[:2] + data = data[2:] else: print("WARN: SBD: DL pkt with unclear header",data.hex(":"), file=sys.stderr) prehdr=data[:7] From 80718569c0f2d61c11e32fae960ee751accb9769 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Sun, 2 Mar 2025 21:31:16 +0100 Subject: [PATCH 04/20] [reassembler] sbd be less verbose --- iridiumtk/reassembler/sbd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iridiumtk/reassembler/sbd.py b/iridiumtk/reassembler/sbd.py index fd8aa21..9640cc1 100755 --- a/iridiumtk/reassembler/sbd.py +++ b/iridiumtk/reassembler/sbd.py @@ -59,11 +59,11 @@ def process_l2(self,q): if data[0]==0x76: if ul: if data[1]<0x0c or data[1]>0x0e: - print("WARN: SBD: ul pkt with unclear type",data.hex(":"), file=sys.stderr) + #print("WARN: SBD: ul pkt with unclear type",data.hex(":"), file=sys.stderr) return else: if data[1]<0x08 or data[1]>0x0b: - print("WARN: SBD: dl pkt with unclear type",data.hex(":"), file=sys.stderr) + #print("WARN: SBD: dl pkt with unclear type",data.hex(":"), file=sys.stderr) return if data[0]==0x06: From f116d7f931baa84b28dc3147dd42d5884c1fb1ef Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Thu, 19 Dec 2024 15:56:47 +0100 Subject: [PATCH 05/20] [reassembler] time: try to mark slots --- iridiumtk/reassembler/time.py | 60 +++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/iridiumtk/reassembler/time.py b/iridiumtk/reassembler/time.py index 4c92a39..01eed16 100755 --- a/iridiumtk/reassembler/time.py +++ b/iridiumtk/reassembler/time.py @@ -4,10 +4,51 @@ import sys import re from util import dt +import numpy as np from .base import * from ..config import config, outfile +early_frame = 3 + +conv_start = None + + +def lbfc_str(ts, start=0): + """Convert milliseconds into L-Band frame counter""" + global conv_start + + if start != 0: + conv_start = start + + if conv_start is None: + return " " + + ts = ts - conv_start + lbfc_c = int((ts)//90) + lbfc_o = ts-lbfc_c*90 + + # guard + simplex + guard + 4* uplink + 4* downlink + # 1 + 20.32 + 1.24 + 4 * (8.28 + 0.22) + 0.02 + 4 * (8.28 + 0.1) - 0.1 + + # moved the first guard from the begining to the end + slots = (20.32+1.24, + 8.28+0.22, 8.28+0.22, 8.28+0.22, 8.28+0.22+0.02, + 8.28+0.1, 8.28+0.1, 8.28+0.1, 8.28+1 + early_frame, ) + + sname = ("S", "U1", "U2", "U3", "U4", "D1", "D2", "D3", "D4", ) + + st = 0 + for i, t in enumerate(slots): + if lbfc_o < st + t - early_frame: # allow slots to start slightly early + if len(sname[i]) == 1: + slot = f"{sname[i]}{round(lbfc_o - st):+03d}" + else: + slot = f"{sname[i]}{round(lbfc_o - st):+2d}" + break + st += t + return f"{lbfc_c:03d}∆{lbfc_o:+03.0f}#{slot}" + class ReassembleTIME(Reassemble): toff = None @@ -17,17 +58,24 @@ def __init__(self): def filter(self, line): q = super().filter(line) - if q is None: return None + if q is None: + print("-", line, end="") + return None return q def process(self, q): q.enrich(channelize=True) - if self.toff is None: + if self.toff is None and q.typ in ("IRA:", "ITL:", "INP:", "IMS:", "MSG:"): self.toff = q.mstime - strtime = dt.epoch(q.time).isoformat(timespec='centiseconds') - lbfc_c = (q.mstime-self.toff+45)//90 - lbfc_o = q.mstime-self.toff-lbfc_c*90 - return [f"{strtime} {lbfc_c%48:02.0f}#{lbfc_o:+07.3f} {q.typ} {q.freq_print} {q.confidence:3d}% {q.level:6.2f} {q.symbols} {q.uldl} {q.data}"] + q.uxtime = np.datetime64(int(q.starttime), 's') + q.uxtime += np.timedelta64(q.nstime, 'ns') + strtime = str(q.uxtime)[:-2] + if False: + lbfc_c = (q.mstime-self.toff+45)//90 + lbfc_o = q.mstime-self.toff-lbfc_c*90 + return [f"{strtime} {lbfc_c%48:02.0f}#{lbfc_o:+07.3f} {q.typ} {q.freq_print} {q.confidence:3d}% {q.level:6.2f} {q.symbols} {q.uldl} {q.data}"] + lbs = lbfc_str(q.mstime, self.toff) + return [f"{strtime} {lbs} {q.typ} {q.freq_print} {q.confidence:3d}% {q.level:6.2f} {q.symbols} {q.uldl} {q.data}"] def consume(self, q): print(q, end="", file=outfile) From 6a6f2c0360370b564b871bbef3576c5763a04419 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Thu, 19 Dec 2024 23:15:03 +0100 Subject: [PATCH 06/20] [reassembler] time: account for extra-long preamble --- iridiumtk/reassembler/time.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/iridiumtk/reassembler/time.py b/iridiumtk/reassembler/time.py index 01eed16..6c3fd1d 100755 --- a/iridiumtk/reassembler/time.py +++ b/iridiumtk/reassembler/time.py @@ -50,6 +50,7 @@ def lbfc_str(ts, start=0): return f"{lbfc_c:03d}∆{lbfc_o:+03.0f}#{slot}" +simplex = ("IRA:", "ITL:", "INP:", "IMS:", "MSG:") class ReassembleTIME(Reassemble): toff = None @@ -65,10 +66,15 @@ def filter(self, line): def process(self, q): q.enrich(channelize=True) - if self.toff is None and q.typ in ("IRA:", "ITL:", "INP:", "IMS:", "MSG:"): - self.toff = q.mstime q.uxtime = np.datetime64(int(q.starttime), 's') q.uxtime += np.timedelta64(q.nstime, 'ns') + if q.typ in ("IBC:", ) + simplex: # has a longer preamble + q.uxtime -= np.timedelta64((64-16)*(1000000//25000), 'us') + q.mstime -= (64-16)/25000 * 1000 + + if self.toff is None and q.typ in simplex: + self.toff = q.mstime + strtime = str(q.uxtime)[:-2] if False: lbfc_c = (q.mstime-self.toff+45)//90 From 843ab5e954450da5e0f88b3edb291bcc24d646d9 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Fri, 14 Mar 2025 13:34:30 +0100 Subject: [PATCH 07/20] [reassembler] msg: recognize and mark burst messages by default --- iridiumtk/reassembler/burst.py | 2 +- iridiumtk/reassembler/msg.py | 38 ++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/iridiumtk/reassembler/burst.py b/iridiumtk/reassembler/burst.py index 1e3dd23..f16c655 100755 --- a/iridiumtk/reassembler/burst.py +++ b/iridiumtk/reassembler/burst.py @@ -54,7 +54,7 @@ def consume(self, msg): multi = {} def process_l2(self, msg): - if not msg.correct: + if not msg.correct or not msg.fmt == 5: return ct = msg.content diff --git a/iridiumtk/reassembler/msg.py b/iridiumtk/reassembler/msg.py index 6b1099a..fe47d2c 100755 --- a/iridiumtk/reassembler/msg.py +++ b/iridiumtk/reassembler/msg.py @@ -4,6 +4,8 @@ import sys import datetime import re +import base64 +import crcmod from util import to_ascii, slice_extra, dt from .base import * @@ -67,12 +69,8 @@ def __init__(self): self.err=re.compile(r' ERR:') self.msg=re.compile(r'.* ric:(\d+) fmt:(\d+) seq:(\d+) (?:C:(..)\S*|[01 ]+) (\d)/(\d) csum:([0-9a-f][0-9a-f]) msg:([0-9a-f]*)\.([01]*) ') self.ms3=re.compile(r'.* ric:(\d+) fmt:(\d+) seq:(\d+) [01]+ \d BCD: ([0-9a-f]+)') - if 'noburst' in config.args: - global base64 - import base64 - import crcmod - self.crc16 = crcmod.predefined.mkPredefinedCrcFun("xmodem") - self.BURST_TRANS = bytes.maketrans(b'*-',b'/=') + self.crc16 = crcmod.predefined.mkPredefinedCrcFun("xmodem") + self.BURST_TRANS = bytes.maketrans(b'*-', b'/=') def filter(self,line): q=super().filter(line) @@ -182,15 +180,18 @@ def consume(self, msg): date = dt.epoch_local(msg.time).isoformat(timespec='seconds') str="Message %07d %02d @%s (len:%d)"%(msg.ric, msg.seq, date, msg.pcnt) txt= msg.content - if 'noburst' in config.args: - try: - ct = txt.translate(self.BURST_TRANS) - dec = base64.b64decode(ct) - crcv = self.crc16(dec) - if crcv == 0: - return - except Exception: - pass + try: + msg.burst = False + ct = txt.translate(self.BURST_TRANS) + dec = base64.b64decode(ct) + crcv = self.crc16(dec) + if crcv == 0: + msg.burst = True + except Exception: + pass + + if 'noburst' in config.args and msg.burst: + return if msg.fmt==5: out=to_ascii(txt, escape=True) @@ -198,7 +199,12 @@ def consume(self, msg): elif msg.fmt==3: out=txt str+= " BCD" - str+= (" fail:"," OK:")[msg.correct] + if msg.burst and msg.correct: + str += " GDB:" + elif msg.correct: + str += " OK:" + else: + str += " fail:" str+= " %s"%(out) print(str, file=outfile) From ff8a9a790b13fbfd5fe0dfc973f22dbb7b52bf16 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Sun, 11 Sep 2022 17:01:37 +0200 Subject: [PATCH 08/20] [parser] experimental: new packet format --- bitsparser.py | 207 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/bitsparser.py b/bitsparser.py index 5f1b070..af7382d 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -7,6 +7,7 @@ import struct import fileinput import datetime +import itertools from math import sqrt,atan2,pi,log import crcmod @@ -336,6 +337,22 @@ def __init__(self,msg): if 'e' not in sym: self.msgtype = "AQ" + if "msgtype" not in self.__dict__: # and (not args.freqclass or self.frequency > f_simplex) and not (args.freqclass and self.uplink): + if len(data)==432*2: + symbols=de_dqpsk(data) + (i_list,q_list)=split_qpsk(symbols) + l1=i_list[0::2] # a<0.111101 ....1011 ...11001 1...0001 1 + l1_id=l1[2:8]+","+l1[12:16]+","+l1[19:25]+","+l1[28:33] + l3=q_list[0::2] + l3_id=l3[0:3]+","+l3[8:10]+","+l3[24:25]+l3[30:31] +# print("l1:",l1_id, "l3:", l3_id) + if l1_id=="111101,1011,110011,00011": # and id_part=="1111": + self.msgtype="NP" + if l1.startswith("0011110110001011011110011010000111100011011100101111100100011000100100011101011001010011"): + self.header="V1" + else: + self.header="V0" + if "msgtype" not in self.__dict__: if args.harder: # try IBC @@ -424,6 +441,10 @@ def __init__(self,msg): self.header=data[:hdrlen] self.descrambled=data[hdrlen:hdrlen+(256*3)] self.descramble_extra=data[hdrlen+(256*3):] + elif self.msgtype=="NP": +# self.header="" + self.descrambled=data[:432*2] + self.descramble_extra=data[(432*2):] elif self.msgtype=="RA": firstlen=3*32 if len(data)" + st+= " CRC=%04x"%int(self.cs_v1,2) + if self.the_crc_v1==0: + st+="[OK]" + else: + st+="[no]" + elif self.type==2: + st+= " d<" + for x in self.csblocks: + st+= "".join(slice(x,8)) + st+= " " + st=st[:-1] + st+=">" + + st+= " CRC=%04x"%int(self.cs_v2,2) + if self.the_crc_v2==0: + st+="[OK]" + else: + st+="[no]" + + st+= " t<"+" ".join(slice(self.v2trail,8))+">" + if all(self.checks): + st+=" MAGIC" + else: + st+=" nomag" + else: + st+= " d<" + for x in self.blocks: + st+= "".join(slice(x,8)) + st+= " " + st=st[:-1] + st+=">" + + st+= " t<"+" ".join(slice(self.trailer,8))+">" + + st+= " v1=%04x"%(self.the_crc_v1) + st+= " v2=%04x"%(self.the_crc_v2) + st+= " magic=%s"%(self.magic) + + st+=self._pretty_trailer() + return st + class IridiumSTLMessage(IridiumMessage): def __init__(self,imsg): self.__dict__=imsg.__dict__ @@ -2038,6 +2211,40 @@ def de_dqpsk(bits): return symbols +def sym2bits(symbols): + bits="" + omap=["00","10","11","01"] + omap=["11","01","00","10"] # XXX: Negate everything. + bits="".join([omap[sym] for sym in symbols]) + return bits + +# calculate "Magic" checksum +# +# Input is 40 bits. 32 bits "data" and 8 bits "checksum" +# +# No idea how it is inteded to work, but it is almost +# completely linear from input bits, so we can check it that +# way, even if it is a bit convoluted. +# +# Checksum uses only lower 7 bits. +# Additionally bits 4&5 are correlated, but depend on +# something yet unknown. +# +def magic_checksum(bits): + cv=int(bits[32:],2) + bits=bits[:32] + + magic=[79, 7, 67, 107, 11, 107, 11, 35, 4, 44, 76, 44, 76, 100, 104, 32, 14, 70, 2, 42, 74, 42, 74, 98, 69, 109, 13, 109, 13, 37, 41, 97, 24] + check=0 + for i, bit in enumerate(bits): + if bit=="1": + check^=magic[i] + + # bit 4 & 5 are correlated + if (check^cv) == 0 or (check^cv) == 0b11000: + return True, check^cv + return False, check^cv + def split_qpsk(symbols): i_list="" q_list="" From 1a8772e08a7994136340fda131298a89e0dc2715 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Mon, 10 Oct 2022 18:28:10 +0200 Subject: [PATCH 09/20] [parser] more new packet things --- bitsparser.py | 117 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index af7382d..d551653 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -341,17 +341,28 @@ def __init__(self,msg): if len(data)==432*2: symbols=de_dqpsk(data) (i_list,q_list)=split_qpsk(symbols) - l1=i_list[0::2] # a<0.111101 ....1011 ...11001 1...0001 1 - l1_id=l1[2:8]+","+l1[12:16]+","+l1[19:25]+","+l1[28:33] - l3=q_list[0::2] - l3_id=l3[0:3]+","+l3[8:10]+","+l3[24:25]+l3[30:31] -# print("l1:",l1_id, "l3:", l3_id) - if l1_id=="111101,1011,110011,00011": # and id_part=="1111": + bits=sym2bits(symbols) + + bits=bits[:160] + h1=bits[0::4] + h2=bits[1::4] + # 1 2 3 1 2 3 + # 01234567890123456789012345678901 01234567890123456789012345678901 + #h<01000010001101001100011001101110 00011000000000001111011000000000 + #h<11000010011101000100011001111110 00010110000000110110110101000000 + # ++++++ ++++ ++++++ ++++ +++ ++ ++++++ + h1_id=h1[2:8]+","+h1[12:16]+","+h1[19:25]+","+h1[28:32] + h2_id=h2[0:3]+","+h2[8:10]+","+h2[26:32] + if h1_id=="000010,0100,001100,1110" and h2_id=="000,00,000000": self.msgtype="NP" - if l1.startswith("0011110110001011011110011010000111100011011100101111100100011000100100011101011001010011"): - self.header="V1" - else: - self.header="V0" + elif args.harder: + h3=bits[2::4] + ok1,_ = magic_checksum(h1) + ok2,_ = magic_checksum(h2) + ok3,_ = magic_checksum(h3) + if ok1 and ok2 and ok3: + self.ec_lcw=1 + self.msgtype="NP" if "msgtype" not in self.__dict__: if args.harder: @@ -800,6 +811,7 @@ def pretty(self): np_crc16=crcmod.mkCrcFun(poly=0b10111010101011011,initCrc=0,rev=False,xorOut=0) +np_crc8=crcmod.mkCrcFun(poly=0x12f,initCrc=0,rev=False,xorOut=0) class IridiumNPMessage(IridiumMessage): def __init__(self,imsg): @@ -808,6 +820,9 @@ def __init__(self,imsg): symbols=de_dqpsk(self.descrambled) bits=sym2bits(symbols) + if "header" not in self.__dict__: + self.header="" + # Re-sort bits trailer=bits[800::2]+bits[801::2] bits=bits[:800] @@ -845,6 +860,25 @@ def __init__(self,imsg): blocks=blocks[3:] checks=checks[3:] + hdr_id=hdr[1][4:8] + + if hdr_id in ('1000','0111'): + self.hdr_type=1 + elif hdr_id in ('0110',): + self.hdr_type=2 + else: + self.hdr_type=0 + + # H1 + self.hdr_crc1=np_crc8(bytes( [int(x,2) for x in slice( + hdr[0]+hdr[1]+hdr[2][:-8] + ,8)])) + + # H2 + self.hdr_crc2=np_crc8(bytes( [int(x,2) for x in slice( + hdr[0]+hdr[1]+hdr[2][:8] + ,8)])) + # Begin pkts self.type=0 self.hdr=hdr @@ -881,6 +915,10 @@ def __init__(self,imsg): if self.the_crc_v2==0: self.type=2 + # Pkt v3 + if all(checks[:-3]) and not any(checks[-3:]): + self.type=3 + self.header+=":%05d"%(int(s2s[0][10:24],2)) return @@ -890,14 +928,32 @@ def upgrade(self): def pretty(self): st= "INP: "+self._pretty_header() + st+= " H:%d"%self.hdr_type st+= " T:%d"%self.type st+= " h<" - for x in self.hdr: - st+= "".join(slice(x,8)) + if self.hdr_type == 1: + st+= self.hdr[0] + " " + self.hdr[1] + " " + self.hdr[2][:-16] + st+=" CRC=%02x"%int(self.hdr[2][-16:-8],2) + if self.hdr_crc1==0: + st+="[OK]" + else: + st+="[no]" + st+= " " + st+= self.hdr[2][-8:] + elif self.hdr_type == 2: + st+= self.hdr[0] + " " + self.hdr[1] + st+=" CRC=%02x"%int(self.hdr[2][:8],2) + if self.hdr_crc2==0: + st+="[OK]" + else: + st+="[no]" st+= " " - st=st[:-1] - st+="> " + st+= " " # alignment + st+= self.hdr[2][8:] + else: + st+= self.hdr[0] + " " + self.hdr[1] + " " + self.hdr[2] + st+=">" if self.type==1: st+= " d<" @@ -913,37 +969,60 @@ def pretty(self): st+="[OK]" else: st+="[no]" + st+= " " # alignment elif self.type==2: st+= " d<" for x in self.csblocks: st+= "".join(slice(x,8)) st+= " " + st+= " " # alignment st=st[:-1] st+=">" + st+=" ["+self.blocks[-1]+"]" + st+= " CRC=%04x"%int(self.cs_v2,2) if self.the_crc_v2==0: st+="[OK]" else: st+="[no]" - st+= " t<"+" ".join(slice(self.v2trail,8))+">" + st+= " t<"+" ".join(slice(self.trailer,8))+">" if all(self.checks): - st+=" MAGIC" + st+=" MAGC" else: - st+=" nomag" - else: + st+=" nomg" + elif self.type==3: st+= " d<" - for x in self.blocks: + for x in self.csblocks[:-3]: + st+= "".join(slice(x,8)) + st+= " " + st+= " " # alignment + for x in self.blocks[-3:]: st+= "".join(slice(x,8)) st+= " " st=st[:-1] st+=">" + st+= " t<"+" ".join(slice(self.trailer,8))+">" + else: + st+= " d<" + for i,x in enumerate(self.blocks): + if self.checks[i]: + st+= "".join(slice(x[:32],8)) + st+= " " # alignment + else: + st+= "".join(slice(x,8)) + st+= " " + st=st[:-1] + st+=">" + st+= " t<"+" ".join(slice(self.trailer,8))+">" st+= " v1=%04x"%(self.the_crc_v1) st+= " v2=%04x"%(self.the_crc_v2) + st+= " h1=%02x"%(self.hdr_crc1) + st+= " h2=%02x"%(self.hdr_crc2) st+= " magic=%s"%(self.magic) st+=self._pretty_trailer() From 5fa649f4bdb9aa8ae7b5f409d439df7f0b13fbdd Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Tue, 1 Nov 2022 16:20:05 +0100 Subject: [PATCH 10/20] [parser] fix confusion with "magic checksum" --- bitsparser.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index d551653..5c25bf0 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -2301,26 +2301,27 @@ def sym2bits(symbols): # # Input is 40 bits. 32 bits "data" and 8 bits "checksum" # -# No idea how it is inteded to work, but it is almost +# No idea how it is inteded to work, but it is # completely linear from input bits, so we can check it that # way, even if it is a bit convoluted. # # Checksum uses only lower 7 bits. -# Additionally bits 4&5 are correlated, but depend on -# something yet unknown. +# +# Initial/Zero value is bits 4&5. # def magic_checksum(bits): cv=int(bits[32:],2) bits=bits[:32] - magic=[79, 7, 67, 107, 11, 107, 11, 35, 4, 44, 76, 44, 76, 100, 104, 32, 14, 70, 2, 42, 74, 42, 74, 98, 69, 109, 13, 109, 13, 37, 41, 97, 24] + magic=[87, 7, 91, 107, 11, 115, 19, 35, 28, 44, 76, 52, 84, 100, 104, 56, 22, 70, 26, 42, 74, 50, 82, 98, 93, 109, 13, 117, 21, 37, 41, 121] + check=0 for i, bit in enumerate(bits): if bit=="1": check^=magic[i] - # bit 4 & 5 are correlated - if (check^cv) == 0 or (check^cv) == 0b11000: + # bit 4 & 5 are the "check value" + if (check^cv) == 0b11000: return True, check^cv return False, check^cv From d5b41bde210f9e4091b437c1f3367e72a9dd3b2e Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Tue, 1 Nov 2022 18:08:57 +0100 Subject: [PATCH 11/20] [parser] cleanup/clarify new packet --- bitsparser.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index 5c25bf0..aed5352 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -340,7 +340,6 @@ def __init__(self,msg): if "msgtype" not in self.__dict__: # and (not args.freqclass or self.frequency > f_simplex) and not (args.freqclass and self.uplink): if len(data)==432*2: symbols=de_dqpsk(data) - (i_list,q_list)=split_qpsk(symbols) bits=sym2bits(symbols) bits=bits[:160] @@ -881,14 +880,14 @@ def __init__(self,imsg): # Begin pkts self.type=0 - self.hdr=hdr - self.trailer=trailer - self.blocks=blocks + self.hdr=hdr # 3 blocks a 32 bits + self.trailer=trailer # 24 bits + self.blocks=blocks # 18 blocks a 40 bits self.checks=checks # Pkt v1 - self.cs_v1=trailer[-16:] - self.v1trail=trailer[:-16] + self.v1trail=trailer[:8] + self.cs_v1=trailer[8:] self.the_crc_v1=np_crc16(bytes( [int(x,2) for x in slice( hdr[-1][-8:]+ @@ -903,13 +902,13 @@ def __init__(self,imsg): csblocks=[b[:32] for b in blocks] self.csblocks=csblocks - self.cs_v2=self.trailer[-24:-8] - self.v2trail=self.trailer[-8:] + self.cs_v2=self.trailer[:16] + self.v2trail=self.trailer[16:] self.the_crc_v2=np_crc16(bytes([int(x,2) for x in slice( hdr[-1][-8:]+ "".join(csblocks)+ - trailer[-24:-8] + trailer[:16] ,8)])) if self.the_crc_v2==0: @@ -950,7 +949,7 @@ def pretty(self): st+="[no]" st+= " " st+= " " # alignment - st+= self.hdr[2][8:] + st+= self.hdr[2][8:] # 24 bit else: st+= self.hdr[0] + " " + self.hdr[1] + " " + self.hdr[2] st+=">" @@ -963,7 +962,7 @@ def pretty(self): st=st[:-1] st+=">" - st+= " t<"+" ".join(slice(self.v1trail,8))+">" + st+= " t<"+self.v1trail+">" st+= " CRC=%04x"%int(self.cs_v1,2) if self.the_crc_v1==0: st+="[OK]" @@ -979,15 +978,14 @@ def pretty(self): st=st[:-1] st+=">" - st+=" ["+self.blocks[-1]+"]" - st+= " CRC=%04x"%int(self.cs_v2,2) if self.the_crc_v2==0: st+="[OK]" else: st+="[no]" - st+= " t<"+" ".join(slice(self.trailer,8))+">" + st+= " t<"+self.v2trail +">" + if all(self.checks): st+=" MAGC" else: From 1e5d33a9c23d79c5cb0776f103269727ff9c7f49 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Tue, 1 Nov 2022 18:09:53 +0100 Subject: [PATCH 12/20] [parser] another variant of the magic checksum --- bitsparser.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/bitsparser.py b/bitsparser.py index aed5352..9f00bff 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -968,7 +968,7 @@ def pretty(self): st+="[OK]" else: st+="[no]" - st+= " " # alignment + st+= " " # alignment elif self.type==2: st+= " d<" for x in self.csblocks: @@ -985,6 +985,12 @@ def pretty(self): st+="[no]" st+= " t<"+self.v2trail +">" + # Trailer checksum: previous block + crc + ok, _=magic_checksum2(self.csblocks[-1]+self.trailer) + if ok: + st+="OK" + else: + st+="no" if all(self.checks): st+=" MAGC" @@ -2323,6 +2329,23 @@ def magic_checksum(bits): return True, check^cv return False, check^cv +# Second version of magic checksum +# Input is 56 bits. 48 bits "data" and 8 bits "checksum" +def magic_checksum2(bits): + cv=int(bits[32+16:],2) + bits=bits[:32+16] + + magic=[44, 52, 28, 100, 4, 4, 100, 28, 4, 124, 28, 28, 124, 4, 52, 44, 56, 32, 8, 112, 16, 16, 112, 8, 71, 111, 83, 99, 99, 99, 51, 27, 22, 70, 26, 42, 74, 50, 82, 98, 93, 109, 13, 117, 21, 37, 41, 121] + + check=0 + for i, bit in enumerate(bits): + if bit=="1": + check^=magic[i] + + if (check^cv) == 0b1111000: + return True, check^cv + return False, check^cv + def split_qpsk(symbols): i_list="" q_list="" From 57ec6d88ff195a26142654d8e72ac6f5082dd9ff Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Mon, 7 Nov 2022 17:57:35 +0100 Subject: [PATCH 13/20] [parser] convert crc to hex --- bitsparser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitsparser.py b/bitsparser.py index 9f00bff..79d5f9d 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -809,7 +809,7 @@ def pretty(self): return st -np_crc16=crcmod.mkCrcFun(poly=0b10111010101011011,initCrc=0,rev=False,xorOut=0) +np_crc16=crcmod.mkCrcFun(poly=0x1755b,initCrc=0,rev=False,xorOut=0) np_crc8=crcmod.mkCrcFun(poly=0x12f,initCrc=0,rev=False,xorOut=0) class IridiumNPMessage(IridiumMessage): From 39ebc69833103df78beb6e9b78969284515f5d4b Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Mon, 7 Nov 2022 17:55:31 +0100 Subject: [PATCH 14/20] [parser] actually there is no second "magic" checksum variant :) --- bitsparser.py | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index 79d5f9d..667e786 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -985,8 +985,8 @@ def pretty(self): st+="[no]" st+= " t<"+self.v2trail +">" - # Trailer checksum: previous block + crc - ok, _=magic_checksum2(self.csblocks[-1]+self.trailer) + # Trailer checksum 'steals' two bytes from the previous block + ok, _=magic_checksum(self.blocks[-1][-16:]+self.trailer) if ok: st+="OK" else: @@ -2329,23 +2329,6 @@ def magic_checksum(bits): return True, check^cv return False, check^cv -# Second version of magic checksum -# Input is 56 bits. 48 bits "data" and 8 bits "checksum" -def magic_checksum2(bits): - cv=int(bits[32+16:],2) - bits=bits[:32+16] - - magic=[44, 52, 28, 100, 4, 4, 100, 28, 4, 124, 28, 28, 124, 4, 52, 44, 56, 32, 8, 112, 16, 16, 112, 8, 71, 111, 83, 99, 99, 99, 51, 27, 22, 70, 26, 42, 74, 50, 82, 98, 93, 109, 13, 117, 21, 37, 41, 121] - - check=0 - for i, bit in enumerate(bits): - if bit=="1": - check^=magic[i] - - if (check^cv) == 0b1111000: - return True, check^cv - return False, check^cv - def split_qpsk(symbols): i_list="" q_list="" From 007acede1abaea1e4ca96817e9bfc83d5216f5b6 Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Sun, 14 May 2023 11:47:04 +0200 Subject: [PATCH 15/20] [reassembler] add INP to stats --- iridiumtk/reassembler/pktstats.py | 2 +- iridiumtk/reassembler/stats.py | 2 +- stats.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/iridiumtk/reassembler/pktstats.py b/iridiumtk/reassembler/pktstats.py index 8f23032..c888754 100755 --- a/iridiumtk/reassembler/pktstats.py +++ b/iridiumtk/reassembler/pktstats.py @@ -25,7 +25,7 @@ def __init__(self): self.default={} for k in ['UL', 'DL']: self.default[k]={} - for x in ['IBC', 'IDA', 'IIP', 'IIQ', 'IIR', 'IIU', 'IMS', 'IRA', 'IRI', 'ISY', 'ITL', 'IU3', 'I36', 'I38', 'MSG', 'VDA', 'VO6', 'VOC', 'VOD', 'MS3', 'VOZ', 'IAQ', 'NXT']: + for x in ['IBC', 'IDA', 'IIP', 'IIQ', 'IIR', 'IIU', 'IMS', 'IRA', 'IRI', 'ISY', 'ITL', 'IU3', 'I36', 'I38', 'MSG', 'VDA', 'VO6', 'VOC', 'VOD', 'MS3', 'VOZ', 'IAQ', 'INP', 'NXT']: self.default[k][x]=0 pass diff --git a/iridiumtk/reassembler/stats.py b/iridiumtk/reassembler/stats.py index ceb63f2..2236ac7 100755 --- a/iridiumtk/reassembler/stats.py +++ b/iridiumtk/reassembler/stats.py @@ -7,7 +7,7 @@ from .base import * from ..config import config, outfile, state -ft=['IBC', 'IDA', 'IIP', 'IIQ', 'IIR', 'IIU', 'IMS', 'IRA', 'IRI', 'ISY', 'ITL', 'IU3', 'I36', 'I38', 'MSG', 'VDA', 'VO6', 'VOC', 'VOD', 'MS3', 'VOZ', 'IAQ', 'NXT'] +ft=['IBC', 'IDA', 'IIP', 'IIQ', 'IIR', 'IIU', 'IMS', 'IRA', 'IRI', 'ISY', 'ITL', 'IU3', 'I36', 'I38', 'MSG', 'VDA', 'VO6', 'VOC', 'VOD', 'MS3', 'VOZ', 'IAQ', 'INP', 'NXT'] class StatsPKT(Reassemble): stats={} diff --git a/stats.py b/stats.py index 80c3070..f8bbb7b 100755 --- a/stats.py +++ b/stats.py @@ -45,6 +45,8 @@ frames['VDA'] = [colors[ 2], 'o', 1] frames['VO6'] = [colors[12], 'o', 1] +frames['INP'] = [colors[ 5], 'x', 1] + frames['IRI'] = ['purple', 'x', 0] frames['RAW'] = ['grey', 'x', 0] frames['NC1'] = ['grey', 'x', 0] From 9156a877aec5c767293b9bcf6d4822a12e0b5afd Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Sat, 27 May 2023 23:27:27 +0200 Subject: [PATCH 16/20] [parser] INP: allow shorter packets also. Scrambling seems to be different.... --- bitsparser.py | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index 667e786..143a1da 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -338,7 +338,7 @@ def __init__(self,msg): self.msgtype = "AQ" if "msgtype" not in self.__dict__: # and (not args.freqclass or self.frequency > f_simplex) and not (args.freqclass and self.uplink): - if len(data)==432*2: + if len(data)>=170:#==432*2: symbols=de_dqpsk(data) bits=sym2bits(symbols) @@ -822,19 +822,38 @@ def __init__(self,imsg): if "header" not in self.__dict__: self.header="" - # Re-sort bits - trailer=bits[800::2]+bits[801::2] - bits=bits[:800] + # Re-sort bits + if len(bits)<840: + bcnt=len(bits)//160 + trailer=bits[160*bcnt::] + bits=bits[:160*bcnt] - s1s=slice(bits[0::4],40) - s2s=slice(bits[1::4],40) - s3s=slice(bits[2::4],40) - s4s=slice(bits[3::4],40) + s1s=slice(bits[0::4],40) + s2s=slice(bits[1::4],40) + s3s=slice(bits[2::4],40) + s4s=slice(bits[3::4],40) - blocks=list(itertools.chain.from_iterable(zip(s1s,s2s,s3s,s4s))) + blocks=list(itertools.chain.from_iterable(zip(s1s,s2s,s3s,s4s))) - blocks+=[trailer[:40]] - trailer=trailer[40:] +# btrail=blocks[-4:] +# blocks=blocks[:-4] +# trailer="".join(btrail)+trailer + +# blocks+=[blocks[-2][-8:]+btrail[0][:32]] +# blocks+=[btrail[1]] + else: + trailer=bits[800::2]+bits[801::2] + bits=bits[:800] + + s1s=slice(bits[0::4],40) + s2s=slice(bits[1::4],40) + s3s=slice(bits[2::4],40) + s4s=slice(bits[3::4],40) + + blocks=list(itertools.chain.from_iterable(zip(s1s,s2s,s3s,s4s))) + + blocks+=[trailer[:40]] + trailer=trailer[40:] checks=[magic_checksum(b)[0] for b in blocks] From f66e36b83d510cee6d1af764c2bee149703542ea Mon Sep 17 00:00:00 2001 From: Stefan `Sec` Zehl Date: Wed, 31 May 2023 20:24:36 +0200 Subject: [PATCH 17/20] [parser] INP: workaround for empty trailer --- bitsparser.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bitsparser.py b/bitsparser.py index 143a1da..3a1a40a 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -907,6 +907,8 @@ def __init__(self,imsg): # Pkt v1 self.v1trail=trailer[:8] self.cs_v1=trailer[8:] + if self.cs_v1 == '': + self.cs_v1='0' self.the_crc_v1=np_crc16(bytes( [int(x,2) for x in slice( hdr[-1][-8:]+ @@ -923,6 +925,8 @@ def __init__(self,imsg): self.cs_v2=self.trailer[:16] self.v2trail=self.trailer[16:] + if self.cs_v2 == '': + self.cs_v2='0' self.the_crc_v2=np_crc16(bytes([int(x,2) for x in slice( hdr[-1][-8:]+ From 12bcf8562c9cdb796685af0d59b0298267d2a4b6 Mon Sep 17 00:00:00 2001 From: Dominik Bay Date: Wed, 15 Apr 2026 12:14:13 +0200 Subject: [PATCH 18/20] [parser] recognize Certus (NEXT/EBBS) traffic bursts by symbol count add IC1/IC2/IC8 msgtypes on the simplex-DL band to prevent Certus bursts from dropping to the unknown-type bucket. payload bits are not yet decoded - coherent QPSK + Turbo requires a gr-iridium demod path plus interleaver/rate-matching params that are not publicly documented. for now classify by symbol count only so we can measure Certus share of the capture. IridiumCertusMessage + un_deqpsk() helper added for later payload work. FORMAT.md: new IC1/IC2/IC8 section documenting the classifier. --- FORMAT.md | 30 +++++++++++++ bitsparser.py | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/FORMAT.md b/FORMAT.md index 5aea15d..4477062 100644 --- a/FORMAT.md +++ b/FORMAT.md @@ -180,6 +180,36 @@ Notes: Work in progress. +### IC1 / IC2 / IC8: Iridium Certus (NEXT / EBBS) traffic bursts + +Recognizer for Iridium NEXT Certus traffic channel bursts carrying user voice or IP data. Same 0x789 unique word as Block1 channels, but the rest of the waveform uses coherent QPSK (or 16APSK) with Turbo FEC instead of DEQPSK with BCH. The Block1-oriented demod pipeline emits differentially-decoded bits that are not directly meaningful for these bursts, so we classify them by symbol count alone and expose the raw (wrongly-decoded) bits for diagnostic purposes. + +Symbol counts and modulations consistent with publicly documented Certus bearers: + +| Tag | Symbol count | Modulation | Code | Payload bits | +|---|---|---|---|---| +| `IC1` | 200 | QPSK 4/5 | Turbo rate 4/5 | 320 | +| `IC2` | 432 | QPSK 2/3 | Turbo rate 2/3 | 576 | +| `IC8` | 1824 | QPSK 2/3 or 16APSK 2/3 | Turbo rate 2/3 | 2432 or 4848 | + +Example: + + IC2: [...] 432 DL raw:0011001111110011 0011001101100011 [...] + +Column|Content|Example|Comment +--:|-|-|- +8|Length in symbols|432|200 for C1, 432 for C2, 1824 for C8 +9|Direction|DL|Certus L-band simplex downlink (1626.1-1626.5 MHz) +10|raw:|raw:\|Raw demod output - NOT directly decodable in this form + +Notes and caveats: + +1. These bits are not the payload. The gr-iridium demodulator applies differential decoding unconditionally. That is correct for Block1 DEQPSK but wrong for coherent-QPSK Certus. Proper decoding requires a gr-iridium patch to expose the coherent-QPSK symbols before differential decode, followed by the Iridium-specific Turbo decoder and interleaver. +2. Interleaver not public. The Iridium-NEXT-specific interleaver and puncturing patterns are not publicly documented; without them, Turbo decoding will not recover user data even with the correct bits. +3. The recognizer only covers NEXT traffic (C1/C2/C8). DBCCH and other new broadcast channels are not currently detected. +4. The current recognizer does not distinguish 16APSK-modulated C8 bursts from QPSK-modulated variants - both produce the same per-symbol count in the demod output. +5. The paper "Systematic Security Analysis of the Iridium Satellite Radio Link" (Jedermann et al., USENIX Security 2026, arXiv:2603.12062) covers the Iridium radio link in depth and is a recommended follow-up reference. + ### IBC: Broadcast IBC: [...] bc:0 sat:028 cell:32 0 slot:0 sv_blkn:0 aq_cl:1111111111111111 aq_sb:22 aq_ch:2 00 0000 tmsi_expiry:2020-06-25T14:18:30.44Z [0 Rid:119 ts:1 ul_sb:22 dl_sb:22 access:3 dtoa:001 dfoa:00 00] [] diff --git a/bitsparser.py b/bitsparser.py index a5c241e..d57b127 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -27,6 +27,18 @@ NXT_UW_UPLINK = [0,2,0,0,0,0,0,0,2,0,2,0] header_messaging="00110011111100110011001111110011" # 0x9669 in BPSK header_time_location="11"+"0"*94 +# Iridium Certus (NEXT / EBBS) traffic bursts. Same 0x789 unique word as all other +# Iridium channels, but coherent QPSK + Turbo coding (not DEQPSK + BCH). +# Symbol counts per publicly documented NEXT traffic bearers: +# C1 30ksps QPSK 4/5: 320 payload bits -> 400 coded -> 200 symbols +# C2 60ksps QPSK 2/3: 576 payload bits -> 864 coded -> 432 symbols +# C8 240ksps QPSK 2/3: 2432 payload bits -> 3648 coded -> 1824 symbols +# The raw bits we see here are the *differentially-decoded* form of the coherent +# QPSK symbols (diff-decode is applied unconditionally in gr-iridium). That's +# wrong for Certus - the underlying demodulation is coherent, not differential - +# so these bits are not directly payload-recoverable without a gr-iridium patch. +# We recognize them by symbol count and simplex-DL band alone; the payload is left +# as-is for future research (Turbo decoding + correct interleaver required). messaging_bch_poly=1897 ringalert_bch_poly=1207 acch_bch_poly=3545 # 1207 also works? @@ -285,6 +297,23 @@ def __init__(self,msg): self._new_error("filtered message") return + # Certus (EBBS / NEXT) traffic burst recognition. Classify by symbol count on the + # simplex DL band - the bits themselves are not meaningful (wrong demod path for + # coherent QPSK + Turbo) but labeling them prevents the frame dropping to "unknown". + if "msgtype" not in self.__dict__ and (not args.freqclass or self.frequency > f_simplex) and not (args.freqclass and self.uplink): + # symbols field counts total symbols including the 12-symbol UW; subtract it for payload length. + payload_syms = self.symbols - (len(iridium_access) // 2) + if payload_syms == 200: + self.msgtype="C1" # NEXT C1 30ksps QPSK 4/5, 320 payload bits + elif payload_syms == 432: + self.msgtype="C2" # NEXT C2 60ksps QPSK 2/3, 576 payload bits + elif payload_syms == 1824: + self.msgtype="C8" # NEXT C8 240ksps QPSK 2/3 (or 16APSK 2/3), 2432 / 4848 payload bits + + if "msgtype" not in self.__dict__ and args.linefilter['type'] == "IridiumCertusMessage": + self._new_error("filtered message") + return + if "msgtype" not in self.__dict__ and (not args.freqclass or self.frequency < f_duplex) and not (args.freqclass and self.uplink): hdrlen=6 blocklen=64 @@ -390,6 +419,19 @@ def __init__(self,msg): self.ec_lcw=1 self.msgtype="MS" + # try Certus traffic bursts by length alone under --harder + if "msgtype" not in self.__dict__ and not (args.freqclass and self.uplink): + payload_syms = self.symbols - (len(iridium_access) // 2) + if payload_syms == 200: + self.ec_lcw=1 + self.msgtype="C1" + elif payload_syms == 432: + self.ec_lcw=1 + self.msgtype="C2" + elif payload_syms == 1824: + self.ec_lcw=1 + self.msgtype="C8" + if "msgtype" not in self.__dict__: if len(data)<64: raise ParserError("Iridium message too short") @@ -409,6 +451,14 @@ def __init__(self,msg): (blocks,self.descramble_extra)=slice_extra(data[hdrlen:],64) for x in blocks: self.descrambled+=de_interleave(x) + elif self.msgtype in ("C1", "C2", "C8"): + # Certus (NEXT / EBBS) traffic burst - carry raw bits through unchanged. + # The differential decoding that gr-iridium applies is incorrect for + # coherent-QPSK Certus waveforms, so these bits are not directly decodable. + # We still expose them so downstream tooling can look at them. + self.header="" + self.descrambled=data + self.descramble_extra="" elif self.msgtype=="AQ": datalen=2*26 self.header="" @@ -499,6 +549,8 @@ def upgrade(self): return IridiumAQMessage(self).upgrade() elif self.msgtype in ("MS", "RA", "BC"): return IridiumECCMessage(self).upgrade() + elif self.msgtype in ("C1", "C2", "C8"): + return IridiumCertusMessage(self) elif self.msgtype == "NX": return IridiumNXTMessage(self).upgrade() raise AssertionError("unknown frame type encountered") @@ -1974,6 +2026,68 @@ def pretty(self): st += self._pretty_trailer() return st +# Inverse of gr-iridium's decode_deqpsk(), so callers can recover the original +# coherent QPSK symbols from a .bits capture. gr-iridium applies diff-decode to +# every burst unconditionally, which is correct for Block1 DEQPSK but wrong for +# coherent-QPSK Certus. Since check_sync_word() in gr-iridium has already +# validated the unique word against the coherent symbols (phase locked via PLL), +# the diff-decode is a deterministic bijection and invertible from the bits alone. +# +# Forward (gr-iridium decode_deqpsk): out[i] = _DEQ_FWD[ (s[i] - s[i-1]) % 4 ] +# Inverse: s[i] = (s[i-1] + _DEQ_INV[out[i]]) % 4 +_DEQ_FWD = (0, 2, 3, 1) +_DEQ_INV = tuple(_DEQ_FWD.index(x) for x in range(4)) # (0, 3, 1, 2) + +def un_deqpsk(bits, seed_symbol=0): + """Invert decode_deqpsk on a bit string (2 bits per symbol, MSB-first). + seed_symbol is the coherent symbol that preceded the first bit pair - for + payload bits stripped of the UW, this is the last UW coherent symbol (=2 + for UW_DL and UW_UL, both of which end with symbol 2). + Returns a bit string of the same length representing coherent QPSK symbols. + """ + old = seed_symbol + out = [] + for i in range(0, len(bits) - 1, 2): + sym_out = (int(bits[i]) << 1) | int(bits[i+1]) + diff = _DEQ_INV[sym_out] + s = (old + diff) & 3 + out.append('1' if s & 2 else '0') + out.append('1' if s & 1 else '0') + old = s + if len(bits) & 1: + out.append(bits[-1]) # stray bit pass-through (shouldn't happen) + return ''.join(out) + +class IridiumCertusMessage(IridiumMessage): + # Iridium Certus (NEXT / EBBS) traffic burst - coherent QPSK + Turbo-coded. + # Identified by symbol count on the simplex DL band: + # C1 = 200 symbols, NEXT C1 30ksps QPSK 4/5 (320 payload bits) + # C2 = 432 symbols, NEXT C2 60ksps QPSK 2/3 (576 payload bits) + # C8 = 1824 symbols, NEXT C8 240ksps QPSK 2/3 or 16APSK 2/3 (2432 or 4848 payload bits) + # + # gr-iridium emits diff-decoded bits that are correct for Block1 DEQPSK but + # wrong for these. We recover the coherent QPSK symbols in un_deqpsk() and + # expose them as the payload. Turbo decoding of the resulting stream is left + # as future work (requires the Iridium-specific interleaver, puncturing + # pattern, and Turbo polynomial, none of which are publicly documented). + def __init__(self, imsg): + self.__dict__ = imsg.__dict__ + # Seed from the last coherent UW symbol (UW_DL[11] = UW_UL[11] = 2). + self.coherent = un_deqpsk(self.descrambled, seed_symbol=2) + + def upgrade(self): + if self.error: return self + return self + + def pretty(self): + st = "I" + self.msgtype + ": " + self._pretty_header() # "IC1:", "IC2:", "IC8:" + # Emit coherent QPSK bits (recovered), grouped per symbol-pair for + # readability. These are the actual transmitted QPSK symbols, + # pre-Turbo-decoded, in the 2-bits-per-symbol form Iridium uses. + st += " coh:" + group(self.coherent, 16) + st += self._pretty_trailer() + return st + def symbol_reverse(bits): r = bytearray(bits.encode("us-ascii")) for x in range(0,len(r)-1,2): From 85b4fbcfb5629a7947680d6a3dcbc3a564f1135a Mon Sep 17 00:00:00 2001 From: Dominik Bay Date: Wed, 15 Apr 2026 12:41:09 +0200 Subject: [PATCH 19/20] [parser] guard Certus C1/C2/C8 classification on self.next Previously our IC1/IC2/IC8 detectors fired on any simplex-DL frame with matching payload symbol count, including frames using iridium_access sync. That mislabeled legacy IRA/VOC/IME bursts that happened to have 200/432/1824 payload symbols. Now only frames with self.next=True (matched next_access_dl or NC1: prefix) are classified as Certus. Confirmed on a 20k-line sample: former IC2=328 false positives correctly redistribute back to IRA (+95), INP (+72), IME (+9), IRI (+3), RAW (+149). --- bitsparser.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bitsparser.py b/bitsparser.py index e9e8b7d..c8b9007 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -299,10 +299,10 @@ def __init__(self,msg): return # Certus (EBBS / NEXT) traffic burst recognition. Classify by symbol count on the - # simplex DL band - the bits themselves are not meaningful (wrong demod path for - # coherent QPSK + Turbo) but labeling them prevents the frame dropping to "unknown". - if "msgtype" not in self.__dict__ and (not args.freqclass or self.frequency > f_simplex) and not (args.freqclass and self.uplink): - # symbols field counts total symbols including the 12-symbol UW; subtract it for payload length. + # simplex DL band. Guarded on self.next so we only claim frames that used + # next_access_dl sync - stops us stealing IRA/VOC bursts that happen to have + # matching symbol counts on iridium_access. + if "msgtype" not in self.__dict__ and self.next and (not args.freqclass or self.frequency > f_simplex) and not (args.freqclass and self.uplink): payload_syms = self.symbols - (len(iridium_access) // 2) if payload_syms == 200: self.msgtype="C1" # NEXT C1 30ksps QPSK 4/5, 320 payload bits @@ -452,7 +452,8 @@ def __init__(self,msg): self.msgtype="MS" # try Certus traffic bursts by length alone under --harder - if "msgtype" not in self.__dict__ and not (args.freqclass and self.uplink): + # guarded on self.next so we don't steal IRA/VOC bursts on iridium_access sync + if "msgtype" not in self.__dict__ and self.next and not (args.freqclass and self.uplink): payload_syms = self.symbols - (len(iridium_access) // 2) if payload_syms == 200: self.ec_lcw=1 From 3b00dfdafc4b0b0b79e87046245d509737517fc0 Mon Sep 17 00:00:00 2001 From: Dominik Bay Date: Wed, 15 Apr 2026 19:58:36 +0200 Subject: [PATCH 20/20] [parser] INP: expose heuristic sv_id/beam_id split of source_id The 14-bit source_id printed as :NNNNN in the frame header partitions cleanly as [sv_id:8][beam_id:6] across the captures we have looked at (sv_id = source_id >> 6, beam_id = source_id & 0x3f). Expose the split on the pretty() line as "sv?:NNN bm?:NN" (the "?" markers flag the fields as heuristic - some hdr[0] values span many sv_ids, so the formula is not universally validated). FORMAT.md: expand the INP section with H:1 / H:2 layouts and document the source_id split heuristic. --- FORMAT.md | 18 +++++++++++++++++- bitsparser.py | 10 +++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/FORMAT.md b/FORMAT.md index 4477062..c2c84c0 100644 --- a/FORMAT.md +++ b/FORMAT.md @@ -178,7 +178,23 @@ Notes: ### INP: "new packet" -Work in progress. +Work in progress. Simplex-DL burst format distinct from Block1 channels, seen in captures on 1626.40-1626.52 MHz. Each burst carries a 96-bit header (three 32-bit hdr words), up to 18 data blocks of 40 bits, and a 24-bit trailer. + +Header variants (selected by `hdr[1][4:8]`): + +- `H:1` (`hdr_id in {1000, 0111}`): `[hdr0:32][hdr1:32][data:16][CRC8][tail:8]`. CRC scope = 80 bits. +- `H:2` (`hdr_id == 0110`): `[hdr0:32][hdr1:32][CRC8][tail:24]`. CRC scope = 72 bits. Source IDs observed so far are disjoint from H:1, suggesting a distinct beam/sat role. +- `H:0`: any other `hdr_id`, layout not yet resolved. + +Types v1/v2/v3 depend on which CRC variant validates; see `IridiumNPMessage` for the exact logic. + +#### source_id split heuristic + +The `:NNNNN` value after the frame header is a 14-bit `source_id` extracted from `s2s[0][10:24]`. In our captures it partitions cleanly as `[sv_id:8][beam_id:6]`, i.e. `sv_id = source_id >> 6` and `beam_id = source_id & 0x3f`. This split is heuristic and not universally validated - some `hdr[0]` values span many sv_ids - so the fields are printed with a `?` marker: + + INP: ... :04294 sv?:067 bm?:06 H:2 T:0 h<...> + +Consumers should treat `sv?` / `bm?` as best-effort interpretation, not authoritative decode. ### IC1 / IC2 / IC8: Iridium Certus (NEXT / EBBS) traffic bursts diff --git a/bitsparser.py b/bitsparser.py index c8b9007..99d89fb 100755 --- a/bitsparser.py +++ b/bitsparser.py @@ -994,7 +994,14 @@ def __init__(self,imsg): if all(checks[:-3]) and not any(checks[-3:]): self.type=3 - self.header+=":%05d"%(int(s2s[0][10:24],2)) + # source_id: 14-bit field at s2s[0][10:24]. In captures the value + # partitions as [sv_id:8][beam_id:6]; the split is heuristic (partial + # empirical support only), so pretty() marks both fields with "?" + # to discourage treating them as authoritative. + self.source_id=int(s2s[0][10:24],2) + self.sv_id_guess=(self.source_id>>6) & 0xff + self.beam_id_guess=self.source_id & 0x3f + self.header+=":%05d"%self.source_id return def upgrade(self): @@ -1003,6 +1010,7 @@ def upgrade(self): def pretty(self): st= "INP: "+self._pretty_header() + st+= " sv?:%03d bm?:%02d"%(self.sv_id_guess, self.beam_id_guess) st+= " H:%d"%self.hdr_type st+= " T:%d"%self.type