Decode data signals, starting with on-off keying
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.
This commit is contained in:
parent
fb2bb3344b
commit
68b05a031c
19 changed files with 3176 additions and 23 deletions
656
tests/test_decode.py
Normal file
656
tests/test_decode.py
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
"""Reading the data out of a data signal.
|
||||
|
||||
Two things have to be true of a decoder and they pull against each other: it
|
||||
has to read a real packet correctly, and it has to refuse a signal that is not
|
||||
a packet. The second is the harder one -- noise sliced at a threshold makes
|
||||
runs, and runs make bits -- so about half of what is here is signals that must
|
||||
come back with nothing.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import signals # noqa: E402
|
||||
from bandsaunter import decode as D # noqa: E402
|
||||
from bandsaunter.decode import (bits_to_hex, check_crc, decode_data,
|
||||
decode_four_level, level_count, nrz_bits,
|
||||
slice_fsk, slice_ook, split_bursts)
|
||||
from bandsaunter.protocols import (POCSAG_BAUDS, SYNC_BITS, decode_ax25,
|
||||
decode_pocsag, pocsag_bits,
|
||||
pocsag_codeword)
|
||||
|
||||
PAYLOAD = "101100100011010101001110" # 24 bits, the usual remote
|
||||
|
||||
|
||||
def _random_bits(n, seed=7):
|
||||
return "".join(np.random.default_rng(seed).choice(list("01"), n))
|
||||
|
||||
|
||||
def _contains(truth: str, got: str, window: int = 120) -> bool:
|
||||
"""True if a long stretch of the truth is in the decode, either polarity.
|
||||
|
||||
Either polarity because nothing in an unframed stream says which level is
|
||||
a one; a protocol's own sync word settles it, and these have none.
|
||||
"""
|
||||
flipped = got.translate(str.maketrans("01", "10"))
|
||||
middle = truth[len(truth) // 4:len(truth) // 4 + window]
|
||||
return middle in got or middle in flipped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slicing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_on_off_keying_slices_into_runs():
|
||||
train = slice_ook(signals.ook_pwm(PAYLOAD), 50_000.0)
|
||||
assert train is not None
|
||||
assert len(train) > 50
|
||||
assert train.contrast_db > 10.0
|
||||
|
||||
|
||||
def test_a_signal_that_is_never_keyed_has_no_runs():
|
||||
"""A steady carrier is not on-off keyed, whatever a threshold would do."""
|
||||
assert slice_ook(signals.make("carrier", n=32000), 32_000.0) is None
|
||||
|
||||
|
||||
def test_two_level_fsk_slices_the_same_way_as_keying():
|
||||
"""The point of the design: after slicing, FSK and OOK are one problem."""
|
||||
train = slice_fsk(signals.fsk_nrz(_random_bits(400)), 48_000.0,
|
||||
baud_hint=1200.0)
|
||||
assert train is not None
|
||||
assert train.source == "fsk"
|
||||
assert len(train) > 100
|
||||
|
||||
|
||||
def test_a_glitch_shorter_than_a_symbol_is_absorbed():
|
||||
"""One sample the wrong side of the threshold must not become two runs."""
|
||||
levels = np.array([True, False, True, False, True], dtype=bool)
|
||||
lengths = np.array([40, 1, 39, 80, 40], dtype=np.int64)
|
||||
out_levels, out_lengths = D._despeckle(levels, lengths, minimum=4)
|
||||
assert out_lengths.tolist() == [80, 80, 40]
|
||||
assert out_levels.tolist() == [True, False, True]
|
||||
|
||||
|
||||
def test_bursts_are_cut_at_the_silence_between_repeats():
|
||||
train = slice_ook(signals.ook_pwm(PAYLOAD, repeats=4), 50_000.0)
|
||||
bursts = split_bursts(train)
|
||||
assert len(bursts) == 4
|
||||
for burst in bursts:
|
||||
# Trimmed to start and end on a pulse: the silence either side
|
||||
# belongs to the gap between packets, not to the packet.
|
||||
assert burst.levels[0] and burst.levels[-1]
|
||||
|
||||
|
||||
def test_a_continuous_stream_is_never_cut_into_bursts():
|
||||
"""Long runs of one tone are data, not the silence between packets."""
|
||||
train = slice_fsk(signals.fsk_nrz(_random_bits(600)), 48_000.0,
|
||||
baud_hint=1200.0)
|
||||
assert len(split_bursts(train)) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The line codes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("build,encoding,exact", [
|
||||
(lambda: signals.ook_pwm(PAYLOAD), "PWM", True),
|
||||
(lambda: signals.ook_pwm(PAYLOAD, fixed_gap=350e-6), "PWM", True),
|
||||
(lambda: signals.ook_ppm(PAYLOAD), "PPM", False),
|
||||
(lambda: signals.ook_manchester(PAYLOAD), "Manchester", True),
|
||||
])
|
||||
def test_each_line_code_is_recognised_and_read(build, encoding, exact):
|
||||
got = decode_data(build(), 50_000.0, family="ook")
|
||||
assert got.ok, got.note
|
||||
assert got.encoding == encoding
|
||||
if exact:
|
||||
assert PAYLOAD in got.bits
|
||||
else:
|
||||
# A gap-length code loses its final bit: that gap ran into the
|
||||
# silence before the next repeat and is no longer separable from it.
|
||||
assert PAYLOAD[:-1] in got.bits
|
||||
|
||||
|
||||
def test_a_pulse_width_code_keeps_its_last_bit():
|
||||
got = decode_data(signals.ook_pwm(PAYLOAD), 50_000.0, family="ook")
|
||||
assert got.bits == PAYLOAD
|
||||
|
||||
|
||||
def test_the_symbol_rate_is_measured_not_guessed():
|
||||
# 350 us units, four to a bit: 714 bits per second.
|
||||
got = decode_data(signals.ook_pwm(PAYLOAD), 50_000.0, family="ook")
|
||||
assert got.baud == pytest.approx(714, rel=0.05)
|
||||
|
||||
|
||||
def test_manchester_reports_the_data_rate_not_the_cell_rate():
|
||||
"""Two cells go out for every bit; the link is not twice as fast."""
|
||||
got = decode_data(signals.ook_manchester(PAYLOAD, baud=2000.0),
|
||||
50_000.0, family="ook")
|
||||
assert got.baud == pytest.approx(2000, rel=0.06)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("baud,fs", [(512.0, 32_000.0), (1200.0, 48_000.0),
|
||||
(2400.0, 48_000.0), (4800.0, 96_000.0)])
|
||||
def test_plain_nrz_comes_back_bit_for_bit(baud, fs):
|
||||
truth = _random_bits(400, seed=int(baud) % 97)
|
||||
got = decode_data(signals.fsk_nrz(truth, fs=fs, baud=baud), fs,
|
||||
family="fsk", baud_hint=baud)
|
||||
assert got.ok, got.note
|
||||
assert got.baud == pytest.approx(baud, rel=0.02)
|
||||
assert _contains(truth, got.bits), "the bits drifted"
|
||||
|
||||
|
||||
def test_a_clock_a_shade_out_does_not_drift_across_a_long_frame():
|
||||
"""Six hundred bits is where a quarter of a per cent of error shows up."""
|
||||
truth = _random_bits(600, seed=13)
|
||||
got = decode_data(signals.fsk_nrz(truth, fs=50_000.0, baud=1200.0),
|
||||
50_000.0, family="fsk")
|
||||
assert got.n_bits == pytest.approx(600, abs=2)
|
||||
assert _contains(truth, got.bits, window=400)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repeats, which are what make a decode believable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_repeats_are_counted_and_have_to_agree():
|
||||
got = decode_data(signals.ook_pwm(PAYLOAD, repeats=6), 50_000.0,
|
||||
family="ook")
|
||||
assert got.repeats >= 4
|
||||
assert got.agreement > 0.95
|
||||
assert got.confidence > 0.85
|
||||
|
||||
|
||||
def test_one_lonely_packet_is_believed_less_than_six():
|
||||
once = decode_data(signals.ook_pwm(PAYLOAD, repeats=1), 50_000.0,
|
||||
family="ook")
|
||||
often = decode_data(signals.ook_pwm(PAYLOAD, repeats=6), 50_000.0,
|
||||
family="ook")
|
||||
assert often.confidence > once.confidence
|
||||
|
||||
|
||||
def test_a_packet_repeated_with_no_gap_is_still_found():
|
||||
"""Some transmitters run their repeats together with nothing between."""
|
||||
bits, repeats = D._repeat_within(PAYLOAD * 4)
|
||||
assert bits == PAYLOAD and repeats == 4
|
||||
|
||||
|
||||
def test_repeats_that_disagree_are_voted_on():
|
||||
consensus, certainty = D._agreement(["10110010", "10110010", "10110011"])
|
||||
assert consensus == "10110010"
|
||||
assert 0.5 < certainty < 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refusing what is not data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("kind", ["usb", "lsb", "nfm", "wfm", "am", "noise",
|
||||
"carrier", "cw", "psk4"])
|
||||
@pytest.mark.parametrize("seed", [1, 3, 5])
|
||||
def test_signals_that_are_not_data_decode_to_nothing(kind, seed):
|
||||
"""The hard half: runs exist in anything, and bits follow from runs."""
|
||||
x = signals.make(kind, n=64000, fs=32_000.0, snr_db=25.0, seed=seed)
|
||||
got = decode_data(x, 32_000.0)
|
||||
assert not got.ok, f"{kind}: invented {got.summary()}"
|
||||
assert got.note
|
||||
|
||||
|
||||
def test_a_lucky_window_in_speech_is_not_a_packet():
|
||||
"""One burst in eight fitting a grid is a coincidence, not a signal."""
|
||||
got = decode_data(signals.make("usb", n=64000, fs=32_000.0), 32_000.0)
|
||||
assert not got.ok
|
||||
assert "bursts" in got.note or "frames" in got.note or "nothing" in got.note
|
||||
|
||||
|
||||
def test_a_bare_grid_fit_is_refused_without_framing():
|
||||
out = D.DataDecode(ok=True, encoding="NRZ", bits="1" * 40, repeats=1)
|
||||
assert D._refuse(out, fit=0.6, share=1.0)
|
||||
assert not D._refuse(out, fit=0.95, share=1.0)
|
||||
|
||||
|
||||
def test_repetition_carries_a_decode_that_fit_alone_would_not():
|
||||
out = D.DataDecode(ok=True, encoding="NRZ", bits="1" * 40, repeats=5,
|
||||
agreement=1.0)
|
||||
assert not D._refuse(out, fit=0.2, share=0.1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Four levels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("kind,levels", [("fsk2", 2), ("fsk4", 4),
|
||||
("nfm", 1), ("carrier", 1),
|
||||
("noise", 1)])
|
||||
def test_the_number_of_levels_is_counted_correctly(kind, levels):
|
||||
x = signals.make(kind, n=64000, fs=32_000.0, snr_db=25.0)
|
||||
assert level_count(D._frequency_of(x, 32_000.0)) == levels
|
||||
|
||||
|
||||
def test_a_four_level_signal_is_never_read_as_two():
|
||||
"""Slicing C4FM down the middle gives bits, and they mean nothing."""
|
||||
x = signals.make("fsk4", n=64000, fs=32_000.0, snr_db=25.0)
|
||||
got = decode_data(x, 32_000.0)
|
||||
assert got.encoding == "4-level FSK"
|
||||
assert "no frame sync" in got.summary()
|
||||
assert got.confidence < 0.6
|
||||
|
||||
|
||||
def test_a_frame_sync_word_names_the_system_that_sent_it():
|
||||
sync = "".join(f"{int(c, 16):04b}" for c in "5575F5FF77FF")
|
||||
payload = "".join(sync + _random_bits(300, seed=i) for i in range(6))
|
||||
got = decode_four_level(signals.c4fm(payload), 48_000.0, 4800.0)
|
||||
assert got is not None and got.ok
|
||||
assert got.protocol == "P25 Phase 1"
|
||||
assert got.baud == pytest.approx(4800, rel=0.02)
|
||||
assert got.confidence > 0.85
|
||||
|
||||
|
||||
def test_an_on_off_keyed_burst_never_takes_the_four_level_path():
|
||||
"""Discriminator noise in the silences would count as extra levels."""
|
||||
assert decode_four_level(signals.ook_pwm(PAYLOAD), 50_000.0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checks and rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_checksum_that_comes_out_right_is_reported():
|
||||
body = bytes([0x12, 0x34, 0x56])
|
||||
bits = "".join(f"{b:08b}" for b in body + bytes([sum(body) & 0xFF]))
|
||||
assert "checksum-8" in check_crc(bits)
|
||||
|
||||
|
||||
def test_a_crc16_that_comes_out_right_is_reported():
|
||||
from bandsaunter.decode import _crc16_ccitt
|
||||
body = bytes([0xDE, 0xAD, 0xBE, 0xEF])
|
||||
crc = _crc16_ccitt(body)
|
||||
bits = "".join(f"{b:08b}" for b in body + bytes([crc >> 8, crc & 0xFF]))
|
||||
assert "CRC-16/CCITT" in check_crc(bits)
|
||||
|
||||
|
||||
def test_a_packet_with_no_valid_check_claims_none():
|
||||
assert check_crc("0" * 32) == [] or "checksum" in check_crc("0" * 32)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bits,expected", [
|
||||
("10110010", "B2"), ("1011001000110101", "B2 35"), ("", ""),
|
||||
("1011", "B0"), # a partial byte, padded on the right
|
||||
])
|
||||
def test_bits_render_as_hex(bits, expected):
|
||||
assert bits_to_hex(bits) == expected
|
||||
|
||||
|
||||
def test_the_summary_says_what_matters_first():
|
||||
got = decode_data(signals.ook_pwm(PAYLOAD), 50_000.0, family="ook")
|
||||
assert "EV1527" in got.summary()
|
||||
assert "24 bits" in got.summary()
|
||||
|
||||
|
||||
def test_a_twenty_four_bit_pulse_width_packet_is_named():
|
||||
got = decode_data(signals.ook_pwm(PAYLOAD), 50_000.0, family="ook")
|
||||
assert got.protocol == "EV1527 / PT2262-style remote"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POCSAG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_pocsag_codeword_checks_out():
|
||||
word = pocsag_codeword(0x0ABCD)
|
||||
assert D and word & 1 in (0, 1)
|
||||
from bandsaunter.protocols import _bch_syndrome, _parity_ok
|
||||
assert _bch_syndrome(word) == 0 and _parity_ok(word)
|
||||
|
||||
|
||||
def test_a_single_bit_error_in_a_codeword_is_corrected():
|
||||
from bandsaunter.protocols import _correct
|
||||
word = pocsag_codeword(0x15555)
|
||||
broken = word ^ (1 << 17)
|
||||
fixed, ok = _correct(broken)
|
||||
assert ok and fixed == word
|
||||
|
||||
|
||||
def test_a_pocsag_transmission_reads_back_as_the_pages_that_went_in():
|
||||
pages = [(1234568, 3, "ENGINE 4 RESPOND"), (98765, 0, "CALL EXT 4412")]
|
||||
bits = pocsag_bits(pages)
|
||||
assert SYNC_BITS in bits
|
||||
got = decode_pocsag(signals.fsk_nrz(bits, fs=48_000.0, baud=1200.0),
|
||||
48_000.0)
|
||||
assert got is not None and got.ok
|
||||
assert got.protocol == "POCSAG 1200"
|
||||
assert got.messages[0] == "[1234568D] ENGINE 4 RESPOND"
|
||||
assert got.messages[1] == "[0098765A] CALL EXT 4412"
|
||||
assert got.checks == ["BCH(31,21)"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("baud", POCSAG_BAUDS)
|
||||
def test_every_pocsag_rate_is_found_without_being_told(baud):
|
||||
"""Nothing in the signal announces the rate, so all three are tried."""
|
||||
bits = pocsag_bits([(2097151, 0, "HELLO")]) # the top address
|
||||
fs = max(32_000.0, baud * 20)
|
||||
got = decode_pocsag(signals.fsk_nrz(bits, fs=fs, baud=baud), fs)
|
||||
assert got is not None and got.ok
|
||||
assert got.protocol == f"POCSAG {baud:.0f}"
|
||||
assert "HELLO" in got.messages[0]
|
||||
|
||||
|
||||
def test_pocsag_survives_being_received_upside_down():
|
||||
"""Which tone is a one is a property of the receiver, not the standard."""
|
||||
bits = pocsag_bits([(1234568, 3, "INVERTED")])
|
||||
flipped = bits.translate(str.maketrans("01", "10"))
|
||||
got = decode_pocsag(signals.fsk_nrz(flipped, fs=48_000.0, baud=1200.0),
|
||||
48_000.0)
|
||||
assert got is not None and "INVERTED" in got.messages[0]
|
||||
|
||||
|
||||
def test_the_same_page_arriving_twice_is_reported_once():
|
||||
bits = pocsag_bits([(1234568, 3, "ONCE ONLY")]) * 3
|
||||
got = decode_pocsag(signals.fsk_nrz(bits, fs=48_000.0, baud=1200.0),
|
||||
48_000.0)
|
||||
assert got is not None
|
||||
assert len(got.messages) == 1
|
||||
|
||||
|
||||
def test_a_numeric_page_is_read_as_digits():
|
||||
bits = pocsag_bits([(1234568, 0, "5551234")])
|
||||
got = decode_pocsag(signals.fsk_nrz(bits, fs=48_000.0, baud=1200.0),
|
||||
48_000.0)
|
||||
assert got is not None and got.ok
|
||||
assert "5551234" in got.messages[0]
|
||||
|
||||
|
||||
def test_an_address_that_will_not_fit_is_refused_rather_than_truncated():
|
||||
"""A silently mangled address is a page delivered to somebody else."""
|
||||
from bandsaunter.protocols import MAX_ADDRESS
|
||||
with pytest.raises(ValueError):
|
||||
pocsag_bits([(MAX_ADDRESS + 1, 0, "NOPE")])
|
||||
|
||||
|
||||
def test_something_that_is_not_pocsag_is_refused():
|
||||
assert decode_pocsag(signals.fsk_nrz(_random_bits(600), fs=48_000.0),
|
||||
48_000.0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AX.25 and APRS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_an_aprs_frame_reads_back_with_its_callsign_and_payload():
|
||||
frame = signals.ax25_frame(("W1AW", 0), ("APRS", 0),
|
||||
"!4142.45N/07243.63W-Newington")
|
||||
got = decode_ax25(signals.aprs_afsk([frame]), 48_000.0)
|
||||
assert got is not None and got.ok
|
||||
assert got.protocol == "AX.25 / APRS"
|
||||
assert got.messages[0].startswith("W1AW>APRS")
|
||||
assert "Newington" in got.messages[0]
|
||||
assert got.checks == ["FCS (CRC-16/X.25)"]
|
||||
|
||||
|
||||
def test_a_digipeater_path_is_kept():
|
||||
frame = signals.ax25_frame(("KU0W", 9), ("APZ001", 0), ">testing",
|
||||
path=[("WIDE1", 1), ("WIDE2", 2)])
|
||||
got = decode_ax25(signals.aprs_afsk([frame]), 48_000.0)
|
||||
assert got is not None
|
||||
assert "KU0W-9" in got.messages[0]
|
||||
assert "WIDE1-1" in got.messages[0] and "WIDE2-2" in got.messages[0]
|
||||
|
||||
|
||||
def test_several_frames_in_one_capture_all_come_back():
|
||||
frames = [signals.ax25_frame(("W1AW", 0), ("APRS", 0), "first"),
|
||||
signals.ax25_frame(("KU0W", 0), ("APRS", 0), "second")]
|
||||
got = decode_ax25(signals.aprs_afsk(frames), 48_000.0)
|
||||
assert got is not None and len(got.messages) == 2
|
||||
|
||||
|
||||
def test_a_frame_whose_checksum_is_wrong_is_thrown_away():
|
||||
"""The frame check is the whole reason to believe an AX.25 decode."""
|
||||
frame = bytearray(signals.ax25_frame(("W1AW", 0), ("APRS", 0), "corrupt"))
|
||||
frame[-1] ^= 0xFF
|
||||
got = decode_ax25(signals.aprs_afsk([bytes(frame)]), 48_000.0)
|
||||
assert got is None
|
||||
|
||||
|
||||
def test_noise_is_not_a_packet_frame():
|
||||
assert decode_ax25(signals.make("noise", n=64000, fs=48_000.0),
|
||||
48_000.0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Robustness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("snr_db", [30.0, 20.0, 12.0])
|
||||
def test_a_remote_still_decodes_as_the_signal_weakens(snr_db):
|
||||
got = decode_data(signals.ook_pwm(PAYLOAD, snr_db=snr_db, repeats=6),
|
||||
50_000.0, family="ook")
|
||||
assert got.ok, f"{snr_db} dB: {got.note}"
|
||||
assert PAYLOAD in got.bits
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snr_db", [30.0, 18.0])
|
||||
def test_paging_still_decodes_as_the_signal_weakens(snr_db):
|
||||
bits = pocsag_bits([(1234568, 3, "WEAK SIGNAL")])
|
||||
got = decode_pocsag(signals.fsk_nrz(bits, fs=48_000.0, baud=1200.0,
|
||||
snr_db=snr_db), 48_000.0)
|
||||
assert got is not None and "WEAK" in got.messages[0]
|
||||
|
||||
|
||||
def test_a_capture_too_short_to_hold_a_packet_says_so():
|
||||
got = decode_data(np.zeros(100, dtype=np.complex64), 48_000.0)
|
||||
assert not got.ok and "short" in got.note
|
||||
|
||||
|
||||
def test_nrz_bits_refuses_a_rate_it_cannot_resolve():
|
||||
"""Fewer than two samples a symbol is not a sampling problem to solve."""
|
||||
train = slice_ook(signals.ook_pwm(PAYLOAD), 50_000.0)
|
||||
assert nrz_bits(train, 40_000.0) == ""
|
||||
assert nrz_bits(train, 0.0) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A scan that finds one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scan(tmp_path, transmitters, **over):
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
from bandsaunter.scanner import Scanner, ScannerCallbacks
|
||||
from bandsaunter.simulator import SimulatedDevice
|
||||
|
||||
cfg = ScanConfig(ranges=parse_range_list(over.pop("ranges", "433.9M-433.95M")),
|
||||
output_dir=str(tmp_path), record_seconds=3.0,
|
||||
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
|
||||
max_cycles=2, revisit_seconds=0.2)
|
||||
on_status = over.pop("_on_status", None)
|
||||
for key, value in over.items():
|
||||
setattr(cfg, key, value)
|
||||
hits = []
|
||||
scanner = Scanner(cfg, device=SimulatedDevice(
|
||||
transmitters=transmitters).open(),
|
||||
callbacks=ScannerCallbacks(on_record_end=hits.append,
|
||||
on_status=on_status))
|
||||
scanner.prepare()
|
||||
scanner.run()
|
||||
return scanner, [h for h in hits if h.kept]
|
||||
|
||||
|
||||
def _remote():
|
||||
from bandsaunter.simulator import VirtualTransmitter as V
|
||||
return [V(433_920_000, "packet", 0.45, 40_000, "remote", baud=2000,
|
||||
payload=PAYLOAD, repeats=6)]
|
||||
|
||||
|
||||
def _pager():
|
||||
from bandsaunter.simulator import VirtualTransmitter as V
|
||||
return [V(929_612_500, "pocsag", 0.45, 12_500, "pager", baud=1200,
|
||||
deviation=4_500,
|
||||
pages=((1234568, 3, "ENGINE 4 RESPOND"),))]
|
||||
|
||||
|
||||
def test_a_scan_reads_the_packet_off_a_remote(tmp_path):
|
||||
_, hits = _scan(tmp_path, _remote())
|
||||
assert hits, "the remote was never captured"
|
||||
assert all(h.data_encoding == "PWM" for h in hits)
|
||||
read = [h for h in hits if h.data_bits == PAYLOAD]
|
||||
assert read, [h.data_bits for h in hits]
|
||||
assert read[0].data_repeats > 1
|
||||
assert read[0].classification == "EV1527 / PT2262-style remote"
|
||||
|
||||
|
||||
def test_what_was_decoded_is_written_beside_the_recording(tmp_path):
|
||||
_, hits = _scan(tmp_path, _remote())
|
||||
assert hits
|
||||
written = list(tmp_path.glob("*_data.txt"))
|
||||
assert written, "nothing was written"
|
||||
bodies = [path.read_text() for path in written]
|
||||
assert any(PAYLOAD in body for body in bodies), bodies
|
||||
assert any("EV1527" in body for body in bodies)
|
||||
# and every record points at the file it wrote
|
||||
for hit in hits:
|
||||
assert Path(hit.data_path).exists()
|
||||
|
||||
|
||||
def test_a_scan_reads_a_page_and_prints_the_message(tmp_path):
|
||||
_, hits = _scan(tmp_path, _pager(), ranges="929.55M-929.7M",
|
||||
record_seconds=4.0)
|
||||
assert hits, "the pager was never captured"
|
||||
hit = hits[0]
|
||||
assert hit.data_protocol == "POCSAG 1200"
|
||||
assert "ENGINE 4 RESPOND" in hit.data_messages[0]
|
||||
assert hit.classification == "POCSAG 1200"
|
||||
assert "BCH(31,21)" in hit.data_checks
|
||||
|
||||
|
||||
def test_the_display_is_told_what_was_decoded(tmp_path):
|
||||
said = []
|
||||
_scan(tmp_path, _remote(), _on_status=said.append)
|
||||
assert any("decoded" in m and PAYLOAD[:8] not in m for m in said), said
|
||||
|
||||
|
||||
def test_decoding_can_be_turned_off(tmp_path):
|
||||
_, hits = _scan(tmp_path, _remote(), decode_data=False)
|
||||
assert hits
|
||||
assert not hits[0].data_encoding
|
||||
assert not list(tmp_path.glob("*_data.txt"))
|
||||
|
||||
|
||||
def test_no_data_file_is_left_beside_a_recording_that_was_thrown_away(tmp_path):
|
||||
"""The capture is renamed after it is kept, and orphans confuse a reader."""
|
||||
_, hits = _scan(tmp_path, _remote())
|
||||
for path in tmp_path.glob("*_data.txt"):
|
||||
stem = path.name[:-len("_data.txt")]
|
||||
assert (tmp_path / f"{stem}.wav").exists(), f"orphan: {path.name}"
|
||||
|
||||
|
||||
def test_a_decoded_packet_is_kept_even_when_the_content_check_says_no(tmp_path):
|
||||
"""A frame whose own checksum came out right is not a statistic."""
|
||||
_, hits = _scan(tmp_path, _pager(), ranges="929.55M-929.7M",
|
||||
record_seconds=4.0, accept=["voice"])
|
||||
assert hits, "a decoded page was discarded as contentless"
|
||||
assert hits[0].category == "digital"
|
||||
|
||||
|
||||
def test_the_browser_shows_what_was_decoded(tmp_path):
|
||||
from rich.console import Console
|
||||
from bandsaunter.browse import Browser, Player
|
||||
_scan(tmp_path, _pager(), ranges="929.55M-929.7M", record_seconds=4.0)
|
||||
console = Console(width=100, height=30, force_terminal=True)
|
||||
browser = Browser(tmp_path, console=console, player=Player([]))
|
||||
assert browser.captures
|
||||
cap = browser.captures[0]
|
||||
assert cap.decoded and "ENGINE 4 RESPOND" in cap.decoded[0]
|
||||
assert "POCSAG" in cap.data_headline
|
||||
with console.capture() as frame:
|
||||
console.print(browser.render())
|
||||
text = frame.get()
|
||||
assert "ENGINE 4 RESPOND" in text
|
||||
assert "decoded" in text
|
||||
|
||||
|
||||
def test_a_pager_message_can_be_searched_for(tmp_path):
|
||||
from rich.console import Console
|
||||
from bandsaunter.browse import Browser, Player
|
||||
_scan(tmp_path, _pager(), ranges="929.55M-929.7M", record_seconds=4.0)
|
||||
browser = Browser(tmp_path,
|
||||
console=Console(width=100, height=30,
|
||||
force_terminal=True),
|
||||
player=Player([]))
|
||||
browser.query = "engine 4"
|
||||
browser.apply()
|
||||
assert browser.view, "searching what a data capture said found nothing"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text that came off the air
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOSTILE = "[1234568D] ALERT [/red] see [bold] the thing"
|
||||
|
||||
|
||||
def test_a_decoded_message_cannot_break_the_live_display():
|
||||
"""Rich reads square brackets as markup, and a page is arbitrary text."""
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from rich.console import Console
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
from bandsaunter.recorder import HitRecord
|
||||
from bandsaunter.scanner import Scanner
|
||||
from bandsaunter.simulator import SimulatedDevice
|
||||
from bandsaunter.ui import ScanDisplay
|
||||
|
||||
console = Console(width=120, height=30, record=True,
|
||||
file=open(os.devnull, "w"))
|
||||
cfg = ScanConfig(ranges=parse_range_list("144M-148M"),
|
||||
output_dir=tempfile.mkdtemp())
|
||||
scanner = Scanner(cfg, device=SimulatedDevice().open())
|
||||
scanner.prepare()
|
||||
display = ScanDisplay(scanner, console=console)
|
||||
hit = HitRecord(frequency=929.6e6, started_at=time.time(), duration=4.5,
|
||||
snr_db=49.5, classification="POCSAG 1200")
|
||||
hit.data_messages = [HOSTILE]
|
||||
hit.data_protocol = "POCSAG 1200"
|
||||
display.hits.appendleft(hit)
|
||||
display.on_status(f"decoded 929.6 MHz: {HOSTILE}")
|
||||
console.print(display._hits_table())
|
||||
console.print(display._footer())
|
||||
text = console.export_text()
|
||||
assert "ALERT" in text
|
||||
|
||||
|
||||
def test_a_decoded_message_cannot_break_the_line_per_hit_output():
|
||||
import os
|
||||
from rich.console import Console
|
||||
from bandsaunter.recorder import HitRecord
|
||||
from bandsaunter.ui import print_hit
|
||||
|
||||
console = Console(width=140, record=True, file=open(os.devnull, "w"))
|
||||
hit = HitRecord(frequency=929.6e6, started_at=0, duration=4.5,
|
||||
snr_db=49.5, classification="POCSAG 1200")
|
||||
hit.kept = True
|
||||
hit.data_messages = [HOSTILE]
|
||||
print_hit(console, hit)
|
||||
assert "ALERT" in console.export_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("typed", ["[/x]", "[bold", "]]]", "[/]"])
|
||||
def test_what_is_typed_at_the_search_prompt_cannot_break_the_browser(typed,
|
||||
tmp_path):
|
||||
"""Typing "[/" used to end the session with a MarkupError."""
|
||||
from rich.console import Console
|
||||
from bandsaunter.browse import Browser, Player
|
||||
|
||||
browser = Browser(tmp_path,
|
||||
console=Console(width=100, height=30,
|
||||
force_terminal=True),
|
||||
player=Player([]))
|
||||
browser.searching = True
|
||||
browser.query = typed
|
||||
browser._footer()
|
||||
browser.searching = False
|
||||
browser.message = f"nothing matches {typed}"
|
||||
browser._footer()
|
||||
Loading…
Add table
Add a link
Reference in a new issue