├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ └── logger.py ├── test_aprs │ ├── __init__.py │ ├── test_router.py │ ├── test_datetime.py │ ├── test_symbol.py │ └── test_frame.py ├── test_frame │ ├── __init__.py │ ├── test_ax25path.py │ ├── test_iframe.py │ ├── test_sframe.py │ ├── test_ax25frameheader.py │ └── test_ax25address.py ├── test_kiss │ ├── __init__.py │ ├── test_factory.py │ ├── test_tcp.py │ ├── test_port.py │ ├── test_command.py │ ├── test_subproc.py │ └── test_serial.py ├── test_peer │ ├── __init__.py │ ├── peer.py │ ├── test_ua.py │ ├── test_state.py │ ├── test_send.py │ ├── test_dm.py │ ├── test_peerhelper.py │ ├── test_rx_path.py │ ├── test_replypath.py │ ├── test_disc.py │ ├── test_frmr.py │ └── test_cleanup.py ├── test_interface │ └── __init__.py ├── test_signal │ ├── __init__.py │ ├── test_slot.py │ └── test_signal.py ├── test_station │ ├── __init__.py │ ├── test_properties.py │ ├── test_constructor.py │ ├── test_attach_detach.py │ ├── test_getpeer.py │ └── test_receive.py ├── conftest.py ├── asynctest.py ├── hex.py ├── loop.py ├── test_uint.py ├── test_unit.py └── mocks.py ├── aioax25 ├── tools │ ├── __init__.py │ ├── dumphex.py │ ├── call.py │ └── listen.py ├── aprs │ ├── __init__.py │ ├── router.py │ ├── compression.py │ ├── datatype.py │ ├── frame.py │ ├── symbol.py │ ├── datetime.py │ └── uidigi.py ├── __init__.py ├── version.py ├── uint.py ├── unit.py ├── signal.py ├── router.py ├── interface.py └── station.py ├── setup.py ├── requirements.txt ├── .coveragerc ├── doc ├── ax25-2p2 │ └── ax25-2p2.pdf └── ax25-2p0 │ └── index_files │ ├── pdf2.gif │ ├── tapr-logo.png │ ├── secure90x72.gif │ ├── tapr-80px-logo.gif │ ├── tl_curve_white.gif │ ├── tr_curve_white.gif │ └── iconochive.css ├── pytest.ini ├── .gitignore ├── .travis.yml ├── pyproject.toml ├── CHANGELOG.md └── .github └── workflows ├── main.yml └── test.yml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aioax25/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_aprs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_frame/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_kiss/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_peer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_interface/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_signal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_station/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial 2 | pyserial-asyncio 3 | signalslot 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch=true 3 | source=aioax25 4 | omit= 5 | tests 6 | aioax25/tools/** 7 | -------------------------------------------------------------------------------- /doc/ax25-2p2/ax25-2p2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjlongland/aioax25/HEAD/doc/ax25-2p2/ax25-2p2.pdf -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --log-level=DEBUG --cov=aioax25 --cov-report=term --cov-report=html --cov-branch 3 | -------------------------------------------------------------------------------- /doc/ax25-2p0/index_files/pdf2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjlongland/aioax25/HEAD/doc/ax25-2p0/index_files/pdf2.gif -------------------------------------------------------------------------------- /doc/ax25-2p0/index_files/tapr-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjlongland/aioax25/HEAD/doc/ax25-2p0/index_files/tapr-logo.png -------------------------------------------------------------------------------- /doc/ax25-2p0/index_files/secure90x72.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjlongland/aioax25/HEAD/doc/ax25-2p0/index_files/secure90x72.gif -------------------------------------------------------------------------------- /doc/ax25-2p0/index_files/tapr-80px-logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjlongland/aioax25/HEAD/doc/ax25-2p0/index_files/tapr-80px-logo.gif -------------------------------------------------------------------------------- /doc/ax25-2p0/index_files/tl_curve_white.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjlongland/aioax25/HEAD/doc/ax25-2p0/index_files/tl_curve_white.gif -------------------------------------------------------------------------------- /doc/ax25-2p0/index_files/tr_curve_white.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjlongland/aioax25/HEAD/doc/ax25-2p0/index_files/tr_curve_white.gif -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | py.test configuration settings and fixture definitions. 5 | """ 6 | 7 | from .fixtures.logger import logger 8 | 9 | assert logger 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.swp 3 | *~ 4 | __pycache__ 5 | *.egg-info 6 | *.pyc 7 | .coverage 8 | aioax25.egg-info/ 9 | cover/ 10 | testreports.xml 11 | dist/ 12 | build/ 13 | htmlcov/ 14 | lcov.info 15 | -------------------------------------------------------------------------------- /aioax25/aprs/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS library 5 | """ 6 | 7 | from .aprs import APRSInterface 8 | from .uidigi import APRSDigipeater 9 | 10 | assert APRSInterface 11 | assert APRSDigipeater 12 | -------------------------------------------------------------------------------- /aioax25/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | __author__ = "Stuart Longland VK4MSL" 4 | __email__ = "me@vk4msl.id.au" 5 | __license__ = "GPL-2.0-or-later" 6 | __copyright__ = "Copyright 2021, Stuart Longland (and contributors)" 7 | __version__ = "0.0.12" 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | - "3.5" 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | - "pypy3" 9 | # command to install dependencies 10 | install: 11 | - pip install -r requirements.txt 12 | # command to run tests 13 | script: 14 | - nosetests --with-coverage --cover-package=aioax25 15 | after_success: 16 | - coveralls 17 | -------------------------------------------------------------------------------- /aioax25/version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | AX.25 Version enumeration. This is used to record whether a station is 5 | using a known version of AX.25. 6 | """ 7 | 8 | import enum 9 | 10 | 11 | class AX25Version(enum.Enum): 12 | UNKNOWN = "0.0" # The version is not known 13 | AX25_10 = "1.x" # AX.25 1.x in use 14 | AX25_20 = "2.0" # AX.25 2.0 in use 15 | AX25_22 = "2.2" # AX.25 2.2 in use 16 | -------------------------------------------------------------------------------- /tests/asynctest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Asynchronous test case handler. 5 | """ 6 | 7 | from asyncio import get_event_loop 8 | from functools import wraps 9 | 10 | 11 | def asynctest(testcase, *args, **kwargs): 12 | """ 13 | Wrap an asynchronous test case. 14 | """ 15 | 16 | @wraps(testcase) 17 | def fn(): 18 | return get_event_loop().run_until_complete(testcase(*args, **kwargs)) 19 | 20 | return fn 21 | -------------------------------------------------------------------------------- /aioax25/aprs/router.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS messaging router. 5 | """ 6 | 7 | from .message import APRSMessageFrame 8 | from ..router import Router 9 | 10 | 11 | class APRSRouter(Router): 12 | """ 13 | Route a APRS message frame according to the addressee field, if any. 14 | """ 15 | 16 | def _get_destination(self, frame): 17 | if isinstance(frame, APRSMessageFrame): 18 | return frame.addressee 19 | else: 20 | return super(APRSRouter, self)._get_destination(frame) 21 | -------------------------------------------------------------------------------- /tests/test_station/test_properties.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.station import AX25Station 4 | from aioax25.version import AX25Version 5 | from aioax25.frame import AX25Address 6 | 7 | from ..mocks import DummyInterface 8 | 9 | 10 | def test_address(): 11 | """ 12 | Test the address of the station is set from the constructor. 13 | """ 14 | station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5") 15 | assert station.address == AX25Address(callsign="VK4MSL", ssid=5) 16 | 17 | 18 | def test_protocol(): 19 | """ 20 | Test the protocol of the station is set from the constructor. 21 | """ 22 | station = AX25Station( 23 | interface=DummyInterface(), 24 | callsign="VK4MSL-5", 25 | protocol=AX25Version.AX25_20, 26 | ) 27 | assert station.protocol == AX25Version.AX25_20 28 | -------------------------------------------------------------------------------- /aioax25/aprs/compression.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS compression algorithm. 5 | """ 6 | 7 | BYTE_VALUE_OFFSET = 33 8 | BYTE_VALUE_RADIX = 91 9 | 10 | 11 | def compress(value, length): 12 | # Initialise our byte values 13 | bvalue = [0] * length 14 | 15 | # Figure out the bytes 16 | for pos in range(length): 17 | (div, rem) = divmod(value, BYTE_VALUE_RADIX ** (length - pos - 1)) 18 | bvalue[pos] += int(div) 19 | value = rem 20 | 21 | # Encode them into ASCII 22 | return "".join([chr(b + BYTE_VALUE_OFFSET) for b in bvalue]) 23 | 24 | 25 | def decompress(value): 26 | length = len(value) 27 | return sum( 28 | [ 29 | ((b - BYTE_VALUE_OFFSET) * (BYTE_VALUE_RADIX ** (length - i - 1))) 30 | for (i, b) in enumerate(bytes(value, "us-ascii")) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /tests/hex.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Human-readable hex strings. 5 | """ 6 | 7 | from binascii import a2b_hex 8 | 9 | 10 | def from_hex(hexstr): 11 | return a2b_hex(hexstr.replace(" ", "")) 12 | 13 | 14 | def to_hex(bytestr): 15 | return " ".join(["%02x" % byte for byte in bytestr]) 16 | 17 | 18 | def hex_cmp(bytestr1, bytestr2): 19 | if not isinstance(bytestr1, bytes): 20 | bytestr1 = from_hex(bytestr1) 21 | 22 | if not isinstance(bytestr2, bytes): 23 | bytestr2 = from_hex(bytestr2) 24 | 25 | assert bytestr1 == bytestr2, ( 26 | "Byte strings do not match:\n" 27 | " 1> %s\n" 28 | " 2> %s\n" 29 | "1^2> %s" 30 | % ( 31 | to_hex(bytestr1), 32 | to_hex(bytestr2), 33 | " ".join( 34 | [ 35 | (("%02x" % (a ^ b)) if a != b else " ") 36 | for (a, b) in zip(bytestr1, bytestr2) 37 | ] 38 | ), 39 | ) 40 | ) 41 | -------------------------------------------------------------------------------- /tests/test_aprs/test_router.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.aprs.message import APRSMessageAckFrame 4 | from aioax25.frame import AX25UnnumberedInformationFrame 5 | from aioax25.aprs.router import APRSRouter 6 | 7 | 8 | def test_get_destination_msgframe(): 9 | """ 10 | Test _get_destination returns the addressee of a APRSMessageFrame. 11 | """ 12 | frame = APRSMessageAckFrame( 13 | destination="APZAIO", 14 | addressee="VK4BWI-2", 15 | source="VK4MSL-7", 16 | msgid="123", 17 | ) 18 | router = APRSRouter() 19 | destination = router._get_destination(frame) 20 | assert destination == frame.addressee 21 | 22 | 23 | def test_get_destination_uiframe(): 24 | """ 25 | Test _get_destination returns the destination field of a generic UI frame 26 | """ 27 | frame = AX25UnnumberedInformationFrame( 28 | destination="APZAIO", 29 | source="VK4MSL-7", 30 | pid=0xF0, 31 | payload=b":BLN4 :Test bulletin", 32 | ) 33 | router = APRSRouter() 34 | destination = router._get_destination(frame) 35 | assert destination == frame.header.destination 36 | -------------------------------------------------------------------------------- /tests/loop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Dummy IOLoop interface 5 | """ 6 | 7 | import time 8 | 9 | 10 | class DummyLoop(object): 11 | def __init__(self): 12 | self.readers = {} 13 | self.calls = [] 14 | 15 | def time(self): 16 | return time.monotonic() 17 | 18 | def call_soon(self, callback, *args): 19 | self.calls.append((self.time(), callback) + args) 20 | 21 | def call_later(self, delay, callback, *args): 22 | when = self.time() + delay 23 | self.calls.append((when, callback) + args) 24 | return DummyTimeoutHandle(when) 25 | 26 | def add_reader(self, fileno, reader): 27 | self.readers[fileno] = reader 28 | 29 | def remove_reader(self, fileno): 30 | self.readers.pop(fileno) 31 | 32 | 33 | class DummyTimeoutHandle(object): 34 | def __init__(self, when): 35 | self._when = when 36 | self._cancelled = False 37 | 38 | def cancel(self): 39 | self._cancelled = True 40 | 41 | # Note: Don't rely on this in real code in Python <3.6 as 42 | # these methods were added in 3.7. 43 | 44 | def when(self): 45 | return self._when 46 | 47 | def cancelled(self): 48 | return self._cancelled 49 | -------------------------------------------------------------------------------- /aioax25/uint.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Unsigned Integer encoding and decoding routines. 5 | 6 | Both endians of integer are used, and sometimes the fields are odd sizes like 7 | the 24-bit fields in XID HDLC Optional Function fields. This allows encoding 8 | or decoding of any integer, of any length, in either endianness. 9 | """ 10 | 11 | 12 | def encode(value, length=None, big_endian=False): 13 | """ 14 | Encode the given unsigned integer value as bytes, optionally of a given 15 | length. 16 | """ 17 | 18 | output = bytearray() 19 | while (value != 0) if (length is None) else (length > 0): 20 | output += bytes([value & 0xFF]) 21 | value >>= 8 22 | if length is not None: 23 | length -= 1 24 | 25 | if not output: 26 | # No output, so return a null byte 27 | output += b"\x00" 28 | 29 | if big_endian: 30 | output.reverse() 31 | 32 | return bytes(output) 33 | 34 | 35 | def decode(value, big_endian=False): 36 | """ 37 | Decode the given bytes as an unsigned integer. 38 | """ 39 | 40 | output = 0 41 | for byte in value if big_endian else reversed(value): 42 | output <<= 8 43 | output |= byte 44 | return output 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "aioax25" 7 | dynamic = ["version"] 8 | classifiers = [ 9 | "Development Status :: 2 - Pre-Alpha", 10 | "Environment :: No Input/Output (Daemon)", 11 | "Framework :: AsyncIO", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", 14 | "Operating System :: POSIX", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Topic :: Communications :: Ham Radio" 17 | ] 18 | description = "Asynchronous AX.25 interface in pure Python using asyncio" 19 | dependencies = [ 20 | "pyserial", 21 | "pyserial-asyncio", 22 | "signalslot" 23 | ] 24 | 25 | [project.optional-dependencies] 26 | call = [ 27 | "prompt_toolkit" 28 | ] 29 | 30 | [project.readme] 31 | file = "README.md" 32 | content-type = "text/markdown" 33 | 34 | [project.license] 35 | text = "GPL-2.0-or-later" 36 | 37 | [[project.authors]] 38 | name = "Stuart Longland VK4MSL" 39 | email = "me@vk4msl.id.au" 40 | 41 | [tool.black] 42 | line-length = 78 43 | 44 | [tool.pytest.ini_options] 45 | log_cli = true 46 | 47 | [tool.setuptools.dynamic] 48 | version = {attr = "aioax25.__version__"} 49 | -------------------------------------------------------------------------------- /aioax25/aprs/datatype.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS message data types. 5 | """ 6 | 7 | from enum import Enum 8 | 9 | 10 | class APRSDataType(Enum): 11 | """ 12 | APRS message types, given as the first byte in the information field, 13 | not including unused or reserved types. Page 17 of APRS 1.0.1 spec. 14 | """ 15 | 16 | # fmt: off 17 | MIC_E_BETA0 = 0x1C 18 | MIC_E_OLD_BETA0 = 0x1D 19 | POSITION = ord("!") 20 | PEET_BROS_WX1 = ord("#") 21 | RAW_GPRS_ULT2K = ord("$") 22 | AGRELO_DFJR = ord("%") 23 | RESERVED_MAP = ord("&") 24 | MIC_E_OLD = ord("'") 25 | ITEM = ord(")") 26 | PEET_BROS_WX2 = ord("*") 27 | TEST_DATA = ord(",") 28 | POSITION_TS = ord("/") 29 | MESSAGE = ord(":") 30 | OBJECT = ord(";") 31 | STATIONCAP = ord("<") 32 | POSITION_MSGCAP = ord("=") 33 | STATUS = ord(">") 34 | QUERY = ord("?") 35 | POSITION_TS_MSGCAP = ord("@") 36 | TELEMETRY = ord("T") 37 | MAIDENHEAD = ord("[") 38 | WX = ord("_") 39 | MIC_E = ord("`") 40 | USER_DEFINED = ord("{") 41 | THIRD_PARTY = ord("}") 42 | # fmt: on 43 | -------------------------------------------------------------------------------- /tests/test_station/test_constructor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.station import AX25Station 4 | from aioax25.version import AX25Version 5 | 6 | from ..mocks import DummyInterface 7 | 8 | 9 | def test_constructor_log(): 10 | """ 11 | Test the AX25Constructor uses the log given. 12 | """ 13 | 14 | class DummyLogger(object): 15 | pass 16 | 17 | log = DummyLogger() 18 | interface = DummyInterface() 19 | station = AX25Station( 20 | interface=interface, callsign="VK4MSL", ssid=3, log=log 21 | ) 22 | assert station._log is log 23 | 24 | 25 | def test_constructor_loop(): 26 | """ 27 | Test the AX25Constructor uses the IO loop given. 28 | """ 29 | 30 | class DummyLoop(object): 31 | pass 32 | 33 | loop = DummyLoop() 34 | interface = DummyInterface() 35 | station = AX25Station( 36 | interface=interface, callsign="VK4MSL", ssid=3, loop=loop 37 | ) 38 | assert station._loop is loop 39 | 40 | 41 | def test_constructor_protocol(): 42 | """ 43 | Test the AX25Constructor validates the protocol 44 | """ 45 | try: 46 | AX25Station( 47 | interface=DummyInterface(), 48 | callsign="VK4MSL", 49 | ssid=3, 50 | protocol=AX25Version.AX25_10, 51 | ) 52 | assert False, "Should not have worked" 53 | except ValueError as e: 54 | assert str(e) == "'1.x' not a supported AX.25 protocol version" 55 | -------------------------------------------------------------------------------- /tests/test_signal/test_slot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for signalslot wrappers 5 | """ 6 | 7 | from aioax25.signal import Slot 8 | 9 | 10 | def test_slot_call(): 11 | """ 12 | Test the __call__ method passes the arguments given. 13 | """ 14 | calls = [] 15 | slot = Slot( 16 | lambda **kwa: calls.append(kwa), 17 | constkeyword1=1, 18 | constkeyword2=2, 19 | keyword3=3, 20 | ) 21 | 22 | slot(callkeyword1=1, callkeyword2=2, keyword3=30) 23 | 24 | # We should have a single call 25 | assert len(calls) == 1 26 | kwargs = calls.pop(0) 27 | 28 | # kwargs should contain the merged keywords 29 | assert set(kwargs.keys()) == set( 30 | [ 31 | "constkeyword1", 32 | "constkeyword2", 33 | "callkeyword1", 34 | "callkeyword2", 35 | "keyword3", 36 | ] 37 | ) 38 | assert kwargs["constkeyword1"] == 1 39 | assert kwargs["constkeyword2"] == 2 40 | assert kwargs["callkeyword1"] == 1 41 | assert kwargs["callkeyword2"] == 2 42 | 43 | # This is given by both, call should override constructor 44 | assert kwargs["keyword3"] == 30 45 | 46 | 47 | def test_slot_exception(): 48 | """ 49 | Test the __call__ swallows exceptions. 50 | """ 51 | 52 | def _slot(**kwargs): 53 | raise RuntimeError("Whoopsie") 54 | 55 | slot = Slot(_slot) 56 | 57 | # This should not trigger an exception 58 | slot() 59 | -------------------------------------------------------------------------------- /tests/test_station/test_attach_detach.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.station import AX25Station 4 | 5 | from ..mocks import DummyInterface 6 | 7 | 8 | def test_attach(): 9 | """ 10 | Test attach binds the station to the interface. 11 | """ 12 | interface = DummyInterface() 13 | station = AX25Station(interface=interface, callsign="VK4MSL-5") 14 | station.attach() 15 | 16 | assert len(interface.bind_calls) == 1 17 | assert len(interface.unbind_calls) == 0 18 | assert len(interface.transmit_calls) == 0 19 | 20 | (args, kwargs) = interface.bind_calls.pop() 21 | assert args == (station._on_receive,) 22 | assert set(kwargs.keys()) == set(["callsign", "ssid", "regex"]) 23 | assert kwargs["callsign"] == "VK4MSL" 24 | assert kwargs["ssid"] == 5 25 | assert kwargs["regex"] == False 26 | 27 | 28 | def test_detach(): 29 | """ 30 | Test attach unbinds the station to the interface. 31 | """ 32 | interface = DummyInterface() 33 | station = AX25Station(interface=interface, callsign="VK4MSL-5") 34 | station.detach() 35 | 36 | assert len(interface.bind_calls) == 0 37 | assert len(interface.unbind_calls) == 1 38 | assert len(interface.transmit_calls) == 0 39 | 40 | (args, kwargs) = interface.unbind_calls.pop() 41 | assert args == (station._on_receive,) 42 | assert set(kwargs.keys()) == set(["callsign", "ssid", "regex"]) 43 | assert kwargs["callsign"] == "VK4MSL" 44 | assert kwargs["ssid"] == 5 45 | assert kwargs["regex"] == False 46 | -------------------------------------------------------------------------------- /tests/test_station/test_getpeer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.station import AX25Station 4 | from aioax25.peer import AX25Peer 5 | from aioax25.frame import AX25Address 6 | 7 | from ..mocks import DummyInterface, DummyPeer 8 | 9 | 10 | def test_unknown_peer_nocreate_keyerror(): 11 | """ 12 | Test fetching an unknown peer with create=False raises KeyError 13 | """ 14 | station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5") 15 | try: 16 | station.getpeer("VK4BWI", create=False) 17 | assert False, "Should not have worked" 18 | except KeyError as e: 19 | assert str(e) == ( 20 | "AX25Address(callsign=VK4BWI, ssid=0, " 21 | "ch=False, res0=True, res1=True, extension=False)" 22 | ) 23 | 24 | 25 | def test_unknown_peer_create_instance(): 26 | """ 27 | Test fetching an unknown peer with create=True generates peer 28 | """ 29 | station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5") 30 | peer = station.getpeer("VK4BWI", create=True) 31 | assert isinstance(peer, AX25Peer) 32 | 33 | 34 | def test_known_peer_fetch_instance(): 35 | """ 36 | Test fetching an known peer returns that known peer 37 | """ 38 | station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5") 39 | mypeer = DummyPeer(station, AX25Address("VK4BWI")) 40 | 41 | # Inject the peer 42 | station._peers[mypeer._address] = mypeer 43 | 44 | # Retrieve the peer instance 45 | peer = station.getpeer("VK4BWI") 46 | assert peer is mypeer 47 | -------------------------------------------------------------------------------- /tests/test_uint.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.uint import encode, decode 4 | from .hex import from_hex, hex_cmp 5 | 6 | 7 | def test_encode_zero(): 8 | """ 9 | Test encode generates at least one byte if given a zero. 10 | """ 11 | hex_cmp(encode(0), b"\x00") 12 | 13 | 14 | def test_encode_le_nolen(): 15 | """ 16 | Test encode represents a little-endian integer in as few bytes as needed. 17 | """ 18 | hex_cmp(encode(0x12345, big_endian=False), from_hex("45 23 01")) 19 | 20 | 21 | def test_encode_le_len(): 22 | """ 23 | Test encode will generate an integer of the required size. 24 | """ 25 | hex_cmp( 26 | encode(0x12345, big_endian=False, length=4), from_hex("45 23 01 00") 27 | ) 28 | 29 | 30 | def test_encode_le_truncate(): 31 | """ 32 | Test encode will truncate an integer to the required size. 33 | """ 34 | hex_cmp( 35 | encode(0x123456789A, big_endian=False, length=4), 36 | from_hex("9a 78 56 34"), 37 | ) 38 | 39 | 40 | def test_encode_be(): 41 | """ 42 | Test we can encode big-endian integers. 43 | """ 44 | hex_cmp(encode(0x11223344, big_endian=True), from_hex("11 22 33 44")) 45 | 46 | 47 | def test_decode_be(): 48 | """ 49 | Test we can decode big-endian integers. 50 | """ 51 | assert decode(from_hex("11 22 33"), big_endian=True) == 0x112233 52 | 53 | 54 | def test_decode_le(): 55 | """ 56 | Test we can decode little-endian integers. 57 | """ 58 | assert decode(from_hex("11 22 33"), big_endian=False) == 0x332211 59 | -------------------------------------------------------------------------------- /tests/test_peer/peer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Fixture for initialising an AX25 Peer 5 | """ 6 | 7 | from aioax25.peer import AX25Peer, AX25RejectMode 8 | from aioax25.version import AX25Version 9 | from ..mocks import DummyIOLoop, DummyLogger 10 | 11 | 12 | class TestingAX25Peer(AX25Peer): 13 | def __init__( 14 | self, 15 | station, 16 | address, 17 | repeaters, 18 | max_ifield=256, 19 | max_ifield_rx=256, 20 | max_retries=10, 21 | max_outstanding_mod8=7, 22 | max_outstanding_mod128=127, 23 | rr_delay=10.0, 24 | rr_interval=30.0, 25 | rnr_interval=10.0, 26 | ack_timeout=3.0, 27 | idle_timeout=900.0, 28 | protocol=AX25Version.UNKNOWN, 29 | modulo128=False, 30 | reject_mode=AX25RejectMode.SELECTIVE_RR, 31 | full_duplex=False, 32 | reply_path=None, 33 | locked_path=False, 34 | paclen=128, 35 | ): 36 | super(TestingAX25Peer, self).__init__( 37 | station, 38 | address, 39 | repeaters, 40 | max_ifield, 41 | max_ifield_rx, 42 | max_retries, 43 | max_outstanding_mod8, 44 | max_outstanding_mod128, 45 | rr_delay, 46 | rr_interval, 47 | rnr_interval, 48 | ack_timeout, 49 | idle_timeout, 50 | protocol, 51 | modulo128, 52 | DummyLogger("peer"), 53 | DummyIOLoop(), 54 | reject_mode, 55 | full_duplex, 56 | reply_path, 57 | locked_path, 58 | paclen, 59 | ) 60 | -------------------------------------------------------------------------------- /tests/test_kiss/test_factory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | KISS device factory unit tests. 5 | """ 6 | 7 | from aioax25.kiss import ( 8 | SerialKISSDevice, 9 | SubprocKISSDevice, 10 | TCPKISSDevice, 11 | make_device, 12 | ) 13 | 14 | 15 | def test_serial_kiss(): 16 | """ 17 | Test we can create a ``SerialKISSDevice``. 18 | """ 19 | dev = make_device(type="serial", device="/dev/ttyS0", baudrate=9600) 20 | assert isinstance(dev, SerialKISSDevice) 21 | assert dev._device == "/dev/ttyS0" 22 | assert dev._baudrate == 9600 23 | 24 | 25 | def test_subproc_kiss(): 26 | """ 27 | Test we can create a ``SubprocKISSDevice``. 28 | """ 29 | dev = make_device(type="subproc", command=["somecmd", "a", "b", "c"]) 30 | assert isinstance(dev, SubprocKISSDevice) 31 | assert dev._command == ["somecmd", "a", "b", "c"] 32 | assert dev._shell is False 33 | 34 | 35 | def test_tcp_kiss(): 36 | """ 37 | Test we can create a ``TCPKISSDevice``. 38 | """ 39 | dev = make_device(type="tcp", host="localhost", port=10000) 40 | assert isinstance(dev, TCPKISSDevice) 41 | assert dev._conn_args == dict( 42 | host="localhost", 43 | port=10000, 44 | ssl=None, 45 | family=0, 46 | proto=0, 47 | flags=0, 48 | sock=None, 49 | local_addr=None, 50 | server_hostname=None, 51 | ) 52 | 53 | 54 | def test_unknown_kiss_type(): 55 | """ 56 | Test that unknown KISS device types are rejected 57 | """ 58 | try: 59 | dev = make_device(type="bogus") 60 | assert False, "Should not have worked, got a %r" % (dev,) 61 | except ValueError as e: 62 | assert str(e) == ("Unrecognised type=%r" % ("bogus",)) 63 | -------------------------------------------------------------------------------- /tests/test_kiss/test_tcp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | TCP KISS interface unit tests. 5 | """ 6 | 7 | # Most of the functionality here is common to SerialKISSDevice, so this 8 | # really just tests that we pass the right commands to the IOLoop when 9 | # establishing a connection. 10 | 11 | from aioax25 import kiss 12 | import logging 13 | from ..asynctest import asynctest 14 | from asyncio import get_event_loop 15 | 16 | 17 | @asynctest 18 | async def test_open_connection(): 19 | # This will receive the arguments passed to create_connection 20 | connection_args = {} 21 | 22 | loop = get_event_loop() 23 | 24 | # Stub the create_connection method 25 | orig_create_connection = loop.create_connection 26 | 27 | async def _create_connection(proto_factory, **kwargs): 28 | # proto_factory should give us a KISSProtocol object 29 | protocol = proto_factory() 30 | assert isinstance(protocol, kiss.KISSProtocol) 31 | 32 | connection_args.update(kwargs) 33 | 34 | loop.create_connection = _create_connection 35 | 36 | try: 37 | device = kiss.TCPKISSDevice( 38 | host="localhost", 39 | port=5432, 40 | loop=loop, 41 | log=logging.getLogger(__name__), 42 | ) 43 | 44 | await device._open_connection() 45 | 46 | # Expect a connection attempt to have been made 47 | assert connection_args == dict( 48 | host="localhost", 49 | port=5432, 50 | ssl=None, 51 | family=0, 52 | proto=0, 53 | flags=0, 54 | sock=None, 55 | local_addr=None, 56 | server_hostname=None, 57 | ) 58 | finally: 59 | # Restore mock 60 | loop.create_connection = orig_create_connection 61 | -------------------------------------------------------------------------------- /tests/test_peer/test_ua.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for AX25Peer UA handling 5 | """ 6 | 7 | from aioax25.frame import ( 8 | AX25Address, 9 | AX25Path, 10 | AX25UnnumberedAcknowledgeFrame, 11 | ) 12 | from aioax25.version import AX25Version 13 | from .peer import TestingAX25Peer 14 | from ..mocks import DummyStation 15 | 16 | 17 | # UA reception 18 | 19 | 20 | def test_peer_recv_ua(): 21 | """ 22 | Test _on_receive_ua does nothing if no UA expected. 23 | """ 24 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 25 | peer = TestingAX25Peer( 26 | station=station, 27 | address=AX25Address("VK4MSL"), 28 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 29 | full_duplex=True, 30 | ) 31 | 32 | peer._on_receive_ua() 33 | 34 | # does nothing 35 | 36 | 37 | # UA transmission 38 | 39 | 40 | def test_peer_send_ua(): 41 | """ 42 | Test _send_ua correctly addresses and sends a UA frame. 43 | """ 44 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 45 | interface = station._interface() 46 | peer = TestingAX25Peer( 47 | station=station, 48 | address=AX25Address("VK4MSL"), 49 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 50 | full_duplex=True, 51 | ) 52 | 53 | # Request a UA frame be sent 54 | peer._send_ua() 55 | 56 | # There should be a frame sent 57 | assert len(interface.transmit_calls) == 1 58 | (tx_args, tx_kwargs) = interface.transmit_calls.pop(0) 59 | 60 | # This should be a UA 61 | assert tx_kwargs == {"callback": None} 62 | assert len(tx_args) == 1 63 | (frame,) = tx_args 64 | assert isinstance(frame, AX25UnnumberedAcknowledgeFrame) 65 | 66 | assert str(frame.header.destination) == "VK4MSL*" 67 | assert str(frame.header.source) == "VK4MSL-1" 68 | assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3" 69 | -------------------------------------------------------------------------------- /tests/test_kiss/test_port.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | KISS command unit tests 5 | """ 6 | 7 | from aioax25.kiss import KISSPort, KISSCmdData, KISSCommand 8 | import logging 9 | 10 | 11 | class DummyKISSDevice(object): 12 | def __init__(self): 13 | self.sent = [] 14 | 15 | def _send(self, frame): 16 | self.sent.append(frame) 17 | 18 | 19 | def test_send(): 20 | """ 21 | Test that frames passed to send are wrapped in a KISS frame and passed up. 22 | """ 23 | dev = DummyKISSDevice() 24 | port = KISSPort(dev, 5, logging.getLogger("port")) 25 | port.send(b"this is a test frame") 26 | 27 | assert len(dev.sent) == 1 28 | last = dev.sent.pop(0) 29 | 30 | assert isinstance(last, KISSCmdData) 31 | assert last.port == 5 32 | assert last.payload == b"this is a test frame" 33 | 34 | 35 | def test_receive_frame(): 36 | """ 37 | Test that _receive_frame_recv_frame passes data frame payloads to signal 38 | """ 39 | sent = [] 40 | dev = DummyKISSDevice() 41 | port = KISSPort(dev, 5, logging.getLogger("port")) 42 | port.received.connect(lambda frame, **k: sent.append(frame)) 43 | 44 | port._receive_frame(KISSCmdData(port=5, payload=b"this is a test frame")) 45 | 46 | # We should have received that via the signal 47 | assert len(sent) == 1 48 | assert sent.pop(0) == b"this is a test frame" 49 | 50 | 51 | def test_receive_frame_filter_nondata(): 52 | """ 53 | Test that _receive_frame filters non-KISSCmdData frames 54 | """ 55 | sent = [] 56 | dev = DummyKISSDevice() 57 | port = KISSPort(dev, 5, logging.getLogger("port")) 58 | port.received.connect(lambda frame, **k: sent.append(frame)) 59 | 60 | port._receive_frame( 61 | KISSCommand(port=5, cmd=8, payload=b"this is a test frame") 62 | ) 63 | 64 | # We should not have received that frame 65 | assert len(sent) == 0 66 | -------------------------------------------------------------------------------- /aioax25/aprs/frame.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS framing. 5 | """ 6 | 7 | from ..frame import AX25UnnumberedInformationFrame 8 | from .datatype import APRSDataType 9 | 10 | 11 | class APRSFrame(AX25UnnumberedInformationFrame): 12 | """ 13 | This is a helper sub-class for encoding and decoding APRS messages into 14 | AX.25 frames. 15 | """ 16 | 17 | DATA_TYPE_HANDLERS = {} 18 | 19 | @classmethod 20 | def decode(cls, uiframe, log): 21 | """ 22 | Decode the given UI frame (AX25UnnumberedInformationFrame) to a 23 | suitable APRSFrame sub-class. 24 | """ 25 | # Do not decode if not the APRS PID value 26 | if uiframe.pid != cls.PID_NO_L3: 27 | # Clearly not an APRS message 28 | log.debug("Frame has wrong PID for APRS") 29 | return uiframe 30 | 31 | if len(uiframe.payload) == 0: 32 | log.debug("Frame has no payload data") 33 | return uiframe 34 | 35 | try: 36 | # Inspect the first byte. 37 | type_code = APRSDataType(uiframe.payload[0]) 38 | handler_class = cls.DATA_TYPE_HANDLERS[type_code] 39 | 40 | # Decode the payload as text 41 | payload = uiframe.payload.decode("US-ASCII") 42 | 43 | return handler_class.decode(uiframe, payload, log) 44 | except: 45 | # Not decodable, leave as-is 46 | log.debug("Failed to decode as APRS", exc_info=1) 47 | return uiframe 48 | 49 | def __init__( 50 | self, 51 | destination, 52 | source, 53 | payload, 54 | repeaters=None, 55 | pf=False, 56 | cr=False, 57 | src_cr=None, 58 | ): 59 | super(APRSFrame, self).__init__( 60 | destination=destination, 61 | source=source, 62 | pid=self.PID_NO_L3, # APRS spec 63 | payload=payload, 64 | repeaters=repeaters, 65 | pf=pf, 66 | cr=cr, 67 | src_cr=src_cr, 68 | ) 69 | -------------------------------------------------------------------------------- /tests/test_peer/test_state.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Test state transition logic 5 | """ 6 | 7 | from aioax25.frame import AX25Address, AX25Path 8 | from aioax25.peer import AX25PeerState 9 | from .peer import TestingAX25Peer 10 | from ..mocks import DummyStation 11 | 12 | # Idle time-out cancellation 13 | 14 | 15 | def test_state_unchanged(): 16 | """ 17 | Test that _set_conn_state is a no-op if the state is not different. 18 | """ 19 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 20 | peer = TestingAX25Peer( 21 | station=station, 22 | address=AX25Address("VK4MSL"), 23 | repeaters=AX25Path("VK4RZB"), 24 | locked_path=True, 25 | ) 26 | 27 | state_changes = [] 28 | 29 | def _on_state_change(**kwargs): 30 | state_changes.append(kwargs) 31 | 32 | peer.connect_state_changed.connect(_on_state_change) 33 | 34 | assert peer._state is AX25PeerState.DISCONNECTED 35 | assert peer.state is AX25PeerState.DISCONNECTED 36 | 37 | peer._set_conn_state(AX25PeerState.DISCONNECTED) 38 | 39 | assert state_changes == [] 40 | 41 | 42 | def test_state_changed(): 43 | """ 44 | Test that _set_conn_state reports and stores state changes. 45 | """ 46 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 47 | peer = TestingAX25Peer( 48 | station=station, 49 | address=AX25Address("VK4MSL"), 50 | repeaters=AX25Path("VK4RZB"), 51 | locked_path=True, 52 | ) 53 | 54 | state_changes = [] 55 | 56 | def _on_state_change(**kwargs): 57 | state_changes.append(kwargs) 58 | 59 | peer.connect_state_changed.connect(_on_state_change) 60 | 61 | assert peer._state is AX25PeerState.DISCONNECTED 62 | assert peer.state is AX25PeerState.DISCONNECTED 63 | 64 | peer._set_conn_state(AX25PeerState.CONNECTED) 65 | 66 | assert peer._state is AX25PeerState.CONNECTED 67 | assert peer.state is AX25PeerState.CONNECTED 68 | assert state_changes[1:] == [] 69 | 70 | change = state_changes.pop(0) 71 | assert change.pop("station") is station 72 | assert change.pop("peer") is peer 73 | assert change.pop("state") is AX25PeerState.CONNECTED 74 | assert change == {} 75 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `aioax25` Change Log 2 | 3 | `aioax25` roughly follows Semantic Versioning, although right now it's 4 | officially in "development" and so nothing is actually locked down API-wise at 5 | this time. Notable changes will be mentioned here. 6 | 7 | --- 8 | 9 | ## Unreleased 10 | 11 | - Use of `serial_asyncio` for asynchronous serial access. 12 | - Re-implemented `TCPKISSDevice` to use `asyncio` transports, TLS now possible. 13 | - Implemented `SubprocKISSDevice` using the same underlying infrastructure as 14 | `SerialKISSDevice` and `TCPKISSDevice`. 15 | - Added `make_device` factory for implementation-agnostic creation of KISS 16 | devices from a configuration file. Supports `serial`, `tcp` and `subproc`. 17 | - Python 3.4 support has been dropped, the library now requires Python 3.5 or 18 | later. 19 | 20 | ## Release 0.0.10 (2021-05-18) 21 | 22 | [Support for TCP-based KISS sockets](https://github.com/sjlongland/aioax25/pull/7). 23 | Many thanks to Walter for this contribution. 24 | 25 | ## Release 0.0.9 (2021-01-23) 26 | 27 | - Fixed buggy APRS message serialisation (payload of 28 | `MYCALL :message{123}None`) when reply-ACK was disabled. 29 | - Always use fixed APRS path in replies as some digipeaters do not do AX.25 30 | digipeating and will therefore *NOT* digipeat a message unless they see 31 | `WIDEn-N` in the digipeater path. 32 | - Test compatibility with Python 3.7 and 3.8. 33 | 34 | ## Release 0.0.8 (2019-08-29) 35 | 36 | Add support for APRS Reply-ACK message (for compatibility with Xastir). 37 | 38 | ## Release 0.0.7 (2019-07-09) 39 | 40 | Send KISS initialisation sequence slower to ensure the console on TNCs like the 41 | Kantronics KPC-3 don't miss anything. 42 | 43 | ## Release 0.0.6 (2019-07-09) 44 | 45 | Prevent APRS digipeater from digipeating old (stale) messages. 46 | 47 | ## Release 0.0.5 (2019-06-29) 48 | 49 | Fix handling of APRS message ACK/REJ. 50 | 51 | ## Release 0.0.4 (2019-06-29) 52 | 53 | APRS MIC-e fixes, and related bugfixes for APRS digipeater. 54 | 55 | ## Release 0.0.3 (2019-06-29) 56 | 57 | Further APRS digipeater enhancements. 58 | 59 | ## Release 0.0.2 (2019-06-22) 60 | 61 | Addition of APRS digipeating. 62 | 63 | ## Release 0.0.1 (2019-05-12) 64 | 65 | Initial release of `aioax25`, publish on pypi. 66 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Pre-merge and post-merge tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | inputs: 8 | skip-coverage: 9 | # lcov won't work in Python 3.5. 10 | description: "Whether to skip coverage checks?" 11 | type: boolean 12 | required: false 13 | default: false 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | test: 20 | name: Run tests 21 | uses: ./.github/workflows/test.yml 22 | # https://wildwolf.name/github-actions-how-to-avoid-running-the-same-workflow-multiple-times/ 23 | if: > 24 | github.event_name != 'pull_request' 25 | || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name 26 | strategy: 27 | matrix: 28 | python: 29 | - "3.5" 30 | - "3.11" 31 | - "3.12" 32 | platform: 33 | # This package is supposed to be OS-independent and is unlikely to have 34 | # OS-specific bugs, so we conserve runner usage by only testing on Linux 35 | # during pre-merge and post-merge testing. If it works on Linux, it'll 36 | # probably work on Mac and Windows too. But if an OS-specific bug does 37 | # slip through, we should catch it in pre-release testing. 38 | - ubuntu-latest 39 | exclude: 40 | # Python 3.5 does not run on ubuntu-latest 41 | - python: "3.5" 42 | platform: ubuntu-latest 43 | include: 44 | - python: "3.5" 45 | platform: ubuntu-20.04 46 | skip-coverage: true 47 | with: 48 | python-version: ${{ matrix.python }} 49 | platform: ${{ matrix.platform }} 50 | skip-coverage: ${{ matrix.skip-coverage || false }} 51 | 52 | check: # This job does nothing and is only used for the branch protection 53 | # https://wildwolf.name/github-actions-how-to-avoid-running-the-same-workflow-multiple-times/ 54 | if: > 55 | github.event_name != 'pull_request' 56 | || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name 57 | 58 | needs: 59 | - test 60 | 61 | runs-on: ubuntu-latest 62 | 63 | steps: 64 | - name: Decide whether the needed jobs succeeded or failed 65 | uses: re-actors/alls-green@release/v1 66 | with: 67 | jobs: ${{ toJSON(needs) }} 68 | -------------------------------------------------------------------------------- /tests/test_frame/test_ax25path.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.frame import AX25Address, AX25Path 4 | 5 | 6 | def test_decode(): 7 | """ 8 | Test given a list of strings, AX25Path decodes them. 9 | """ 10 | path = AX25Path("VK4MSL", "VK4RZB", "VK4RZA") 11 | assert path._path[0]._callsign == "VK4MSL" 12 | assert path._path[1]._callsign == "VK4RZB" 13 | assert path._path[2]._callsign == "VK4RZA" 14 | 15 | 16 | def test_addresses(): 17 | """ 18 | Test given a list of AX25Addresses, AX25Path passes them in. 19 | """ 20 | path = AX25Path( 21 | AX25Address.decode("VK4MSL"), 22 | AX25Address.decode("VK4RZB"), 23 | AX25Address.decode("VK4RZA"), 24 | ) 25 | assert path._path[0]._callsign == "VK4MSL" 26 | assert path._path[1]._callsign == "VK4RZB" 27 | assert path._path[2]._callsign == "VK4RZA" 28 | 29 | 30 | def test_str(): 31 | """ 32 | Test we can return the canonical format for a repeater path. 33 | """ 34 | path = AX25Path("VK4MSL", "VK4RZB", "VK4RZA") 35 | assert str(path) == "VK4MSL,VK4RZB,VK4RZA" 36 | 37 | 38 | def test_repr(): 39 | """ 40 | Test we can return the Python representation for a repeater path. 41 | """ 42 | path = AX25Path("VK4MSL", "VK4RZB", "VK4RZA") 43 | assert repr(path) == ( 44 | "AX25Path(" 45 | "AX25Address(" 46 | "callsign=VK4MSL, ssid=0, ch=False, " 47 | "res0=True, res1=True, extension=False" 48 | "), " 49 | "AX25Address(" 50 | "callsign=VK4RZB, ssid=0, ch=False, " 51 | "res0=True, res1=True, extension=False" 52 | "), " 53 | "AX25Address(" 54 | "callsign=VK4RZA, ssid=0, ch=False, " 55 | "res0=True, res1=True, extension=False" 56 | ")" 57 | ")" 58 | ) 59 | 60 | 61 | def test_reply(): 62 | """ 63 | Test 'reply' returns a reverse path for sending replies. 64 | """ 65 | path = AX25Path("VK4MSL*", "VK4RZB*", "VK4RZA*") 66 | reply = path.reply 67 | assert str(reply) == "VK4RZA,VK4RZB,VK4MSL" 68 | 69 | 70 | def test_reply_unused(): 71 | """ 72 | Test 'reply' ignores unused repeaters. 73 | """ 74 | path = AX25Path("VK4MSL*", "VK4RZB*", "VK4RZA") 75 | reply = path.reply 76 | assert str(reply) == "VK4RZB,VK4MSL" 77 | 78 | 79 | def test_replace(): 80 | """ 81 | Test we can replace an alias with our own call-sign. 82 | """ 83 | path1 = AX25Path("WIDE2-2", "WIDE1-1") 84 | path2 = path1.replace("WIDE2-2", "VK4MSL*") 85 | assert str(path2) == "VK4MSL*,WIDE1-1" 86 | -------------------------------------------------------------------------------- /tests/fixtures/logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | A logging.Logger substitute for testing purposes. 5 | """ 6 | 7 | # Was going to use pytest_logdog, *BUT* that needs Python 3.7+ and I'm 8 | # targetting Python 3.4! 9 | 10 | import pytest 11 | from sys import exc_info 12 | 13 | 14 | class DummyLogger(object): 15 | def __init__(self): 16 | self.logrecords = [] 17 | self.children = {} 18 | 19 | def _addrecord(self, log_method, log_args, log_kwargs, _exc_info=False): 20 | if _exc_info: 21 | (ex_type, ex_val, ex_tb) = exc_info() 22 | else: 23 | ex_type = None 24 | ex_val = None 25 | ex_tb = None 26 | 27 | self.logrecords.append( 28 | dict( 29 | method=log_method, 30 | args=log_args, 31 | kwargs=log_kwargs, 32 | ex_type=ex_type, 33 | ex_val=ex_val, 34 | ex_tb=ex_tb, 35 | ) 36 | ) 37 | 38 | # Message logging endpoints 39 | def critical(self, *args, exc_info=False, **kwargs): 40 | self._addrecord("critical", args, kwargs, exc_info) 41 | 42 | def debug(self, *args, exc_info=False, **kwargs): 43 | self._addrecord("debug", args, kwargs, exc_info) 44 | 45 | def error(self, *args, exc_info=False, **kwargs): 46 | self._addrecord("error", args, kwargs, exc_info) 47 | 48 | def exception(self, *args, **kwargs): 49 | self._addrecord("exception", args, kwargs, True) 50 | 51 | def fatal(self, *args, exc_info=False, **kwargs): 52 | self._addrecord("fatal", args, kwargs, exc_info) 53 | 54 | def info(self, *args, exc_info=False, **kwargs): 55 | self._addrecord("info", args, kwargs, exc_info) 56 | 57 | def log(self, *args, exc_info=False, **kwargs): 58 | self._addrecord("log", args, kwargs, exc_info) 59 | 60 | def warn(self, *args, exc_info=False, **kwargs): 61 | self._addrecord("warn", args, kwargs, exc_info) 62 | 63 | def warning(self, *args, exc_info=False, **kwargs): 64 | self._addrecord("warning", args, kwargs, exc_info) 65 | 66 | # Info endpoints 67 | def isEnabledFor(self, level): 68 | return True 69 | 70 | # Hierarchy endpoints 71 | def getChild(self, suffix): 72 | try: 73 | return self.children[suffix] 74 | except KeyError: 75 | child = DummyLogger() 76 | self.children[suffix] = child 77 | return child 78 | 79 | 80 | @pytest.fixture 81 | def logger(): 82 | """Dummy logger instance that mocks logging.Logger.""" 83 | return DummyLogger() 84 | -------------------------------------------------------------------------------- /tests/test_unit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Unit handling tests 5 | """ 6 | 7 | from pytest import skip 8 | 9 | try: 10 | from pint import Quantity 11 | 12 | PINT_SUPPORTED = True 13 | except ImportError: 14 | PINT_SUPPORTED = False 15 | 16 | from aioax25.unit import checknumeric, convertvalue 17 | 18 | 19 | def test_checknumeric_required_none(): 20 | """ 21 | checknumeric should raise ValueError if required value is None 22 | """ 23 | try: 24 | checknumeric("myparam", None, required=True) 25 | assert False, "Should not have passed" 26 | except ValueError as e: 27 | assert str(e) == "myparam is a required parameter" 28 | 29 | 30 | def test_checknumeric_optional_none(): 31 | """ 32 | checknumeric should return None if optional value is None 33 | """ 34 | assert ( 35 | checknumeric("myparam", None, required=False) is None 36 | ), "Should have passed through None for optional parameter" 37 | 38 | 39 | def test_checknumeric_int(): 40 | """ 41 | checknumeric should cast int to float 42 | """ 43 | v = checknumeric("myparam", 1234) 44 | assert v == 1234.0 45 | assert isinstance(v, float), "Should cast to float" 46 | 47 | 48 | def test_checknumeric_str(): 49 | """ 50 | checknumeric should cast str to float 51 | """ 52 | v = checknumeric("myparam", "123.45") 53 | assert v == 123.45 54 | assert isinstance(v, float), "Should cast to float" 55 | 56 | 57 | def test_checknumeric_float(): 58 | """ 59 | checknumeric should pass through float 60 | """ 61 | v = checknumeric("myparam", 1234.56) 62 | assert v == 1234.56 63 | 64 | 65 | def test_convertvalue_none(): 66 | """ 67 | convertvalue should handle None without barfing 68 | """ 69 | assert ( 70 | convertvalue("optparam", None, "m", required=False) is None 71 | ), "Should have returned None here" 72 | 73 | 74 | def test_convertvalue_barevalue(): 75 | """ 76 | convertvalue should pass through bare value 77 | """ 78 | assert ( 79 | convertvalue("optparam", 123.45, "m", required=False) == 123.45 80 | ), "Should have passed through value" 81 | 82 | 83 | def test_convertvalue_quantity(): 84 | """ 85 | convertvalue should convert Quantity to correct unit 86 | """ 87 | if not PINT_SUPPORTED: 88 | skip( 89 | "pint.Quantity could not be imported, " 90 | "so unit conversion won't work as expected." 91 | ) 92 | 93 | assert ( 94 | convertvalue("optparam", Quantity(1, "in"), "cm", required=False) 95 | == 2.54 96 | ), "Should have converted the value" 97 | -------------------------------------------------------------------------------- /aioax25/unit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Unit handling. Optionally wraps `pint`, otherwise we just 5 | assert numeric types and assume correct units. 6 | """ 7 | 8 | 9 | def checknumeric(name, value, required=False): 10 | """ 11 | Assert the value is a numeric value, or throw a TypeError. 12 | """ 13 | if value is None: 14 | if required: 15 | raise ValueError("%s is a required parameter" % name) 16 | else: 17 | return None 18 | 19 | # This will throw ValueError if it can't be converted 20 | return float(value) 21 | 22 | 23 | # Optional dependency: pint (for unit conversion) 24 | try: 25 | from pint import Quantity 26 | 27 | def convertvalue(name, quantity, units, required=False): 28 | """ 29 | Assert the value is a numeric value and convert to the appropriate 30 | units if possible. 31 | """ 32 | if (quantity is not None) and isinstance(quantity, Quantity): 33 | # Convert to target units, take the magnitude 34 | return quantity.to(units).magnitude 35 | else: 36 | # Pass through to handler 37 | return checknumeric(name, quantity, required=required) 38 | 39 | except ImportError: # pragma: no cover 40 | 41 | class Quantity(object): 42 | """ 43 | Dummy Quantity class work-alike for systems without `pint`. 44 | """ 45 | 46 | def __init__(self, magnitude, units): 47 | self.magnitude = magnitude 48 | self.units = units 49 | 50 | def __str__(self): 51 | return "%r %s" % (self.magnitude, self.units) 52 | 53 | def __repr__(self): 54 | return "Quantity(magnitude=%r, units=%r)" % ( 55 | self.magnitude, 56 | self.units, 57 | ) 58 | 59 | def to(self, units, *a, **kwa): 60 | if units == self.units: 61 | # No conversion required 62 | return self 63 | 64 | raise NotImplementedError( 65 | "Unit conversion is not implemented here. " 66 | "`pip install pint` if you want unit conversion." 67 | ) 68 | 69 | def convertvalue(name, quantity, units, required=False): 70 | if (quantity is not None) and isinstance(quantity, Quantity): 71 | # Assert correct units! 72 | if quantity.units != units: 73 | raise ValueError( 74 | "%s parameter must be in %s units (`pip install pint` " 75 | "for unit conversion)" % (name, units) 76 | ) 77 | return quantity.magnitude 78 | else: 79 | # Pass through to handler 80 | return checknumeric(name, quantity, required=required) 81 | -------------------------------------------------------------------------------- /tests/test_kiss/test_command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | KISS command unit tests 5 | """ 6 | 7 | from aioax25.kiss import KISSCommand, KISSCmdReturn, KISSCmdData 8 | 9 | 10 | def test_stuff_bytes(): 11 | """ 12 | Test that bytes are correctly escaped. 13 | """ 14 | assert bytes( 15 | KISSCommand._stuff_bytes( 16 | b"A test frame.\n" 17 | b"FEND becomes FESC TFEND: \xc0\n" 18 | b"while FESC becomes FESC TFESC: \xdb\n" 19 | ) 20 | ) == ( 21 | # This should be decoded as the following: 22 | b"A test frame.\n" 23 | b"FEND becomes FESC TFEND: \xdb\xdc\n" 24 | b"while FESC becomes FESC TFESC: \xdb\xdd\n" 25 | ) 26 | 27 | 28 | def test_unstuff_bytes(): 29 | """ 30 | Test that bytes are correctly unescaped. 31 | """ 32 | assert bytes( 33 | KISSCommand._unstuff_bytes( 34 | b"A test frame.\n" 35 | b"If we see FESC TFEND, we should get FEND: \xdb\xdc\n" 36 | b"while if we see FESC TFESC, we should get FESC: \xdb\xdd\n" 37 | b"FESC followed by any other byte should yield those\n" 38 | b"two original bytes: \xdb\xaa \xdb\xdb \xdb\xdb\xdd\n" 39 | ) 40 | ) == ( 41 | # This should unstuff to the following 42 | b"A test frame.\n" 43 | b"If we see FESC TFEND, we should get FEND: \xc0\n" 44 | b"while if we see FESC TFESC, we should get FESC: \xdb\n" 45 | b"FESC followed by any other byte should yield those\n" 46 | b"two original bytes: \xdb\xaa \xdb\xdb \xdb\xdb\n" 47 | ) 48 | 49 | 50 | def test_decode_unknown(): 51 | """ 52 | Test unknown KISS frames are decoded to the base KISSCommand base class. 53 | """ 54 | frame = KISSCommand.decode(b"\x58unknown command payload") 55 | assert isinstance(frame, KISSCommand) 56 | assert frame.cmd == 8 57 | assert frame.port == 5 58 | assert frame.payload == b"unknown command payload" 59 | 60 | 61 | def test_decode_data(): 62 | """ 63 | Test the DATA frame is decoded correctly. 64 | """ 65 | frame = KISSCommand.decode(b"\x90this is a data frame") 66 | assert isinstance(frame, KISSCmdData) 67 | assert frame.payload == b"this is a data frame" 68 | 69 | 70 | def test_encode_unknown(): 71 | """ 72 | Test we can encode an arbitrary frame. 73 | """ 74 | assert bytes(KISSCommand(port=3, cmd=12, payload=b"testing")) == ( 75 | b"\x3ctesting" 76 | ) 77 | 78 | 79 | def test_encode_return(): 80 | """ 81 | Test we can encode a RETURN frame 82 | """ 83 | assert bytes(KISSCmdReturn()) == b"\xff" 84 | 85 | 86 | def test_encode_data(): 87 | """ 88 | Test we can encode a DATA frame 89 | """ 90 | assert bytes(KISSCmdData(port=2, payload=b"a frame")) == b"\x20a frame" 91 | -------------------------------------------------------------------------------- /aioax25/aprs/symbol.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS symbol handling. 5 | """ 6 | 7 | from enum import Enum 8 | 9 | 10 | PRI_SYMBOL = "/" 11 | SEC_SYMBOL = "\\" 12 | NUM_UNCOMPRESSED = "0123456789" 13 | NUM_COMPRESSED = "abcdefghij" 14 | ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 15 | 16 | 17 | class APRSSymbolTable(Enum): 18 | PRIMARY = PRI_SYMBOL 19 | SECONDARY = SEC_SYMBOL 20 | 21 | 22 | class APRSOverlayType(Enum): 23 | NUM_UNCOMPRESSED = "NUM_UNCOMPRESSED" 24 | NUM_COMPRESSED = "NUM_COMPRESSED" 25 | ALPHA = "ALPHA" 26 | 27 | @staticmethod 28 | def identify(overlay): 29 | try: 30 | index = NUM_UNCOMPRESSED.index(overlay) 31 | return (APRSOverlayType.NUM_UNCOMPRESSED, index) 32 | except ValueError: 33 | pass 34 | 35 | try: 36 | index = NUM_COMPRESSED.index(overlay) 37 | return (APRSOverlayType.NUM_COMPRESSED, index) 38 | except ValueError: 39 | pass 40 | 41 | try: 42 | index = ALPHA.index(overlay) 43 | return (APRSOverlayType.ALPHA, index) 44 | except ValueError: 45 | pass 46 | 47 | raise ValueError("Not a valid overlay character: %r" % overlay) 48 | 49 | 50 | class APRSSymbol(object): 51 | """ 52 | Representation of an APRS symbol. 53 | """ 54 | 55 | def __init__(self, table, symbol, overlay=None): 56 | try: 57 | table = APRSSymbolTable(table) 58 | except ValueError: 59 | # Okay, one of the overlay sets 60 | overlay = table 61 | table = APRSSymbolTable.SECONDARY 62 | 63 | # Validate the overlay if given 64 | if overlay is not None: 65 | if table != APRSSymbolTable.SECONDARY: 66 | raise ValueError("Overlays only available on secondary table") 67 | (overlay_type, overlay) = APRSOverlayType.identify(overlay) 68 | else: 69 | overlay_type = None 70 | 71 | self.table = table 72 | self.symbol = symbol 73 | self.overlay = overlay 74 | self.overlay_type = overlay_type 75 | 76 | def __repr__(self): # pragma: no cover 77 | return "%s(table=%r, symbol=%r, overlay=%r)" % ( 78 | self.__class__.__name__, 79 | self.table, 80 | self.symbol, 81 | self.overlay, 82 | ) 83 | 84 | @property 85 | def tableident(self): 86 | """ 87 | Return the table identifier character 88 | """ 89 | if self.overlay_type == APRSOverlayType.NUM_UNCOMPRESSED: 90 | return NUM_UNCOMPRESSED[self.overlay] 91 | elif self.overlay_type == APRSOverlayType.NUM_COMPRESSED: 92 | return NUM_COMPRESSED[self.overlay] 93 | elif self.overlay_type == APRSOverlayType.ALPHA: 94 | return ALPHA[self.overlay] 95 | else: 96 | return self.table.value 97 | -------------------------------------------------------------------------------- /tests/test_aprs/test_datetime.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import datetime 4 | from aioax25.aprs.datetime import ( 5 | DHMUTCTimestamp, 6 | DHMLocalTimestamp, 7 | HMSTimestamp, 8 | MDHMTimestamp, 9 | decode, 10 | ) 11 | 12 | """ 13 | Time-stamp handling tests. 14 | """ 15 | 16 | REF_DT = datetime.datetime( 17 | 2022, 2, 12, 22, 2, 23, tzinfo=datetime.timezone.utc 18 | ) 19 | 20 | 21 | def test_dhmutctimestamp_encode(): 22 | """ 23 | Test we can format a DHM timestamp in UTC. 24 | """ 25 | assert str(DHMUTCTimestamp(12, 21, 52)) == "122152z" 26 | 27 | 28 | def test_dhmlocaltimestamp_encode(): 29 | """ 30 | Test we can format a DHM timestamp in local time. 31 | """ 32 | assert str(DHMLocalTimestamp(12, 21, 52)) == "122152/" 33 | 34 | 35 | def test_hmstimestamp_encode(): 36 | """ 37 | Test we can format a HMS timestamp. 38 | """ 39 | assert str(HMSTimestamp(21, 59, 6)) == "215906h" 40 | 41 | 42 | def test_mdhmtimestamp_encode(): 43 | """ 44 | Test we can format a MDHM timestamp. 45 | """ 46 | assert str(MDHMTimestamp(2, 12, 21, 52)) == "02122152" 47 | 48 | 49 | def test_dhmutctimestamp_decode(): 50 | """ 51 | Test we can decode a DHM timestamp in UTC. 52 | """ 53 | res = decode("162152z") 54 | assert isinstance(res, DHMUTCTimestamp) 55 | assert (res.day, res.hour, res.minute) == (16, 21, 52) 56 | 57 | 58 | def test_dhmlocaltimestamp_decode(): 59 | """ 60 | Test we can handle a DHM timestamp in local time. 61 | """ 62 | res = decode("162152/") 63 | assert isinstance(res, DHMLocalTimestamp) 64 | assert (res.day, res.hour, res.minute) == (16, 21, 52) 65 | 66 | 67 | def test_hmstimestamp_decode(): 68 | """ 69 | Test we can decode a HMS timestamp. 70 | """ 71 | res = decode("193207h") 72 | assert isinstance(res, HMSTimestamp) 73 | assert (res.hour, res.minute, res.second) == (19, 32, 7) 74 | 75 | 76 | def test_mdhmtimestamp_decode(): 77 | """ 78 | Test we can decode a MDHM timestamp. 79 | """ 80 | res = decode("02081932") 81 | assert isinstance(res, MDHMTimestamp) 82 | assert (res.month, res.day, res.hour, res.minute) == (2, 8, 19, 32) 83 | 84 | 85 | def test_too_short_decode(): 86 | """ 87 | Test the decoder rejects strings that are too short. 88 | """ 89 | try: 90 | decode("09:19") 91 | assert False, "Should not have decoded" 92 | except ValueError as e: 93 | assert str(e) == "Timestamp string too short" 94 | 95 | 96 | def test_invalid_format(): 97 | """ 98 | Test the decoder rejects strings that are too short. 99 | """ 100 | try: 101 | decode("130919\\") 102 | assert False, "Should not have decoded" 103 | except ValueError as e: 104 | assert str(e) == "Timestamp format not recognised" 105 | -------------------------------------------------------------------------------- /tests/test_signal/test_signal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for signalslot wrappers 5 | """ 6 | 7 | from aioax25.signal import Signal, Slot, OneshotSlot 8 | 9 | 10 | def test_connect(): 11 | """ 12 | Test connect links a slot function to the signal. 13 | """ 14 | calls = [] 15 | signal = Signal() 16 | signal.connect(lambda **kw: calls.append(kw)) 17 | signal.emit(myarg=123) 18 | signal.emit(myarg=456) 19 | 20 | assert len(calls) == 2 21 | 22 | # First call should have myarg=123 23 | call = calls.pop(0) 24 | assert set(call.keys()) == set(["myarg"]) 25 | assert call["myarg"] == 123 26 | 27 | # Last call should have myarg=456 28 | call = calls.pop(0) 29 | assert set(call.keys()) == set(["myarg"]) 30 | assert call["myarg"] == 456 31 | 32 | 33 | def test_connect_oneshot(): 34 | """ 35 | Test connect_oneshot links a slot function to the signal in one-shot mode. 36 | """ 37 | calls = [] 38 | signal = Signal() 39 | signal.connect_oneshot(lambda **kw: calls.append(kw)) 40 | signal.emit(myarg=123) 41 | signal.emit(myarg=456) 42 | 43 | assert len(calls) == 1 44 | 45 | # Only call should have myarg=123 46 | call = calls.pop(0) 47 | assert set(call.keys()) == set(["myarg"]) 48 | assert call["myarg"] == 123 49 | 50 | 51 | def test_find_slot(): 52 | """ 53 | Test _find_slot can locate a slot 54 | """ 55 | slot_fn = lambda **kw: None 56 | signal = Signal() 57 | signal.connect(slot_fn) 58 | 59 | slot = signal._find_slot(slot_fn) 60 | assert isinstance(slot, Slot) 61 | assert not isinstance(slot, OneshotSlot) 62 | 63 | 64 | def test_find_oneshot_slot(): 65 | """ 66 | Test _find_slot can locate a one-shot slot 67 | """ 68 | slot_fn = lambda **kw: None 69 | signal = Signal() 70 | signal.connect_oneshot(slot_fn) 71 | 72 | slot = signal._find_slot(slot_fn) 73 | assert isinstance(slot, Slot) # Subclass 74 | assert isinstance(slot, OneshotSlot) 75 | 76 | 77 | def test_disconnect(): 78 | """ 79 | Test disconnect detaches a slot function from the signal. 80 | """ 81 | calls = [] 82 | slot_fn = lambda **kw: calls.append(kw) 83 | signal = Signal() 84 | signal.connect(slot_fn) 85 | signal.emit(myarg=123) 86 | signal.disconnect(slot_fn) 87 | signal.emit(myarg=456) 88 | 89 | assert len(calls) == 1 90 | 91 | # Only call should have myarg=123 92 | call = calls.pop(0) 93 | assert set(call.keys()) == set(["myarg"]) 94 | assert call["myarg"] == 123 95 | 96 | 97 | def test_is_connected(): 98 | """ 99 | Test is_connected returns True for connected signals. 100 | """ 101 | slot_fn = lambda **kw: None 102 | signal = Signal() 103 | signal.connect(slot_fn) 104 | assert signal.is_connected(slot_fn) 105 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Reusable workflow that runs all tests 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | python-version: 7 | type: string 8 | required: true 9 | platform: 10 | type: string 11 | required: true 12 | skip-coverage: 13 | type: boolean 14 | required: false 15 | default: false 16 | 17 | permissions: 18 | contents: read 19 | 20 | env: 21 | # Environment variables to support color support (jaraco/skeleton#66): 22 | # Request colored output from CLI tools supporting it. Different tools 23 | # interpret the value differently. For some, just being set is sufficient. 24 | # For others, it must be a non-zero integer. For yet others, being set 25 | # to a non-empty value is sufficient. For tox, it must be one of 26 | # , 0, 1, false, no, off, on, true, yes. The only enabling value 27 | # in common is "1". 28 | FORCE_COLOR: 1 29 | # Recognized by the `py` package, dependency of `pytest` (must be "1") 30 | PY_COLORS: 1 31 | 32 | # Suppress noisy pip warnings 33 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 34 | PIP_NO_PYTHON_VERSION_WARNING: 'true' 35 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 36 | 37 | 38 | jobs: 39 | test: 40 | runs-on: ${{ inputs.platform }} 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Setup Python 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ inputs.python-version }} 47 | allow-prereleases: true 48 | - name: Sanity check 49 | run: | 50 | which python 51 | python --version 52 | - name: Install dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | pip install flake8 pytest pytest-cov coverage toml pint 56 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 57 | - name: Install coverage-lcov 58 | if: ${{ !inputs.skip-coverage }} 59 | run: | 60 | pip install coverage-lcov 61 | - name: Lint with flake8 62 | run: | 63 | # stop the build if there are Python syntax errors or undefined names 64 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 65 | # exit-zero treats all errors as warnings. 66 | flake8 . --count --exit-zero --max-complexity=10 --statistics 67 | - name: Test with py.test (with coverage) 68 | if: ${{ !inputs.skip-coverage }} 69 | run: | 70 | python -m pytest --cov=aioax25 --cov-report=term-missing --cov-report=lcov 71 | - name: Test with py.test (without lcov coverage) 72 | if: ${{ inputs.skip-coverage }} 73 | run: | 74 | python -m pytest --cov=aioax25 --cov-report=term-missing 75 | - name: Coveralls 76 | if: ${{ !inputs.skip-coverage }} 77 | uses: coverallsapp/github-action@master 78 | with: 79 | github-token: ${{ secrets.GITHUB_TOKEN }} 80 | path-to-lcov: ./coverage.lcov 81 | -------------------------------------------------------------------------------- /tests/test_frame/test_iframe.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.frame import ( 4 | AX25Frame, 5 | AX258BitInformationFrame, 6 | AX2516BitInformationFrame, 7 | ) 8 | 9 | from ..hex import from_hex, hex_cmp 10 | 11 | 12 | def test_8bit_iframe_decode(): 13 | """ 14 | Test we can decode an 8-bit information frame. 15 | """ 16 | frame = AX25Frame.decode( 17 | from_hex( 18 | "ac 96 68 84 ae 92 60" # Destination 19 | "ac 96 68 9a a6 98 e1" # Source 20 | "d4" # Control 21 | "ff" # PID 22 | "54 68 69 73 20 69 73 20 61 20 74 65 73 74" # Payload 23 | ), 24 | modulo128=False, 25 | ) 26 | 27 | assert isinstance( 28 | frame, AX258BitInformationFrame 29 | ), "Did not decode to 8-bit I-Frame" 30 | assert frame.nr == 6 31 | assert frame.ns == 2 32 | assert frame.pid == 0xFF 33 | assert frame.payload == b"This is a test" 34 | 35 | 36 | def test_16bit_iframe_decode(): 37 | """ 38 | Test we can decode an 16-bit information frame. 39 | """ 40 | frame = AX25Frame.decode( 41 | from_hex( 42 | "ac 96 68 84 ae 92 60" # Destination 43 | "ac 96 68 9a a6 98 e1" # Source 44 | "04 0d" # Control 45 | "ff" # PID 46 | "54 68 69 73 20 69 73 20 61 20 74 65 73 74" # Payload 47 | ), 48 | modulo128=True, 49 | ) 50 | 51 | assert isinstance( 52 | frame, AX2516BitInformationFrame 53 | ), "Did not decode to 16-bit I-Frame" 54 | assert frame.nr == 6 55 | assert frame.ns == 2 56 | assert frame.pid == 0xFF 57 | assert frame.payload == b"This is a test" 58 | 59 | 60 | def test_iframe_str(): 61 | """ 62 | Test we can get the string representation of an information frame. 63 | """ 64 | frame = AX258BitInformationFrame( 65 | destination="VK4BWI", 66 | source="VK4MSL", 67 | nr=6, 68 | ns=2, 69 | pid=0xFF, 70 | pf=True, 71 | payload=b"Testing 1 2 3", 72 | ) 73 | 74 | assert str(frame) == ( 75 | "AX258BitInformationFrame VK4MSL>VK4BWI: " 76 | "N(R)=6 P/F=True N(S)=2 PID=0xff\n" 77 | "Payload=b'Testing 1 2 3'" 78 | ) 79 | 80 | 81 | def test_iframe_copy(): 82 | """ 83 | Test we can get the string representation of an information frame. 84 | """ 85 | frame = AX258BitInformationFrame( 86 | destination="VK4BWI", 87 | source="VK4MSL", 88 | nr=6, 89 | ns=2, 90 | pid=0xFF, 91 | pf=True, 92 | payload=b"Testing 1 2 3", 93 | ) 94 | framecopy = frame.copy() 95 | 96 | assert framecopy is not frame 97 | hex_cmp( 98 | bytes(framecopy), 99 | "ac 96 68 84 ae 92 60" # Destination 100 | "ac 96 68 9a a6 98 e1" # Source 101 | "d4" # Control byte 102 | "ff" # PID 103 | "54 65 73 74 69 6e 67 20" 104 | "31 20 32 20 33", # Payload 105 | ) 106 | -------------------------------------------------------------------------------- /aioax25/tools/dumphex.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Very crude AX.25 KISS packet dump dissector. 5 | 6 | This code takes the KISS traffic seen through `socat -x` (so it could be a 7 | real serial port, a virtual one on a VM, or network sockets), and dumps the 8 | traffic it saw to the output. 9 | 10 | Usage: 11 | python3 -m aioax25.tools.dumphex > 12 | """ 13 | 14 | from aioax25.kiss import BaseKISSDevice, KISSDeviceState, KISSCmdData 15 | from aioax25.frame import AX25Frame 16 | from binascii import a2b_hex 17 | from argparse import ArgumentParser 18 | import logging 19 | import asyncio 20 | import re 21 | 22 | 23 | SOCAT_HEX_RE = re.compile(r"^ [0-9a-f]{2}[0-9a-f ]*\n*$") 24 | NON_HEX_RE = re.compile(r"[^0-9a-f]") 25 | 26 | 27 | class FileKISSDevice(BaseKISSDevice): 28 | def __init__(self, filename, **kwargs): 29 | super(FileKISSDevice, self).__init__(**kwargs) 30 | self._filename = filename 31 | self._read_finished = False 32 | self._frames = [] 33 | self._future = asyncio.Future() 34 | 35 | async def dump(self): 36 | self.open() 37 | await self._future 38 | self.close() 39 | return self._frames 40 | 41 | def _open(self): 42 | with open(self._filename, "r") as f: 43 | self._log.info("Reading frames from %r", self._filename) 44 | self._state = KISSDeviceState.OPEN 45 | for line in f: 46 | match = SOCAT_HEX_RE.match(line) 47 | if match: 48 | self._log.debug("Parsing %r", line) 49 | self._receive(a2b_hex(NON_HEX_RE.sub("", line))) 50 | else: 51 | self._log.debug("Ignoring %r", line) 52 | 53 | self._log.info("Read %r", self._filename) 54 | self._read_finished = True 55 | 56 | def _receive_frame(self): 57 | super(FileKISSDevice, self)._receive_frame() 58 | 59 | if not self._read_finished: 60 | return 61 | 62 | if self._future.done(): 63 | return 64 | 65 | if len(self._rx_buffer) < 2: 66 | self._log.info("Buffer is now empty") 67 | self._future.set_result(None) 68 | 69 | def _send_raw_data(self, data): 70 | pass 71 | 72 | def _dispatch_rx_frame(self, frame): 73 | self._frames.append(frame) 74 | 75 | def _close(self): 76 | self._state = KISSDeviceState.CLOSED 77 | 78 | 79 | async def main(): 80 | ap = ArgumentParser() 81 | ap.add_argument("hexfile", nargs="+") 82 | 83 | args = ap.parse_args() 84 | 85 | logging.basicConfig(level=logging.DEBUG) 86 | 87 | for filename in args.hexfile: 88 | kissdev = FileKISSDevice(filename) 89 | frames = await kissdev.dump() 90 | 91 | for frame in frames: 92 | print(frame) 93 | if isinstance(frame, KISSCmdData): 94 | axframe = AX25Frame.decode(frame.payload, modulo128=False) 95 | print(axframe) 96 | 97 | 98 | if __name__ == "__main__": 99 | asyncio.run(main()) 100 | -------------------------------------------------------------------------------- /aioax25/signal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Convenience wrappers around `signalslot` 5 | """ 6 | 7 | from signalslot import Signal as BaseSignal, Slot as BaseSlot 8 | from weakref import ref 9 | import logging 10 | 11 | 12 | class Slot(BaseSlot): 13 | """ 14 | Wrapper class around a slot function. This will wrap the given slot up in 15 | an exception handler. It is the caller's responsibility to ensure the 16 | slot function handles its own exceptions. 17 | """ 18 | 19 | def __init__(self, slot_fn, **kwargs): 20 | super(Slot, self).__init__(slot_fn) 21 | self._slot_kwargs = kwargs 22 | 23 | def __call__(self, **kwargs): 24 | try: 25 | call_kwargs = self._slot_kwargs.copy() 26 | call_kwargs.update(kwargs) 27 | super(Slot, self).__call__(**call_kwargs) 28 | except: 29 | logging.getLogger(self.__class__.__module__).exception( 30 | "Exception in slot %s", self.func 31 | ) 32 | 33 | 34 | class OneshotSlot(Slot): 35 | """ 36 | Helper class that calls a slot exactly once. 37 | """ 38 | 39 | def __init__(self, signal, slot_fn, **kwargs): 40 | self._signal = ref(signal) 41 | super(OneshotSlot, self).__init__(slot_fn, **kwargs) 42 | 43 | def __call__(self, **kwargs): 44 | super(OneshotSlot, self).__call__(**kwargs) 45 | signal = self._signal() 46 | if signal: 47 | signal.disconnect(self) 48 | 49 | 50 | class Signal(BaseSignal): 51 | """ 52 | Wrap the `signalslot.Signal` so that *all* "slots" get called, regardless 53 | of whether they return something or not, or throw exceptions. 54 | """ 55 | 56 | def connect(self, slot, **kwargs): 57 | """ 58 | Connect a slot to the signal. This will wrap the given slot up in 59 | an exception handler. It is the caller's responsibility to ensure 60 | the slot handles its own exceptions. 61 | """ 62 | super(Signal, self).connect(Slot(slot, **kwargs)) 63 | 64 | def connect_oneshot(self, slot, **kwargs): 65 | """ 66 | Connect a slot to the signal, and call it exactly once when the 67 | signal fires. (Disconnect after calling.) 68 | """ 69 | super(Signal, self).connect(OneshotSlot(self, slot, **kwargs)) 70 | 71 | def _find_slot(self, slot): 72 | """ 73 | Locate a slot connected to the signal. 74 | """ 75 | for maybe_slot in super(Signal, self).slots: 76 | if isinstance(maybe_slot, Slot) and (maybe_slot.func is slot): 77 | # Here it is 78 | return maybe_slot 79 | elif maybe_slot is slot: 80 | # Here it is 81 | return maybe_slot 82 | 83 | def disconnect(self, slot): 84 | """ 85 | Disconnect the first located matching slot. 86 | """ 87 | slot = self._find_slot(slot) 88 | if slot: 89 | super(Signal, self).disconnect(slot) 90 | 91 | def is_connected(self, slot): 92 | """ 93 | Check if a callback slot is connected to this signal. 94 | """ 95 | if isinstance(slot, Slot): 96 | return super(Signal, self).is_connected(slot.func) 97 | else: 98 | return super(Signal, self).is_connected(slot) 99 | -------------------------------------------------------------------------------- /tests/test_peer/test_send.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for AX25Peer transmit segmentation 5 | """ 6 | 7 | from aioax25.frame import ( 8 | AX25Address, 9 | ) 10 | from .peer import TestingAX25Peer 11 | from ..mocks import DummyStation 12 | 13 | 14 | # UA reception 15 | 16 | 17 | def test_peer_send_short(): 18 | """ 19 | Test send accepts short payloads and enqueues a single transmission. 20 | """ 21 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 22 | peer = TestingAX25Peer( 23 | station=station, 24 | address=AX25Address("VK4MSL"), 25 | repeaters=[], 26 | full_duplex=True, 27 | ) 28 | 29 | peer._send_next_iframe_scheduled = False 30 | 31 | def _send_next_iframe(): 32 | peer._send_next_iframe_scheduled = True 33 | 34 | peer._send_next_iframe = _send_next_iframe 35 | 36 | peer.send(b"Testing 1 2 3 4") 37 | 38 | assert peer._send_next_iframe_scheduled is True 39 | assert peer._pending_data == [(0xF0, b"Testing 1 2 3 4")] 40 | 41 | 42 | def test_peer_send_long(): 43 | """ 44 | Test send accepts long payloads and enqueues multiple transmissions. 45 | """ 46 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 47 | peer = TestingAX25Peer( 48 | station=station, 49 | address=AX25Address("VK4MSL"), 50 | repeaters=[], 51 | full_duplex=True, 52 | ) 53 | 54 | peer._send_next_iframe_scheduled = False 55 | 56 | def _send_next_iframe(): 57 | peer._send_next_iframe_scheduled = True 58 | 59 | peer._send_next_iframe = _send_next_iframe 60 | 61 | peer.send( 62 | b"(0) Testing 1 2 3 4 5\n(1) Testing 1 2 3 4 5\n(2) Testing 1 2 3 4 5" 63 | b"\n(3) Testing 1 2 3 4 5\n(4) Testing 1 2 3 4 5\n(5) Testing 1 2 3 4" 64 | b" 5\n(6) Testing 1 2 3 4 5\n(7) Testing 1 2 3 4 5\n" 65 | ) 66 | 67 | assert peer._send_next_iframe_scheduled is True 68 | assert peer._pending_data == [ 69 | ( 70 | 0xF0, 71 | b"(0) Testing 1 2 3 4 5\n(1) Testing 1 2 3 4 5\n(2) Testing " 72 | b"1 2 3 4 5\n(3) Testing 1 2 3 4 5\n(4) Testing 1 2 3 4 5\n(5) " 73 | b"Testing 1 2 3 ", 74 | ), 75 | (0xF0, b"4 5\n(6) Testing 1 2 3 4 5\n(7) Testing 1 2 3 4 5\n"), 76 | ] 77 | 78 | 79 | def test_peer_send_paclen(): 80 | """ 81 | Test send respects PACLEN. 82 | """ 83 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 84 | peer = TestingAX25Peer( 85 | station=station, 86 | address=AX25Address("VK4MSL"), 87 | repeaters=[], 88 | full_duplex=True, 89 | paclen=16, 90 | ) 91 | 92 | peer._send_next_iframe_scheduled = False 93 | 94 | def _send_next_iframe(): 95 | peer._send_next_iframe_scheduled = True 96 | 97 | peer._send_next_iframe = _send_next_iframe 98 | 99 | peer.send( 100 | b"(0) Testing 1 2 3 4 5\n(1) Testing 1 2 3 4 5\n(2) Testing 1 2 3 4 5" 101 | b"\n(3) Testing 1 2 3 4 5\n(4) Testing 1 2 3 4 5\n(5) Testing 1 2 3 4" 102 | b" 5\n(6) Testing 1 2 3 4 5\n(7) Testing 1 2 3 4 5\n" 103 | ) 104 | 105 | assert peer._send_next_iframe_scheduled is True 106 | assert peer._pending_data == [ 107 | (0xF0, b"(0) Testing 1 2 "), 108 | (0xF0, b"3 4 5\n(1) Testin"), 109 | (0xF0, b"g 1 2 3 4 5\n(2) "), 110 | (0xF0, b"Testing 1 2 3 4 "), 111 | (0xF0, b"5\n(3) Testing 1 "), 112 | (0xF0, b"2 3 4 5\n(4) Test"), 113 | (0xF0, b"ing 1 2 3 4 5\n(5"), 114 | (0xF0, b") Testing 1 2 3 "), 115 | (0xF0, b"4 5\n(6) Testing "), 116 | (0xF0, b"1 2 3 4 5\n(7) Te"), 117 | (0xF0, b"sting 1 2 3 4 5\n"), 118 | ] 119 | -------------------------------------------------------------------------------- /aioax25/aprs/datetime.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS date and time formats. 5 | """ 6 | 7 | from datetime import time 8 | 9 | 10 | class APRSTimestamp(object): 11 | """ 12 | Base abstract class for APRS timestamps. 13 | """ 14 | 15 | pass 16 | 17 | 18 | class DHMBaseTimestamp(APRSTimestamp): 19 | """ 20 | Day/Hour/Minute timestamp (base class) 21 | """ 22 | 23 | TS_LENGTH = 7 24 | 25 | def __init__(self, day, hour, minute): 26 | self.day = day 27 | self.hour = hour 28 | self.minute = minute 29 | 30 | def __str__(self): 31 | return "%02d%02d%02d%s" % ( 32 | self.day, 33 | self.hour, 34 | self.minute, 35 | self.TS_SUFFIX, 36 | ) 37 | 38 | 39 | class DHMUTCTimestamp(DHMBaseTimestamp): 40 | """ 41 | Day/Hour/Minute timestamp in UTC. 42 | """ 43 | 44 | TS_SUFFIX = "z" 45 | 46 | 47 | class DHMLocalTimestamp(DHMBaseTimestamp): 48 | """ 49 | Day/Hour/Minute timestamp in local time. 50 | """ 51 | 52 | TS_SUFFIX = "/" 53 | 54 | 55 | class HMSTimestamp(time, APRSTimestamp): 56 | """ 57 | Hour/Minute/Second timestamp in UTC. 58 | """ 59 | 60 | TS_LENGTH = 7 61 | TS_SUFFIX = "h" 62 | 63 | def __str__(self): 64 | return "%02d%02d%02d%s" % ( 65 | self.hour, 66 | self.minute, 67 | self.second, 68 | self.TS_SUFFIX, 69 | ) 70 | 71 | 72 | class MDHMTimestamp(APRSTimestamp): 73 | """ 74 | Month/Day/Hour/Minute timestamp in UTC. 75 | """ 76 | 77 | TS_LENGTH = 8 78 | 79 | def __init__(self, month, day, hour, minute): 80 | self.month = month 81 | self.day = day 82 | self.hour = hour 83 | self.minute = minute 84 | 85 | def __str__(self): 86 | return "%02d%02d%02d%02d" % ( 87 | self.month, 88 | self.day, 89 | self.hour, 90 | self.minute, 91 | ) 92 | 93 | 94 | def decode(dtstr): 95 | """ 96 | Decode a APRS date/time date code. Since not full information is given 97 | in the timestamp, and date/time values are a nightmare to handle 98 | (especially for local timestamps), we just reproduce enough to round-trip 99 | the values in APRS. 100 | """ 101 | if len(dtstr) < DHMBaseTimestamp.TS_LENGTH: 102 | # Not a valid date/time format 103 | raise ValueError("Timestamp string too short") 104 | 105 | if dtstr[6] in (DHMLocalTimestamp.TS_SUFFIX, DHMUTCTimestamp.TS_SUFFIX): 106 | # Day/Hours/Minutes in UTC or local-time 107 | # Format is: 108 | # DDHHMM{z|/} 109 | day = int(dtstr[0:2]) 110 | hour = int(dtstr[2:4]) 111 | minute = int(dtstr[4:6]) 112 | 113 | if dtstr[6] == DHMUTCTimestamp.TS_SUFFIX: 114 | return DHMUTCTimestamp(day, hour, minute) 115 | else: 116 | return DHMLocalTimestamp(day, hour, minute) 117 | elif dtstr[6] == HMSTimestamp.TS_SUFFIX: 118 | # Hours/Minutes/Seconds in UTC 119 | # Format is: 120 | # HHMMSSh 121 | hour = int(dtstr[0:2]) 122 | minute = int(dtstr[2:4]) 123 | second = int(dtstr[4:6]) 124 | 125 | return HMSTimestamp(hour, minute, second) 126 | elif len(dtstr) >= MDHMTimestamp.TS_LENGTH: 127 | # Month/Day/Hours/Minutes in UTC 128 | # Format is: 129 | # MMDDHHMM 130 | month = int(dtstr[0:2]) 131 | day = int(dtstr[2:4]) 132 | hour = int(dtstr[4:6]) 133 | minute = int(dtstr[6:8]) 134 | 135 | return MDHMTimestamp(month, day, hour, minute) 136 | 137 | raise ValueError("Timestamp format not recognised") 138 | -------------------------------------------------------------------------------- /tests/test_aprs/test_symbol.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.aprs.symbol import APRSSymbol, APRSSymbolTable, APRSOverlayType 4 | 5 | """ 6 | Symbol handling tests. 7 | """ 8 | 9 | 10 | def test_sym_primary(): 11 | """ 12 | Test we can identify a primary symbol. 13 | """ 14 | sym = APRSSymbol("/", "$") 15 | assert sym.table == APRSSymbolTable.PRIMARY 16 | assert sym.symbol == "$" 17 | assert sym.overlay_type is None 18 | assert sym.overlay is None 19 | 20 | 21 | def test_sym_secondary(): 22 | """ 23 | Test we can identify a secondary symbol. 24 | """ 25 | sym = APRSSymbol("\\", "u") 26 | assert sym.table == APRSSymbolTable.SECONDARY 27 | assert sym.symbol == "u" 28 | assert sym.overlay_type is None 29 | assert sym.overlay is None 30 | 31 | 32 | def test_sym_secondary_numover_uncompressed(): 33 | """ 34 | Test we can identify a secondary symbol with a numeric overlay. 35 | (Uncompressed set) 36 | """ 37 | sym = APRSSymbol("3", "u") 38 | assert sym.table == APRSSymbolTable.SECONDARY 39 | assert sym.symbol == "u" 40 | assert sym.overlay_type == APRSOverlayType.NUM_UNCOMPRESSED 41 | assert sym.overlay == 3 42 | 43 | 44 | def test_sym_secondary_numover_compressed(): 45 | """ 46 | Test we can identify a secondary symbol with a numeric overlay. 47 | (Compressed set) 48 | """ 49 | sym = APRSSymbol("d", "u") 50 | assert sym.table == APRSSymbolTable.SECONDARY 51 | assert sym.symbol == "u" 52 | assert sym.overlay_type == APRSOverlayType.NUM_COMPRESSED 53 | assert sym.overlay == 3 54 | 55 | 56 | def test_sym_secondary_alphaover(): 57 | """ 58 | Test we can identify a secondary symbol with an alphabetic overlay. 59 | """ 60 | sym = APRSSymbol("D", "u") 61 | assert sym.table == APRSSymbolTable.SECONDARY 62 | assert sym.symbol == "u" 63 | assert sym.overlay_type == APRSOverlayType.ALPHA 64 | assert sym.overlay == 3 65 | 66 | 67 | def test_sym_primary_tableident(): 68 | """ 69 | Test primary symbols' table identifies with "/" 70 | """ 71 | sym = APRSSymbol(APRSSymbolTable.PRIMARY, "$") 72 | assert sym.tableident == "/" 73 | 74 | 75 | def test_sym_secondary_tableident(): 76 | """ 77 | Test we can identify a secondary symbol. 78 | """ 79 | sym = APRSSymbol(APRSSymbolTable.SECONDARY, "u") 80 | assert sym.tableident == "\\" 81 | 82 | 83 | def test_sym_secondary_numover_uncompressed_tableident(): 84 | """ 85 | Test uncompressed numeric overlay is reported with digit. 86 | """ 87 | sym = APRSSymbol(APRSSymbolTable.SECONDARY, "u", "3") 88 | assert sym.tableident == "3" 89 | 90 | 91 | def test_sym_secondary_numover_compressed_tableident(): 92 | """ 93 | Test uncompressed numeric overlay is reported with lower letter. 94 | """ 95 | sym = APRSSymbol(APRSSymbolTable.SECONDARY, "u", "d") 96 | assert sym.tableident == "d" 97 | 98 | 99 | def test_sym_secondary_alphaover_tableident(): 100 | """ 101 | Test alphabetic overlay is reported with upper letter. 102 | """ 103 | sym = APRSSymbol(APRSSymbolTable.SECONDARY, "u", "D") 104 | assert sym.tableident == "D" 105 | 106 | 107 | def test_wrong_table(): 108 | """ 109 | Test that symbol forbids overlays with the wrong table. 110 | """ 111 | try: 112 | APRSSymbol(APRSSymbolTable.PRIMARY, "u", "D") 113 | assert False, "Should not have worked" 114 | except ValueError as e: 115 | assert str(e) == "Overlays only available on secondary table" 116 | 117 | 118 | def test_invalid_overlay(): 119 | try: 120 | APRSSymbol(APRSSymbolTable.SECONDARY, "u", "x") 121 | assert False, "Should not have worked" 122 | except ValueError as e: 123 | assert str(e) == "Not a valid overlay character: 'x'" 124 | -------------------------------------------------------------------------------- /tests/test_kiss/test_subproc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Subprocess KISS interface unit tests. 5 | """ 6 | 7 | # Most of the functionality here is common to SerialKISSDevice, so this 8 | # really just tests that we pass the right commands to the IOLoop when 9 | # establishing a connection. 10 | 11 | from aioax25 import kiss 12 | import logging 13 | from ..asynctest import asynctest 14 | from asyncio import get_event_loop 15 | 16 | 17 | @asynctest 18 | async def test_open_connection(): 19 | """ 20 | Test we can open a subprocess without using a shell. 21 | """ 22 | # This will receive the arguments passed to subprocess_exec 23 | connection_args = [] 24 | 25 | loop = get_event_loop() 26 | 27 | # Stub the subprocess_exec method 28 | orig_subprocess_exec = loop.subprocess_exec 29 | 30 | async def _subprocess_exec(proto_factory, *args): 31 | # proto_factory should give us a KISSSubprocessProtocol object 32 | protocol = proto_factory() 33 | assert isinstance(protocol, kiss.KISSSubprocessProtocol) 34 | 35 | connection_args.extend(args) 36 | 37 | loop.subprocess_exec = _subprocess_exec 38 | 39 | try: 40 | device = kiss.SubprocKISSDevice( 41 | command=["kisspipecmd", "arg1", "arg2"], 42 | loop=loop, 43 | log=logging.getLogger(__name__), 44 | ) 45 | 46 | await device._open_connection() 47 | 48 | # Expect a connection attempt to have been made 49 | assert connection_args == ["kisspipecmd", "arg1", "arg2"] 50 | finally: 51 | # Restore mock 52 | loop.subprocess_exec = orig_subprocess_exec 53 | 54 | 55 | @asynctest 56 | async def test_open_connection_shell(): 57 | """ 58 | Test we can open a subprocess using a shell. 59 | """ 60 | # This will receive the arguments passed to subprocess_shell 61 | connection_args = [] 62 | 63 | loop = get_event_loop() 64 | 65 | # Stub the subprocess_shell method 66 | orig_subprocess_shell = loop.subprocess_shell 67 | 68 | async def _subprocess_shell(proto_factory, *args): 69 | # proto_factory should give us a KISSSubprocessProtocol object 70 | protocol = proto_factory() 71 | assert isinstance(protocol, kiss.KISSSubprocessProtocol) 72 | 73 | connection_args.extend(args) 74 | 75 | loop.subprocess_shell = _subprocess_shell 76 | 77 | try: 78 | device = kiss.SubprocKISSDevice( 79 | command=["kisspipecmd", "arg1", "arg2"], 80 | shell=True, 81 | loop=loop, 82 | log=logging.getLogger(__name__), 83 | ) 84 | 85 | await device._open_connection() 86 | 87 | # Expect a connection attempt to have been made 88 | assert connection_args == ["kisspipecmd arg1 arg2"] 89 | finally: 90 | # Restore mock 91 | loop.subprocess_shell = orig_subprocess_shell 92 | 93 | 94 | def test_send_raw_data(): 95 | """ 96 | Test data written to the device gets written to the subprocess ``stdin``. 97 | """ 98 | 99 | # Mock transport 100 | class DummyStream(object): 101 | def __init__(self): 102 | self.written = b"" 103 | 104 | def write(self, data): 105 | self.written += bytes(data) 106 | 107 | class DummyTransport(object): 108 | def __init__(self): 109 | self.stdin = DummyStream() 110 | 111 | def get_pipe_transport(self, fd): 112 | assert fd == 0 113 | return self.stdin 114 | 115 | loop = get_event_loop() 116 | 117 | device = kiss.SubprocKISSDevice( 118 | command=["kisspipecmd", "arg1", "arg2"], 119 | loop=loop, 120 | log=logging.getLogger(__name__), 121 | ) 122 | device._transport = DummyTransport() 123 | 124 | device._send_raw_data(b"testing") 125 | 126 | assert device._transport.stdin.written == b"testing" 127 | -------------------------------------------------------------------------------- /tests/test_peer/test_dm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for AX25Peer DM handling 5 | """ 6 | 7 | from aioax25.frame import AX25Address, AX25Path, AX25DisconnectModeFrame 8 | from aioax25.peer import AX25PeerState 9 | from aioax25.version import AX25Version 10 | from .peer import TestingAX25Peer 11 | from ..mocks import DummyStation, DummyTimeout 12 | 13 | 14 | # DM reception 15 | 16 | 17 | def test_peer_recv_dm(): 18 | """ 19 | Test when receiving a DM whilst connected, the peer disconnects. 20 | """ 21 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 22 | interface = station._interface() 23 | peer = TestingAX25Peer( 24 | station=station, 25 | address=AX25Address("VK4MSL"), 26 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 27 | full_duplex=True, 28 | ) 29 | 30 | # Set some dummy data in fields -- this should be cleared out. 31 | ack_timer = DummyTimeout(None, None) 32 | peer._ack_timeout_handle = ack_timer 33 | peer._state = AX25PeerState.CONNECTED 34 | peer._modulo = 8 35 | peer._send_state = 1 36 | peer._send_seq = 2 37 | peer._recv_state = 3 38 | peer._recv_seq = 4 39 | peer._pending_iframes = dict(comment="pending data") 40 | peer._pending_data = ["pending data"] 41 | 42 | # Pass the peer a DM frame 43 | peer._on_receive( 44 | AX25DisconnectModeFrame( 45 | destination=station.address, source=peer.address, repeaters=None 46 | ) 47 | ) 48 | 49 | # We should now be "disconnected" 50 | assert peer._ack_timeout_handle is None 51 | assert peer._state is AX25PeerState.DISCONNECTED 52 | assert peer._send_state == 0 53 | assert peer._send_seq == 0 54 | assert peer._recv_state == 0 55 | assert peer._recv_seq == 0 56 | assert peer._pending_iframes == {} 57 | assert peer._pending_data == [] 58 | 59 | 60 | def test_peer_recv_dm_disconnected(): 61 | """ 62 | Test when receiving a DM whilst not connected, the peer does nothing. 63 | """ 64 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 65 | interface = station._interface() 66 | peer = TestingAX25Peer( 67 | station=station, 68 | address=AX25Address("VK4MSL"), 69 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 70 | full_duplex=True, 71 | ) 72 | 73 | # Set some dummy data in fields -- this should be cleared out. 74 | ack_timer = DummyTimeout(None, None) 75 | peer._ack_timeout_handle = ack_timer 76 | peer._state = AX25PeerState.NEGOTIATING 77 | peer._modulo = 8 78 | peer._send_state = 1 79 | peer._send_seq = 2 80 | peer._recv_state = 3 81 | peer._recv_seq = 4 82 | peer._pending_iframes = dict(comment="pending data") 83 | peer._pending_data = ["pending data"] 84 | 85 | # Pass the peer a DM frame 86 | peer._on_receive( 87 | AX25DisconnectModeFrame( 88 | destination=station.address, source=peer.address, repeaters=None 89 | ) 90 | ) 91 | 92 | # State should be unchanged from before 93 | assert peer._ack_timeout_handle is ack_timer 94 | assert peer._state is AX25PeerState.NEGOTIATING 95 | assert peer._send_state == 1 96 | assert peer._send_seq == 2 97 | assert peer._recv_state == 3 98 | assert peer._recv_seq == 4 99 | assert peer._pending_iframes == dict(comment="pending data") 100 | assert peer._pending_data == ["pending data"] 101 | 102 | 103 | # DM transmission 104 | 105 | 106 | def test_peer_send_dm(): 107 | """ 108 | Test _send_dm correctly addresses and sends a DM frame. 109 | """ 110 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 111 | interface = station._interface() 112 | peer = TestingAX25Peer( 113 | station=station, 114 | address=AX25Address("VK4MSL"), 115 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 116 | full_duplex=True, 117 | ) 118 | 119 | # Request a DM frame be sent 120 | peer._send_dm() 121 | 122 | # There should be a frame sent 123 | assert len(interface.transmit_calls) == 1 124 | (tx_args, tx_kwargs) = interface.transmit_calls.pop(0) 125 | 126 | # This should be a DM 127 | assert tx_kwargs == {"callback": None} 128 | assert len(tx_args) == 1 129 | (frame,) = tx_args 130 | assert isinstance(frame, AX25DisconnectModeFrame) 131 | 132 | assert str(frame.header.destination) == "VK4MSL*" 133 | assert str(frame.header.source) == "VK4MSL-1" 134 | assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3" 135 | -------------------------------------------------------------------------------- /tests/test_peer/test_peerhelper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for AX25PeerHelper 5 | """ 6 | 7 | from aioax25.peer import AX25PeerHelper 8 | from aioax25.frame import AX25Address 9 | from ..mocks import DummyPeer, DummyStation 10 | 11 | 12 | def test_peerhelper_start_timer(): 13 | """ 14 | Test _start_timer sets up a timeout timer. 15 | """ 16 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 17 | peer = DummyPeer(station, AX25Address("VK4MSL")) 18 | 19 | class TestHelper(AX25PeerHelper): 20 | def _on_timeout(self): 21 | pass 22 | 23 | helper = TestHelper(peer, timeout=0.1) 24 | 25 | assert helper._timeout_handle is None 26 | 27 | helper._start_timer() 28 | assert len(peer._loop.call_later_list) == 1 29 | timeout = peer._loop.call_later_list.pop(0) 30 | 31 | assert timeout is helper._timeout_handle 32 | assert timeout.delay == 0.1 33 | assert timeout.callback == helper._on_timeout 34 | assert timeout.args == () 35 | assert timeout.kwargs == {} 36 | 37 | 38 | def test_peerhelper_stop_timer(): 39 | """ 40 | Test _stop_timer clears an existing timeout timer. 41 | """ 42 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 43 | peer = DummyPeer(station, AX25Address("VK4MSL")) 44 | helper = AX25PeerHelper(peer, timeout=0.1) 45 | 46 | # Inject a timeout timer 47 | timeout = peer._loop.call_later(0, lambda: None) 48 | helper._timeout_handle = timeout 49 | assert not timeout.cancelled 50 | 51 | helper._stop_timer() 52 | 53 | assert timeout.cancelled 54 | assert helper._timeout_handle is None 55 | 56 | 57 | def test_peerhelper_stop_timer_cancelled(): 58 | """ 59 | Test _stop_timer does not call cancel on already cancelled timer. 60 | """ 61 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 62 | peer = DummyPeer(station, AX25Address("VK4MSL")) 63 | helper = AX25PeerHelper(peer, timeout=0.1) 64 | 65 | # Inject a timeout timer 66 | timeout = peer._loop.call_later(0, lambda: None) 67 | helper._timeout_handle = timeout 68 | 69 | # Cancel it 70 | timeout.cancel() 71 | 72 | # Now stop the timer, this should not call .cancel itself 73 | helper._stop_timer() 74 | assert helper._timeout_handle is None 75 | 76 | 77 | def test_peerhelper_stop_timer_absent(): 78 | """ 79 | Test _stop_timer does nothing if time-out object absent. 80 | """ 81 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 82 | peer = DummyPeer(station, AX25Address("VK4MSL")) 83 | helper = AX25PeerHelper(peer, timeout=0.1) 84 | 85 | # Cancel the non-existent timer, this should not trigger errors 86 | helper._stop_timer() 87 | 88 | 89 | def test_finish(): 90 | """ 91 | Test _finish stops the timer and emits the done signal. 92 | """ 93 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 94 | peer = DummyPeer(station, AX25Address("VK4MSL")) 95 | helper = AX25PeerHelper(peer, timeout=0.1) 96 | assert not helper._done 97 | 98 | # Hook the done signal 99 | done_events = [] 100 | helper.done_sig.connect(lambda **kw: done_events.append(kw)) 101 | 102 | # Inject a timeout timer 103 | timeout = peer._loop.call_later(0, lambda: None) 104 | helper._timeout_handle = timeout 105 | 106 | # Call _finish to end the helper 107 | helper._finish(arg1="abc", arg2=123) 108 | 109 | # Task should be done 110 | assert helper._done 111 | 112 | # Signal should have fired 113 | assert done_events == [{"arg1": "abc", "arg2": 123}] 114 | 115 | # Timeout should have been cancelled 116 | assert timeout.cancelled 117 | 118 | 119 | def test_finish_repeat(): 120 | """ 121 | Test _finish does nothing if already "done" 122 | """ 123 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 124 | peer = DummyPeer(station, AX25Address("VK4MSL")) 125 | helper = AX25PeerHelper(peer, timeout=0.1) 126 | 127 | # Force the done flag. 128 | helper._done = True 129 | 130 | # Hook the done signal 131 | done_events = [] 132 | helper.done_sig.connect(lambda **kw: done_events.append(kw)) 133 | 134 | # Inject a timeout timer 135 | timeout = peer._loop.call_later(0, lambda: None) 136 | helper._timeout_handle = timeout 137 | 138 | # Call _finish to end the helper 139 | helper._finish(arg1="abc", arg2=123) 140 | 141 | # Signal should not have fired 142 | assert done_events == [] 143 | 144 | # Timeout should not have been cancelled 145 | assert not timeout.cancelled 146 | -------------------------------------------------------------------------------- /tests/test_peer/test_rx_path.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for receive path handling 5 | """ 6 | 7 | from aioax25.frame import AX25Address, AX25TestFrame, AX25Path 8 | from ..mocks import DummyStation 9 | from .peer import TestingAX25Peer 10 | 11 | 12 | def test_rx_path_stats_unlocked(): 13 | """ 14 | Test that incoming message paths are counted when path NOT locked. 15 | """ 16 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 17 | peer = TestingAX25Peer( 18 | station=station, 19 | address=AX25Address("VK4MSL"), 20 | repeaters=AX25Path("VK4RZB"), 21 | locked_path=False, 22 | ) 23 | 24 | # Stub the peer's _on_receive_test method 25 | rx_frames = [] 26 | 27 | def _on_receive_test(frame): 28 | rx_frames.append(frame) 29 | 30 | peer._on_receive_test = _on_receive_test 31 | 32 | # Send a few test frames via different paths 33 | peer._on_receive( 34 | AX25TestFrame( 35 | destination=peer.address, 36 | source=station.address, 37 | repeaters=AX25Path("VK4RZB*"), 38 | payload=b"test 1", 39 | cr=True, 40 | ) 41 | ) 42 | peer._on_receive( 43 | AX25TestFrame( 44 | destination=peer.address, 45 | source=station.address, 46 | repeaters=AX25Path("VK4RZA*", "VK4RZB*"), 47 | payload=b"test 2", 48 | cr=True, 49 | ) 50 | ) 51 | peer._on_receive( 52 | AX25TestFrame( 53 | destination=peer.address, 54 | source=station.address, 55 | repeaters=AX25Path("VK4RZD*", "VK4RZB*"), 56 | payload=b"test 3", 57 | cr=True, 58 | ) 59 | ) 60 | peer._on_receive( 61 | AX25TestFrame( 62 | destination=peer.address, 63 | source=station.address, 64 | repeaters=AX25Path("VK4RZB*"), 65 | payload=b"test 4", 66 | cr=True, 67 | ) 68 | ) 69 | 70 | # For test readability, convert the tuple keys to strings 71 | # AX25Path et all has its own tests for str. 72 | rx_path_count = dict( 73 | [ 74 | (str(AX25Path(*path)), count) 75 | for path, count in peer._rx_path_count.items() 76 | ] 77 | ) 78 | 79 | assert rx_path_count == { 80 | "VK4RZB": 2, 81 | "VK4RZA,VK4RZB": 1, 82 | "VK4RZD,VK4RZB": 1, 83 | } 84 | 85 | 86 | def test_rx_path_stats_locked(): 87 | """ 88 | Test that incoming message paths are NOT counted when path locked. 89 | """ 90 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 91 | peer = TestingAX25Peer( 92 | station=station, 93 | address=AX25Address("VK4MSL"), 94 | repeaters=AX25Path("VK4RZB"), 95 | locked_path=True, 96 | ) 97 | 98 | # Stub the peer's _on_receive_test method 99 | rx_frames = [] 100 | 101 | def _on_receive_test(frame): 102 | rx_frames.append(frame) 103 | 104 | peer._on_receive_test = _on_receive_test 105 | 106 | # Send a few test frames via different paths 107 | peer._on_receive( 108 | AX25TestFrame( 109 | destination=peer.address, 110 | source=station.address, 111 | repeaters=AX25Path("VK4RZB*"), 112 | payload=b"test 1", 113 | cr=True, 114 | ) 115 | ) 116 | peer._on_receive( 117 | AX25TestFrame( 118 | destination=peer.address, 119 | source=station.address, 120 | repeaters=AX25Path("VK4RZA*", "VK4RZB*"), 121 | payload=b"test 2", 122 | cr=True, 123 | ) 124 | ) 125 | peer._on_receive( 126 | AX25TestFrame( 127 | destination=peer.address, 128 | source=station.address, 129 | repeaters=AX25Path("VK4RZD*", "VK4RZB*"), 130 | payload=b"test 3", 131 | cr=True, 132 | ) 133 | ) 134 | peer._on_receive( 135 | AX25TestFrame( 136 | destination=peer.address, 137 | source=station.address, 138 | repeaters=AX25Path("VK4RZB*"), 139 | payload=b"test 4", 140 | cr=True, 141 | ) 142 | ) 143 | 144 | # For test readability, convert the tuple keys to strings 145 | # AX25Path et all has its own tests for str. 146 | rx_path_count = dict( 147 | [ 148 | (str(AX25Path(*path)), count) 149 | for path, count in peer._rx_path_count.items() 150 | ] 151 | ) 152 | 153 | assert rx_path_count == {} 154 | -------------------------------------------------------------------------------- /tests/test_peer/test_replypath.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for AX25Peer reply path handling 5 | """ 6 | 7 | from aioax25.frame import AX25Address, AX25Path 8 | from .peer import TestingAX25Peer 9 | from ..mocks import DummyStation 10 | 11 | 12 | def test_peer_reply_path_locked(): 13 | """ 14 | Test reply_path with a locked path returns the repeaters given 15 | """ 16 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 17 | peer = TestingAX25Peer( 18 | station=station, 19 | address=AX25Address("VK4MSL"), 20 | repeaters=AX25Path("VK4RZB"), 21 | locked_path=True, 22 | ) 23 | 24 | # Ensure not pre-determined path is set 25 | peer._reply_path = None 26 | 27 | assert str(peer.reply_path) == "VK4RZB" 28 | 29 | 30 | def test_peer_reply_path_predetermined(): 31 | """ 32 | Test reply_path with pre-determined path returns the chosen path 33 | """ 34 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 35 | peer = TestingAX25Peer( 36 | station=station, 37 | address=AX25Address("VK4MSL"), 38 | repeaters=None, 39 | locked_path=False, 40 | ) 41 | 42 | # Inject pre-determined path 43 | peer._reply_path = AX25Path("VK4RZB") 44 | 45 | assert str(peer.reply_path) == "VK4RZB" 46 | 47 | 48 | def test_peer_reply_path_weight_score(): 49 | """ 50 | Test reply_path tries to select the "best" scoring path. 51 | """ 52 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 53 | peer = TestingAX25Peer( 54 | station=station, 55 | address=AX25Address("VK4MSL"), 56 | repeaters=None, 57 | locked_path=False, 58 | ) 59 | 60 | # Ensure not pre-determined path is set 61 | peer._reply_path = None 62 | 63 | # Inject path scores 64 | peer._tx_path_score = {AX25Path("VK4RZB"): 2, AX25Path("VK4RZA"): 1} 65 | 66 | assert str(peer.reply_path) == "VK4RZB" 67 | 68 | # We should also use this from now on: 69 | assert str(peer._reply_path) == "VK4RZB" 70 | 71 | 72 | def test_peer_reply_path_rx_count(): 73 | """ 74 | Test reply_path considers received paths if no rated TX path. 75 | """ 76 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 77 | peer = TestingAX25Peer( 78 | station=station, 79 | address=AX25Address("VK4MSL"), 80 | repeaters=None, 81 | locked_path=False, 82 | ) 83 | 84 | # Ensure not pre-determined path is set 85 | peer._reply_path = None 86 | 87 | # Ensure empty TX path scores 88 | peer._tx_path_score = {} 89 | 90 | # Inject path counts 91 | peer._rx_path_count = {AX25Path("VK4RZB"): 2, AX25Path("VK4RZA"): 1} 92 | 93 | assert str(peer.reply_path) == "VK4RZB" 94 | 95 | # We should also use this from now on: 96 | assert str(peer._reply_path) == "VK4RZB" 97 | 98 | 99 | # Path weighting 100 | 101 | 102 | def test_weight_path_absolute(): 103 | """ 104 | Test we can set the score for a given path. 105 | """ 106 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 107 | peer = TestingAX25Peer( 108 | station=station, 109 | address=AX25Address("VK4MSL"), 110 | repeaters=None, 111 | locked_path=False, 112 | ) 113 | 114 | # Ensure known weights 115 | peer._tx_path_score = { 116 | tuple(AX25Path("VK4RZB", "VK4RZA")): 1, 117 | tuple(AX25Path("VK4RZA")): 2, 118 | } 119 | 120 | # Rate a few paths 121 | peer.weight_path(AX25Path("VK4RZB*", "VK4RZA*"), 5, relative=False) 122 | peer.weight_path(AX25Path("VK4RZA*"), 3, relative=False) 123 | 124 | assert peer._tx_path_score == { 125 | tuple(AX25Path("VK4RZB", "VK4RZA")): 5, 126 | tuple(AX25Path("VK4RZA")): 3, 127 | } 128 | 129 | 130 | def test_weight_path_relative(): 131 | """ 132 | Test we can increment the score for a given path. 133 | """ 134 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 135 | peer = TestingAX25Peer( 136 | station=station, 137 | address=AX25Address("VK4MSL"), 138 | repeaters=None, 139 | locked_path=False, 140 | ) 141 | 142 | # Ensure known weights 143 | peer._tx_path_score = { 144 | tuple(AX25Path("VK4RZB", "VK4RZA")): 5, 145 | tuple(AX25Path("VK4RZA")): 3, 146 | } 147 | 148 | # Rate a few paths 149 | peer.weight_path(AX25Path("VK4RZB*", "VK4RZA*"), 2, relative=True) 150 | peer.weight_path(AX25Path("VK4RZA*"), 1, relative=True) 151 | 152 | assert peer._tx_path_score == { 153 | tuple(AX25Path("VK4RZB", "VK4RZA")): 7, 154 | tuple(AX25Path("VK4RZA")): 4, 155 | } 156 | -------------------------------------------------------------------------------- /tests/test_station/test_receive.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.station import AX25Station 4 | from aioax25.frame import ( 5 | AX25Address, 6 | AX25TestFrame, 7 | AX25UnnumberedInformationFrame, 8 | ) 9 | 10 | from ..mocks import DummyInterface, DummyPeer 11 | 12 | 13 | def test_testframe_cmd_echo(): 14 | """ 15 | Test passing a test frame with CR=True triggers a reply frame. 16 | """ 17 | interface = DummyInterface() 18 | station = AX25Station(interface=interface, callsign="VK4MSL-5") 19 | 20 | # Pass in a frame 21 | station._on_receive( 22 | frame=AX25TestFrame( 23 | destination="VK4MSL-5", 24 | source="VK4MSL-7", 25 | cr=True, 26 | payload=b"This is a test frame", 27 | ) 28 | ) 29 | 30 | # There should be no peers 31 | assert station._peers == {} 32 | 33 | # There should be a reply queued up 34 | assert interface.bind_calls == [] 35 | assert interface.unbind_calls == [] 36 | assert len(interface.transmit_calls) == 1 37 | 38 | (tx_call_args, tx_call_kwargs) = interface.transmit_calls.pop() 39 | assert tx_call_kwargs == {} 40 | assert len(tx_call_args) == 1 41 | frame = tx_call_args[0] 42 | 43 | # The reply should have the source/destination swapped and the 44 | # CR bit cleared. 45 | assert isinstance(frame, AX25TestFrame), "Not a test frame" 46 | assert frame.header.cr == False 47 | assert frame.header.destination == AX25Address("VK4MSL", ssid=7) 48 | assert frame.header.source == AX25Address("VK4MSL", ssid=5) 49 | assert frame.payload == b"This is a test frame" 50 | 51 | 52 | def test_route_testframe_reply(): 53 | """ 54 | Test passing a test frame reply routes to the appropriate AX25Peer instance. 55 | """ 56 | interface = DummyInterface() 57 | station = AX25Station(interface=interface, callsign="VK4MSL-5") 58 | 59 | # Stub out _on_test_frame 60 | def stub_on_test_frame(*args, **kwargs): 61 | assert False, "Should not have been called" 62 | 63 | station._on_test_frame = stub_on_test_frame 64 | 65 | # Inject a couple of peers 66 | peer1 = DummyPeer(station, AX25Address("VK4MSL", ssid=7)) 67 | peer2 = DummyPeer(station, AX25Address("VK4BWI", ssid=7)) 68 | station._peers[peer1._address] = peer1 69 | station._peers[peer2._address] = peer2 70 | 71 | # Pass in the message 72 | txframe = AX25TestFrame( 73 | destination="VK4MSL-5", 74 | source="VK4MSL-7", 75 | cr=False, 76 | payload=b"This is a test frame", 77 | ) 78 | station._on_receive(frame=txframe) 79 | 80 | # There should be no replies queued 81 | assert interface.bind_calls == [] 82 | assert interface.unbind_calls == [] 83 | assert interface.transmit_calls == [] 84 | 85 | # This should have gone to peer1, not peer2 86 | assert peer2.on_receive_calls == [] 87 | assert len(peer1.on_receive_calls) == 1 88 | (rx_call_args, rx_call_kwargs) = peer1.on_receive_calls.pop() 89 | assert rx_call_kwargs == {} 90 | assert len(rx_call_args) == 1 91 | assert rx_call_args[0] is txframe 92 | 93 | 94 | def test_route_incoming_msg(): 95 | """ 96 | Test passing a frame routes to the appropriate AX25Peer instance. 97 | """ 98 | interface = DummyInterface() 99 | station = AX25Station(interface=interface, callsign="VK4MSL-5") 100 | 101 | # Stub out _on_test_frame 102 | def stub_on_test_frame(*args, **kwargs): 103 | assert False, "Should not have been called" 104 | 105 | station._on_test_frame = stub_on_test_frame 106 | 107 | # Inject a couple of peers 108 | peer1 = DummyPeer(station, AX25Address("VK4MSL", ssid=7)) 109 | peer2 = DummyPeer(station, AX25Address("VK4BWI", ssid=7)) 110 | station._peers[peer1._address] = peer1 111 | station._peers[peer2._address] = peer2 112 | 113 | # Pass in the message 114 | txframe = AX25UnnumberedInformationFrame( 115 | destination="VK4MSL-5", 116 | source="VK4BWI-7", 117 | cr=True, 118 | pid=0xAB, 119 | payload=b"This is a test frame", 120 | ) 121 | station._on_receive(frame=txframe) 122 | 123 | # There should be no replies queued 124 | assert interface.bind_calls == [] 125 | assert interface.unbind_calls == [] 126 | assert interface.transmit_calls == [] 127 | 128 | # This should have gone to peer2, not peer1 129 | assert peer1.on_receive_calls == [] 130 | assert len(peer2.on_receive_calls) == 1 131 | (rx_call_args, rx_call_kwargs) = peer2.on_receive_calls.pop() 132 | assert rx_call_kwargs == {} 133 | assert len(rx_call_args) == 1 134 | assert rx_call_args[0] is txframe 135 | -------------------------------------------------------------------------------- /tests/test_frame/test_sframe.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.frame import ( 4 | AX25Frame, 5 | AX258BitReceiveReadyFrame, 6 | AX2516BitReceiveReadyFrame, 7 | AX258BitRejectFrame, 8 | AX2516BitRejectFrame, 9 | ) 10 | 11 | from ..hex import from_hex, hex_cmp 12 | 13 | 14 | def test_sframe_payload_reject(): 15 | """ 16 | Test payloads are forbidden for S-frames 17 | """ 18 | try: 19 | AX25Frame.decode( 20 | from_hex( 21 | "ac 96 68 84 ae 92 60" # Destination 22 | "ac 96 68 9a a6 98 e1" # Source 23 | "41" # Control 24 | "31 32 33 34 35" # Payload 25 | ), 26 | modulo128=False, 27 | ) 28 | assert False, "Should not have worked" 29 | except ValueError as e: 30 | assert str(e) == "Supervisory frames do not support payloads." 31 | 32 | 33 | def test_16bs_truncated_reject(): 34 | """ 35 | Test that 16-bit S-frames with truncated control fields are rejected. 36 | """ 37 | try: 38 | AX25Frame.decode( 39 | from_hex( 40 | "ac 96 68 84 ae 92 60" # Destination 41 | "ac 96 68 9a a6 98 e1" # Source 42 | "01" # Control (LSB only) 43 | ), 44 | modulo128=True, 45 | ) 46 | assert False, "Should not have worked" 47 | except ValueError as e: 48 | assert str(e) == "Insufficient packet data" 49 | 50 | 51 | def test_8bs_rr_frame(): 52 | """ 53 | Test we can generate a 8-bit RR supervisory frame 54 | """ 55 | frame = AX25Frame.decode( 56 | from_hex( 57 | "ac 96 68 84 ae 92 60" # Destination 58 | "ac 96 68 9a a6 98 e1" # Source 59 | "41" # Control 60 | ), 61 | modulo128=False, 62 | ) 63 | assert isinstance(frame, AX258BitReceiveReadyFrame) 64 | assert frame.nr == 2 65 | 66 | 67 | def test_16bs_rr_frame(): 68 | """ 69 | Test we can generate a 16-bit RR supervisory frame 70 | """ 71 | frame = AX25Frame.decode( 72 | from_hex( 73 | "ac 96 68 84 ae 92 60" # Destination 74 | "ac 96 68 9a a6 98 e1" # Source 75 | "01 5c" # Control 76 | ), 77 | modulo128=True, 78 | ) 79 | assert isinstance(frame, AX2516BitReceiveReadyFrame) 80 | assert frame.nr == 46 81 | 82 | 83 | def test_16bs_rr_encode(): 84 | """ 85 | Test we can encode a 16-bit RR supervisory frame 86 | """ 87 | frame = AX2516BitReceiveReadyFrame( 88 | destination="VK4BWI", source="VK4MSL", nr=46, pf=True 89 | ) 90 | hex_cmp( 91 | bytes(frame), 92 | "ac 96 68 84 ae 92 60" # Destination 93 | "ac 96 68 9a a6 98 e1" # Source 94 | "01 5d", # Control 95 | ) 96 | assert frame.control == 0x5D01 97 | 98 | 99 | def test_8bs_rej_decode_frame(): 100 | """ 101 | Test we can decode a 8-bit REJ supervisory frame 102 | """ 103 | frame = AX25Frame.decode( 104 | from_hex( 105 | "ac 96 68 84 ae 92 60" # Destination 106 | "ac 96 68 9a a6 98 e1" # Source 107 | "09" # Control byte 108 | ), 109 | modulo128=False, 110 | ) 111 | assert isinstance( 112 | frame, AX258BitRejectFrame 113 | ), "Did not decode to REJ frame" 114 | assert frame.nr == 0 115 | assert frame.pf == False 116 | 117 | 118 | def test_16bs_rej_decode_frame(): 119 | """ 120 | Test we can decode a 16-bit REJ supervisory frame 121 | """ 122 | frame = AX25Frame.decode( 123 | from_hex( 124 | "ac 96 68 84 ae 92 60" # Destination 125 | "ac 96 68 9a a6 98 e1" # Source 126 | "09 00" # Control bytes 127 | ), 128 | modulo128=True, 129 | ) 130 | assert isinstance( 131 | frame, AX2516BitRejectFrame 132 | ), "Did not decode to REJ frame" 133 | assert frame.nr == 0 134 | assert frame.pf == False 135 | 136 | 137 | def test_rr_frame_str(): 138 | """ 139 | Test we can get the string representation of a RR frame. 140 | """ 141 | frame = AX258BitReceiveReadyFrame( 142 | destination="VK4BWI", source="VK4MSL", nr=6 143 | ) 144 | 145 | assert str(frame) == ( 146 | "AX258BitReceiveReadyFrame VK4MSL>VK4BWI: N(R)=6 P/F=False " 147 | "Code=0x00" 148 | ) 149 | 150 | 151 | def test_rr_frame_copy(): 152 | """ 153 | Test we can get the string representation of a RR frame. 154 | """ 155 | frame = AX258BitReceiveReadyFrame( 156 | destination="VK4BWI", source="VK4MSL", nr=6 157 | ) 158 | framecopy = frame.copy() 159 | 160 | assert framecopy is not frame 161 | hex_cmp( 162 | bytes(framecopy), 163 | "ac 96 68 84 ae 92 60" # Destination 164 | "ac 96 68 9a a6 98 e1" # Source 165 | "c1", # Control byte 166 | ) 167 | -------------------------------------------------------------------------------- /aioax25/tools/call.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import argparse 5 | import logging 6 | 7 | from prompt_toolkit import PromptSession 8 | from prompt_toolkit.patch_stdout import patch_stdout 9 | from yaml import safe_load 10 | 11 | # aioax25 imports 12 | # from aioax25.kiss import … 13 | # from aioax25.interface import … 14 | # etc… if you're copying this for your own code 15 | from ..frame import AX25Frame 16 | from ..kiss import make_device, KISSDeviceState 17 | from ..interface import AX25Interface 18 | from ..station import AX25Station 19 | from ..peer import AX25PeerState 20 | from ..version import AX25Version 21 | 22 | 23 | class AX25Call(object): 24 | def __init__(self, source, destination, kissparams, port=0): 25 | log = logging.getLogger(self.__class__.__name__) 26 | kisslog = log.getChild("kiss") 27 | kisslog.setLevel(logging.INFO) # KISS logs are verbose! 28 | intflog = log.getChild("interface") 29 | intflog.setLevel(logging.INFO) # interface logs are verbose too! 30 | stnlog = log.getChild("station") 31 | 32 | self._log = log 33 | self._device = make_device(**kissparams, log=kisslog) 34 | self._interface = AX25Interface(self._device[port], log=intflog) 35 | self._station = AX25Station( 36 | self._interface, 37 | source, 38 | log=stnlog, 39 | ) 40 | self._station.attach() 41 | self._peer = self._station.getpeer(destination) 42 | self._peer.received_information.connect(self._on_receive) 43 | 44 | def _on_receive(self, frame, **kwargs): 45 | with patch_stdout(): 46 | if frame.pid == AX25Frame.PID_NO_L3: 47 | # No L3 protocol 48 | print("\n".join(frame.payload.decode().split("\r"))) 49 | else: 50 | print("[PID=0x%02x] %r" % (frame.pid, frame.payload)) 51 | 52 | async def interact(self): 53 | # Open the KISS interface 54 | self._device.open() 55 | 56 | # TODO: implement async functions on KISS device to avoid this! 57 | while self._device.state != KISSDeviceState.OPEN: 58 | await asyncio.sleep(0.1) 59 | 60 | # Connect to the remote station 61 | future = asyncio.Future() 62 | 63 | def _state_change_fn(state, **kwa): 64 | if state is AX25PeerState.CONNECTED: 65 | future.set_result(None) 66 | elif state is AX25PeerState.DISCONNECTED: 67 | future.set_exception(IOError("Connection refused")) 68 | 69 | self._peer.connect_state_changed.connect(_state_change_fn) 70 | self._peer.connect() 71 | await future 72 | self._peer.connect_state_changed.disconnect(_state_change_fn) 73 | 74 | # We should now be connected 75 | self._log.info("CONNECTED to %s", self._peer.address) 76 | finished = False 77 | session = PromptSession() 78 | while not finished: 79 | if self._peer.state is not AX25PeerState.CONNECTED: 80 | finished = True 81 | 82 | with patch_stdout(): 83 | # Prompt for user input 84 | txinput = await session.prompt_async( 85 | "%s>" % self._peer.address 86 | ) 87 | if txinput: 88 | self._peer.send(("%s\r" % txinput).encode()) 89 | 90 | if self._peer.state is not AX25PeerState.DISCONNECTED: 91 | self._log.info("DISCONNECTING") 92 | future = asyncio.Future() 93 | 94 | def _state_change_fn(state, **kwa): 95 | if state is AX25PeerState.DISCONNECTED: 96 | future.set_result(None) 97 | 98 | self._peer.connect_state_changed.connect(_state_change_fn) 99 | self._peer.disconnect() 100 | await future 101 | self._peer.connect_state_changed.disconnect(_state_change_fn) 102 | 103 | self._log.info("Finished") 104 | 105 | 106 | async def main(): 107 | ap = argparse.ArgumentParser() 108 | 109 | ap.add_argument("--log-level", default="info", type=str, help="Log level") 110 | ap.add_argument("--port", default=0, type=int, help="KISS port number") 111 | ap.add_argument( 112 | "config", type=str, help="KISS serial port configuration file" 113 | ) 114 | ap.add_argument("source", type=str, help="Source callsign/SSID") 115 | ap.add_argument("destination", type=str, help="Source callsign/SSID") 116 | 117 | args = ap.parse_args() 118 | 119 | logging.basicConfig( 120 | level=args.log_level.upper(), 121 | format=( 122 | "%(asctime)s %(name)s[%(filename)s:%(lineno)4d] " 123 | "%(levelname)s %(message)s" 124 | ), 125 | ) 126 | config = safe_load(open(args.config, "r").read()) 127 | 128 | ax25call = AX25Call(args.source, args.destination, config, args.port) 129 | await ax25call.interact() 130 | 131 | 132 | if __name__ == "__main__": 133 | asyncio.get_event_loop().run_until_complete(main()) 134 | -------------------------------------------------------------------------------- /tests/test_peer/test_disc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for AX25Peer DISC handling 5 | """ 6 | 7 | from aioax25.frame import ( 8 | AX25Address, 9 | AX25Path, 10 | AX25DisconnectFrame, 11 | AX25UnnumberedAcknowledgeFrame, 12 | ) 13 | from aioax25.peer import AX25PeerState 14 | from aioax25.version import AX25Version 15 | from .peer import TestingAX25Peer 16 | from ..mocks import DummyStation, DummyTimeout 17 | 18 | 19 | # DISC reception handling 20 | 21 | 22 | def test_peer_recv_disc(): 23 | """ 24 | Test when receiving a DISC whilst connected, the peer disconnects. 25 | """ 26 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 27 | interface = station._interface() 28 | peer = TestingAX25Peer( 29 | station=station, 30 | address=AX25Address("VK4MSL"), 31 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 32 | full_duplex=True, 33 | locked_path=True, 34 | ) 35 | 36 | # Set some dummy data in fields -- this should be cleared out. 37 | ack_timer = DummyTimeout(None, None) 38 | peer._ack_timeout_handle = ack_timer 39 | peer._state = AX25PeerState.CONNECTED 40 | peer._modulo = 8 41 | peer._send_state = 1 42 | peer._send_seq = 2 43 | peer._recv_state = 3 44 | peer._recv_seq = 4 45 | peer._pending_iframes = dict(comment="pending data") 46 | peer._pending_data = ["pending data"] 47 | 48 | # Pass the peer a DISC frame 49 | peer._on_receive( 50 | AX25DisconnectFrame( 51 | destination=station.address, source=peer.address, repeaters=None 52 | ) 53 | ) 54 | 55 | # This was a request, so there should be a reply waiting 56 | assert len(interface.transmit_calls) == 1 57 | (tx_args, tx_kwargs) = interface.transmit_calls.pop(0) 58 | 59 | # This should be a UA in reply to the DISC 60 | assert tx_kwargs == {"callback": None} 61 | assert len(tx_args) == 1 62 | (frame,) = tx_args 63 | assert isinstance(frame, AX25UnnumberedAcknowledgeFrame) 64 | 65 | assert str(frame.header.destination) == "VK4MSL*" 66 | assert str(frame.header.source) == "VK4MSL-1" 67 | assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3" 68 | 69 | # We should now be "disconnected" 70 | assert peer._ack_timeout_handle is None 71 | assert peer._state is AX25PeerState.DISCONNECTED 72 | assert peer._send_state == 0 73 | assert peer._send_seq == 0 74 | assert peer._recv_state == 0 75 | assert peer._recv_seq == 0 76 | assert peer._pending_iframes == {} 77 | assert peer._pending_data == [] 78 | 79 | 80 | # DISC transmission 81 | 82 | 83 | def test_peer_send_disc(): 84 | """ 85 | Test _send_disc correctly addresses and sends a DISC frame. 86 | """ 87 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 88 | interface = station._interface() 89 | peer = TestingAX25Peer( 90 | station=station, 91 | address=AX25Address("VK4MSL"), 92 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 93 | full_duplex=True, 94 | ) 95 | peer._modulo = 8 96 | 97 | # Request a DISC frame be sent 98 | peer._send_disc() 99 | 100 | # There should be our outgoing request here 101 | assert len(interface.transmit_calls) == 1 102 | (tx_args, tx_kwargs) = interface.transmit_calls.pop(0) 103 | 104 | # This should be a DISC 105 | assert tx_kwargs == {"callback": None} 106 | assert len(tx_args) == 1 107 | (frame,) = tx_args 108 | assert isinstance(frame, AX25DisconnectFrame) 109 | 110 | assert str(frame.header.destination) == "VK4MSL*" 111 | assert str(frame.header.source) == "VK4MSL-1" 112 | assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3" 113 | 114 | 115 | # DISC UA time-out handling 116 | 117 | 118 | def test_peer_ua_timeout_disconnecting(): 119 | """ 120 | Test _on_disc_ua_timeout cleans up the connection if no UA heard 121 | from peer after DISC frame. 122 | """ 123 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 124 | peer = TestingAX25Peer( 125 | station=station, 126 | address=AX25Address("VK4MSL"), 127 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 128 | full_duplex=True, 129 | ) 130 | 131 | peer._state = AX25PeerState.DISCONNECTING 132 | peer._modulo = 8 133 | peer._ack_timeout_handle = "time-out handle" 134 | 135 | peer._on_disc_ua_timeout() 136 | 137 | assert peer._state is AX25PeerState.DISCONNECTED 138 | assert peer._ack_timeout_handle is None 139 | 140 | 141 | def test_peer_ua_timeout_notdisconnecting(): 142 | """ 143 | Test _on_disc_ua_timeout does nothing if not disconnecting. 144 | """ 145 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 146 | peer = TestingAX25Peer( 147 | station=station, 148 | address=AX25Address("VK4MSL"), 149 | repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"), 150 | full_duplex=True, 151 | ) 152 | 153 | peer._state = AX25PeerState.CONNECTED 154 | peer._ack_timeout_handle = "time-out handle" 155 | peer._modulo = 8 156 | 157 | peer._on_disc_ua_timeout() 158 | 159 | assert peer._state is AX25PeerState.CONNECTED 160 | assert peer._ack_timeout_handle == "time-out handle" 161 | -------------------------------------------------------------------------------- /aioax25/router.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | AX.25 Interface handler 5 | """ 6 | 7 | from functools import partial 8 | from .signal import Signal 9 | import re 10 | 11 | from .frame import AX25Frame 12 | 13 | 14 | class Router(object): 15 | """ 16 | The Router routes incoming messages to receivers. It is a mix-in class 17 | used by AX25Interface. The base class is assumed to have IOLoop and 18 | logger instances 19 | """ 20 | 21 | def __init__(self): 22 | # Receivers 23 | self._receiver_str = {} 24 | self._receiver_re = {} 25 | 26 | # Received message call-back. This is triggered whenever a message 27 | # comes in regardless of destination call-sign. 28 | self.received_msg = Signal() 29 | 30 | def _get_destination(self, frame): 31 | """ 32 | Retrieve the destination of the given frame and return it. 33 | """ 34 | return frame.header.destination 35 | 36 | def bind(self, callback, callsign, ssid=0, regex=False): 37 | """ 38 | Bind a receiver to the given call-sign and optional SSID. The callsign 39 | argument is expected to be a string, but may also be a regular 40 | expression pattern which is then matched against the callsign (but not 41 | the SSID!). 42 | 43 | ssid may be set to None, which means all SSIDs. 44 | """ 45 | if not isinstance(callsign, str): 46 | raise TypeError( 47 | "callsign must be a string (use " "regex=True for regex)" 48 | ) 49 | if regex: 50 | (_, call_receivers) = self._receiver_re.setdefault( 51 | callsign, (re.compile(callsign), {}) 52 | ) 53 | else: 54 | call_receivers = self._receiver_str.setdefault(callsign, {}) 55 | 56 | self._log.debug( 57 | "Binding callsign %r (regex %r) SSID %r to %r", 58 | callsign, 59 | regex, 60 | ssid, 61 | callback, 62 | ) 63 | call_receivers.setdefault(ssid, []).append(callback) 64 | 65 | def unbind(self, callback, callsign, ssid=0, regex=False): 66 | """ 67 | Unbind a receiver from the given callsign/SSID combo. 68 | """ 69 | try: 70 | if regex: 71 | receivers = self._receiver_re 72 | (_, call_receivers) = receivers[callsign] 73 | else: 74 | receivers = self._receiver_str 75 | call_receivers = receivers[callsign] 76 | 77 | ssid_receivers = call_receivers[ssid] 78 | except KeyError: 79 | return 80 | 81 | try: 82 | ssid_receivers.remove(callback) 83 | self._log.debug( 84 | "Unbound callsign %r (regex %r) SSID %r to %r", 85 | callsign, 86 | regex, 87 | ssid, 88 | callback, 89 | ) 90 | except ValueError: 91 | return 92 | 93 | if len(ssid_receivers) == 0: 94 | call_receivers.pop(ssid) 95 | 96 | if len(call_receivers) == 0: 97 | receivers.pop(callsign) 98 | 99 | def _on_receive(self, frame): 100 | """ 101 | Handle an incoming message. 102 | """ 103 | # Decode from raw bytes 104 | if not isinstance(frame, AX25Frame): 105 | frame = AX25Frame.decode(frame) 106 | self._log.debug("Handling incoming frame %s", frame) 107 | 108 | # Pass the message to those who elected to receive all traffic 109 | self._loop.call_soon( 110 | partial(self.received_msg.emit, interface=self, frame=frame) 111 | ) 112 | 113 | destination = self._get_destination(frame) 114 | callsign = destination.callsign 115 | ssid = destination.ssid 116 | 117 | # Dispatch the incoming message notification to string match receivers 118 | calls = [] 119 | try: 120 | callsign_receivers = self._receiver_str[callsign] 121 | calls.extend( 122 | [ 123 | partial(receiver, interface=self, frame=frame) 124 | for receiver in callsign_receivers.get(None, []) 125 | + callsign_receivers.get(ssid, []) 126 | ] 127 | ) 128 | except KeyError: 129 | pass 130 | 131 | # Compare the incoming frame destination to our regex receivers 132 | for pattern, pat_receivers in self._receiver_re.values(): 133 | match = pattern.search(callsign) 134 | if not match: 135 | continue 136 | 137 | calls.extend( 138 | [ 139 | partial( 140 | receiver, interface=self, frame=frame, match=match 141 | ) 142 | for receiver in pat_receivers.get(None, []) 143 | + pat_receivers.get(ssid, []) 144 | ] 145 | ) 146 | 147 | # Dispatch the received message 148 | self._log.debug("Dispatching frame to %d receivers", len(calls)) 149 | for receiver in calls: 150 | self._loop.call_soon(receiver) 151 | -------------------------------------------------------------------------------- /tests/mocks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | import logging 5 | from signalslot import Signal 6 | from aioax25.version import AX25Version 7 | 8 | 9 | class DummyInterface(object): 10 | def __init__(self): 11 | self.bind_calls = [] 12 | self.unbind_calls = [] 13 | self.transmit_calls = [] 14 | 15 | def bind(self, *args, **kwargs): 16 | self.bind_calls.append((args, kwargs)) 17 | 18 | def unbind(self, *args, **kwargs): 19 | self.unbind_calls.append((args, kwargs)) 20 | 21 | def transmit(self, *args, **kwargs): 22 | self.transmit_calls.append((args, kwargs)) 23 | 24 | 25 | class DummyLogger(object): 26 | def __init__(self, name, parent=None): 27 | self.parent = parent 28 | self.name = name 29 | self.logs = [] 30 | 31 | def _log(self, name, level, msg, *args, **kwargs): 32 | if self.parent is not None: 33 | self.parent._log(self.name, level, msg, *args, **kwargs) 34 | self.logs.append((self.name, level, msg, args, kwargs)) 35 | 36 | def log(self, level, msg, *args, **kwargs): 37 | self._log(self.name, level, msg, *args, **kwargs) 38 | 39 | def critical(self, msg, *args, **kwargs): 40 | self.log(logging.CRITICAL, msg, *args, **kwargs) 41 | 42 | def debug(self, msg, *args, **kwargs): 43 | self.log(logging.DEBUG, msg, *args, **kwargs) 44 | 45 | def error(self, msg, *args, **kwargs): 46 | self.log(logging.ERROR, msg, *args, **kwargs) 47 | 48 | def info(self, msg, *args, **kwargs): 49 | self.log(logging.INFO, msg, *args, **kwargs) 50 | 51 | def warn(self, msg, *args, **kwargs): 52 | self.log(logging.WARNING, msg, *args, **kwargs) 53 | 54 | def warning(self, msg, *args, **kwargs): 55 | self.log(logging.WARNING, msg, *args, **kwargs) 56 | 57 | def getChild(self, name): 58 | return DummyLogger(self.name + "." + name, parent=self) 59 | 60 | def isEnabledFor(self, level): 61 | return True 62 | 63 | 64 | class DummyTimeout(object): 65 | def __init__(self, delay, callback, *args, **kwargs): 66 | self.args = args 67 | self.kwargs = kwargs 68 | self.callback = callback 69 | self.delay = delay 70 | 71 | self.cancelled = False 72 | 73 | def cancel(self): 74 | self.cancelled = True 75 | 76 | 77 | class DummyIOLoop(object): 78 | def __init__(self): 79 | self.call_soon_list = [] 80 | self.call_later_list = [] 81 | 82 | def time(self): 83 | return time.monotonic() 84 | 85 | def call_soon(self, callback, *args, **kwargs): 86 | self.call_soon_list.append((callback, args, kwargs)) 87 | 88 | def call_later(self, delay, callback, *args, **kwargs): 89 | timeout = DummyTimeout(delay, callback, *args, **kwargs) 90 | self.call_later_list.append(timeout) 91 | return timeout 92 | 93 | 94 | class DummyStation(object): 95 | def __init__(self, address, reply_path=None): 96 | self._interface_ref = DummyInterface() 97 | self.address = address 98 | self.reply_path = reply_path or [] 99 | self._full_duplex = False 100 | self._protocol = AX25Version.AX25_22 101 | self.connection_request = Signal() 102 | 103 | def _interface(self): 104 | return self._interface_ref 105 | 106 | 107 | class DummyPeer(object): 108 | def __init__(self, station, address): 109 | self._station_ref = station 110 | self._log = DummyLogger("peer") 111 | self._loop = DummyIOLoop() 112 | 113 | self._max_retries = 2 114 | self._ack_timeout = 0.1 115 | 116 | self.address_read = False 117 | self._address = address 118 | 119 | self._negotiate_calls = [] 120 | self.transmit_calls = [] 121 | self.on_receive_calls = [] 122 | 123 | self._testframe_handler = None 124 | self._uaframe_handler = None 125 | self._frmrframe_handler = None 126 | self._dmframe_handler = None 127 | self._sabmframe_handler = None 128 | self._xidframe_handler = None 129 | 130 | self._negotiated = False 131 | self._protocol = AX25Version.UNKNOWN 132 | 133 | self._modulo128 = False 134 | self._init_connection_modulo = None 135 | 136 | # Our fake weakref 137 | def _station(self): 138 | return self._station_ref 139 | 140 | @property 141 | def address(self): 142 | self.address_read = True 143 | return self._address 144 | 145 | def _init_connection(self, extended): 146 | if extended is True: 147 | self._init_connection_modulo = 128 148 | elif extended is False: 149 | self._init_connection_modulo = 8 150 | else: 151 | raise ValueError("Invalid extended value %r" % extended) 152 | 153 | def _negotiate(self, callback): 154 | self._negotiate_calls.append(callback) 155 | 156 | def _on_receive(self, *args, **kwargs): 157 | self.on_receive_calls.append((args, kwargs)) 158 | 159 | def _transmit_frame(self, frame, callback=None): 160 | self.transmit_calls.append((frame, callback)) 161 | 162 | def _send_sabm(self): 163 | self._transmit_frame("sabm") 164 | 165 | def _send_dm(self): 166 | self._transmit_frame("dm") 167 | 168 | def _send_xid(self, cr=False): 169 | self._transmit_frame("xid:cr=%s" % cr) 170 | -------------------------------------------------------------------------------- /aioax25/interface.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | AX.25 Interface handler 5 | """ 6 | 7 | import logging 8 | import asyncio 9 | import random 10 | from functools import partial 11 | import time 12 | 13 | from .router import Router 14 | 15 | 16 | class AX25Interface(Router): 17 | """ 18 | The AX25Interface class represents a logical AX.25 interface. 19 | The interface handles basic queueing and routing of message traffic. 20 | 21 | Outgoing messages are queued and sent when there is a break of greater 22 | than the cts_delay (10ms) + a randomisation factor (cts_rand). 23 | Messages may be cancelled prior to transmission. 24 | """ 25 | 26 | def __init__( 27 | self, kissport, cts_delay=0.01, cts_rand=0.01, log=None, loop=None 28 | ): 29 | # Initialise the superclass 30 | super(AX25Interface, self).__init__() 31 | 32 | if log is None: 33 | log = logging.getLogger(self.__class__.__module__) 34 | 35 | if loop is None: 36 | loop = asyncio.get_event_loop() 37 | 38 | self._log = log 39 | self._loop = loop 40 | self._port = kissport 41 | 42 | # Message queue 43 | self._tx_queue = [] 44 | self._tx_pending = None 45 | 46 | # Clear-to-send delay and randomisation factor 47 | self._cts_delay = cts_delay 48 | self._cts_rand = cts_rand 49 | 50 | # Clear-to-send expiry 51 | self._cts_expiry = ( 52 | loop.time() + cts_delay + (random.random() * cts_rand) 53 | ) 54 | 55 | # Bind to the KISS port to receive raw messages. 56 | kissport.received.connect(self._on_receive) 57 | 58 | def transmit(self, frame, callback=None): 59 | """ 60 | Enqueue a message for transmission. Optionally give a call-back 61 | function to receive notification of transmission. 62 | """ 63 | self._log.debug("Adding to queue: %s", frame) 64 | self._tx_queue.append((frame, callback)) 65 | if not self._tx_pending: 66 | self._schedule_tx() 67 | 68 | def cancel_transmit(self, frame): 69 | """ 70 | Cancel the transmission of a frame. 71 | """ 72 | self._log.debug("Removing from queue: %s", frame) 73 | self._tx_queue = list( 74 | filter(lambda item: item[0] is not frame, self._tx_queue) 75 | ) 76 | 77 | def _reset_cts(self): 78 | """ 79 | Reset the clear-to-send timer. 80 | """ 81 | cts_expiry = ( 82 | self._loop.time() 83 | + self._cts_delay 84 | + (random.random() * self._cts_rand) 85 | ) 86 | 87 | # Ensure CTS expiry never goes backwards! 88 | while cts_expiry < self._cts_expiry: 89 | cts_expiry += random.random() * self._cts_rand 90 | self._cts_expiry = cts_expiry 91 | 92 | self._log.debug("Clear-to-send expiry at %s", self._cts_expiry) 93 | if self._tx_pending: 94 | # We were waiting for a clear-to-send, so re-schedule. 95 | self._schedule_tx() 96 | 97 | def _on_receive(self, frame): 98 | """ 99 | Handle an incoming message. 100 | """ 101 | self._reset_cts() 102 | super(AX25Interface, self)._on_receive(frame) 103 | 104 | def _schedule_tx(self): 105 | """ 106 | Schedule the transmit timer to take place after the CTS expiry. 107 | """ 108 | if self._tx_pending: 109 | self._tx_pending.cancel() 110 | 111 | delay = self._cts_expiry - self._loop.time() 112 | if delay > 0: 113 | self._log.debug("Scheduling next transmission in %s", delay) 114 | self._tx_pending = self._loop.call_later(delay, self._tx_next) 115 | else: 116 | self._log.debug("Scheduling next transmission ASAP") 117 | self._tx_pending = self._loop.call_soon(self._tx_next) 118 | 119 | def _tx_next(self): 120 | """ 121 | Transmit the next message. 122 | """ 123 | self._tx_pending = None 124 | 125 | try: 126 | (frame, callback) = self._tx_queue.pop(0) 127 | except IndexError: 128 | self._log.debug("No traffic to transmit") 129 | return 130 | 131 | try: 132 | if (frame.deadline is not None) and ( 133 | frame.deadline < time.time() 134 | ): 135 | self._log.info("Dropping expired frame: %s", frame) 136 | self._schedule_tx() 137 | return 138 | except AttributeError: # pragma: no cover 139 | # Technically, all objects that pass through here should be 140 | # AX25Frame sub-classes, so this branch should not get executed. 141 | # If it does, we just pretend there is no deadline. 142 | pass 143 | 144 | try: 145 | self._log.debug("Transmitting %s", frame) 146 | self._port.send(frame) 147 | if callback: 148 | self._log.debug("Notifying sender of %s", frame) 149 | self._loop.call_soon( 150 | partial(callback, interface=self, frame=frame) 151 | ) 152 | except: 153 | self._log.exception("Failed to transmit %s", frame) 154 | 155 | self._reset_cts() 156 | if len(self._tx_queue) > 0: 157 | self._schedule_tx() 158 | -------------------------------------------------------------------------------- /tests/test_frame/test_ax25frameheader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.frame import AX25Address, AX25FrameHeader 4 | from ..hex import from_hex, hex_cmp 5 | 6 | 7 | def test_decode_incomplete(): 8 | """ 9 | Test that an incomplete frame does not cause a crash. 10 | """ 11 | try: 12 | AX25FrameHeader.decode( 13 | from_hex("ac 96 68 84 ae 92 e0") # Destination 14 | ) 15 | assert False, "This should not have worked" 16 | except ValueError as e: 17 | assert str(e) == "Too few addresses" 18 | 19 | 20 | def test_decode_no_digis(): 21 | """ 22 | Test we can decode an AX.25 frame without digipeaters. 23 | """ 24 | (header, data) = AX25FrameHeader.decode( 25 | from_hex( 26 | "ac 96 68 84 ae 92 e0" # Destination 27 | "ac 96 68 9a a6 98 61" # Source 28 | ) 29 | + b"frame data goes here" # Frame data 30 | ) 31 | assert header.destination == AX25Address("VK4BWI", ch=True) 32 | assert header.source == AX25Address("VK4MSL", extension=True) 33 | assert len(header.repeaters) == 0 34 | assert data == b"frame data goes here" 35 | 36 | 37 | def test_decode_legacy(): 38 | """ 39 | Test we can decode an AX.25 1.x frame. 40 | """ 41 | (header, data) = AX25FrameHeader.decode( 42 | from_hex( 43 | "ac 96 68 84 ae 92 e0" # Destination 44 | "ac 96 68 9a a6 98 e1" # Source 45 | ) 46 | + b"frame data goes here" # Frame data 47 | ) 48 | assert header.legacy 49 | assert header.cr 50 | assert header.destination == AX25Address("VK4BWI", ch=True) 51 | assert header.source == AX25Address("VK4MSL", extension=True, ch=True) 52 | assert len(header.repeaters) == 0 53 | assert data == b"frame data goes here" 54 | 55 | 56 | def test_encode_legacy(): 57 | """ 58 | Test we can encode an AX.25 1.x frame. 59 | """ 60 | header = AX25FrameHeader( 61 | destination="VK4BWI", source="VK4MSL", cr=False, legacy=True 62 | ) 63 | hex_cmp( 64 | bytes(header), 65 | "ac 96 68 84 ae 92 60" # Destination 66 | "ac 96 68 9a a6 98 61", # Source 67 | ) 68 | 69 | 70 | def test_decode_with_1digi(): 71 | """ 72 | Test we can decode an AX.25 frame with one digipeater. 73 | """ 74 | (header, data) = AX25FrameHeader.decode( 75 | from_hex( 76 | "ac 96 68 84 ae 92 e0" # Destination 77 | "ac 96 68 9a a6 98 60" # Source 78 | "ac 96 68 a4 b4 84 61" # Digi 79 | ) 80 | + b"frame data goes here" # Frame data 81 | ) 82 | assert header.destination == AX25Address("VK4BWI", ch=True) 83 | assert header.source == AX25Address("VK4MSL") 84 | assert header.repeaters[0] == AX25Address("VK4RZB", extension=True) 85 | assert data == b"frame data goes here" 86 | 87 | 88 | def test_decode_with_2digis(): 89 | """ 90 | Test we can decode an AX.25 frame with two digipeaters. 91 | """ 92 | (header, data) = AX25FrameHeader.decode( 93 | from_hex( 94 | "ac 96 68 84 ae 92 e0" # Destination 95 | "ac 96 68 9a a6 98 60" # Source 96 | "ac 96 68 a4 b4 84 60" # Digi 1 97 | "ac 96 68 a4 b4 82 61" # Digi 2 98 | ) 99 | + b"frame data goes here" # Frame data 100 | ) 101 | assert header.destination == AX25Address("VK4BWI", ch=True) 102 | assert header.source == AX25Address("VK4MSL") 103 | assert len(header.repeaters) == 2 104 | assert header.repeaters[0] == AX25Address("VK4RZB") 105 | assert header.repeaters[1] == AX25Address("VK4RZA", extension=True) 106 | assert data == b"frame data goes here" 107 | 108 | 109 | def test_encode_no_digis(): 110 | """ 111 | Test we can encode an AX.25 frame without digipeaters. 112 | """ 113 | header = AX25FrameHeader(destination="VK4BWI", source="VK4MSL", cr=True) 114 | hex_cmp( 115 | bytes(header), 116 | "ac 96 68 84 ae 92 e0 " # Destination 117 | "ac 96 68 9a a6 98 61", # Source 118 | ) 119 | 120 | 121 | def test_encode_no_digis_ax25v1(): 122 | """ 123 | Test we can encode an AX.25v1 frame without digipeaters. 124 | """ 125 | header = AX25FrameHeader( 126 | destination="VK4BWI", source="VK4MSL", cr=False, src_cr=False 127 | ) 128 | hex_cmp( 129 | bytes(header), 130 | "ac 96 68 84 ae 92 60 " # Destination 131 | "ac 96 68 9a a6 98 61", # Source 132 | ) 133 | 134 | 135 | def test_encode_1digi(): 136 | """ 137 | Test we can encode an AX.25 frame with one digipeater. 138 | """ 139 | header = AX25FrameHeader( 140 | destination="VK4BWI", source="VK4MSL", repeaters=("VK4RZB",), cr=True 141 | ) 142 | hex_cmp( 143 | bytes(header), 144 | "ac 96 68 84 ae 92 e0 " # Destination 145 | "ac 96 68 9a a6 98 60 " # Source 146 | "ac 96 68 a4 b4 84 61", # Digi 147 | ) 148 | 149 | 150 | def test_encode_2digis(): 151 | """ 152 | Test we can encode an AX.25 frame with two digipeaters. 153 | """ 154 | header = AX25FrameHeader( 155 | destination="VK4BWI", 156 | source="VK4MSL", 157 | repeaters=("VK4RZB", "VK4RZA"), 158 | cr=True, 159 | ) 160 | hex_cmp( 161 | bytes(header), 162 | "ac 96 68 84 ae 92 e0 " # Destination 163 | "ac 96 68 9a a6 98 60 " # Source 164 | "ac 96 68 a4 b4 84 60 " # Digi 1 165 | "ac 96 68 a4 b4 82 61", # Digi 2 166 | ) 167 | -------------------------------------------------------------------------------- /tests/test_aprs/test_frame.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | 5 | from aioax25.aprs.frame import APRSFrame 6 | from aioax25.aprs.message import ( 7 | APRSMessageAckFrame, 8 | APRSMessageRejFrame, 9 | APRSMessageFrame, 10 | ) 11 | from aioax25.aprs.position import APRSPositionFrame 12 | from aioax25.frame import AX25UnnumberedInformationFrame 13 | 14 | 15 | def test_decode_wrong_pid(): 16 | """ 17 | Test the decode routine ignores UI frames with wrong PID. 18 | """ 19 | frame = AX25UnnumberedInformationFrame( 20 | destination="APZAIO", 21 | source="VK4MSL-7", 22 | pid=0x80, # Not correct for APRS, should not get decoded. 23 | payload=b"Test frame that's not APRS", 24 | ) 25 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 26 | assert decoded is frame 27 | 28 | 29 | def test_decode_no_payload(): 30 | """ 31 | Test the decode routine rejects frames without a payload. 32 | """ 33 | frame = AX25UnnumberedInformationFrame( 34 | destination="APZAIO", 35 | source="VK4MSL-7", 36 | pid=0xF0, 37 | payload=b"", # Empty payload 38 | ) 39 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 40 | assert decoded is frame 41 | 42 | 43 | def test_decode_unknown_type(): 44 | """ 45 | Test the decode routine rejects frames it cannot recognise. 46 | """ 47 | frame = AX25UnnumberedInformationFrame( 48 | destination="APZAIO", 49 | source="VK4MSL-7", 50 | pid=0xF0, 51 | payload=b"X A mystery frame X", 52 | ) 53 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 54 | assert decoded is frame 55 | 56 | 57 | def test_decode_position(): 58 | """ 59 | Test the decode routine can recognise a position report. 60 | """ 61 | frame = AX25UnnumberedInformationFrame( 62 | destination="APZAIO", 63 | source="VK4MSL-7", 64 | pid=0xF0, 65 | payload=b"!3722.20N/07900.66W&000/000/A=000685Mobile", 66 | ) 67 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 68 | assert decoded is not frame 69 | assert isinstance(decoded, APRSPositionFrame) 70 | assert decoded.position.lat.degrees == 37 71 | assert decoded.position.lat.minutes == 22 72 | assert abs(decoded.position.lat.seconds - 12) < 0.001 73 | assert decoded.position.lng.degrees == -79 74 | assert decoded.position.lng.minutes == 0 75 | assert abs(decoded.position.lng.seconds - 39.6) < 0.001 76 | assert decoded.position.symbol.tableident == "/" 77 | assert decoded.position.symbol.symbol == "&" 78 | assert decoded.message == "000/000/A=000685Mobile" 79 | 80 | 81 | def test_decode_message(): 82 | """ 83 | Test the decode routine can recognise a message frame. 84 | """ 85 | frame = AX25UnnumberedInformationFrame( 86 | destination="APZAIO", 87 | source="VK4MSL-7", 88 | pid=0xF0, 89 | payload=b":VK4MDL-7 :Hi", 90 | ) 91 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 92 | assert decoded is not frame 93 | assert isinstance(decoded, APRSMessageFrame) 94 | 95 | 96 | def test_decode_message_confirmable(): 97 | """ 98 | Test the decode routine can recognise a confirmable message frame. 99 | """ 100 | frame = AX25UnnumberedInformationFrame( 101 | destination="APZAIO", 102 | source="VK4MSL-7", 103 | pid=0xF0, 104 | payload=b":VK4MDL-7 :Hi{14", 105 | ) 106 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 107 | assert decoded is not frame 108 | assert isinstance(decoded, APRSMessageFrame) 109 | assert decoded.msgid == "14" 110 | 111 | 112 | def test_decode_message_replyack_capable(): 113 | """ 114 | Test the decode routine can recognise a message frame from a Reply-ACK 115 | capable station. 116 | """ 117 | frame = AX25UnnumberedInformationFrame( 118 | destination="APZAIO", 119 | source="VK4MSL-7", 120 | pid=0xF0, 121 | payload=b":VK4MDL-7 :Hi{01}", 122 | ) 123 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 124 | assert decoded is not frame 125 | assert isinstance(decoded, APRSMessageFrame) 126 | assert decoded.replyack == True 127 | assert decoded.msgid == "01" 128 | 129 | 130 | def test_decode_message_replyack_reply(): 131 | """ 132 | Test the decode routine can recognise a message frame sent as a 133 | reply-ack. 134 | """ 135 | frame = AX25UnnumberedInformationFrame( 136 | destination="APZAIO", 137 | source="VK4MSL-7", 138 | pid=0xF0, 139 | payload=b":VK4MDL-7 :Hi{01}45", 140 | ) 141 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 142 | assert decoded is not frame 143 | assert isinstance(decoded, APRSMessageFrame) 144 | assert decoded.replyack == "45" 145 | assert decoded.msgid == "01" 146 | 147 | 148 | def test_decode_message_ack(): 149 | """ 150 | Test the decode routine can recognise a message acknowledgement frame. 151 | """ 152 | frame = AX25UnnumberedInformationFrame( 153 | destination="APZAIO", 154 | source="VK4MSL-7", 155 | pid=0xF0, 156 | payload=b":VK4MDL-7 :ack2", 157 | ) 158 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 159 | assert decoded is not frame 160 | assert isinstance(decoded, APRSMessageAckFrame) 161 | assert decoded.msgid == "2" 162 | 163 | 164 | def test_decode_message_rej(): 165 | """ 166 | Test the decode routine can recognise a message rejection frame. 167 | """ 168 | frame = AX25UnnumberedInformationFrame( 169 | destination="APZAIO", 170 | source="VK4MSL-7", 171 | pid=0xF0, 172 | payload=b":VK4MDL-7 :rej3", 173 | ) 174 | decoded = APRSFrame.decode(frame, logging.getLogger("decoder")) 175 | assert decoded is not frame 176 | assert isinstance(decoded, APRSMessageRejFrame) 177 | assert decoded.msgid == "3" 178 | -------------------------------------------------------------------------------- /doc/ax25-2p0/index_files/iconochive.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:'Iconochive-Regular';src:url('https://archive.org/includes/fonts/Iconochive-Regular.eot?-ccsheb');src:url('https://archive.org/includes/fonts/Iconochive-Regular.eot?#iefix-ccsheb') format('embedded-opentype'),url('https://archive.org/includes/fonts/Iconochive-Regular.woff?-ccsheb') format('woff'),url('https://archive.org/includes/fonts/Iconochive-Regular.ttf?-ccsheb') format('truetype'),url('https://archive.org/includes/fonts/Iconochive-Regular.svg?-ccsheb#Iconochive-Regular') format('svg');font-weight:normal;font-style:normal} 2 | [class^="iconochive-"],[class*=" iconochive-"]{font-family:'Iconochive-Regular'!important;speak:none;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} 3 | .iconochive-Uplevel:before{content:"\21b5"} 4 | .iconochive-exit:before{content:"\1f6a3"} 5 | .iconochive-beta:before{content:"\3b2"} 6 | .iconochive-logo:before{content:"\1f3db"} 7 | .iconochive-audio:before{content:"\1f568"} 8 | .iconochive-movies:before{content:"\1f39e"} 9 | .iconochive-software:before{content:"\1f4be"} 10 | .iconochive-texts:before{content:"\1f56e"} 11 | .iconochive-etree:before{content:"\1f3a4"} 12 | .iconochive-image:before{content:"\1f5bc"} 13 | .iconochive-web:before{content:"\1f5d4"} 14 | .iconochive-collection:before{content:"\2211"} 15 | .iconochive-folder:before{content:"\1f4c2"} 16 | .iconochive-data:before{content:"\1f5c3"} 17 | .iconochive-tv:before{content:"\1f4fa"} 18 | .iconochive-article:before{content:"\1f5cf"} 19 | .iconochive-question:before{content:"\2370"} 20 | .iconochive-question-dark:before{content:"\3f"} 21 | .iconochive-info:before{content:"\69"} 22 | .iconochive-info-small:before{content:"\24d8"} 23 | .iconochive-comment:before{content:"\1f5e9"} 24 | .iconochive-comments:before{content:"\1f5ea"} 25 | .iconochive-person:before{content:"\1f464"} 26 | .iconochive-people:before{content:"\1f465"} 27 | .iconochive-eye:before{content:"\1f441"} 28 | .iconochive-rss:before{content:"\221e"} 29 | .iconochive-time:before{content:"\1f551"} 30 | .iconochive-quote:before{content:"\275d"} 31 | .iconochive-disc:before{content:"\1f4bf"} 32 | .iconochive-tv-commercial:before{content:"\1f4b0"} 33 | .iconochive-search:before{content:"\1f50d"} 34 | .iconochive-search-star:before{content:"\273d"} 35 | .iconochive-tiles:before{content:"\229e"} 36 | .iconochive-list:before{content:"\21f6"} 37 | .iconochive-list-bulleted:before{content:"\2317"} 38 | .iconochive-latest:before{content:"\2208"} 39 | .iconochive-left:before{content:"\2c2"} 40 | .iconochive-right:before{content:"\2c3"} 41 | .iconochive-left-solid:before{content:"\25c2"} 42 | .iconochive-right-solid:before{content:"\25b8"} 43 | .iconochive-up-solid:before{content:"\25b4"} 44 | .iconochive-down-solid:before{content:"\25be"} 45 | .iconochive-dot:before{content:"\23e4"} 46 | .iconochive-dots:before{content:"\25a6"} 47 | .iconochive-columns:before{content:"\25af"} 48 | .iconochive-sort:before{content:"\21d5"} 49 | .iconochive-atoz:before{content:"\1f524"} 50 | .iconochive-ztoa:before{content:"\1f525"} 51 | .iconochive-upload:before{content:"\1f4e4"} 52 | .iconochive-download:before{content:"\1f4e5"} 53 | .iconochive-favorite:before{content:"\2605"} 54 | .iconochive-heart:before{content:"\2665"} 55 | .iconochive-play:before{content:"\25b6"} 56 | .iconochive-play-framed:before{content:"\1f3ac"} 57 | .iconochive-fullscreen:before{content:"\26f6"} 58 | .iconochive-mute:before{content:"\1f507"} 59 | .iconochive-unmute:before{content:"\1f50a"} 60 | .iconochive-share:before{content:"\1f381"} 61 | .iconochive-edit:before{content:"\270e"} 62 | .iconochive-reedit:before{content:"\2710"} 63 | .iconochive-gear:before{content:"\2699"} 64 | .iconochive-remove-circle:before{content:"\274e"} 65 | .iconochive-plus-circle:before{content:"\1f5d6"} 66 | .iconochive-minus-circle:before{content:"\1f5d5"} 67 | .iconochive-x:before{content:"\1f5d9"} 68 | .iconochive-fork:before{content:"\22d4"} 69 | .iconochive-trash:before{content:"\1f5d1"} 70 | .iconochive-warning:before{content:"\26a0"} 71 | .iconochive-flash:before{content:"\1f5f2"} 72 | .iconochive-world:before{content:"\1f5fa"} 73 | .iconochive-lock:before{content:"\1f512"} 74 | .iconochive-unlock:before{content:"\1f513"} 75 | .iconochive-twitter:before{content:"\1f426"} 76 | .iconochive-facebook:before{content:"\66"} 77 | .iconochive-googleplus:before{content:"\67"} 78 | .iconochive-reddit:before{content:"\1f47d"} 79 | .iconochive-tumblr:before{content:"\54"} 80 | .iconochive-pinterest:before{content:"\1d4df"} 81 | .iconochive-popcorn:before{content:"\1f4a5"} 82 | .iconochive-email:before{content:"\1f4e7"} 83 | .iconochive-embed:before{content:"\1f517"} 84 | .iconochive-gamepad:before{content:"\1f579"} 85 | .iconochive-Zoom_In:before{content:"\2b"} 86 | .iconochive-Zoom_Out:before{content:"\2d"} 87 | .iconochive-RSS:before{content:"\1f4e8"} 88 | .iconochive-Light_Bulb:before{content:"\1f4a1"} 89 | .iconochive-Add:before{content:"\2295"} 90 | .iconochive-Tab_Activity:before{content:"\2318"} 91 | .iconochive-Forward:before{content:"\23e9"} 92 | .iconochive-Backward:before{content:"\23ea"} 93 | .iconochive-No_Audio:before{content:"\1f508"} 94 | .iconochive-Pause:before{content:"\23f8"} 95 | .iconochive-No_Favorite:before{content:"\2606"} 96 | .iconochive-Unike:before{content:"\2661"} 97 | .iconochive-Song:before{content:"\266b"} 98 | .iconochive-No_Flag:before{content:"\2690"} 99 | .iconochive-Flag:before{content:"\2691"} 100 | .iconochive-Done:before{content:"\2713"} 101 | .iconochive-Check:before{content:"\2714"} 102 | .iconochive-Refresh:before{content:"\27f3"} 103 | .iconochive-Headphones:before{content:"\1f3a7"} 104 | .iconochive-Chart:before{content:"\1f4c8"} 105 | .iconochive-Bookmark:before{content:"\1f4d1"} 106 | .iconochive-Documents:before{content:"\1f4da"} 107 | .iconochive-Newspaper:before{content:"\1f4f0"} 108 | .iconochive-Podcast:before{content:"\1f4f6"} 109 | .iconochive-Radio:before{content:"\1f4fb"} 110 | .iconochive-Cassette:before{content:"\1f4fc"} 111 | .iconochive-Shuffle:before{content:"\1f500"} 112 | .iconochive-Loop:before{content:"\1f501"} 113 | .iconochive-Low_Audio:before{content:"\1f509"} 114 | .iconochive-First:before{content:"\1f396"} 115 | .iconochive-Invisible:before{content:"\1f576"} 116 | .iconochive-Computer:before{content:"\1f5b3"} 117 | -------------------------------------------------------------------------------- /tests/test_peer/test_frmr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Tests for FRMR handling 5 | """ 6 | 7 | from pytest import approx 8 | import weakref 9 | 10 | from aioax25.frame import ( 11 | AX25Address, 12 | AX25Path, 13 | AX25FrameRejectFrame, 14 | AX25SetAsyncBalancedModeFrame, 15 | AX25DisconnectFrame, 16 | AX25UnnumberedAcknowledgeFrame, 17 | AX25TestFrame, 18 | ) 19 | from aioax25.peer import AX25PeerState 20 | from ..mocks import DummyPeer, DummyStation 21 | from .peer import TestingAX25Peer 22 | 23 | 24 | def test_on_receive_frmr_no_handler(): 25 | """ 26 | Test that a FRMR frame with no handler sends SABM. 27 | """ 28 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 29 | peer = TestingAX25Peer( 30 | station=station, 31 | address=AX25Address("VK4MSL"), 32 | repeaters=AX25Path("VK4RZB"), 33 | locked_path=True, 34 | ) 35 | 36 | peer._frmrframe_handler = None 37 | 38 | actions = [] 39 | 40 | def _send_sabm(): 41 | actions.append("sent-sabm") 42 | 43 | peer._send_sabm = _send_sabm 44 | 45 | peer._on_receive( 46 | AX25FrameRejectFrame( 47 | destination=peer.address, 48 | source=station.address, 49 | repeaters=AX25Path("VK4RZB*"), 50 | w=False, 51 | x=False, 52 | y=False, 53 | z=False, 54 | vr=0, 55 | frmr_cr=False, 56 | vs=0, 57 | frmr_control=0, 58 | ) 59 | ) 60 | 61 | assert actions == ["sent-sabm"] 62 | 63 | 64 | def test_on_receive_frmr_with_handler(): 65 | """ 66 | Test that a FRMR frame passes to given FRMR handler. 67 | """ 68 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 69 | peer = TestingAX25Peer( 70 | station=station, 71 | address=AX25Address("VK4MSL"), 72 | repeaters=AX25Path("VK4RZB"), 73 | locked_path=True, 74 | ) 75 | 76 | frames = [] 77 | 78 | def _frmr_handler(frame): 79 | frames.append(frame) 80 | 81 | peer._frmrframe_handler = _frmr_handler 82 | 83 | def _send_dm(): 84 | assert False, "Should not send DM" 85 | 86 | peer._send_dm = _send_dm 87 | 88 | frame = AX25FrameRejectFrame( 89 | destination=peer.address, 90 | source=station.address, 91 | repeaters=AX25Path("VK4RZB*"), 92 | w=False, 93 | x=False, 94 | y=False, 95 | z=False, 96 | vr=0, 97 | frmr_cr=False, 98 | vs=0, 99 | frmr_control=0, 100 | ) 101 | peer._on_receive(frame) 102 | 103 | assert frames == [frame] 104 | 105 | 106 | # Test handling whilst in FRMR handling mode 107 | 108 | 109 | def test_on_receive_in_frmr_drop_test(): 110 | """ 111 | Test _on_receive drops TEST frames when in FRMR state. 112 | """ 113 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 114 | peer = TestingAX25Peer( 115 | station=station, 116 | address=AX25Address("VK4MSL"), 117 | repeaters=AX25Path("VK4RZB"), 118 | locked_path=True, 119 | ) 120 | 121 | peer._state = AX25PeerState.FRMR 122 | 123 | def _on_receive_test(*a, **kwa): 124 | assert False, "Should have ignored frame" 125 | 126 | peer._on_receive_test = _on_receive_test 127 | 128 | peer._on_receive( 129 | AX25TestFrame( 130 | destination=peer.address, 131 | source=station.address, 132 | repeaters=AX25Path("VK4RZB*"), 133 | payload=b"test 1", 134 | cr=False, 135 | ) 136 | ) 137 | 138 | 139 | def test_on_receive_in_frmr_drop_ua(): 140 | """ 141 | Test _on_receive drops UA frames when in FRMR state. 142 | """ 143 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 144 | peer = TestingAX25Peer( 145 | station=station, 146 | address=AX25Address("VK4MSL"), 147 | repeaters=AX25Path("VK4RZB"), 148 | locked_path=True, 149 | ) 150 | 151 | peer._state = AX25PeerState.FRMR 152 | 153 | def _on_receive_ua(*a, **kwa): 154 | assert False, "Should have ignored frame" 155 | 156 | peer._on_receive_ua = _on_receive_ua 157 | 158 | peer._on_receive( 159 | AX25UnnumberedAcknowledgeFrame( 160 | destination=peer.address, 161 | source=station.address, 162 | repeaters=AX25Path("VK4RZB*"), 163 | cr=False, 164 | ) 165 | ) 166 | 167 | 168 | def test_on_receive_in_frmr_pass_sabm(): 169 | """ 170 | Test _on_receive passes SABM frames when in FRMR state. 171 | """ 172 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 173 | peer = TestingAX25Peer( 174 | station=station, 175 | address=AX25Address("VK4MSL"), 176 | repeaters=AX25Path("VK4RZB"), 177 | locked_path=True, 178 | ) 179 | 180 | peer._state = AX25PeerState.FRMR 181 | 182 | frames = [] 183 | 184 | def _on_receive_sabm(frame): 185 | frames.append(frame) 186 | 187 | peer._on_receive_sabm = _on_receive_sabm 188 | 189 | frame = AX25SetAsyncBalancedModeFrame( 190 | destination=peer.address, 191 | source=station.address, 192 | repeaters=AX25Path("VK4RZB*"), 193 | cr=False, 194 | ) 195 | peer._on_receive(frame) 196 | 197 | assert frames == [frame] 198 | 199 | 200 | def test_on_receive_in_frmr_pass_disc(): 201 | """ 202 | Test _on_receive passes DISC frames when in FRMR state. 203 | """ 204 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 205 | peer = TestingAX25Peer( 206 | station=station, 207 | address=AX25Address("VK4MSL"), 208 | repeaters=AX25Path("VK4RZB"), 209 | locked_path=True, 210 | ) 211 | 212 | peer._state = AX25PeerState.FRMR 213 | 214 | events = [] 215 | 216 | def _on_receive_disc(): 217 | events.append("disc") 218 | 219 | peer._on_receive_disc = _on_receive_disc 220 | 221 | peer._on_receive( 222 | AX25DisconnectFrame( 223 | destination=peer.address, 224 | source=station.address, 225 | repeaters=AX25Path("VK4RZB*"), 226 | cr=False, 227 | ) 228 | ) 229 | 230 | assert events == ["disc"] 231 | -------------------------------------------------------------------------------- /aioax25/aprs/uidigi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | APRS Digipeating module 5 | """ 6 | 7 | import logging 8 | import re 9 | import time 10 | from ..frame import AX25FrameHeader, AX25Address 11 | 12 | # APRS WIDEn/TRACEn regular expression pattern 13 | DIGI_RE = re.compile(r"^(WIDE|TRACE)(\d)$") 14 | 15 | 16 | class APRSDigipeater(object): 17 | """ 18 | The APRSDigipeater class implemenets a pure WIDEn-N style digipeater 19 | handler, hooking into the Router handling hooks and editing the 20 | digipeater path of all unique APRS messages seen. 21 | """ 22 | 23 | def __init__(self, digipeat_timeout=5.0, log=None): 24 | """ 25 | Create a new digipeater module instance. 26 | """ 27 | if log is None: 28 | log = logging.getLogger(self.__class__.__module__) 29 | self._digipeat_timeout = digipeat_timeout 30 | self._log = log 31 | self._mydigi = set() 32 | 33 | @property 34 | def mydigi(self): 35 | """ 36 | Return the set of digipeater calls and aliases this digi responds to. 37 | """ 38 | return self._mydigi.copy() 39 | 40 | @mydigi.setter 41 | def mydigi(self, aliases): 42 | """ 43 | Replace the list of digipeater calls and aliases this digi responds to. 44 | """ 45 | self._mydigi = set( 46 | [AX25Address.decode(call).normalised for call in aliases] 47 | ) 48 | 49 | def addaliases(self, *aliases): 50 | """ 51 | Add one or more aliases to the digipeater handler. 52 | """ 53 | for call in aliases: 54 | self._mydigi.add(AX25Address.decode(call).normalised) 55 | 56 | def rmaliases(self, *aliases): 57 | """ 58 | Remove one or more aliases from the digipeater handler. 59 | """ 60 | for call in aliases: 61 | self._mydigi.discard(AX25Address.decode(call).normalised) 62 | 63 | def connect(self, aprsint, addcall=True): 64 | """ 65 | Connect to an APRS interface. This hooks the received_msg signal 66 | to receive (de-duplicated) incoming traffic and adds the APRS 67 | interface's call-sign/SSID to the "mydigi" list. 68 | 69 | Note that a message is digipeated on the interface it was received 70 | *ONLY*. Cross-interface digipeating is not implemented at this time. 71 | """ 72 | self._log.debug("Connecting to %s (add call %s)", aprsint, addcall) 73 | aprsint.received_msg.connect(self._on_receive) 74 | if addcall: 75 | self.addaliases(aprsint.mycall) 76 | 77 | def disconnect(self, aprsint, rmcall=True): 78 | """ 79 | Disconnect from an APRS interface. This removes the hook to the 80 | received_msg signal and removes that APRS interface's call-sign/SSID 81 | from the "mydigi" list. 82 | """ 83 | if rmcall: 84 | self.rmaliases(aprsint.mycall) 85 | aprsint.received_msg.disconnect(self._on_receive) 86 | 87 | def _on_receive(self, interface, frame, **kwargs): 88 | """ 89 | Handle the incoming to-be-digipeated message. 90 | """ 91 | # First, have we already digipeated this? 92 | self._log.debug( 93 | "On receive call-back: interface=%s, frame=%s", interface, frame 94 | ) 95 | mycall = interface.mycall 96 | idx = None 97 | alias = None 98 | rem_hops = None 99 | 100 | prev = None 101 | for digi_idx, digi in enumerate(frame.header.repeaters): 102 | if digi.normalised in self._mydigi: 103 | self._log.debug( 104 | "MYDIGI digipeat for %s, last was %s", digi, prev 105 | ) 106 | if ((prev is None) or prev.ch) and (not digi.ch): 107 | # This is meant to be directly digipeated by us! 108 | outgoing = frame.copy( 109 | header=AX25FrameHeader( 110 | destination=frame.header.destination, 111 | source=frame.header.source, 112 | repeaters=frame.header.repeaters.replace( 113 | alias=digi, address=mycall.copy(ch=True) 114 | ), 115 | cr=frame.header.cr, 116 | ) 117 | ) 118 | outgoing.deadline = time.time() + self._digipeat_timeout 119 | self._on_transmit( 120 | interface=interface, alias=alias, frame=outgoing 121 | ) 122 | return 123 | else: 124 | # Is this a WIDEn/TRACEn call? 125 | match = DIGI_RE.match(digi.callsign) 126 | self._log.debug("WIDEn-N? digi=%s match=%s", digi, match) 127 | if match: 128 | # It is 129 | idx = digi_idx 130 | alias = digi 131 | rem_hops = min(digi.ssid, int(match.group(2))) 132 | break 133 | else: 134 | prev = digi 135 | 136 | if alias is None: 137 | # The path did not mention a WIDEn digi call 138 | self._log.debug("No alias, ignoring frame") 139 | return 140 | 141 | if rem_hops == 0: 142 | # Number of hops expired, do not digipeat this 143 | self._log.debug("Hops exhausted, ignoring frame") 144 | return 145 | 146 | # This is to be digipeated. 147 | digi_path = list(frame.header.repeaters[:idx]) + [ 148 | mycall.copy(ch=True) 149 | ] 150 | if rem_hops > 1: 151 | # There are more hops left, tack the next hop on 152 | digi_path.append(alias.copy(ssid=rem_hops - 1, ch=False)) 153 | digi_path.extend(frame.header.repeaters[idx + 1 :]) 154 | 155 | outgoing = frame.copy( 156 | header=AX25FrameHeader( 157 | destination=frame.header.destination, 158 | source=frame.header.source, 159 | repeaters=digi_path, 160 | cr=frame.header.cr, 161 | ) 162 | ) 163 | 164 | outgoing.deadline = time.time() + self._digipeat_timeout 165 | 166 | self._on_transmit(interface=interface, alias=alias, frame=outgoing) 167 | 168 | def _on_transmit(self, interface, alias, frame): 169 | """ 170 | Transmit a message to be digipeated. This function is a wrapper 171 | around the interface.transmit method so we can support subclasses 172 | that do more advanced routing such as cross-interface digipeating. 173 | """ 174 | interface.transmit(frame) 175 | -------------------------------------------------------------------------------- /tests/test_peer/test_cleanup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Test handling of clean-up logic 5 | """ 6 | 7 | from aioax25.frame import AX25Address, AX25Path 8 | from aioax25.peer import AX25PeerState 9 | from .peer import TestingAX25Peer 10 | from ..mocks import DummyStation, DummyTimeout 11 | 12 | # Idle time-out cancellation 13 | 14 | 15 | def test_cancel_idle_timeout_inactive(): 16 | """ 17 | Test that calling _cancel_idle_timeout with no time-out is a no-op. 18 | """ 19 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 20 | peer = TestingAX25Peer( 21 | station=station, 22 | address=AX25Address("VK4MSL"), 23 | repeaters=AX25Path("VK4RZB"), 24 | locked_path=True, 25 | ) 26 | 27 | # Constructor resets the timer, so discard that time-out handle 28 | # This is safe because TestingAX25Peer does not use a real IOLoop 29 | peer._idle_timeout_handle = None 30 | 31 | peer._cancel_idle_timeout() 32 | 33 | 34 | def test_cancel_idle_timeout_active(): 35 | """ 36 | Test that calling _cancel_idle_timeout active time-out cancels it. 37 | """ 38 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 39 | peer = TestingAX25Peer( 40 | station=station, 41 | address=AX25Address("VK4MSL"), 42 | repeaters=AX25Path("VK4RZB"), 43 | locked_path=True, 44 | ) 45 | 46 | timeout = DummyTimeout(0, lambda: None) 47 | peer._idle_timeout_handle = timeout 48 | 49 | peer._cancel_idle_timeout() 50 | 51 | assert peer._idle_timeout_handle is None 52 | assert timeout.cancelled is True 53 | 54 | 55 | # Idle time-out reset 56 | 57 | 58 | def test_reset_idle_timeout(): 59 | """ 60 | Test that calling _reset_idle_timeout re-creates a time-out object 61 | """ 62 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 63 | peer = TestingAX25Peer( 64 | station=station, 65 | address=AX25Address("VK4MSL"), 66 | repeaters=AX25Path("VK4RZB"), 67 | locked_path=True, 68 | ) 69 | 70 | # Grab the original time-out created by the constructor 71 | orig_timeout = peer._idle_timeout_handle 72 | assert orig_timeout is not None 73 | 74 | # Reset the time-out 75 | peer._reset_idle_timeout() 76 | 77 | assert peer._idle_timeout_handle is not orig_timeout 78 | assert orig_timeout.cancelled is True 79 | 80 | # New time-out should call the appropriate routine at the right time 81 | assert peer._idle_timeout_handle.delay == peer._idle_timeout 82 | assert peer._idle_timeout_handle.callback == peer._cleanup 83 | 84 | 85 | # Clean-up steps 86 | 87 | 88 | def test_cleanup_disconnected(): 89 | """ 90 | Test that clean-up whilst disconnect just cancels RR notifications 91 | """ 92 | # Most of the time, there will be no pending RR notifications, so 93 | # _cancel_rr_notification will be a no-op in this case. 94 | 95 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 96 | peer = TestingAX25Peer( 97 | station=station, 98 | address=AX25Address("VK4MSL"), 99 | repeaters=AX25Path("VK4RZB"), 100 | locked_path=True, 101 | ) 102 | 103 | # Stub methods 104 | 105 | actions = [] 106 | 107 | def _cancel_rr_notification(): 108 | actions.append("cancel-rr") 109 | 110 | peer._cancel_rr_notification = _cancel_rr_notification 111 | 112 | def disconnect(): 113 | assert False, "Should not call disconnect" 114 | 115 | peer.disconnect = disconnect 116 | 117 | def _send_dm(): 118 | assert False, "Should not send DM" 119 | 120 | peer._send_dm = _send_dm 121 | 122 | # Set state 123 | peer._state = AX25PeerState.DISCONNECTED 124 | 125 | # Do clean-up 126 | peer._cleanup() 127 | 128 | assert actions == ["cancel-rr"] 129 | 130 | 131 | def test_cleanup_disconnecting(): 132 | """ 133 | Test that clean-up whilst disconnecting cancels RR notification 134 | """ 135 | # Most of the time, there will be no pending RR notifications, so 136 | # _cancel_rr_notification will be a no-op in this case. 137 | 138 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 139 | peer = TestingAX25Peer( 140 | station=station, 141 | address=AX25Address("VK4MSL"), 142 | repeaters=AX25Path("VK4RZB"), 143 | locked_path=True, 144 | ) 145 | 146 | # Stub methods 147 | 148 | actions = [] 149 | 150 | def _cancel_rr_notification(): 151 | actions.append("cancel-rr") 152 | 153 | peer._cancel_rr_notification = _cancel_rr_notification 154 | 155 | def disconnect(): 156 | assert False, "Should not call disconnect" 157 | 158 | peer.disconnect = disconnect 159 | 160 | def _send_dm(): 161 | assert False, "Should not send DM" 162 | 163 | peer._send_dm = _send_dm 164 | 165 | # Set state 166 | peer._state = AX25PeerState.DISCONNECTING 167 | 168 | # Do clean-up 169 | peer._cleanup() 170 | 171 | assert actions == ["cancel-rr"] 172 | 173 | 174 | def test_cleanup_connecting(): 175 | """ 176 | Test that clean-up whilst connecting sends DM then cancels RR notifications 177 | """ 178 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 179 | peer = TestingAX25Peer( 180 | station=station, 181 | address=AX25Address("VK4MSL"), 182 | repeaters=AX25Path("VK4RZB"), 183 | locked_path=True, 184 | ) 185 | 186 | # Stub methods 187 | 188 | actions = [] 189 | 190 | def _cancel_rr_notification(): 191 | actions.append("cancel-rr") 192 | 193 | peer._cancel_rr_notification = _cancel_rr_notification 194 | 195 | def disconnect(): 196 | assert False, "Should not call disconnect" 197 | 198 | peer.disconnect = disconnect 199 | 200 | def _send_dm(): 201 | actions.append("sent-dm") 202 | 203 | peer._send_dm = _send_dm 204 | 205 | # Set state 206 | peer._state = AX25PeerState.CONNECTING 207 | 208 | # Do clean-up 209 | peer._cleanup() 210 | 211 | assert actions == ["sent-dm", "cancel-rr"] 212 | 213 | 214 | def test_cleanup_connected(): 215 | """ 216 | Test that clean-up whilst connected sends DISC then cancels RR notifications 217 | """ 218 | station = DummyStation(AX25Address("VK4MSL", ssid=1)) 219 | peer = TestingAX25Peer( 220 | station=station, 221 | address=AX25Address("VK4MSL"), 222 | repeaters=AX25Path("VK4RZB"), 223 | locked_path=True, 224 | ) 225 | 226 | # Stub methods 227 | 228 | actions = [] 229 | 230 | def _cancel_rr_notification(): 231 | actions.append("cancel-rr") 232 | 233 | peer._cancel_rr_notification = _cancel_rr_notification 234 | 235 | def disconnect(): 236 | actions.append("disconnect") 237 | 238 | peer.disconnect = disconnect 239 | 240 | def _send_dm(): 241 | assert False, "Should not send DM" 242 | 243 | peer._send_dm = _send_dm 244 | 245 | # Set state 246 | peer._state = AX25PeerState.CONNECTED 247 | 248 | # Do clean-up 249 | peer._cleanup() 250 | 251 | assert actions == ["disconnect", "cancel-rr"] 252 | -------------------------------------------------------------------------------- /aioax25/station.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | AX.25 Station interface. 5 | 6 | This implements the base-level AX.25 logic for a station listening at a given 7 | SSID. 8 | """ 9 | 10 | 11 | import logging 12 | import asyncio 13 | from .signal import Signal 14 | import weakref 15 | 16 | from .frame import AX25Address, AX25Path, AX25TestFrame 17 | 18 | from .peer import AX25Peer, AX25RejectMode 19 | from .version import AX25Version 20 | 21 | 22 | class AX25Station(object): 23 | """ 24 | The AX25Station class represents the station on the AX.25 network 25 | implemented by the caller of this library. Notably, it provides 26 | hooks for handling incoming connections, and methods for making 27 | connections to other AX.25 stations. 28 | 29 | To be able to participate as a connected-mode station, create an instance 30 | of AX25Station, referencing an instance of AX25Interface as the interface 31 | parameter; then call the attach method. 32 | """ 33 | 34 | def __init__( 35 | self, 36 | interface, 37 | # Station call-sign and SSID 38 | callsign, 39 | ssid=None, 40 | # Classes of Procedures options 41 | full_duplex=False, 42 | # HDLC Optional Functions 43 | modulo128=False, # Whether to use Mod128 by default 44 | reject_mode=AX25RejectMode.SELECTIVE_RR, 45 | # What reject mode to use? 46 | # Parameters (AX.25 2.2 sect 6.7.2) 47 | max_ifield=256, # aka N1 48 | max_ifield_rx=256, # the N1 we advertise in XIDs 49 | max_retries=10, # aka N2, value from figure 4.5 50 | # k value, for mod128 and mod8 connections, this sets the 51 | # advertised window size in XID. Peer station sets actual 52 | # value used here. 53 | max_outstanding_mod8=7, 54 | max_outstanding_mod128=127, 55 | # Timer parameters 56 | ack_timeout=3.0, # Acknowledge timeout (aka T1) 57 | idle_timeout=900.0, # Idle timeout before we "forget" peers 58 | rr_delay=3.0, # Delay between I-frame and RR 59 | rr_interval=30.0, # Poll interval when peer in busy state 60 | rnr_interval=10.0, # Delay between RNRs when busy 61 | # Protocol version to use for our station 62 | protocol=AX25Version.AX25_22, 63 | # IOLoop and logging 64 | log=None, 65 | loop=None, 66 | ): 67 | if log is None: 68 | log = logging.getLogger(self.__class__.__module__) 69 | 70 | if loop is None: 71 | loop = asyncio.get_event_loop() 72 | 73 | # Ensure we are running a supported version of AX.25 74 | protocol = AX25Version(protocol) 75 | if protocol not in (AX25Version.AX25_20, AX25Version.AX25_22): 76 | raise ValueError( 77 | "%r not a supported AX.25 protocol version" % protocol.value 78 | ) 79 | 80 | # Configuration parameters 81 | self._address = AX25Address.decode(callsign, ssid).normalised 82 | self._interface = weakref.ref(interface) 83 | self._protocol = protocol 84 | self._ack_timeout = ack_timeout 85 | self._idle_timeout = idle_timeout 86 | self._reject_mode = AX25RejectMode(reject_mode) 87 | self._modulo128 = modulo128 88 | self._max_ifield = max_ifield 89 | self._max_ifield_rx = max_ifield_rx 90 | self._max_retries = max_retries 91 | self._max_outstanding_mod8 = max_outstanding_mod8 92 | self._max_outstanding_mod128 = max_outstanding_mod128 93 | self._rr_delay = rr_delay 94 | self._rr_interval = rr_interval 95 | self._rnr_interval = rnr_interval 96 | self._full_duplex = full_duplex 97 | self._log = log 98 | self._loop = loop 99 | 100 | # Remote station handlers 101 | self._peers = weakref.WeakValueDictionary() 102 | 103 | # Signal emitted when a SABM(E) is received 104 | self.connection_request = Signal() 105 | 106 | @property 107 | def address(self): 108 | """ 109 | Return the source address of this station. 110 | """ 111 | return self._address 112 | 113 | @property 114 | def protocol(self): 115 | """ 116 | Return the protocol version of this station. 117 | """ 118 | return self._protocol 119 | 120 | def attach(self): 121 | """ 122 | Connect the station to the interface. 123 | """ 124 | interface = self._interface() 125 | interface.bind( 126 | self._on_receive, 127 | callsign=self.address.callsign, 128 | ssid=self.address.ssid, 129 | regex=False, 130 | ) 131 | 132 | def detach(self): 133 | """ 134 | Disconnect from the interface. 135 | """ 136 | interface = self._interface() 137 | interface.unbind( 138 | self._on_receive, 139 | callsign=self.address.callsign, 140 | ssid=self.address.ssid, 141 | regex=False, 142 | ) 143 | 144 | def getpeer( 145 | self, callsign, ssid=None, repeaters=None, create=True, **kwargs 146 | ): 147 | """ 148 | Retrieve an instance of a peer context. This creates the peer 149 | object if it doesn't already exist unless create is set to False 150 | (in which case it will raise KeyError). 151 | """ 152 | address = AX25Address.decode(callsign, ssid).normalised 153 | try: 154 | return self._peers[address] 155 | except KeyError: 156 | if not create: 157 | raise 158 | pass 159 | 160 | # Not there, so set some defaults, then create 161 | kwargs.setdefault("full_duplex", self._full_duplex) 162 | kwargs.setdefault("reject_mode", self._reject_mode) 163 | kwargs.setdefault("modulo128", self._modulo128) 164 | kwargs.setdefault("max_ifield", self._max_ifield) 165 | kwargs.setdefault("max_ifield_rx", self._max_ifield_rx) 166 | kwargs.setdefault("max_retries", self._max_retries) 167 | kwargs.setdefault("max_outstanding_mod8", self._max_outstanding_mod8) 168 | kwargs.setdefault( 169 | "max_outstanding_mod128", self._max_outstanding_mod128 170 | ) 171 | kwargs.setdefault("rr_delay", self._rr_delay) 172 | kwargs.setdefault("rr_interval", self._rr_interval) 173 | kwargs.setdefault("rnr_interval", self._rnr_interval) 174 | kwargs.setdefault("ack_timeout", self._ack_timeout) 175 | kwargs.setdefault("idle_timeout", self._idle_timeout) 176 | kwargs.setdefault("protocol", AX25Version.UNKNOWN) 177 | peer = AX25Peer( 178 | self, 179 | address, 180 | repeaters=AX25Path(*(repeaters or [])), 181 | log=self._log.getChild("peer.%s" % address), 182 | loop=self._loop, 183 | **kwargs 184 | ) 185 | self._peers[address] = peer 186 | return peer 187 | 188 | def _on_receive(self, frame, **kwargs): 189 | """ 190 | Handling of incoming messages. 191 | """ 192 | if frame.header.cr: 193 | # This is a command frame 194 | self._log.debug("Checking command frame sub-class: %s", frame) 195 | if isinstance(frame, AX25TestFrame): 196 | # A TEST request frame, context not required 197 | return self._on_receive_test(frame) 198 | 199 | # If we're still here, then we don't handle unsolicited frames 200 | # of this type, so pass it to a handler if we have one. 201 | peer = self.getpeer( 202 | frame.header.source, repeaters=frame.header.repeaters.reply 203 | ) 204 | self._log.debug("Passing frame to peer %s: %s", peer.address, frame) 205 | peer._on_receive(frame) 206 | 207 | def _on_receive_test(self, frame): 208 | """ 209 | Handle a TEST frame. 210 | """ 211 | # The frame is a test request. 212 | self._log.debug("Responding to test frame: %s", frame) 213 | interface = self._interface() 214 | interface.transmit( 215 | AX25TestFrame( 216 | destination=frame.header.source, 217 | source=self.address, 218 | repeaters=frame.header.repeaters.reply, 219 | payload=frame.payload, 220 | cr=False, 221 | ) 222 | ) 223 | -------------------------------------------------------------------------------- /tests/test_kiss/test_serial.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Serial KISS interface unit tests. 5 | """ 6 | 7 | from collections import namedtuple 8 | from serial import EIGHTBITS, PARITY_NONE, STOPBITS_ONE 9 | from aioax25 import kiss 10 | import logging 11 | from ..asynctest import asynctest 12 | from asyncio import get_event_loop, sleep 13 | 14 | 15 | class DummySerial(object): 16 | def __init__( 17 | self, 18 | port, 19 | baudrate, 20 | bytesize, 21 | parity, 22 | stopbits, 23 | timeout, 24 | xonxoff, 25 | rtscts, 26 | write_timeout, 27 | dsrdtr, 28 | inter_byte_timeout, 29 | ): 30 | assert port == "/dev/ttyS0" 31 | assert baudrate == 9600 32 | assert bytesize == EIGHTBITS 33 | assert parity == PARITY_NONE 34 | assert stopbits == STOPBITS_ONE 35 | assert timeout == None 36 | assert xonxoff == False 37 | assert rtscts == False 38 | assert write_timeout == None 39 | assert dsrdtr == False 40 | assert inter_byte_timeout == None 41 | 42 | self.rx_buffer = bytearray() 43 | self.tx_buffer = bytearray() 44 | self.flushes = 0 45 | self.closed = False 46 | self.read_exception = None 47 | 48 | def flush(self): 49 | self.flushes += 1 50 | 51 | def write(self, data): 52 | self.tx_buffer += data 53 | 54 | def read(self, length): 55 | if self.read_exception is not None: 56 | raise self.read_exception 57 | 58 | data = self.rx_buffer[0:length] 59 | self.rx_buffer = self.rx_buffer[length:] 60 | return data 61 | 62 | def close(self): 63 | self.closed = True 64 | 65 | @property 66 | def in_waiting(self): 67 | return len(self.rx_buffer) 68 | 69 | 70 | # Dummy transport 71 | class DummyTransport(object): 72 | def __init__(self, loop, port): 73 | self._loop = loop 74 | self._port = port 75 | 76 | def flush(self): 77 | future = self._loop.create_future() 78 | self._loop.call_soon(lambda: future.set_result(None)) 79 | return future 80 | 81 | def write(self, *args, **kwargs): 82 | future = self._loop.create_future() 83 | self._port.write(*args, **kwargs) 84 | self._loop.call_soon(lambda: future.set_result(None)) 85 | return future 86 | 87 | 88 | # Keep a record of all ports, transports and protocols 89 | PortConnection = namedtuple( 90 | "PortConnection", ["port", "protocol", "transport"] 91 | ) 92 | create_serial_conn_log = logging.getLogger("create_serial_connection") 93 | connections = [] 94 | 95 | 96 | # Stub the serial port connection factory 97 | async def dummy_create_serial_connection( 98 | loop, proto_factory, *args, **kwargs 99 | ): 100 | future = loop.create_future() 101 | create_serial_conn_log.debug( 102 | "Creating new serial connection: " 103 | "loop=%r proto=%r args=%r kwargs=%r", 104 | loop, 105 | proto_factory, 106 | args, 107 | kwargs, 108 | ) 109 | 110 | def _open(): 111 | create_serial_conn_log.debug("Creating objects") 112 | # Create the objects 113 | protocol = proto_factory() 114 | port = DummySerial(*args, **kwargs) 115 | transport = DummyTransport(loop, port) 116 | 117 | # Record the created object references 118 | connections.append( 119 | PortConnection(port=port, protocol=protocol, transport=transport) 120 | ) 121 | 122 | # Pass the protocol the transport object 123 | create_serial_conn_log.debug("Passing transport to protocol") 124 | protocol.connection_made(transport) 125 | 126 | # Finish up the future 127 | create_serial_conn_log.debug("Finishing up") 128 | future.set_result((protocol, transport)) 129 | 130 | create_serial_conn_log.debug("Scheduled in IOLoop") 131 | loop.call_soon(_open) 132 | 133 | create_serial_conn_log.debug("Returning future") 134 | return await future 135 | 136 | 137 | kiss.create_serial_connection = dummy_create_serial_connection 138 | 139 | 140 | class TestDevice(kiss.SerialKISSDevice): 141 | def __init__(self, *args, **kwargs): 142 | super(TestDevice, self).__init__(*args, **kwargs) 143 | self.init_called = False 144 | 145 | def _init_kiss(self): 146 | self.init_called = True 147 | 148 | 149 | @asynctest 150 | async def test_open(): 151 | """ 152 | Test we can open the port. 153 | """ 154 | loop = get_event_loop() 155 | kissdev = TestDevice(device="/dev/ttyS0", baudrate=9600, loop=loop) 156 | assert kissdev._transport is None 157 | 158 | kissdev.open() 159 | await sleep(0.01) 160 | 161 | # We should have created a new port 162 | assert len(connections) == 1 163 | connection = connections.pop(0) 164 | 165 | # We should have a reference to the transport created. 166 | assert kissdev._transport == connection.transport 167 | 168 | # The device should have been initialised 169 | assert kissdev.init_called 170 | 171 | 172 | @asynctest 173 | async def test_close(): 174 | """ 175 | Test we can close the port. 176 | """ 177 | loop = get_event_loop() 178 | kissdev = TestDevice( 179 | device="/dev/ttyS0", baudrate=9600, loop=loop, reset_on_close=False 180 | ) 181 | 182 | # Force the port open 183 | kissdev._state = kiss.KISSDeviceState.OPEN 184 | serial = DummySerial( 185 | port="/dev/ttyS0", 186 | baudrate=9600, 187 | bytesize=EIGHTBITS, 188 | parity=PARITY_NONE, 189 | stopbits=STOPBITS_ONE, 190 | timeout=None, 191 | xonxoff=False, 192 | rtscts=False, 193 | write_timeout=None, 194 | dsrdtr=False, 195 | inter_byte_timeout=None, 196 | ) 197 | kissdev._transport = serial 198 | 199 | # Now try closing the port 200 | kissdev.close() 201 | await sleep(0.01) 202 | 203 | # The port should have been flushed 204 | assert serial.flushes == 1 205 | 206 | # The port should be closed 207 | assert serial.closed == True 208 | 209 | # The device should not reference the port 210 | assert kissdev._transport == None 211 | 212 | # The port should now be in the closed state 213 | assert kissdev._state == kiss.KISSDeviceState.CLOSED 214 | 215 | 216 | def test_on_close_err(logger): 217 | """ 218 | Test errors are logged if given 219 | """ 220 | 221 | # Yeah, kludgy… but py.test won't see the fixture if I don't 222 | # do it this way. 223 | @asynctest 224 | async def _run(): 225 | loop = get_event_loop() 226 | kissdev = TestDevice( 227 | device="/dev/ttyS0", 228 | baudrate=9600, 229 | log=logger, 230 | loop=loop, 231 | reset_on_close=False, 232 | ) 233 | 234 | # Force the port open 235 | kissdev._state = kiss.KISSDeviceState.OPEN 236 | serial = DummySerial( 237 | port="/dev/ttyS0", 238 | baudrate=9600, 239 | bytesize=EIGHTBITS, 240 | parity=PARITY_NONE, 241 | stopbits=STOPBITS_ONE, 242 | timeout=None, 243 | xonxoff=False, 244 | rtscts=False, 245 | write_timeout=None, 246 | dsrdtr=False, 247 | inter_byte_timeout=None, 248 | ) 249 | kissdev._transport = serial 250 | 251 | # Define a close error 252 | class CommsError(IOError): 253 | pass 254 | 255 | my_err = CommsError() 256 | 257 | # Now report the closure of the port 258 | kissdev._on_close(my_err) 259 | 260 | # We should have seen a log message reported 261 | assert logger.logrecords == [ 262 | dict( 263 | method="error", 264 | args=( 265 | "Closing port due to error %r", 266 | my_err, 267 | ), 268 | kwargs={}, 269 | ex_type=None, 270 | ex_val=None, 271 | ex_tb=None, 272 | ) 273 | ] 274 | 275 | # The device should not reference the port 276 | assert kissdev._transport == None 277 | 278 | # The port should now be in the closed state 279 | assert kissdev._state == kiss.KISSDeviceState.CLOSED 280 | 281 | _run() 282 | 283 | 284 | @asynctest 285 | async def test_send_raw_data(): 286 | """ 287 | Test _send_raw_data passes the data to the serial device. 288 | """ 289 | loop = get_event_loop() 290 | kissdev = TestDevice(device="/dev/ttyS0", baudrate=9600, loop=loop) 291 | assert kissdev._transport is None 292 | 293 | kissdev.open() 294 | await sleep(0.01) 295 | 296 | # We should have created a new port 297 | assert len(connections) == 1 298 | connection = connections.pop(0) 299 | 300 | kissdev._send_raw_data(b"a test frame") 301 | assert bytes(connection.port.tx_buffer) == b"a test frame" 302 | -------------------------------------------------------------------------------- /aioax25/tools/listen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Very crude program for listening for an AX.25 connection, then launching a 5 | program for the remote caller to interact with. e.g. to make a Python 6 | interpreter available over the packet network (and open a remote code 7 | execution hole in the process!), use something like: 8 | 9 | ``` 10 | $ python3 -m aioax25.tools.listen kiss-config.yml N0CALL-12 -- python -i 11 | ``` 12 | 13 | """ 14 | 15 | import asyncio 16 | from asyncio import subprocess 17 | import argparse 18 | import logging 19 | 20 | from yaml import safe_load 21 | 22 | # aioax25 imports 23 | # from aioax25.kiss import … 24 | # from aioax25.interface import … 25 | # etc… if you're copying this for your own code 26 | from ..kiss import make_device, KISSDeviceState 27 | from ..interface import AX25Interface 28 | from ..station import AX25Station 29 | from ..peer import AX25PeerState 30 | from ..version import AX25Version 31 | 32 | 33 | class SubprocProtocol(asyncio.Protocol): 34 | """ 35 | SubprocProtocol manages the link to the sub-process on behalf of the peer 36 | session. 37 | """ 38 | 39 | def __init__(self, on_connect, on_receive, on_close, log): 40 | super(SubprocProtocol, self).__init__() 41 | 42 | self._on_connect = on_connect 43 | self._on_receive = on_receive 44 | self._on_close = on_close 45 | self._log = log 46 | 47 | def connection_made(self, transport): 48 | try: 49 | self._log.debug("Announcing connection: %r", transport) 50 | self._on_connect(transport) 51 | except Exception as e: 52 | self._log.exception("Failed to handle connection establishment") 53 | transport.close() 54 | self._on_connect(None) 55 | 56 | def pipe_data_received(self, fd, data): 57 | try: 58 | if fd == 1: # stdout 59 | self._on_receive(data) 60 | else: 61 | self._log.debug("Data received on fd=%d: %r", fd, data) 62 | except: 63 | self._log.exception( 64 | "Failed to handle incoming data %r on fd=%d", data, fd 65 | ) 66 | 67 | def pipe_connection_lost(self, fd, exc): 68 | self._log.debug("FD %d closed (exc=%s)", fd, exc) 69 | try: 70 | self._on_close(exc) 71 | except: 72 | self._log.exception("Failed to handle process pipe close") 73 | 74 | def process_exited(self): 75 | try: 76 | self._on_close(None) 77 | except: 78 | self._log.exception("Failed to handle process exit") 79 | 80 | 81 | class PeerSession(object): 82 | def __init__(self, peer, command, echo, log): 83 | self._peer = peer 84 | self._log = log 85 | self._command = command 86 | self._cmd_transport = None 87 | self._echo = echo 88 | 89 | peer.received_information.connect(self._on_peer_received) 90 | peer.connect_state_changed.connect(self._on_peer_state_change) 91 | 92 | async def init(self): 93 | self._log.info("Launching sub-process") 94 | await asyncio.get_event_loop().subprocess_exec( 95 | self._make_protocol, 96 | *self._command, 97 | stdout=subprocess.PIPE, 98 | stderr=subprocess.STDOUT, 99 | bufsize=0 100 | ) 101 | 102 | def _make_protocol(self): 103 | """ 104 | Return a SubprocessProtocol instance that will handle the KISS traffic for the 105 | asyncio transport. 106 | """ 107 | 108 | def _on_connect(transport): 109 | self._log.info("Sub-process transport now open") 110 | self._cmd_transport = transport 111 | 112 | return SubprocProtocol( 113 | _on_connect, 114 | self._on_subproc_received, 115 | self._on_subproc_closed, 116 | self._log.getChild("protocol"), 117 | ) 118 | 119 | def _on_subproc_received(self, data): 120 | """ 121 | Pass data from the sub-process to the AX.25 peer. 122 | """ 123 | self._log.debug("Received from subprocess: %r", data) 124 | if self._peer.state is AX25PeerState.CONNECTED: 125 | # Peer still connected, pass to the peer, translating newline with 126 | # CR as per AX.25 conventions. 127 | data = b"\r".join(data.split(b"\n")) 128 | self._log.debug("Writing to peer: %r", data) 129 | self._peer.send(data) 130 | elif self._peer.state is AX25PeerState.DISCONNECTED: 131 | # Peer is not connected, close the subprocess. 132 | self._log.info("Peer no longer connected, shutting down") 133 | self._cmd_transport.close() 134 | 135 | def _on_subproc_closed(self, exc=None): 136 | if exc is not None: 137 | self._log.error("Closing port due to error %r", exc) 138 | 139 | self._log.info("Sub-process has exited") 140 | self._cmd_transport = None 141 | if self._peer.state is not AX25PeerState.DISCONNECTED: 142 | self._log.info("Closing peer connection") 143 | self._peer.disconnect() 144 | 145 | def _on_peer_received(self, payload, **kwargs): 146 | """ 147 | Pass data from the AX.25 peer to the sub-process. 148 | """ 149 | self._log.debug("Received from peer: %r", payload) 150 | if self._echo: 151 | # Echo back to peer 152 | self._peer.send(payload) 153 | 154 | if self._cmd_transport: 155 | payload = b"\n".join(payload.split(b"\r")) 156 | self._log.debug("Writing to subprocess: %r", payload) 157 | self._cmd_transport.get_pipe_transport(0).write(payload) 158 | else: 159 | # Subprocess no longer running, so shut it down. 160 | self._log.info("Sub-process no longer running, disconnecting") 161 | self._peer.disconnect() 162 | 163 | def _on_peer_state_change(self, state, **kwargs): 164 | """ 165 | Handle peer connection state change. 166 | """ 167 | if state is AX25PeerState.DISCONNECTED: 168 | self._log.info("Peer has disconnected") 169 | if self._cmd_transport: 170 | self._cmd_transport.close() 171 | 172 | 173 | class AX25Listen(object): 174 | def __init__(self, source, command, kissparams, port=0, echo=False): 175 | log = logging.getLogger(self.__class__.__name__) 176 | kisslog = log.getChild("kiss") 177 | kisslog.setLevel(logging.INFO) # KISS logs are verbose! 178 | intflog = log.getChild("interface") 179 | intflog.setLevel(logging.INFO) # interface logs are verbose too! 180 | stnlog = log.getChild("station") 181 | 182 | self._log = log 183 | self._device = make_device(**kissparams, log=kisslog) 184 | self._interface = AX25Interface(self._device[port], log=intflog) 185 | self._station = AX25Station( 186 | self._interface, 187 | source, 188 | log=stnlog, 189 | ) 190 | self._station.attach() 191 | self._command = command 192 | self._station.connection_request.connect(self._on_connection_request) 193 | self._echo = echo 194 | 195 | async def listen(self): 196 | # Open the KISS interface 197 | self._device.open() 198 | 199 | # TODO: implement async functions on KISS device to avoid this! 200 | while self._device.state != KISSDeviceState.OPEN: 201 | await asyncio.sleep(0.1) 202 | 203 | self._log.info("Listening for connections") 204 | while True: 205 | await asyncio.sleep(1) 206 | 207 | def _on_connection_request(self, peer, **kwargs): 208 | # Bounce to the I/O loop 209 | asyncio.ensure_future(self._connect_peer(peer)) 210 | 211 | async def _connect_peer(self, peer): 212 | self._log.info("Incoming connection from %s", peer.address) 213 | try: 214 | session = PeerSession( 215 | peer, 216 | self._command, 217 | self._echo, 218 | self._log.getChild(str(peer.address)), 219 | ) 220 | await session.init() 221 | except: 222 | self._log.exception("Failed to initialise peer connection") 223 | peer.reject() 224 | return 225 | 226 | # All good? Accept the connection. 227 | peer.accept() 228 | 229 | 230 | async def main(): 231 | ap = argparse.ArgumentParser() 232 | 233 | ap.add_argument("--log-level", default="info", type=str, help="Log level") 234 | ap.add_argument("--port", default=0, type=int, help="KISS port number") 235 | ap.add_argument( 236 | "--echo", 237 | default=False, 238 | action="store_const", 239 | const=True, 240 | help="Echo input back to the caller", 241 | ) 242 | ap.add_argument( 243 | "config", type=str, help="KISS serial port configuration file" 244 | ) 245 | ap.add_argument("source", type=str, help="Source callsign/SSID") 246 | ap.add_argument( 247 | "command", type=str, nargs="+", help="Program + args to run" 248 | ) 249 | 250 | args = ap.parse_args() 251 | 252 | logging.basicConfig( 253 | level=args.log_level.upper(), 254 | format=( 255 | "%(asctime)s %(name)s[%(filename)s:%(lineno)4d] " 256 | "%(levelname)s %(message)s" 257 | ), 258 | ) 259 | config = safe_load(open(args.config, "r").read()) 260 | 261 | ax25listen = AX25Listen( 262 | args.source, args.command, config, args.port, args.echo 263 | ) 264 | await ax25listen.listen() 265 | 266 | 267 | if __name__ == "__main__": 268 | asyncio.get_event_loop().run_until_complete(main()) 269 | -------------------------------------------------------------------------------- /tests/test_frame/test_ax25address.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from aioax25.frame import AX25Address 4 | from ..hex import from_hex, to_hex 5 | 6 | 7 | def test_decode_wrongtype(): 8 | """ 9 | Test that incorrect types are handled. 10 | """ 11 | try: 12 | AX25Address.decode(12345) 13 | assert False, "Should not have worked" 14 | except TypeError as e: 15 | assert str(e) == "Don't know how to decode 12345" 16 | 17 | 18 | def test_decode_bytes_short(): 19 | """ 20 | Test decoding a truncated address does not crash. 21 | """ 22 | try: 23 | AX25Address.decode(from_hex("ac 96 68 9a a6")) 24 | assert False, "This should not work" 25 | except ValueError as e: 26 | assert str(e) == "AX.25 addresses must be 7 bytes!" 27 | 28 | 29 | def test_decode_bytes(): 30 | """ 31 | Test we can decode a plain AX.25 address in binary. 32 | """ 33 | addr = AX25Address.decode(from_hex("ac 96 68 9a a6 98 00")) 34 | assert addr._callsign == "VK4MSL" 35 | 36 | 37 | def test_decode_bytes_spaces(): 38 | """ 39 | Test trailing spaces are truncated in call-signs. 40 | """ 41 | addr = AX25Address.decode(from_hex("ac 96 68 84 82 40 00")) 42 | assert addr._callsign == "VK4BA" 43 | 44 | 45 | def test_decode_bytes_ext(): 46 | """ 47 | Test we can decode the extension bit set in binary. 48 | """ 49 | addr = AX25Address.decode(from_hex("ac 96 68 9a a6 98 01")) 50 | assert addr._extension is True 51 | 52 | 53 | def test_decode_bytes_ssid(): 54 | """ 55 | Test we can decode the SSID in binary. 56 | """ 57 | addr = AX25Address.decode(from_hex("ac 96 68 9a a6 98 14")) 58 | assert addr._ssid == 10 59 | 60 | 61 | def test_decode_bytes_res0(): 62 | """ 63 | Test we can decode the first reserved bit in binary. 64 | """ 65 | addr = AX25Address.decode(from_hex("ac 96 68 9a a6 98 20")) 66 | assert addr._res0 is True 67 | 68 | 69 | def test_decode_bytes_res1(): 70 | """ 71 | Test we can decode the first reserved bit in binary. 72 | """ 73 | addr = AX25Address.decode(from_hex("ac 96 68 9a a6 98 40")) 74 | assert addr._res1 is True 75 | 76 | 77 | def test_decode_bytes_ch(): 78 | """ 79 | Test we can decode the C/H bit in binary. 80 | """ 81 | addr = AX25Address.decode(from_hex("ac 96 68 9a a6 98 80")) 82 | assert addr._ch is True 83 | 84 | 85 | def test_decode_str(): 86 | """ 87 | Test that we can decode a call-sign into an AX.25 address. 88 | """ 89 | addr = AX25Address.decode("VK4MSL") 90 | assert addr._callsign == "VK4MSL" 91 | 92 | 93 | def test_decode_str_invalid(): 94 | """ 95 | Test that strings are correctly validated. 96 | """ 97 | try: 98 | AX25Address.decode("VK4-MSL") 99 | assert False, "Should not have worked" 100 | except ValueError as e: 101 | assert str(e) == "Not a valid SSID: VK4-MSL" 102 | 103 | 104 | def test_decode_str_ssid(): 105 | """ 106 | Test that we can decode the SSID in a string. 107 | """ 108 | addr = AX25Address.decode("VK4MSL-12") 109 | assert addr._ssid == 12 110 | 111 | 112 | def test_decode_str_override_ssid(): 113 | """ 114 | Test that we can override the SSID in a string. 115 | """ 116 | addr = AX25Address.decode( 117 | "VK4MSL-12", 118 | # This will override the -12 above 119 | ssid=9, 120 | ) 121 | assert addr._ssid == 9 122 | 123 | 124 | def test_decode_str_ch(): 125 | """ 126 | Test that we can decode the C/H bit in a string. 127 | """ 128 | addr = AX25Address.decode("VK4MSL*") 129 | assert addr._ch is True 130 | 131 | 132 | def test_decode_ax25address(): 133 | """ 134 | Test that passing in a AX25Address results in a clone being made. 135 | """ 136 | addr1 = AX25Address("VK4MSL", 5) 137 | addr2 = AX25Address.decode(addr1) 138 | assert addr1 is not addr2 139 | for field in ( 140 | "_callsign", 141 | "_ssid", 142 | "_ch", 143 | "_res0", 144 | "_res1", 145 | "_extension", 146 | ): 147 | assert getattr(addr1, field) == getattr(addr2, field) 148 | 149 | 150 | def test_encode_str(): 151 | """ 152 | Test we can encode a AX25Address as a string 153 | """ 154 | assert str(AX25Address("VK4MSL", 0)) == "VK4MSL" 155 | 156 | 157 | def test_encode_str_ssid(): 158 | """ 159 | Test we can encode a AX25Address as a string 160 | """ 161 | assert str(AX25Address("VK4MSL", 11)) == "VK4MSL-11" 162 | 163 | 164 | def test_encode_str_ch(): 165 | """ 166 | Test we can encode a AX25Address' C/H bit as a string 167 | """ 168 | assert str(AX25Address("VK4MSL", ch=True)) == "VK4MSL*" 169 | 170 | 171 | def test_encode_repr(): 172 | """ 173 | Test we can represent the AX25Address as a Python string 174 | """ 175 | assert repr(AX25Address("VK4MSL", ch=True)) == ( 176 | "AX25Address(callsign=VK4MSL, ssid=0, ch=True, " 177 | "res0=True, res1=True, extension=False)" 178 | ) 179 | 180 | 181 | def test_encode_bytes(): 182 | """ 183 | Test we can encode a AX25Address as binary 184 | """ 185 | assert ( 186 | to_hex( 187 | bytes( 188 | AX25Address( 189 | "VK4MSL", 190 | 0, 191 | res0=False, 192 | res1=False, 193 | ch=False, 194 | extension=False, 195 | ) 196 | ) 197 | ) 198 | == "ac 96 68 9a a6 98 00" 199 | ) 200 | 201 | 202 | def test_encode_bytes_ssid(): 203 | """ 204 | Test we can encode a AX25Address as binary 205 | """ 206 | assert ( 207 | to_hex( 208 | bytes( 209 | AX25Address( 210 | "VK4MSL", 211 | 11, 212 | res0=False, 213 | res1=False, 214 | ch=False, 215 | extension=False, 216 | ) 217 | ) 218 | ) 219 | == "ac 96 68 9a a6 98 16" 220 | ) 221 | 222 | 223 | def test_encode_bytes_ch(): 224 | """ 225 | Test we can encode a AX25Address' C/H bit as binary 226 | """ 227 | assert ( 228 | to_hex( 229 | bytes( 230 | AX25Address( 231 | "VK4MSL", res0=False, res1=False, ch=True, extension=False 232 | ) 233 | ) 234 | ) 235 | == "ac 96 68 9a a6 98 80" 236 | ) 237 | 238 | 239 | def test_encode_bytes_ext(): 240 | """ 241 | Test we can encode a AX25Address' extension bit as binary 242 | """ 243 | assert ( 244 | to_hex( 245 | bytes( 246 | AX25Address( 247 | "VK4MSL", res0=False, res1=False, ch=False, extension=True 248 | ) 249 | ) 250 | ) 251 | == "ac 96 68 9a a6 98 01" 252 | ) 253 | 254 | 255 | def test_encode_bytes_res1(): 256 | """ 257 | Test we can encode a AX25Address' Reserved 1 bit as binary 258 | """ 259 | assert ( 260 | to_hex( 261 | bytes( 262 | AX25Address( 263 | "VK4MSL", res0=False, res1=True, ch=False, extension=False 264 | ) 265 | ) 266 | ) 267 | == "ac 96 68 9a a6 98 40" 268 | ) 269 | 270 | 271 | def test_encode_bytes_res0(): 272 | """ 273 | Test we can encode a AX25Address' Reserved 0 bit as binary 274 | """ 275 | assert ( 276 | to_hex( 277 | bytes( 278 | AX25Address( 279 | "VK4MSL", res0=True, res1=False, ch=False, extension=False 280 | ) 281 | ) 282 | ) 283 | == "ac 96 68 9a a6 98 20" 284 | ) 285 | 286 | 287 | def test_eq_match(): 288 | """ 289 | Test the __eq__ operator correctly matches addresses. 290 | """ 291 | a = AX25Address("VK4MSL", 12, ch=False) 292 | b = AX25Address("VK4MSL", 12, ch=False) 293 | assert a is not b 294 | assert a == b 295 | 296 | 297 | def test_eq_notmatch(): 298 | """ 299 | Test the __eq__ operator correctly identifies non-matching addresses. 300 | """ 301 | a = AX25Address("VK4MSL", 12, ch=False) 302 | b = AX25Address("VK4MSL", 12, ch=True) 303 | assert a != b 304 | 305 | 306 | def test_eq_notaddr(): 307 | """ 308 | Test the __eq__ operator does not attempt to compare non-addresses. 309 | """ 310 | a = AX25Address("VK4MSL", 12, ch=False) 311 | assert a != "foobar" 312 | 313 | 314 | def test_hash(): 315 | """ 316 | Test we can obtain a reliable hash. 317 | """ 318 | a = AX25Address("VK4MSL", 12, ch=False) 319 | b = AX25Address("VK4MSL", 12, ch=False) 320 | c = AX25Address("VK4MSL", 12, ch=True) 321 | assert a is not b 322 | assert a is not c 323 | assert hash(a) == hash(b) 324 | assert hash(a) != hash(c) 325 | 326 | 327 | def test_copy(): 328 | """ 329 | Test we can make copies of the address with arbitrary fields set. 330 | """ 331 | a = AX25Address("VK4MSL", 15, ch=False) 332 | b = a.copy(ch=True) 333 | 334 | assert b._ch is True 335 | 336 | # Everything else should be the same 337 | for field in ("_callsign", "_ssid", "_res0", "_res1", "_extension"): 338 | assert getattr(a, field) == getattr(b, field) 339 | 340 | 341 | def test_normcopy(): 342 | """ 343 | Test we can get normalised copies with specific bits set. 344 | """ 345 | a = AX25Address("VK4MSL", 15, ch=True, res0=False, res1=False) 346 | b = a.normcopy(res0=False, ch=True) 347 | 348 | assert b._ch is True 349 | assert b._res0 is False 350 | assert b._res1 is True 351 | 352 | 353 | def test_normalised(): 354 | """ 355 | Test we can get normalised copies for comparison. 356 | """ 357 | a = AX25Address("VK4MSL", 15, ch=True, res0=False, res1=False) 358 | b = a.normalised 359 | 360 | assert b._ch is False 361 | assert b._res0 is True 362 | assert b._res1 is True 363 | 364 | 365 | def test_ch_setter(): 366 | """ 367 | Test we can mutate the C/H bit. 368 | """ 369 | a = AX25Address("VK4MSL", 15, ch=False) 370 | a.ch = True 371 | assert a._ch is True 372 | 373 | 374 | def test_extension_setter(): 375 | """ 376 | Test we can mutate the extension bit. 377 | """ 378 | a = AX25Address("VK4MSL", 15, extension=False) 379 | a.extension = True 380 | assert a._extension is True 381 | --------------------------------------------------------------------------------