"""The APRS information field: about twenty formats, and what must not happen. Every format here has a writer beside its reader, so a position goes in and the same position comes out. That proves the arithmetic and the framing and proves nothing about anything the specification and this both get wrong -- which is worth saying, because the last section of this program shipped unable to decode anything at all for exactly that reason. The other half is about refusing to guess. A packet whose format does not match what its first character promised comes back as unparsed, with its text intact, rather than as a confident position a thousand miles from where the station is. """ import math import pytest from bandsaunter import packets as p # Every quadrant, the prime meridian, the equator, and the longitudes either # side of the boundaries Mic-E re-maps to keep its bytes printable. PLACES = [ (49.0583, -72.0292), (-33.8688, 151.2093), (51.5074, -0.1278), (35.6762, 139.6503), (5.0, -5.0), (0.0, 0.0), (-45.0, -179.5), (64.1466, -21.9426), (-1.2921, 36.8219), (47.55, -122.30), ] # --------------------------------------------------------------------------- # Positions # --------------------------------------------------------------------------- @pytest.mark.parametrize("latitude,longitude", PLACES) def test_an_uncompressed_position_comes_back_where_it_was_sent(latitude, longitude): info = p.position_report(latitude, longitude, "/>", "hello") packet = p.parse_info(info) assert packet.kind == "position" assert packet.position.latitude == pytest.approx(latitude, abs=0.0002) assert packet.position.longitude == pytest.approx(longitude, abs=0.0002) assert packet.position.symbol == "car" assert packet.comment == "hello" @pytest.mark.parametrize("latitude,longitude", PLACES) def test_a_compressed_position_comes_back_where_it_was_sent(latitude, longitude): packet = p.parse_info(p.compressed_report(latitude, longitude, "/>")) assert packet.kind == "position" assert packet.position.compressed assert packet.position.latitude == pytest.approx(latitude, abs=0.0002) assert packet.position.longitude == pytest.approx(longitude, abs=0.0002) @pytest.mark.parametrize("latitude,longitude", PLACES) def test_a_mic_e_position_comes_back_where_it_was_sent(latitude, longitude): """The awkward one: half of it lives in the destination callsign, and the longitude is re-mapped in three ranges to keep the bytes printable.""" destination, info = p.mic_e_report(latitude, longitude, course=251, speed=37.0, symbol="/j", status="returning") packet = p.parse_info(info, destination) assert packet.kind == "position" assert packet.position.latitude == pytest.approx(latitude, abs=0.0002) assert packet.position.longitude == pytest.approx(longitude, abs=0.0002) assert packet.course == 251 assert packet.speed == pytest.approx(37.0, abs=1.0) assert packet.status == "returning" assert packet.position.symbol == "jeep" def test_the_compressed_position_from_the_specification_reads_correctly(): """The example in the specification itself: 49 30.00 N, 72 45.00 W, with a pre-computed radio range of 20.13 miles.""" packet = p.parse_info("!/5L!!<*e7>{?!") assert packet.position.latitude == pytest.approx(49.5, abs=0.0001) assert packet.position.longitude == pytest.approx(-72.75, abs=0.0001) assert packet.range == pytest.approx(20.13 * 1.609344, rel=0.01) @pytest.mark.parametrize("blanked,within_km", [(0, 0.0), (1, 0.34), (2, 3.4), (3, 34.0), (4, 340.0)]) def test_a_station_that_blanks_its_minutes_is_not_drawn_as_a_point(blanked, within_km): """Blanking the minute digits is a deliberate act by the operator. Drawing a fuzzy position as a sharp one is a lie they specifically asked not to be told, so the count is kept and turned into a distance. """ info = p.position_report(49.0583, -72.0292, "/-", ambiguity=blanked) packet = p.parse_info(info) assert packet.position.ambiguity == blanked assert packet.position.uncertainty_km == within_km @pytest.mark.parametrize("info", [ "=4760.37N/07201.75W-", # sixty minutes is not a minute "=4903.50X/07201.75W-", # not a hemisphere "=49 3.50N/07201.75W-", # a gap in the middle, not ambiguity "=9903.50N/07201.75W-", # past the pole "=4903.50N/19201.75W-", # past the antimeridian ]) def test_a_position_that_cannot_be_one_is_not_reported_as_a_position(info): """And in particular is not quietly re-read as a compressed position. Thirteen characters of a malformed uncompressed position are perfectly good base-91, so falling back does not fail -- it succeeds, as a confident and completely different place. """ packet = p.parse_info(info) assert packet.kind == "unparsed" assert packet.position is None assert packet.info == info # the text is kept regardless def test_a_leading_digit_always_means_an_uncompressed_position(): """Which is why the compressed format writes a numeric overlay as a letter: so the two can never be confused by anything that reads the rule.""" overlaid = p.parse_info("!a5L!!<*e7> sT") assert overlaid.position.table == "0" # the overlay, as a digit assert overlaid.position.compressed @pytest.mark.parametrize("timestamp", ["092345z", "092345/", "234500h"]) def test_a_position_with_a_timestamp_keeps_both(timestamp): info = p.position_report(49.0583, -72.0292, "/-", timestamp=timestamp) packet = p.parse_info(info, now=1_600_000_000.0) assert packet.position is not None assert packet.reported > 0.0 def test_a_timestamp_with_no_year_lands_on_the_nearest_side_of_today(): """None of the four formats carries a year, so a packet stamped the thirty-first heard on the first is from yesterday, not four weeks on.""" import calendar now = calendar.timegm((2026, 3, 1, 0, 30, 0, 0, 0, 0)) when, _rest = p.timestamp_from("282345z", now) assert now - when < 3 * 24 * 3600 # late February, not next year assert when < now # --------------------------------------------------------------------------- # What rides in the comment # --------------------------------------------------------------------------- def test_a_course_and_speed_are_read_in_the_units_they_are_sent_in(): """Knots here, which is not the same as the miles an hour a weather report uses for wind, and the two differ by fifteen per cent.""" packet = p.parse_info(p.position_report(49.0, -72.0, "/>", course=88, speed=66.672)) assert packet.course == 88 assert packet.speed == pytest.approx(66.672, abs=1.0) def test_an_antenna_description_is_read_rather_than_shown_as_letters(): extra, rest = p.extensions_from("PHG5132Hi") assert extra["power"] == 25 # watts, from a single digit assert extra["height"] == pytest.approx(10.0 * 2 ** 1 * 0.3048, rel=0.01) assert extra["gain"] == 3 and extra["beam"] == "E" assert rest == "Hi" def test_a_pre_computed_range_is_read(): extra, rest = p.extensions_from("RNG0050here") assert extra["range"] == pytest.approx(50 * 1.609344, rel=0.01) assert rest == "here" def test_a_comment_that_is_only_a_comment_is_left_alone(): extra, rest = p.extensions_from("Hello there") assert extra == {} and rest == "Hello there" def test_an_altitude_is_pulled_out_of_wherever_in_the_comment_it_sits(): extra, rest = p.comment_from("climbing /A=012345 steadily") assert extra["altitude"] == pytest.approx(12345 * 0.3048, rel=0.001) assert "A=" not in rest and "climbing" in rest and "steadily" in rest def test_an_altitude_is_shown_in_metres_and_not_rounded_to_a_kilometre(): """Four hundred metres and the ground both read as "0 km", which is the sort of rounding that makes a number worse than no number.""" assert p.height_text(376.0) == "376 m" assert p.height_text(376.0, imperial=True) == "1,234 ft" # --------------------------------------------------------------------------- # Weather # --------------------------------------------------------------------------- def test_a_weather_report_with_a_position_reads_both(): info = "@092345z4903.50N/07201.75W_220/004g005t077r000p000P000h50b09900" packet = p.parse_info(info) assert packet.kind == "weather" assert packet.position is not None wx = packet.weather assert wx["temperature"].value == pytest.approx(25.0, abs=0.1) assert wx["humidity"].value == 50 assert wx["pressure"].value == pytest.approx(990.0, abs=0.1) assert wx["wind from"].value == 220 assert wx["wind"].value == pytest.approx(4 * 1.852, abs=0.1) def test_a_positionless_weather_report_reads_the_numbers(): packet = p.parse_info("_10090556c220s004g005t077r000p000P000h50b09900") assert packet.kind == "weather" and packet.position is None assert packet.weather["wind"].value == pytest.approx(4 * 1.609344, abs=0.1) def test_the_letter_s_means_wind_speed_or_snow_depending_on_the_form(): """The same letter, and the two differ by a factor of forty. In a positionless report the wind arrives as "c" then "s"; in a position report it arrives in the course-and-speed field, so a later "s" is snowfall. Guessing is not an option. """ positionless = p.parse_info("_10090556c220s004g005t077") assert "wind" in positionless.weather and "snow" not in positionless.weather with_position = p.parse_info( "@092345z4903.50N/07201.75W_220/004g005t077s010") assert with_position.weather["snow"].value == pytest.approx(254.0, abs=1) assert with_position.weather["wind"].value == pytest.approx(4 * 1.852, abs=0.1) def test_a_humidity_of_zero_means_a_hundred_per_cent(): packet = p.parse_info("_10090556c220s004g005t077h00") assert packet.weather["humidity"].value == 100 def test_a_weather_report_comes_back_as_it_was_written(): info = p.weather_report(49.0583, -72.0292, timestamp="092345z", wind_from=220, wind=7.4, gust=18.0, temperature=25.0, humidity=50, pressure=990.0) packet = p.parse_info(info) assert packet.weather["temperature"].value == pytest.approx(25.0, abs=0.3) assert packet.weather["humidity"].value == 50 assert packet.weather["pressure"].value == pytest.approx(990.0, abs=0.1) # --------------------------------------------------------------------------- # Messages, objects, items, status, telemetry # --------------------------------------------------------------------------- def test_a_message_reads_with_its_addressee_and_its_number(): packet = p.parse_info(p.message_text("KU0W", "on my way", number="42")) assert packet.kind == "message" assert packet.message.to == "KU0W" assert packet.message.text == "on my way" assert packet.message.number == "42" @pytest.mark.parametrize("body,field,value", [ ("ack42", "acknowledges", "42"), ("rej42", "rejects", "42"), ]) def test_an_acknowledgement_is_not_read_as_a_message_saying_ack(body, field, value): packet = p.parse_info(":W1AW :" + body) assert getattr(packet.message, field) == value assert packet.message.text == "" def test_a_bulletin_is_addressed_to_everybody_and_says_so(): packet = p.parse_info(":BLN1 :Net tonight at eight") assert packet.message.is_bulletin and packet.message.bulletin == "1" assert "Net tonight" in packet.message.describe() def test_an_object_carries_a_name_that_is_not_the_station_that_placed_it(): info = p.object_report("LEADER", 49.0583, -72.0292, "/>", timestamp="092345z") packet = p.parse_info(info) assert packet.kind == "object" assert packet.name == "LEADER" assert packet.station == "LEADER" # the object, not the sender assert packet.position.latitude == pytest.approx(49.0583, abs=0.0002) assert packet.live def test_an_object_can_be_killed(): info = p.object_report("LEADER", 49.0583, -72.0292, live=False) assert p.parse_info(info).live is False def test_an_item_is_an_object_without_a_timestamp(): packet = p.parse_info(p.item_report("AID", 49.0583, -72.0292)) assert packet.kind == "item" and packet.name == "AID" assert packet.position is not None def test_a_status_report_is_kept_as_what_the_operator_typed(): packet = p.parse_info(">Monitoring 146.52") assert packet.kind == "status" and packet.status == "Monitoring 146.52" def test_telemetry_reads_as_five_channels_and_eight_bits(): packet = p.parse_info(p.telemetry_report("005", [199, 0, 255, 73, 123], "01101001")) assert packet.kind == "telemetry" assert packet.telemetry.analogue == (199.0, 0.0, 255.0, 73.0, 123.0) assert packet.telemetry.digital == "01101001" def test_a_relayed_packet_is_credited_to_whoever_originally_sent_it(): inner = p.position_report(49.0583, -72.0292, "/>") packet = p.parse_info("}W1AW-5>APRS,TCPIP*:" + inner) assert packet.source == "W1AW-5" assert packet.position is not None assert "relayed" in packet.comment # --------------------------------------------------------------------------- # Refusing to guess # --------------------------------------------------------------------------- @pytest.mark.parametrize("info", [ "", "x", "!", "=short", ":notamessage", ";tooshort", "T#", ">", "@notatimestamp", "}", "_", "`", "'", ]) def test_a_packet_that_cannot_be_read_keeps_its_text_and_says_so(info): packet = p.parse_info(info) assert packet.position is None assert packet.info == info assert packet.kind in p.KINDS def test_nothing_is_read_out_of_random_text(): import random rng = random.Random(4) alphabet = "".join(chr(c) for c in range(32, 127)) placed = 0 for _ in range(4000): info = "".join(rng.choice(alphabet) for _ in range(rng.randint(5, 60))) packet = p.parse_info(info) if packet.position is not None: placed += 1 # Some random text really is a valid compressed position -- thirteen # printable characters is all one takes -- so the bar is a rate. What # reaches this off the air has also had to satisfy a sixteen-bit CRC. assert placed <= 120, f"{placed} of 4000 random strings became a place" # --------------------------------------------------------------------------- # Symbols # --------------------------------------------------------------------------- @pytest.mark.parametrize("table,code,name", [ ("/", ">", "car"), ("/", "_", "weather station"), ("/", "#", "digipeater"), ("/", "O", "balloon"), ("\\", "s", "boat"), ("/", "[", "person"), ]) def test_a_station_is_described_by_what_it_draws_itself_as(table, code, name): assert p.symbol_name(table, code) == name def test_an_overlaid_symbol_says_which_character_is_over_it(): assert "(T)" in p.symbol_name("T", "#") def test_a_symbol_nobody_named_is_reported_by_its_characters(): assert p.symbol_name("/", "\x01") == "symbol /\x01"