"""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()