Much of what a scanner finds is not speech. Doorbells, tyre-pressure sensors, weather stations, remote controls, paging and packet radio all carry something a receiver can read, and until now the answer was "OOK / ASK data burst" and a WAV file. Now the bits come out. The observation the whole thing is built on is that whatever the modulation, a data signal is the same shape once it has been sliced: a train of alternating runs whose lengths carry the information. On-off keying gives that directly -- the carrier is up or it is down -- and two-level FSK gives exactly the same thing from the discriminator, one tone or the other. So both reduce to a run-length train and everything after that is shared. What the runs mean is the line code, and it is worked out from the runs alone rather than configured, because each code makes a different prediction about which of the two histograms is the bimodal one: PWM (EV1527, PT2262, and nearly every 433 MHz remote), PPM, Manchester, and plain NRZ. Four-level FSK is recognised as such and read as symbols rather than sliced down the middle, which produces bits that mean nothing; where a frame sync word appears the system is named outright. Two protocols carry their own framing and checksums and so are read in full. POCSAG paging: all three rates tried because nothing in the signal says which it is, every codeword checked and single-bit errors corrected against the BCH code, and the address, function letter and message text reported. AX.25 as APRS uses it: the frame check has to come out right before a frame is reported at all, and the sender's callsign goes onto the map with everyone else's. The hard half is refusing what is not data. Noise sliced at a threshold produces runs and runs produce bits, so three things guard against it: the runs have to quantise to the line code's own grid; most of the bursts in a capture have to decode the same way, because one lucky window in eight is a coincidence and that is exactly what SSB voice produced; and, much the strongest, the packet has to repeat, because bits that come back identical six times did not come from noise. A reading with none of that behind it is reported as nothing at all rather than as a bit string with a low number beside it that somebody will read anyway. Across 27 recordings of speech, music, static, a bare carrier, Morse and PSK it returns nothing 27 times. A firm decode also outranks the content check, which is statistical: a burst of keying demodulated as FM audio is a buzz and the speech detector likes a buzz, but a frame whose own checksum came out right is not a statistic. Such a capture is kept and filed as data, not as voice. What comes out is written to a _data.txt beside the recording, shown on the live display and in the line-per-hit output, and takes the place of the transcript at the top of saunterbrowse -- where it is searchable, so "which page mentioned engine 4" is a question that can be asked. `bandsaunter analyze` decodes a file you already have. The simulator gained two honest transmitters to test against: a pulse-width remote that repeats a real payload, and a pager that sends real POCSAG batches with real BCH check bits. Random keying exercises the classifier but leaves a decoder nothing to get right. The POCSAG encoder lives next to the decoder rather than in the test helpers, so a bug shared by both cannot hide. Fixed along the way: - Rich reads a square bracket as markup, and a decoded page is arbitrary text off the air. "[/x]" in a message ended the live display with a MarkupError; so did typing "[/" at saunterbrowse's search prompt. Everything that did not come from this program is escaped now. - Otsu returned the first bin of a plateau. Two populations with nothing between them -- silence and full carrier, which is what on-off keying is -- make every threshold in the gap equally good, and taking the first put it hard against the lower population with the hysteresis band outside the data entirely, so nothing sliced at all. - Estimating the symbol clock by counting along a cumulative grid is a fixed point: a unit two per cent small produces two per cent more symbols and reproduces itself exactly. Rounding each run on its own converges instead, because every run votes independently. The grid is then the right way to extract the bits, where rounding runs one at a time drifts. - A clipped first repeat used to truncate every other repeat to its length. The consensus is taken over the commonest length now. 761 -> 869 tests.
453 lines
16 KiB
Python
453 lines
16 KiB
Python
"""Data protocols that carry their own framing, and so can be read outright.
|
|
|
|
The generic decoder in :mod:`bandsaunter.decode` recovers bits and has to
|
|
argue about whether they are real. The protocols here do not have that
|
|
problem: each has a sync word to find and a checksum to verify, so a frame
|
|
either passes or it does not, and one that passes is not a guess.
|
|
|
|
Two are implemented, because between them they cover most of what an ordinary
|
|
receiver actually hears carrying words rather than measurements:
|
|
|
|
``POCSAG``
|
|
Paging. Still in daily use by hospitals, fire services and industrial
|
|
plant long after the consumer pagers went away, and it carries plain
|
|
text. 512, 1200 or 2400 baud two-level FSK.
|
|
|
|
``AX.25 / APRS``
|
|
Amateur packet. 1200 baud AFSK on VHF, and the payload is position
|
|
reports, weather and messages -- with the sender's callsign in the
|
|
header, which the map already knows what to do with.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import numpy as np
|
|
|
|
from .decode import DataDecode, PulseTrain, nrz_bits, slice_fsk
|
|
|
|
__all__ = ["decode_pocsag", "decode_ax25", "PROTOCOLS", "AX25Frame",
|
|
"POCSAG_BAUDS", "SYNC_WORD", "MAX_ADDRESS", "pocsag_bits",
|
|
"pocsag_codeword"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _find(bits: str, pattern: str, start: int = 0) -> int:
|
|
return bits.find(pattern, start)
|
|
|
|
|
|
def _invert(bits: str) -> str:
|
|
return bits.translate(str.maketrans("01", "10"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POCSAG
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# The frame synchronisation codeword, sent before each batch of sixteen.
|
|
SYNC_WORD = 0x7CD215D8
|
|
SYNC_BITS = f"{SYNC_WORD:032b}"
|
|
|
|
# The three rates in the standard. Which one a transmitter uses is not
|
|
# announced anywhere in the signal, so all three are tried and the one whose
|
|
# sync word appears is the right one -- a 32-bit pattern does not turn up in
|
|
# the wrong reading by accident.
|
|
POCSAG_BAUDS: tuple[float, ...] = (1200.0, 512.0, 2400.0)
|
|
|
|
# BCH(31,21) with an even parity bit, which is what protects each codeword.
|
|
_BCH_POLY = 0b11101101001 # x^10 + x^9 + x^8 + x^6 + x^5 + x^3 + 1
|
|
|
|
_IDLE = 0x7A89C197
|
|
|
|
# The 20 data bits of a message codeword are packed end to end and then read
|
|
# out either as 7-bit ASCII or as 4-bit digits, depending on the pager.
|
|
_NUMERIC = "0123456789*U -)("
|
|
|
|
|
|
def _bch_syndrome(word: int) -> int:
|
|
"""Zero when the codeword's BCH check passes."""
|
|
remainder = word >> 1 # drop the parity bit
|
|
for shift in range(30, 9, -1):
|
|
if remainder & (1 << shift):
|
|
remainder ^= _BCH_POLY << (shift - 10)
|
|
return remainder & 0x3FF
|
|
|
|
|
|
def _parity_ok(word: int) -> bool:
|
|
return bin(word).count("1") % 2 == 0
|
|
|
|
|
|
def _correct(word: int) -> tuple[int, bool]:
|
|
"""Check a codeword, correcting a single bit error if there is one.
|
|
|
|
Worth doing rather than discarding: one bad bit in thirty-two is exactly
|
|
what a fading paging signal delivers, and the BCH code was put there to
|
|
survive it.
|
|
"""
|
|
if _bch_syndrome(word) == 0 and _parity_ok(word):
|
|
return word, True
|
|
for bit in range(32):
|
|
trial = word ^ (1 << bit)
|
|
if _bch_syndrome(trial) == 0 and _parity_ok(trial):
|
|
return trial, True
|
|
return word, False
|
|
|
|
|
|
def _pocsag_text(payload: str) -> tuple[str, str]:
|
|
"""Read a run of message bits as text and as digits.
|
|
|
|
Both, because nothing in the message says which it is: an alphanumeric
|
|
pager sends 7-bit ASCII least significant bit first, a numeric one sends
|
|
4-bit digits, and the only way to tell is to look at what comes out.
|
|
"""
|
|
letters = []
|
|
for i in range(0, len(payload) - 6, 7):
|
|
chunk = payload[i:i + 7]
|
|
code = int(chunk[::-1], 2) # LSB first on the air
|
|
letters.append(chr(code) if 32 <= code < 127 else
|
|
("\n" if code in (10, 13) else "."))
|
|
digits = []
|
|
for i in range(0, len(payload) - 3, 4):
|
|
code = int(payload[i:i + 4][::-1], 2)
|
|
digits.append(_NUMERIC[code])
|
|
return "".join(letters).rstrip(". \n"), "".join(digits).rstrip(" ")
|
|
|
|
|
|
def _readable(text: str) -> float:
|
|
"""What share of a string is characters a message would really contain."""
|
|
if not text:
|
|
return 0.0
|
|
good = sum(1 for c in text if c.isalnum() or c in " .,:;/-+()@#'\"!?\n")
|
|
return good / len(text)
|
|
|
|
|
|
def _pocsag_batches(bits: str) -> list[tuple[int, str, str]]:
|
|
"""Every address and message in a bit stream.
|
|
|
|
Returns ``(address, function, text)``. Frames are read from each sync
|
|
word independently, so a stream that loses lock partway through still
|
|
yields everything before and after it.
|
|
"""
|
|
out: list[tuple[int, str, str]] = []
|
|
pos = 0
|
|
pending_address: int | None = None
|
|
pending_function = 0
|
|
pending_bits: list[str] = []
|
|
|
|
def flush():
|
|
if pending_address is None:
|
|
return
|
|
payload = "".join(pending_bits)
|
|
text, digits = _pocsag_text(payload)
|
|
# Whichever reading looks more like a message someone would send.
|
|
chosen = text if _readable(text) >= 0.75 and len(text) >= 2 else digits
|
|
# Function bits 00..11 are the pager's four addresses, written A to
|
|
# D by everyone who documents them.
|
|
out.append((pending_address, "ABCD"[pending_function & 3],
|
|
chosen.strip()))
|
|
|
|
while True:
|
|
at = _find(bits, SYNC_BITS, pos)
|
|
if at < 0:
|
|
break
|
|
pos = at + 32
|
|
for frame in range(8):
|
|
for half in range(2):
|
|
start = pos + (frame * 2 + half) * 32
|
|
if start + 32 > len(bits):
|
|
pos = len(bits)
|
|
break
|
|
word = int(bits[start:start + 32], 2)
|
|
if word == _IDLE:
|
|
flush()
|
|
pending_address, pending_bits = None, []
|
|
continue
|
|
word, valid = _correct(word)
|
|
if not valid:
|
|
continue
|
|
if word & 0x80000000:
|
|
# A message codeword: twenty bits of payload.
|
|
if pending_address is not None:
|
|
pending_bits.append(f"{word:032b}"[1:21])
|
|
continue
|
|
flush()
|
|
# An address codeword. The low three bits of the address are
|
|
# not sent: they are which of the eight frames it arrived in.
|
|
pending_address = ((word >> 13) & 0x3FFFF) << 3 | frame
|
|
pending_function = (word >> 11) & 0x3
|
|
pending_bits = []
|
|
else:
|
|
continue
|
|
break
|
|
pos += 16 * 32
|
|
flush()
|
|
return out
|
|
|
|
|
|
def decode_pocsag(x: np.ndarray, sample_rate: float,
|
|
baud_hint: float = 0.0) -> DataDecode | None:
|
|
"""Read POCSAG paging out of a two-level FSK signal."""
|
|
train = slice_fsk(x, sample_rate)
|
|
if train is None or len(train) < 16:
|
|
return None
|
|
order = list(POCSAG_BAUDS)
|
|
if baud_hint:
|
|
order.sort(key=lambda b: abs(b - baud_hint))
|
|
for baud in order:
|
|
raw = nrz_bits(train, baud)
|
|
if len(raw) < 64:
|
|
continue
|
|
for bits in (raw, _invert(raw)):
|
|
if SYNC_BITS not in bits:
|
|
continue
|
|
pages = _pocsag_batches(bits)
|
|
if not pages:
|
|
continue
|
|
# A capture usually spans several batches and a transmitter
|
|
# repeats its queue, so the same page arrives more than once.
|
|
# Reported once, in the order it first appeared.
|
|
messages: list[str] = []
|
|
for address, function, text in pages:
|
|
line = f"[{address:07d}{function}]"
|
|
line = f"{line} {text}" if text else line
|
|
if line not in messages:
|
|
messages.append(line)
|
|
syncs = bits.count(SYNC_BITS)
|
|
return DataDecode(
|
|
ok=True, protocol=f"POCSAG {baud:.0f}", encoding="NRZ",
|
|
baud=baud, bits=bits[:256], repeats=syncs,
|
|
agreement=1.0 if syncs > 1 else 0.0,
|
|
checks=["BCH(31,21)"], messages=messages,
|
|
confidence=min(0.97, 0.72 + 0.05 * len(pages)
|
|
+ 0.05 * min(3, syncs)))
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AX.25 over Bell 202 AFSK -- APRS and amateur packet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MARK_HZ = 1200.0
|
|
SPACE_HZ = 2200.0
|
|
AFSK_BAUD = 1200.0
|
|
_FLAG = "01111110"
|
|
|
|
|
|
class AX25Frame:
|
|
"""One AX.25 frame whose frame-check sequence was correct."""
|
|
|
|
def __init__(self, raw: bytes):
|
|
self.raw = raw
|
|
self.source = ""
|
|
self.destination = ""
|
|
self.path: list[str] = []
|
|
self.info = ""
|
|
self._parse()
|
|
|
|
def _parse(self) -> None:
|
|
addresses = []
|
|
i = 0
|
|
while i + 7 <= len(self.raw) and len(addresses) < 10:
|
|
field = self.raw[i:i + 7]
|
|
call = "".join(chr(b >> 1) for b in field[:6]).strip()
|
|
ssid = (field[6] >> 1) & 0x0F
|
|
addresses.append(f"{call}-{ssid}" if ssid else call)
|
|
i += 7
|
|
if field[6] & 0x01: # the end-of-address bit
|
|
break
|
|
if len(addresses) >= 2:
|
|
self.destination, self.source = addresses[0], addresses[1]
|
|
self.path = addresses[2:]
|
|
# Skip the control and protocol-identifier bytes.
|
|
body = self.raw[i + 2:] if len(self.raw) > i + 2 else b""
|
|
self.info = body.decode("ascii", "replace").rstrip("\r\n")
|
|
|
|
def describe(self) -> str:
|
|
route = ">".join([self.source or "?", self.destination or "?"]
|
|
+ self.path)
|
|
return f"{route}: {self.info}" if self.info else route
|
|
|
|
|
|
def _fcs(data: bytes) -> int:
|
|
"""The AX.25 frame check: CRC-16/X.25, reflected, inverted at the end."""
|
|
crc = 0xFFFF
|
|
for byte in data:
|
|
crc ^= byte
|
|
for _ in range(8):
|
|
crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1
|
|
return crc ^ 0xFFFF
|
|
|
|
|
|
def _afsk_symbols(x: np.ndarray, sample_rate: float) -> np.ndarray | None:
|
|
"""Turn Bell 202 audio into a two-level signal, mark against space.
|
|
|
|
A correlator rather than a discriminator: the two tones are close enough
|
|
together, and radio audio distorted enough, that measuring which of the
|
|
two a bit-length window contains more of works where measuring the
|
|
instantaneous frequency does not.
|
|
"""
|
|
x = np.asarray(x)
|
|
if np.iscomplexobj(x):
|
|
# FM first: AFSK is audio, and on VHF it arrives inside an FM carrier.
|
|
phase = np.unwrap(np.angle(x))
|
|
audio = np.diff(phase)
|
|
else:
|
|
audio = x.astype(np.float64)
|
|
if audio.size < int(sample_rate / 100.0):
|
|
return None
|
|
audio = audio - float(audio.mean())
|
|
window = int(round(sample_rate / AFSK_BAUD))
|
|
if window < 4:
|
|
return None
|
|
n = np.arange(window)
|
|
out = []
|
|
for tone in (MARK_HZ, SPACE_HZ):
|
|
arg = 2.0 * math.pi * tone * n / sample_rate
|
|
i = np.convolve(audio, np.cos(arg)[::-1], mode="same")
|
|
q = np.convolve(audio, np.sin(arg)[::-1], mode="same")
|
|
out.append(np.hypot(i, q))
|
|
return out[0] - out[1]
|
|
|
|
|
|
def _hdlc_frames(bits: str) -> list[bytes]:
|
|
"""Split a bit stream at HDLC flags and undo the bit stuffing."""
|
|
frames: list[bytes] = []
|
|
pos = bits.find(_FLAG)
|
|
if pos < 0:
|
|
return frames
|
|
while True:
|
|
# Flags repeat back to back between frames; skip past all of them.
|
|
while bits.startswith(_FLAG, pos):
|
|
pos += 8
|
|
end = bits.find(_FLAG, pos)
|
|
if end < 0:
|
|
break
|
|
body = bits[pos:end]
|
|
pos = end
|
|
if len(body) < 8 * 17: # shorter than an empty AX.25 frame
|
|
continue
|
|
# Undo stuffing: a zero inserted after every five ones.
|
|
out, ones = [], 0
|
|
for bit in body:
|
|
if ones == 5:
|
|
ones = 0
|
|
if bit == "0":
|
|
continue # the stuffed bit
|
|
out.append(bit)
|
|
ones = ones + 1 if bit == "1" else 0
|
|
clean = "".join(out)
|
|
whole = len(clean) - len(clean) % 8
|
|
# Least significant bit first on the air.
|
|
frames.append(bytes(int(clean[i:i + 8][::-1], 2)
|
|
for i in range(0, whole, 8)))
|
|
return frames
|
|
|
|
|
|
def decode_ax25(x: np.ndarray, sample_rate: float,
|
|
baud_hint: float = 0.0) -> DataDecode | None:
|
|
"""Read AX.25 packet, as APRS uses it, from 1200 baud AFSK."""
|
|
soft = _afsk_symbols(x, sample_rate)
|
|
if soft is None:
|
|
return None
|
|
levels = soft > 0
|
|
edges = np.flatnonzero(np.diff(levels.astype(np.int8))) + 1
|
|
if edges.size < 16:
|
|
return None
|
|
starts = np.concatenate(([0], edges))
|
|
ends = np.concatenate((edges, [levels.size]))
|
|
train = PulseTrain(levels[starts], (ends - starts).astype(np.int64),
|
|
float(sample_rate), "fsk")
|
|
raw = nrz_bits(train, AFSK_BAUD)
|
|
if len(raw) < 200:
|
|
return None
|
|
|
|
frames: list[AX25Frame] = []
|
|
for stream in (raw, _invert(raw)):
|
|
# NRZI: the data is in whether the level changed, not what it is.
|
|
decoded = ["1" if a == b else "0"
|
|
for a, b in zip(stream, stream[1:])]
|
|
for frame in _hdlc_frames("".join(decoded)):
|
|
if len(frame) < 17 or _fcs(frame[:-2]) != \
|
|
(frame[-1] << 8 | frame[-2]):
|
|
continue
|
|
frames.append(AX25Frame(frame[:-2]))
|
|
if frames:
|
|
break
|
|
if not frames:
|
|
return None
|
|
return DataDecode(
|
|
ok=True, protocol="AX.25 / APRS", encoding="NRZI",
|
|
baud=AFSK_BAUD, bits=raw[:256], repeats=len(frames),
|
|
agreement=1.0, checks=["FCS (CRC-16/X.25)"],
|
|
messages=[f.describe() for f in frames],
|
|
confidence=min(0.98, 0.85 + 0.04 * len(frames)))
|
|
|
|
|
|
# Tried in order; each returns None when the signal is not its own.
|
|
PROTOCOLS = (decode_pocsag, decode_ax25)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Encoding POCSAG, for the simulator and the tests
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Kept next to the decoder rather than in the test helpers so the two cannot
|
|
# drift apart: a bug shared by an encoder and its decoder is invisible, and
|
|
# the way to avoid one is to have exactly one copy of the polynomial, the
|
|
# sync word and the idle word for both to use.
|
|
|
|
def pocsag_codeword(payload: int) -> int:
|
|
"""Add the BCH check bits and the parity bit to 21 bits of payload."""
|
|
word = (payload & 0x1FFFFF) << 10
|
|
remainder = word
|
|
for shift in range(30, 9, -1):
|
|
if remainder & (1 << shift):
|
|
remainder ^= _BCH_POLY << (shift - 10)
|
|
word = (word | (remainder & 0x3FF)) << 1
|
|
return word | (1 if bin(word).count("1") % 2 else 0)
|
|
|
|
|
|
# Eighteen bits go out on the air and three more are implied by which frame
|
|
# the codeword arrived in, so this is the whole address space.
|
|
MAX_ADDRESS = (1 << 21) - 1
|
|
|
|
|
|
def pocsag_bits(pages, preamble: int = 600) -> str:
|
|
"""A complete POCSAG transmission: preamble, then batches of sixteen.
|
|
|
|
Raises on an address that will not fit rather than truncating it: a
|
|
stream built around a silently mangled address decodes to a different
|
|
pager, which is worse than not building one.
|
|
"""
|
|
for address, _, _ in pages:
|
|
if not 0 <= address <= MAX_ADDRESS:
|
|
raise ValueError(
|
|
f"POCSAG address {address} is outside 0-{MAX_ADDRESS}")
|
|
out = ["10" * (preamble // 2)]
|
|
batch: list[int] = []
|
|
|
|
def flush() -> None:
|
|
if not batch:
|
|
return
|
|
while len(batch) < 16:
|
|
batch.append(_IDLE)
|
|
out.append(SYNC_BITS + "".join(f"{w:032b}" for w in batch[:16]))
|
|
batch.clear()
|
|
|
|
for address, function, text in pages:
|
|
frame = address & 0x7
|
|
while len(batch) < frame * 2:
|
|
batch.append(_IDLE)
|
|
batch.append(pocsag_codeword(((address >> 3) << 2) | (function & 3)))
|
|
payload = "".join(f"{ord(c):07b}"[::-1] for c in text)
|
|
payload += "0" * (-len(payload) % 20)
|
|
for i in range(0, len(payload), 20):
|
|
batch.append(pocsag_codeword((1 << 20) | int(payload[i:i + 20], 2)))
|
|
if len(batch) >= 16:
|
|
flush()
|
|
flush()
|
|
return "".join(out)
|