├── tests ├── __init__.py ├── test_frames.log ├── test_geo_util.py └── test_decode_regression.py ├── doc └── APRS101.pdf ├── COPYRIGHT.txt ├── .pylintrc ├── setup.py ├── .gitignore ├── LICENSE ├── .github └── workflows │ ├── pytest.yml │ └── publish.yml ├── TODO.txt ├── examples ├── tcp_kiss_send_recv.py └── aprsis_decode.py ├── CONTRIBUTORS ├── tox.ini ├── pyproject.toml ├── aprs ├── constants.py ├── kiss.py ├── __init__.py ├── base91.py ├── geo_util.py ├── timestamp.py ├── aprsis.py ├── decimaldegrees.py ├── data_ext.py ├── position.py └── classes.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/APRS101.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-aprs/aprs3/HEAD/doc/APRS101.pdf -------------------------------------------------------------------------------- /tests/test_frames.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-aprs/aprs3/HEAD/tests/test_frames.log -------------------------------------------------------------------------------- /COPYRIGHT.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Masen Furer and Contributors 2 | 3 | Copyright 2016 Orion Labs, Inc. and Contributors 4 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=invalid-name,consider-using-f-string,missing-function-docstring,missing-class-docstring 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Bare-bones setup.py for editable install support.""" 2 | from setuptools import setup 3 | 4 | setup( 5 | use_scm_version=True, 6 | setup_requires=['setuptools_scm'], 7 | ) 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.deb 2 | *.egg 3 | *.egg-info/ 4 | *.egg/ 5 | *.ignore 6 | *.py[co] 7 | *.py[oc] 8 | *.spl 9 | *.vagrant 10 | .DS_Store 11 | .coverage 12 | .eggs/ 13 | .eggs/* 14 | .idea 15 | .idea/ 16 | .pt 17 | .vagrant/ 18 | RELEASE-VERSION.txt 19 | build/ 20 | cover/ 21 | dist/ 22 | dump.rdb 23 | flake8.log 24 | local/ 25 | local_* 26 | metadata/ 27 | nosetests.xml 28 | output.xml 29 | pylint.log 30 | redis-server.log 31 | redis-server/ 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Greg Albrecht and Contributors 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Run tox 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | max-parallel: 5 11 | matrix: 12 | python-version: [3.7, 3.8, 3.9, "3.10"] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | # aprs TODO 2 | 3 | The following features are possible or on the roadmap, but not yet being implemented. 4 | 5 | # Decode/Encode 6 | 7 | ## Position Reports 8 | 9 | ### Position ambiguity 10 | 11 | ### DF reports 12 | 13 | The report contains the DF symbol (i.e. the Symbol Table ID is / and the Symbol Code is \). 14 | I don't really see these being used much on the air, so the implementation is deferred. 15 | 16 | ### Maidenhead grid beacon (obsolete) 17 | 18 | ### Raw NMEA sentence (find external parser) 19 | 20 | ### Compressed position 21 | 22 | ### Mic-E encoded 23 | 24 | ### Weather Reports 25 | 26 | ## Telemetry Data 27 | 28 | ## Station Capability / Query 29 | 30 | ## User-defined packets -------------------------------------------------------------------------------- /examples/tcp_kiss_send_recv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | import aprs 5 | 6 | 7 | MYCALL = os.environ.get("MYCALL", "N0CALL") 8 | KISS_HOST = os.environ.get("KISS_HOST", "localhost") 9 | KISS_PORT = os.environ.get("KISS_PORT", "8001") 10 | 11 | 12 | def main(): 13 | ki = aprs.TCPKISS(host=KISS_HOST, port=int(KISS_PORT)) 14 | ki.start() 15 | frame = aprs.APRSFrame.ui( 16 | destination="APZ001", 17 | source=MYCALL, 18 | path=["WIDE1-1"], 19 | info=b">Hello World!", 20 | ) 21 | ki.write(frame) 22 | while True: 23 | for frame in ki.read(min_frames=1): 24 | print(repr(frame)) 25 | 26 | 27 | if __name__ == "__main__": 28 | main() 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v3 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install tox 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | TWINE_REPOSITORY_URL: ${{ secrets.PYPI_INDEX }} 25 | run: | 26 | tox -e publish -- upload dist/* -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | - Avi Solomon - https://github.com/genepool99 2 | - Ben Benesh - https://github.com/bbene 3 | - Enrico - https://github.com/Enrico204 4 | - Greg Albrecht W2GMD - https://github.com/ampledata 5 | - Humphreybas - https://github.com/Humphreybas 6 | - JDat - https://github.com/JDat 7 | - Jay Nugent 8 | - Jeffrey Phillips Freeman (WI2ARD) - http://JeffreyFreeman.me 9 | - Joe Goforth 10 | - John Hogenmiller KB3DFZ - https://github.com/ytjohn 11 | - Masen Furer KF7HVM - https://github.com/python-aprs 12 | - Paul McMillan - https://github.com/PaulMcMillan 13 | - Pete Loveall AE5PL 14 | - Phil Gagnon N1HHG 15 | - Rick Eason 16 | - Russ Innes 17 | - agmuino - https://github.com/agmuino 18 | - bgstewart - https://github.com/bgstewart 19 | - darksidelemm - https://github.com/darksidelemm 20 | - webbermr - https://github.com/webbermr 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.7: py37 4 | 3.8: py38 5 | 3.9: py39 6 | 3.10: py310 7 | 8 | [tox] 9 | isolated_build = True 10 | envlist = py37,py38,py39,py310,publish,static 11 | 12 | [testenv] 13 | deps = 14 | pytest ~= 7.0 15 | pytest-cov ~= 3.0 16 | commands = 17 | pytest --cov aprs --cov-report term-missing --cov-fail-under 80 -ra {posargs:tests} 18 | 19 | [testenv:publish] 20 | passenv = TWINE_* 21 | deps = 22 | build ~= 0.7.0 23 | twine ~= 4.0.0 24 | commands = 25 | python -m build 26 | python -m twine {posargs:check} {env:TWINE_DIST_DIR:dist/*} 27 | 28 | [flake8] 29 | max-line-length = 90 30 | extend-ignore = E203 31 | 32 | [testenv:static] 33 | deps = 34 | black ~= 22.3.0 35 | flake8 ~= 4.0 36 | mypy 37 | pylint 38 | commands = 39 | black --check aprs tests 40 | flake8 aprs tests 41 | pylint aprs 42 | -mypy --strict aprs -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=61", 4 | "setuptools_scm[toml]>=6.2", 5 | "wheel", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [project] 10 | name = "aprs3" 11 | description = "Python module for encoding and decoding APRS data." 12 | authors = [ 13 | {name = "Masen Furer KF7HVM", email = "kf7hvm@0x26.net"}, 14 | {name = "Greg Albrecht W2GMD", email = "oss@undef.net"} 15 | ] 16 | dependencies = [ 17 | "attrs > 20", 18 | "ax253 >= 0.1.5", 19 | "kiss3 >= 8.0.0", 20 | ] 21 | readme = "README.rst" 22 | license = {file = "LICENSE"} 23 | classifiers = [ 24 | "License :: OSI Approved :: Apache Software License", 25 | "Topic :: Communications :: Ham Radio", 26 | "Programming Language :: Python", 27 | "License :: OSI Approved :: Apache Software License" 28 | ] 29 | keywords=[ 30 | 'Ham Radio', 'APRS', 'KISS' 31 | ] 32 | dynamic = ["version"] 33 | 34 | [project.urls] 35 | Homepage = "https://github.com/python-aprs/aprs3" 36 | 37 | [tool.setuptools_scm] -------------------------------------------------------------------------------- /examples/aprsis_decode.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from aprs import APRSFrame, create_aprsis_connection, InformationField 5 | 6 | MYCALL = os.environ.get("MYCALL", "N0CALL") 7 | DESTINATION = os.environ.get("DESTINATION", "APZ069") 8 | HOST = os.environ.get("APRSIS_HOST", "noam.aprs2.net") 9 | PORT = int(os.environ.get("APRSIS_PORT", "14580")) 10 | PASSCODE = os.environ.get("PASSCODE", "-1") 11 | COMMAND = os.environ.get("COMMAND", "filter r/46.1/-122.9/50") 12 | INFO = os.environ.get("INFO", ":TEST :this is only a test message") 13 | 14 | 15 | async def main(): 16 | transport, protocol = await create_aprsis_connection( 17 | host=HOST, 18 | port=PORT, 19 | user=MYCALL, 20 | passcode=PASSCODE, 21 | command=COMMAND, 22 | ) 23 | if int(PASSCODE) > 0: 24 | protocol.write( 25 | APRSFrame.ui( 26 | destination=DESTINATION, 27 | source=MYCALL, 28 | info=InformationField.from_bytes(INFO.encode("ascii")), 29 | ) 30 | ) 31 | 32 | async for packet in protocol.read(): 33 | print(str(packet)) 34 | print(repr(packet.info)) 35 | 36 | 37 | if __name__ == "__main__": 38 | asyncio.run(main()) 39 | -------------------------------------------------------------------------------- /aprs/constants.py: -------------------------------------------------------------------------------- 1 | """Python APRS Module Constants.""" 2 | import enum 3 | import os 4 | 5 | __author__ = "Greg Albrecht W2GMD " 6 | __copyright__ = "Copyright 2017 Greg Albrecht and Contributors" 7 | __license__ = "Apache License, Version 2.0" 8 | 9 | 10 | APRSIS_HTTP_HEADERS = { 11 | "content-type": "application/octet-stream", 12 | "accept": "text/plain", 13 | } 14 | 15 | APRSIS_SERVERS = ["rotate.aprs.net", "noam.aprs2.net"] 16 | APRSIS_FILTER_PORT = int(os.environ.get("APRSIS_FILTER_PORT", 14580)) 17 | APRSIS_RX_PORT = int(os.environ.get("APRSIS_RX_PORT", 8080)) 18 | APRSIS_URL = os.environ.get("APRSIS_URL", "http://srvr.aprs-is.net:8080") 19 | 20 | DEFAULT_MYCALL = "N0CALL" 21 | DEFAULT_TOCALL = "APZ069" 22 | 23 | 24 | class TimestampFormat(enum.Enum): 25 | DayHoursMinutesLocal = b"/" 26 | DayHoursMinutesZulu = b"z" 27 | HoursMinutesSecondsZulu = b"h" 28 | MonthDayHoursMinutesZulu = b"" 29 | 30 | 31 | timestamp_formats_map = { 32 | TimestampFormat.DayHoursMinutesZulu: "%d%H%M", 33 | TimestampFormat.DayHoursMinutesLocal: "%d%H%M", 34 | TimestampFormat.HoursMinutesSecondsZulu: "%H%M%S", 35 | TimestampFormat.MonthDayHoursMinutesZulu: "%m%d%H%M", 36 | } 37 | 38 | 39 | class PositionFormat(enum.Enum): 40 | Uncompressed = 0 41 | Compressed = 1 42 | -------------------------------------------------------------------------------- /aprs/kiss.py: -------------------------------------------------------------------------------- 1 | """Helpers for attaching TNC via Serial KISS or KISS-over-TCP""" 2 | import logging 3 | from typing import Iterable 4 | 5 | from attrs import define, field 6 | 7 | import kiss 8 | 9 | from .classes import APRSFrame 10 | 11 | # pylint: disable=duplicate-code 12 | __author__ = "Masen Furer KF7HVM " 13 | __copyright__ = "Copyright 2022 Masen Furer and Contributors" 14 | __license__ = "Apache License, Version 2.0" 15 | # pylint: enable=duplicate-code 16 | 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | 21 | @define 22 | class APRSDecode(kiss.KISSDecode): 23 | strip_df_start: bool = field(default=True, converter=lambda v: True) 24 | 25 | def decode_frames(self, frame: bytes) -> Iterable[APRSFrame]: 26 | for kiss_frame in super().decode_frames(frame): 27 | try: 28 | yield APRSFrame.from_bytes(kiss_frame) 29 | except Exception: # pylint: disable=broad-except 30 | log.debug("Ignore frame AX.25 decode error %r", frame, exc_info=True) 31 | 32 | 33 | async def create_tcp_connection(*args, protocol_kwargs=None, **kwargs): 34 | if protocol_kwargs is None: 35 | protocol_kwargs = {} 36 | protocol_kwargs["decoder"] = protocol_kwargs.pop("decoder", APRSDecode()) 37 | return await kiss.create_tcp_connection( 38 | *args, protocol_kwargs=protocol_kwargs, **kwargs 39 | ) 40 | 41 | 42 | async def create_serial_connection(*args, protocol_kwargs=None, **kwargs): 43 | if protocol_kwargs is None: 44 | protocol_kwargs = {} 45 | protocol_kwargs["decoder"] = protocol_kwargs.pop("decoder", APRSDecode()) 46 | return await kiss.create_serial_connection( 47 | *args, protocol_kwargs=protocol_kwargs, **kwargs 48 | ) 49 | 50 | 51 | class TCPKISS(kiss.TCPKISS): 52 | decode_class = APRSDecode 53 | 54 | 55 | class SerialKISS(kiss.SerialKISS): 56 | decode_class = APRSDecode 57 | -------------------------------------------------------------------------------- /aprs/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python APRS Module. 3 | ~~~~ 4 | 5 | :author: Greg Albrecht W2GMD 6 | :copyright: Copyright 2017 Greg Albrecht and Contributors 7 | :license: Apache License, Version 2.0 8 | :source: 9 | 10 | """ 11 | from importlib_metadata import version 12 | 13 | from . import decimaldegrees, geo_util, position, timestamp 14 | from .aprsis import APRSISProtocol, create_aprsis_connection, TCP 15 | from .classes import ( 16 | APRSFrame, 17 | DataType, 18 | DataTypeError, 19 | InformationField, 20 | ItemReport, 21 | Message, 22 | ObjectReport, 23 | PositionReport, 24 | StatusReport, 25 | ) 26 | from .constants import ( 27 | APRSIS_HTTP_HEADERS, 28 | APRSIS_SERVERS, 29 | APRSIS_FILTER_PORT, 30 | APRSIS_RX_PORT, 31 | APRSIS_URL, 32 | DEFAULT_TOCALL, 33 | PositionFormat, 34 | TimestampFormat, 35 | timestamp_formats_map, 36 | ) 37 | from .data_ext import ( 38 | AreaObject, 39 | CourseSpeed, 40 | DataExt, 41 | DFS, 42 | PHG, 43 | RNG, 44 | ) 45 | from .kiss import create_serial_connection, create_tcp_connection, SerialKISS, TCPKISS 46 | from .position import Position 47 | from .timestamp import Timestamp 48 | 49 | __author__ = "Greg Albrecht W2GMD " 50 | __copyright__ = "Copyright 2017 Greg Albrecht and Contributors" 51 | __license__ = "Apache License, Version 2.0" 52 | __distribution__ = "aprs3" 53 | __version__ = version(__distribution__) 54 | __all__ = [ 55 | "APRSFrame", 56 | "APRSIS_HTTP_HEADERS", 57 | "APRSIS_SERVERS", 58 | "APRSIS_FILTER_PORT", 59 | "APRSIS_RX_PORT", 60 | "APRSIS_URL", 61 | "APRSISProtocol", 62 | "AreaObject", 63 | "CourseSpeed", 64 | "create_aprsis_connection", 65 | "create_serial_connection", 66 | "create_tcp_connection", 67 | "DataExt", 68 | "DataType", 69 | "DataTypeError", 70 | "decimaldegrees", 71 | "DEFAULT_TOCALL", 72 | "DFS", 73 | "geo_util", 74 | "ItemReport", 75 | "InformationField", 76 | "Message", 77 | "ObjectReport", 78 | "PHG", 79 | "position", 80 | "Position", 81 | "PositionFormat", 82 | "PositionReport", 83 | "RNG", 84 | "SerialKISS", 85 | "StatusReport", 86 | "TCP", 87 | "TCPKISS", 88 | "timestamp", 89 | "Timestamp", 90 | "TimestampFormat", 91 | "timestamp_formats_map", 92 | "__author__", 93 | "__copyright__", 94 | "__license__", 95 | "__distribution__", 96 | "__version__", 97 | ] 98 | -------------------------------------------------------------------------------- /aprs/base91.py: -------------------------------------------------------------------------------- 1 | # aprslib - Python library for working with APRS 2 | # Copyright (C) 2013-2014 Rossen Georgiev 3 | # 4 | # This program is free software; you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation; either version 2 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License along 15 | # with this program; if not, write to the Free Software Foundation, Inc., 16 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 17 | 18 | """ 19 | Provides facilities for conversion from/to base91 20 | 21 | Original source: 22 | https://github.com/rossengeorgiev/aprs-python/blob/2b139d18578e62818adb6fac217a96b622c490f7/aprslib/base91.py 23 | """ 24 | 25 | __all__ = ["to_decimal", "from_decimal"] 26 | from math import log, ceil 27 | from re import findall 28 | 29 | 30 | def to_decimal(text): 31 | """ 32 | Takes a base91 char string and returns decimal 33 | """ 34 | 35 | if not isinstance(text, str): 36 | raise TypeError("expected str or unicode, %s given" % type(text)) 37 | 38 | if findall(r"[\x00-\x20\x7c-\xff]", text): 39 | raise ValueError("invalid character in sequence") 40 | 41 | text = text.lstrip("!") 42 | decimal = 0 43 | length = len(text) - 1 44 | for i, char in enumerate(text): 45 | decimal += (ord(char) - 33) * (91 ** (length - i)) 46 | 47 | return decimal if text != "" else 0 48 | 49 | 50 | def from_decimal(number, width=1): 51 | """ 52 | Takes a decimal and returns base91 char string. 53 | With optional parameter for fix with output 54 | """ 55 | text = [] 56 | 57 | if not isinstance(number, int): 58 | raise TypeError("Expected number to be int, got %s" % type(number)) 59 | if not isinstance(width, int): 60 | raise TypeError("Expected width to be int, got %s" % type(number)) 61 | if number < 0: 62 | raise ValueError("Expected number to be positive integer") 63 | if number > 0: 64 | max_n = ceil(log(number) / log(91)) 65 | 66 | for n in range(int(max_n), -1, -1): 67 | quotient, number = divmod(number, 91**n) 68 | text.append(chr(33 + quotient)) 69 | 70 | return "".join(text).lstrip("!").rjust(max(1, width), "!") 71 | -------------------------------------------------------------------------------- /aprs/geo_util.py: -------------------------------------------------------------------------------- 1 | """Python APRS Module Geo Utility Function Definitions.""" 2 | from . import decimaldegrees 3 | 4 | __author__ = "Greg Albrecht W2GMD " 5 | __copyright__ = "Copyright 2017 Greg Albrecht and Contributors" 6 | __license__ = "Apache License, Version 2.0" 7 | 8 | 9 | def dec2dm_lat(dec: float) -> bytes: 10 | """ 11 | Converts DecDeg to APRS Coord format. 12 | 13 | See: http://ember2ash.com/lat.htm 14 | 15 | Source: http://stackoverflow.com/questions/2056750 16 | 17 | Example: 18 | >>> test_lat = 37.7418096 19 | >>> aprs_lat = dec2dm_lat(test_lat) 20 | >>> aprs_lat 21 | '3744.51N' 22 | >>> test_lat = -8.01 23 | >>> aprs_lat = dec2dm_lat(test_lat) 24 | >>> aprs_lat 25 | '0800.60S' 26 | """ 27 | dec_min = decimaldegrees.decimal2dm(dec) 28 | 29 | deg = dec_min[0] 30 | abs_deg = abs(deg) 31 | 32 | if not deg == abs_deg: 33 | suffix = b"S" 34 | else: 35 | suffix = b"N" 36 | 37 | return b"%02d%05.2f%s" % (abs_deg, dec_min[1], suffix) 38 | 39 | 40 | def dec2dm_lng(dec: float) -> bytes: 41 | """ 42 | Converts DecDeg to APRS Coord format. 43 | 44 | See: http://ember2ash.com/lat.htm 45 | 46 | Example: 47 | >>> test_lng = 122.38833 48 | >>> aprs_lng = dec2dm_lng(test_lng) 49 | >>> aprs_lng 50 | '12223.30E' 51 | >>> test_lng = -99.01 52 | >>> aprs_lng = dec2dm_lng(test_lng) 53 | >>> aprs_lng 54 | '09900.60W' 55 | """ 56 | dec_min = decimaldegrees.decimal2dm(dec) 57 | 58 | deg = dec_min[0] 59 | abs_deg = abs(deg) 60 | 61 | if not deg == abs_deg: 62 | suffix = b"W" 63 | else: 64 | suffix = b"E" 65 | 66 | return b"%03d%05.2f%s" % (abs_deg, dec_min[1], suffix) 67 | 68 | 69 | def ambiguate(pos: bytes, ambiguity: int) -> bytes: 70 | """ 71 | Adjust ambiguity of position. 72 | 73 | Derived from @asdil12's `process_ambiguity()`. 74 | 75 | >>> pos = '12345.67N' 76 | >>> ambiguate(pos, 0) 77 | '12345.67N' 78 | >>> ambiguate(pos, 1) 79 | '12345.6 N' 80 | >>> ambiguate(pos, 2) 81 | '12345. N' 82 | >>> ambiguate(pos, 3) 83 | '1234 . N' 84 | """ 85 | if not isinstance(pos, bytes): 86 | pos = str(pos).encode("ascii") 87 | amb = [] 88 | for b in reversed(pos): 89 | if ord(b"0") <= b <= ord(b"9") and ambiguity: 90 | amb.append(ord(b" ")) 91 | ambiguity -= 1 92 | continue 93 | amb.append(b) 94 | return bytes(reversed(amb)) 95 | 96 | 97 | def deambiguate(pos: bytes) -> int: 98 | """ 99 | Return the ambiguity of the position 100 | 101 | >>> deambiguate(b'12345.67N') 102 | 0 103 | >>> deambiguate(b'12345.6 N') 104 | 1 105 | >>> deambiguate(b'12345. N') 106 | 2 107 | >>> deambiguate(b'1234 . N') 108 | 3 109 | """ 110 | return pos.count(b" ") 111 | 112 | 113 | def run_doctest(): # pragma: no cover 114 | """Runs doctests for this module.""" 115 | import doctest # pylint: disable=import-outside-toplevel 116 | 117 | return doctest.testmod() 118 | 119 | 120 | if __name__ == "__main__": 121 | run_doctest() # pragma: no cover 122 | -------------------------------------------------------------------------------- /aprs/timestamp.py: -------------------------------------------------------------------------------- 1 | """Represent various timestamp formats""" 2 | from datetime import datetime, timedelta, timezone 3 | from typing import Optional 4 | 5 | from attrs import define, field 6 | 7 | from .constants import TimestampFormat, timestamp_formats_map 8 | 9 | __author__ = "Masen Furer KF7HVM " 10 | __copyright__ = "Copyright 2022 Masen Furer and Contributors" 11 | __license__ = "Apache License, Version 2.0" 12 | 13 | # when receiving timestamps, consider 1 hour ahead of our clock 14 | # to be "plausible" rather than adjusting the year/month/day to 15 | # have the timestamp exist in the past 16 | FUTURE_TIMESTAMP_THRESHOLD = timedelta(hours=1) 17 | 18 | 19 | def utcnow_tz(): 20 | return datetime.now(tz=timezone.utc) 21 | 22 | 23 | def decode_timestamp_dhm(data: bytes) -> datetime: 24 | ts_format = TimestampFormat(data[6:7]) 25 | tzinfo = None if ts_format == TimestampFormat.DayHoursMinutesLocal else timezone.utc 26 | now = datetime.now(tz=tzinfo) 27 | ts = datetime.strptime(data[:6].decode("ascii"), timestamp_formats_map[ts_format]) 28 | maybe_ts = ts.replace(year=now.year, month=now.month, tzinfo=tzinfo) 29 | if maybe_ts > (now + FUTURE_TIMESTAMP_THRESHOLD): 30 | # can't have a timestamp in the future, so assume it's from last month 31 | if maybe_ts.month == 1: 32 | return maybe_ts.replace(year=maybe_ts.year - 1, month=12) 33 | return maybe_ts.replace(month=maybe_ts.month - 1) 34 | return maybe_ts 35 | 36 | 37 | def decode_timestamp_hms(data: bytes) -> datetime: 38 | now = utcnow_tz() 39 | ts = datetime.strptime( 40 | data[:6].decode("ascii"), 41 | timestamp_formats_map[TimestampFormat.HoursMinutesSecondsZulu], 42 | ) 43 | maybe_ts = ts.replace( 44 | year=now.year, month=now.month, day=now.day, tzinfo=timezone.utc 45 | ) 46 | if maybe_ts > (now + FUTURE_TIMESTAMP_THRESHOLD): 47 | # can't have a timestamp (too far) in the future, so assume it's from yesterday 48 | return maybe_ts - timedelta(days=1) 49 | return maybe_ts 50 | 51 | 52 | def decode_timestamp_mdhm(data: bytes) -> datetime: 53 | now = utcnow_tz() 54 | ts = datetime.strptime( 55 | data[:8].decode("ascii"), 56 | timestamp_formats_map[TimestampFormat.MonthDayHoursMinutesZulu], 57 | ).replace( 58 | year=now.year, 59 | tzinfo=timezone.utc, 60 | ) 61 | if ts > (now + FUTURE_TIMESTAMP_THRESHOLD): 62 | # can't have a timestamp in the future, so assume it's from last year 63 | return ts.replace(year=ts.year - 1) 64 | return ts 65 | 66 | 67 | @define(frozen=True, slots=True) 68 | class Timestamp: 69 | """Represents a timestamp for an APRS information field.""" 70 | 71 | timestamp_format: TimestampFormat = field( 72 | default=TimestampFormat.DayHoursMinutesZulu 73 | ) 74 | timestamp: datetime = field(factory=utcnow_tz) 75 | 76 | @classmethod 77 | def from_bytes(cls, raw: bytes) -> "Timestamp": 78 | try: 79 | ts_format = TimestampFormat(raw[6:7]) 80 | if ts_format in [ 81 | TimestampFormat.DayHoursMinutesLocal, 82 | TimestampFormat.DayHoursMinutesZulu, 83 | ]: 84 | return cls(ts_format, decode_timestamp_dhm(raw)) 85 | return cls(ts_format, decode_timestamp_hms(raw)) 86 | except ValueError: 87 | # assume Month Day Hours Minutes 88 | return cls( 89 | TimestampFormat.MonthDayHoursMinutesZulu, decode_timestamp_mdhm(raw) 90 | ) 91 | 92 | def __bytes__(self) -> bytes: 93 | # pylint: disable=no-member 94 | return ( 95 | self.timestamp.strftime(self.timestamp_format_string).encode("ascii") 96 | + self.timestamp_format.value 97 | ) 98 | # pylint: enable=no-member 99 | 100 | @property 101 | def timestamp_format_string(self): 102 | return timestamp_formats_map[self.timestamp_format] 103 | 104 | 105 | class TimestampMixin: # pylint: disable=too-few-public-methods 106 | _timestamp: Optional[Timestamp] 107 | 108 | @property 109 | def timestamp(self) -> Optional[datetime]: 110 | if self._timestamp: 111 | return self._timestamp.timestamp 112 | return None 113 | -------------------------------------------------------------------------------- /aprs/aprsis.py: -------------------------------------------------------------------------------- 1 | """APRS-IS send/receive protocol for async and sync use.""" 2 | import asyncio 3 | import functools 4 | import logging 5 | from typing import ( 6 | Any, 7 | Dict, 8 | Iterable, 9 | Optional, 10 | Tuple, 11 | ) 12 | 13 | from attrs import define, field 14 | 15 | from ax253 import SyncFrameDecode, TNC2Decode, TNC2Protocol 16 | 17 | # pylint: disable=cyclic-import 18 | from .classes import APRSFrame 19 | from .constants import APRSIS_SERVERS, APRSIS_FILTER_PORT, DEFAULT_MYCALL 20 | 21 | # pylint: disable=duplicate-code 22 | __author__ = "Masen Furer KF7HVM " 23 | __copyright__ = "Copyright 2022 Masen Furer and Contributors" 24 | __license__ = "Apache License, Version 2.0" 25 | # pylint: enable=duplicate-code 26 | 27 | 28 | log = logging.getLogger(__name__) 29 | 30 | 31 | @define 32 | class APRSDecode(TNC2Decode): 33 | """Decode info fields as APRS.""" 34 | 35 | @staticmethod 36 | def decode_frames(frame: bytes) -> Iterable[APRSFrame]: 37 | try: 38 | yield APRSFrame.from_str(frame.decode("latin1")) 39 | except Exception: # pylint: disable=broad-except 40 | log.debug("Ignore frame decode error %r", frame, exc_info=True) 41 | 42 | 43 | @define 44 | class APRSISProtocol(TNC2Protocol): 45 | """Protocol for logging into APRS-IS servers (TNC2).""" 46 | 47 | decoder: APRSDecode = field(factory=APRSDecode) 48 | 49 | def login(self, user: str, passcode: str, command: str): 50 | # pylint: disable=import-outside-toplevel 51 | # avoid circular import 52 | from . import __distribution__, __version__ 53 | 54 | # pylint: enable=import-outside-toplevel 55 | 56 | self.transport.write( 57 | "user {} pass {} vers {} {} {}\r\n".format( 58 | user, 59 | passcode, 60 | __distribution__, 61 | __version__, 62 | command, 63 | ).encode("ascii"), 64 | ) 65 | 66 | 67 | def _handle_kwargs( 68 | protocol_kwargs: Dict[str, Any], 69 | create_connection_kwargs: Dict[str, Any], 70 | **kwargs: Any 71 | ) -> Dict[str, Any]: 72 | """Handle async connection kwarg combination to avoid duplication.""" 73 | if create_connection_kwargs is None: 74 | create_connection_kwargs = {} 75 | create_connection_kwargs.update(kwargs) 76 | create_connection_kwargs["protocol_factory"] = functools.partial( 77 | create_connection_kwargs.pop("protocol_factory", APRSISProtocol), 78 | **(protocol_kwargs or {}), 79 | ) 80 | return create_connection_kwargs 81 | 82 | 83 | async def create_aprsis_connection( # pylint: disable=too-many-arguments 84 | host: str = APRSIS_SERVERS[0], 85 | port: int = APRSIS_FILTER_PORT, 86 | user: str = DEFAULT_MYCALL, 87 | passcode: str = "-1", 88 | command: str = "", 89 | protocol_kwargs: Optional[Dict[str, Any]] = None, 90 | loop: Optional[asyncio.BaseEventLoop] = None, 91 | create_connection_kwargs: Optional[Dict[str, Any]] = None, 92 | ) -> Tuple[asyncio.BaseTransport, APRSISProtocol]: 93 | """ 94 | Establish an async APRS-IS connection. 95 | 96 | :param host: the APRS-IS host to connect to 97 | :param port: the TCP port to connect to (14580 is usually a good choice) 98 | :param user: callsign of the user to authenticate 99 | :param passcode: APRS-IS passcode associated with the callsign 100 | :param command: initial command to send after connecting 101 | :param protocol_kwargs: These kwargs are passed directly to APRSISProtocol 102 | :param loop: override the asyncio event loop (default calls `get_event_loop()`) 103 | :param create_connection_kwargs: These kwargs are passed directly to 104 | loop.create_connection 105 | :return: (TCPTransport, APRSISProtocol) 106 | """ 107 | if loop is None: 108 | loop = asyncio.get_event_loop() 109 | protocol: APRSISProtocol 110 | transport, protocol = await loop.create_connection( 111 | host=host, 112 | port=port, 113 | **_handle_kwargs( 114 | protocol_kwargs=protocol_kwargs, 115 | create_connection_kwargs=create_connection_kwargs, 116 | ), 117 | ) 118 | await protocol.connection_future 119 | protocol.login(user, passcode, command) 120 | return transport, protocol 121 | 122 | 123 | class TCP(SyncFrameDecode[APRSFrame]): 124 | """APRSIS-over-TCP.""" 125 | 126 | def __init__(self, *args, **kwargs) -> None: 127 | super().__init__() 128 | self.args = args 129 | self.kwargs = kwargs 130 | 131 | def stop(self) -> None: 132 | if self.protocol: 133 | self.protocol.transport.close() 134 | 135 | def start(self, **kwargs: Any) -> None: 136 | """ 137 | Open the APRS-IS connection and start decoding packets. 138 | """ 139 | _, self.protocol = self.loop.run_until_complete( 140 | create_aprsis_connection(*self.args, **self.kwargs), 141 | ) 142 | self.loop.run_until_complete(self.protocol.connection_future) 143 | -------------------------------------------------------------------------------- /aprs/decimaldegrees.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyDecimalDegrees - geographic coordinates conversion utility. 3 | 4 | Copyright (C) 2006-2013 by Mateusz Łoskot 5 | Copyright (C) 2010-2013 by Evan Wheeler 6 | 7 | This file is part of PyDecimalDegrees module. 8 | 9 | This software is provided 'as-is', without any express or implied warranty. 10 | In no event will the authors be held liable for any damages arising from 11 | the use of this software. 12 | 13 | Permission is granted to anyone to use this software for any purpose, 14 | including commercial applications, and to alter it and redistribute it freely, 15 | subject to the following restrictions: 16 | 1. The origin of this software must not be misrepresented; you must not 17 | claim that you wrote the original software. If you use this software 18 | in a product, an acknowledgment in the product documentation would be 19 | appreciated but is not required. 20 | 2. Altered source versions must be plainly marked as such, and must not be 21 | misrepresented as being the original software. 22 | 3. This notice may not be removed or altered from any source distribution. 23 | 24 | DESCRIPTION 25 | 26 | DecimalDegrees module provides functions to convert between 27 | degrees/minutes/seconds and decimal degrees. 28 | 29 | Original source distribution: 30 | http://mateusz.loskot.net/software/gis/pydecimaldegrees/ 31 | 32 | Inspired by Walter Mankowski's Geo::Coordinates::DecimalDegrees module 33 | for Perl, originally located in CPAN Archives: 34 | http://search.cpan.org/~waltman/Geo-Coordinates-DecimalDegrees-0.05/ 35 | 36 | doctest examples are based following coordinates: 37 | DMS: 121 8' 6" 38 | DM: 121 8.1' 39 | DD: 121.135 40 | 41 | To run doctest units just execut this module script as follows 42 | (-v instructs Python to run script in verbose mode): 43 | 44 | $ python decimaldegrees.py [-v] 45 | 46 | """ 47 | import decimal as libdecimal 48 | 49 | from decimal import Decimal as D 50 | 51 | __revision__ = "$Revision: 1.1 $" 52 | 53 | 54 | def decimal2dms(decimal_degrees): 55 | """Converts a floating point number of degrees to the equivalent 56 | number of degrees, minutes, and seconds, which are returned 57 | as a 3-element tuple of decimals. If 'decimal_degrees' is negative, 58 | only degrees (1st element of returned tuple) will be negative, 59 | minutes (2nd element) and seconds (3rd element) will always be positive. 60 | 61 | Example: 62 | 63 | >>> decimal2dms(121.135) 64 | (Decimal('121'), Decimal('8'), Decimal('6.000')) 65 | >>> decimal2dms(-121.135) 66 | (Decimal('-121'), Decimal('8'), Decimal('6.000')) 67 | 68 | """ 69 | 70 | degrees = D(int(decimal_degrees)) 71 | decimal_minutes = libdecimal.getcontext().multiply( 72 | (D(str(decimal_degrees)) - degrees).copy_abs(), D(60) 73 | ) 74 | minutes = D(int(decimal_minutes)) 75 | seconds = libdecimal.getcontext().multiply((decimal_minutes - minutes), D(60)) 76 | return (degrees, minutes, seconds) 77 | 78 | 79 | def decimal2dm(decimal_degrees): 80 | """ 81 | Converts a floating point number of degrees to the degress & minutes. 82 | 83 | Returns a 2-element tuple of decimals. 84 | 85 | If 'decimal_degrees' is negative, only degrees (1st element of returned 86 | tuple) will be negative, minutes (2nd element) will always be positive. 87 | 88 | Example: 89 | 90 | >>> decimal2dm(121.135) 91 | (Decimal('121'), Decimal('8.100')) 92 | >>> decimal2dm(-121.135) 93 | (Decimal('-121'), Decimal('8.100')) 94 | 95 | """ 96 | degrees = D(int(decimal_degrees)) 97 | 98 | minutes = libdecimal.getcontext().multiply( 99 | (D(str(decimal_degrees)) - degrees).copy_abs(), D(60) 100 | ) 101 | 102 | return (degrees, minutes) 103 | 104 | 105 | def dms2decimal(degrees, minutes, seconds): 106 | """Converts degrees, minutes, and seconds to the equivalent 107 | number of decimal degrees. If parameter 'degrees' is negative, 108 | then returned decimal-degrees will also be negative. 109 | 110 | NOTE: this method returns a decimal.Decimal 111 | 112 | Example: 113 | 114 | >>> dms2decimal(121, 8, 6) 115 | Decimal('121.135') 116 | >>> dms2decimal(-121, 8, 6) 117 | Decimal('-121.135') 118 | 119 | """ 120 | decimal = D(0) 121 | degs = D(str(degrees)) 122 | mins = libdecimal.getcontext().divide(D(str(minutes)), D(60)) 123 | secs = libdecimal.getcontext().divide(D(str(seconds)), D(3600)) 124 | 125 | if degrees >= D(0): 126 | decimal = degs + mins + secs 127 | else: 128 | decimal = degs - mins - secs 129 | 130 | return libdecimal.getcontext().normalize(decimal) 131 | 132 | 133 | def dm2decimal(degrees, minutes): 134 | """Converts degrees and minutes to the equivalent number of decimal 135 | degrees. If parameter 'degrees' is negative, then returned decimal-degrees 136 | will also be negative. 137 | 138 | Example: 139 | 140 | >>> dm2decimal(121, 8.1) 141 | Decimal('121.135') 142 | >>> dm2decimal(-121, 8.1) 143 | Decimal('-121.135') 144 | 145 | """ 146 | return dms2decimal(degrees, minutes, 0) 147 | 148 | 149 | def run_doctest(): # pragma: no cover 150 | """Runs doctests for this module.""" 151 | import doctest # pylint: disable=import-outside-toplevel 152 | 153 | return doctest.testmod() 154 | 155 | 156 | if __name__ == "__main__": 157 | run_doctest() # pragma: no cover 158 | -------------------------------------------------------------------------------- /tests/test_geo_util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Python APRS Module Geo Utility Function Tests. 6 | 7 | Spec per ftp://ftp.tapr.org/aprssig/aprsspec/spec/aprs101/APRS101.pdf 8 | 9 | Latitude 10 | -------- 11 | 12 | Latitude is expressed as a fixed 8-character field, in degrees and decimal 13 | minutes (to two decimal places), followed by the letter N for north or S for 14 | south. 15 | 16 | Latitude degrees are in the range 00 to 90. Latitude minutes are expressed as 17 | whole minutes and hundredths of a minute, separated by a decimal point. 18 | 19 | For example: 20 | 21 | 4903.50N is 49 degrees 3 minutes 30 seconds north. 22 | 23 | In generic format examples, the latitude is shown as the 8-character string 24 | ddmm.hhN (i.e. degrees, minutes and hundredths of a minute north). 25 | 26 | 27 | Longitude Format 28 | ---------------- 29 | 30 | Longitude is expressed as a fixed 9-character field, in degrees and decimal 31 | minutes (to two decimal places), followed by the letter E for east or W for 32 | west. 33 | 34 | Longitude degrees are in the range 000 to 180. Longitude minutes are expressed 35 | as whole minutes and hundredths of a minute, separated by a decimal point. 36 | 37 | For example: 38 | 39 | 07201.75W is 72 degrees 1 minute 45 seconds west. 40 | 41 | In generic format examples, the longitude is shown as the 9-character string 42 | dddmm.hhW (i.e. degrees, minutes and hundredths of a minute west). 43 | 44 | """ 45 | 46 | from aprs import geo_util 47 | 48 | __author__ = "Greg Albrecht W2GMD " # NOQA pylint: disable=R0801 49 | __copyright__ = ( 50 | "Copyright 2017 Greg Albrecht and Contributors" # NOQA pylint: disable=R0801 51 | ) 52 | __license__ = "Apache License, Version 2.0" # NOQA pylint: disable=R0801 53 | 54 | 55 | def test_latitude_north(): 56 | """Test Decimal to APRS Latitude conversion.""" 57 | test_lat = 37.7418096 58 | aprs_lat = geo_util.dec2dm_lat(test_lat) 59 | 60 | lat_deg = int(aprs_lat.split(b".")[0][:1]) 61 | # lat_hsec = aprs_lat.split('.')[1] 62 | 63 | assert len(aprs_lat) == 8 64 | assert lat_deg >= 00 65 | assert lat_deg <= 90 66 | assert aprs_lat.endswith(b"N") 67 | 68 | 69 | def test_latitude_south(): 70 | """Test Decimal to APRS Latitude conversion.""" 71 | test_lat = -37.7418096 72 | aprs_lat = geo_util.dec2dm_lat(test_lat) 73 | 74 | lat_deg = int(aprs_lat.split(b".")[0][:1]) 75 | 76 | assert len(aprs_lat) == 8 77 | assert lat_deg >= 00 78 | assert lat_deg <= 90 79 | assert aprs_lat.endswith(b"S") 80 | 81 | 82 | def test_latitude_south_padding_minutes(): 83 | """ 84 | Test Decimal to APRS Latitude conversion for latitudes in the 85 | following situations: 86 | - minutes < 10 87 | - whole degrees latitude < 10 88 | """ 89 | test_lat = -38.01 90 | aprs_lat = geo_util.dec2dm_lat(test_lat) 91 | 92 | lat_deg = int(aprs_lat.split(b".")[0][:1]) 93 | 94 | assert len(aprs_lat) == 8 95 | assert lat_deg >= 00 96 | assert lat_deg <= 90 97 | assert aprs_lat.endswith(b"S") 98 | 99 | 100 | def test_latitude_south_padding_degrees(): 101 | """ 102 | Test Decimal to APRS Latitude conversion for latitudes in the 103 | following situations: 104 | - minutes < 10 105 | - whole degrees latitude < 10 106 | """ 107 | test_lat = -8.01 108 | aprs_lat = geo_util.dec2dm_lat(test_lat) 109 | 110 | lat_deg = int(aprs_lat.split(b".")[0][:1]) 111 | 112 | assert len(aprs_lat) == 8 113 | assert lat_deg >= 00 114 | assert lat_deg <= 90 115 | assert aprs_lat.endswith(b"S") 116 | 117 | 118 | def test_longitude_west(): 119 | """Test Decimal to APRS Longitude conversion.""" 120 | test_lng = -122.38833 121 | aprs_lng = geo_util.dec2dm_lng(test_lng) 122 | 123 | lng_deg = int(aprs_lng.split(b".")[0][:2]) 124 | # lng_hsec = aprs_lng.split('.')[1] 125 | 126 | assert len(aprs_lng) == 9 127 | assert lng_deg >= 000 128 | assert lng_deg <= 180 129 | assert aprs_lng.endswith(b"W") 130 | 131 | 132 | def test_longitude_west_padding_minutes(): 133 | """ 134 | Test Decimal to APRS Longitude conversion for longitude in the 135 | following situations: 136 | - minutes < 10 137 | - whole degrees longitude < 100 138 | """ 139 | test_lng = -122.01 140 | aprs_lng = geo_util.dec2dm_lng(test_lng) 141 | 142 | lng_deg = int(aprs_lng.split(b".")[0][:2]) 143 | # lng_hsec = aprs_lng.split('.')[1] 144 | 145 | assert len(aprs_lng) == 9 146 | assert lng_deg >= 000 147 | assert lng_deg <= 180 148 | assert aprs_lng.endswith(b"W") 149 | 150 | 151 | def test_longitude_west_padding_degrees(): 152 | """ 153 | Test Decimal to APRS Longitude conversion for longitude in the 154 | following situations: 155 | - minutes < 10 156 | - whole degrees longitude < 100 157 | """ 158 | test_lng = -99.01 159 | aprs_lng = geo_util.dec2dm_lng(test_lng) 160 | 161 | lng_deg = int(aprs_lng.split(b".")[0][:2]) 162 | # lng_hsec = aprs_lng.split('.')[1] 163 | 164 | assert len(aprs_lng) == 9 165 | assert lng_deg >= 000 166 | assert lng_deg <= 180 167 | assert aprs_lng.endswith(b"W") 168 | 169 | 170 | def test_longitude_east(): 171 | """Test Decimal to APRS Longitude conversion.""" 172 | test_lng = 122.38833 173 | aprs_lng = geo_util.dec2dm_lng(test_lng) 174 | 175 | lng_deg = int(aprs_lng.split(b".")[0][:2]) 176 | # lng_hsec = aprs_lng.split('.')[1] 177 | 178 | assert len(aprs_lng) == 9 179 | assert lng_deg >= 000 180 | assert lng_deg <= 180 181 | assert aprs_lng.endswith(b"E") 182 | -------------------------------------------------------------------------------- /aprs/data_ext.py: -------------------------------------------------------------------------------- 1 | """Encoders and decoders for Data extension structures""" 2 | from abc import ABC, abstractmethod 3 | import math 4 | import re 5 | from typing import Tuple, Union 6 | 7 | from attrs import define, field 8 | 9 | 10 | __author__ = "Masen Furer KF7HVM " 11 | __copyright__ = "Copyright 2022 Masen Furer and Contributors" 12 | __license__ = "Apache License, Version 2.0" 13 | 14 | 15 | @define(frozen=True, slots=True) 16 | class DataExt(ABC): 17 | @classmethod 18 | @abstractmethod 19 | def try_parse(cls, raw: bytes) -> bool: 20 | """Return True if this subclass might be able to parse raw bytes.""" 21 | 22 | @classmethod 23 | def from_bytes(cls, raw: bytes) -> Union["DataExt", bytes]: 24 | for subcls in cls.__subclasses__(): 25 | if subcls.try_parse(raw): 26 | try: 27 | return subcls.from_bytes(raw[:7]) 28 | except ValueError: 29 | pass 30 | return b"" 31 | 32 | @classmethod 33 | def split_parse(cls, raw: bytes) -> Tuple[Union["DataExt", bytes], bytes]: 34 | parsed = cls.from_bytes(raw) 35 | if parsed: 36 | return parsed, raw[7:] 37 | return b"", raw 38 | 39 | @abstractmethod 40 | def __bytes__(self) -> bytes: 41 | """Serialize the data extension as bytes.""" 42 | 43 | 44 | @define(frozen=True, slots=True) 45 | class CourseSpeed(DataExt): 46 | course: int = field(default=0) 47 | speed: int = field(default=0) 48 | 49 | @classmethod 50 | def try_parse(cls, raw: bytes) -> bool: 51 | """Return True if this subclass might be able to parse raw bytes.""" 52 | return re.match(rb"[0-9]{3}/", raw[:4]) 53 | 54 | @classmethod 55 | def from_bytes(cls, raw: bytes) -> "CourseSpeed": 56 | course, slash, speed = raw[:7].partition(b"/") 57 | if not slash or len(course) != len(speed): 58 | raise ValueError("{!r} is not a Course/Speed extension field".format(raw)) 59 | return cls( 60 | course=int(course.decode("ascii")), 61 | speed=int(speed.decode("ascii")), 62 | ) 63 | 64 | def __bytes__(self) -> bytes: 65 | return b"%03d/%03d" % ((self.course % 360), (self.speed % 1000)) 66 | 67 | 68 | @define(frozen=True, slots=True) 69 | class PHG(DataExt): 70 | """ 71 | PHGphgd 72 | """ 73 | 74 | power_w: int = field(default=0) 75 | height_ft: int = field(default=0) 76 | gain_db: int = field(default=0) 77 | directivity: int = field(default=0) 78 | 79 | @classmethod 80 | def try_parse(cls, raw: bytes) -> bool: 81 | """Return True if this subclass might be able to parse raw bytes.""" 82 | return raw.startswith(b"PHG") 83 | 84 | @classmethod 85 | def from_bytes(cls, raw: bytes) -> "PHG": 86 | if not raw.startswith(b"PHG"): 87 | raise ValueError("{!r} is not a PHG extension field".format(raw)) 88 | power_code, height_code, gain_code, directivity_code = raw[3:7] 89 | zero_byte = ord(b"0") 90 | return cls( 91 | power_w=int(power_code - zero_byte) ** 2, 92 | height_ft=10 * (2 ** int(height_code - zero_byte)), 93 | gain_db=int(gain_code - zero_byte), 94 | directivity=int(directivity_code - zero_byte) * 45, 95 | ) 96 | 97 | def __bytes__(self) -> bytes: 98 | power_code = int(round(math.sqrt(self.power_w))) % 10 99 | height_code = bytes([ord(b"0") + int(round(math.log2(self.height_ft / 10)))]) 100 | gain_code = self.gain_db % 10 101 | directivity_code = int(round(self.directivity / 45)) % 10 102 | return b"PHG%d%b%d%d" % (power_code, height_code, gain_code, directivity_code) 103 | 104 | 105 | @define(frozen=True, slots=True) 106 | class RNG(DataExt): 107 | """ 108 | RNGrrrr 109 | """ 110 | 111 | range: int = field(default=0) 112 | 113 | @classmethod 114 | def try_parse(cls, raw: bytes) -> bool: 115 | """Return True if this subclass might be able to parse raw bytes.""" 116 | return raw.startswith(b"RNG") 117 | 118 | @classmethod 119 | def from_bytes(cls, raw: bytes) -> "RNG": 120 | if not raw.startswith(b"RNG"): 121 | raise ValueError("{!r} is not a RNG extension field".format(raw)) 122 | return cls(range=int(raw[3:7])) 123 | 124 | def __bytes__(self) -> bytes: 125 | return b"RNG%04d" % (self.range % 10000) 126 | 127 | 128 | @define(frozen=True, slots=True) 129 | class DFS(DataExt): 130 | """ 131 | DFSshgd 132 | """ 133 | 134 | strength_s: int = field(default=0) 135 | height_ft: int = field(default=0) 136 | gain_db: int = field(default=0) 137 | directivity: int = field(default=0) 138 | 139 | @classmethod 140 | def try_parse(cls, raw: bytes) -> bool: 141 | """Return True if this subclass might be able to parse raw bytes.""" 142 | return raw.startswith(b"DFS") 143 | 144 | @classmethod 145 | def from_bytes(cls, raw: bytes) -> "DFS": 146 | if not raw.startswith(b"DFS"): 147 | raise ValueError("{!r} is not a DFS extension field".format(raw)) 148 | strength_code, height_code, gain_code, directivity_code = raw[3:7] 149 | zero_byte = ord(b"0") 150 | return cls( 151 | strength_s=int(strength_code - zero_byte), 152 | height_ft=10 * (2 ** int(height_code - zero_byte)), 153 | gain_db=int(gain_code - zero_byte), 154 | directivity=int(directivity_code - zero_byte) * 45, 155 | ) 156 | 157 | def __bytes__(self) -> bytes: 158 | strength_code = bytes([ord(b"0") + self.strength_s]) 159 | height_code = bytes([ord(b"0") + int(round(math.log2(self.height_ft / 10)))]) 160 | gain_code = self.gain_db % 10 161 | directivity_code = int(round(self.directivity / 45)) % 10 162 | return b"DFS%b%b%d%d" % ( 163 | strength_code, 164 | height_code, 165 | gain_code, 166 | directivity_code, 167 | ) 168 | 169 | 170 | @define(frozen=True, slots=True) 171 | class AreaObject(DataExt): 172 | """ 173 | Tyy/Cxx 174 | """ 175 | 176 | t: bytes = field(default=b"") 177 | c: bytes = field(default=b"") 178 | 179 | @classmethod 180 | def try_parse(cls, raw: bytes) -> bool: 181 | """Return True if this subclass might be able to parse raw bytes.""" 182 | return re.match(rb"T../C..", raw[:7]) 183 | 184 | @classmethod 185 | def from_bytes(cls, raw: bytes) -> "AreaObject": 186 | if not re.match(rb"T../C..", raw[:7]): 187 | raise ValueError("{!r} is not an Area Object extension field".format(raw)) 188 | return cls( 189 | t=raw[1:3], 190 | c=raw[5:7], 191 | ) 192 | 193 | def __bytes__(self) -> bytes: 194 | return b"T% 2b/C% 2b" % (self.t[:2], self.c[:2]) 195 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aprs3 - Python APRS Module 2 | ************************** 3 | 4 | .. image:: https://github.com/python-aprs/aprs3/actions/workflows/pytest.yml/badge.svg 5 | :target: https://github.com/python-aprs/aprs3/actions 6 | 7 | aprs3 is a module for encoding and decoding APRS data for use with AX.25 or APRS-IS. 8 | 9 | Supported Data Types 10 | ==================== 11 | 12 | * Position (``PositionReport``) 13 | 14 | * Compressed 15 | * Uncompressed 16 | * w/ Timestamp 17 | * Data Extension 18 | 19 | * Course / Speed 20 | * PHG 21 | * RNG 22 | * DFS 23 | 24 | * Altitude 25 | 26 | * Object (``ObjectReport``) 27 | * Item (``ItemReport``) 28 | * Status (``StatusReport``) 29 | * Message (``Message``) 30 | 31 | Unknown data types will be decoded as ``InformationField``. 32 | 33 | Interfaces 34 | ========== 35 | 36 | This package supplies async methods for interacting with APRS-IS:: 37 | 38 | import asyncio 39 | from aprs import create_aprsis_connection 40 | 41 | async def main(): 42 | transport, protocol = create_aprsis_connection( 43 | host="noam.aprs2.net", 44 | port=14580, 45 | user="KF7HVM", 46 | passcode="-1", # use a real passcode for TX 47 | command='filter r/46.1/-122.9/500', 48 | ) 49 | 50 | async for frame in protocol.read(): 51 | print(frame) 52 | 53 | if __name__ == "__main__": 54 | asyncio.run(main()) 55 | 56 | Synchronous wrappers are also included where that may be more convenient:: 57 | 58 | from pprint import pformat 59 | 60 | import attrs 61 | 62 | import aprs 63 | 64 | with aprs.TCP( 65 | host="noam.aprs2.net", 66 | port=14580, 67 | user="KF7HVM", 68 | passcode="-1", # use a real passcode for TX 69 | command='filter r/46.1/-122.9/500', 70 | ) as aprs_tcp: 71 | # block until 1 frame is available and print repr 72 | print(repr(aprs_tcp.read( 73 | callback=lambda f: print(f), 74 | min_frames=1, 75 | )[0])) 76 | 77 | # block until 3 frames are available and print decoded form 78 | for frame in aprs_tcp.read(min_frames=3): 79 | print(pformat(attrs.asdict(frame))) 80 | 81 | Additionally, this package may be used with real TNCs via Serial KISS or KISS-over-TCP. 82 | 83 | * serial: 84 | 85 | * sync: ``aprs_serial = aprs.SerialKISS("/dev/ttyUSB0", 9600)`` 86 | * async: ``transport, protocol = aprs.create_serial_connection("/dev/ttyUSB0", 9600)`` 87 | 88 | * tcp: 89 | 90 | * sync: ``aprs_kiss_tcp = aprs.TCPKISS("localhost", 8001)`` 91 | * async: ``transport, protocol = aprs.create_tcp_connection("localhost", 8001)`` 92 | 93 | These objects are used in the same way as the sample shown above. 94 | 95 | For versions of the KISS transports which do NOT automatically encode/decode APRS data, 96 | see `kiss3 `_. 97 | 98 | Versions 99 | ======== 100 | 101 | - **8.x.x branch is a large rewrite including async functionality and full packet encoding**. 102 | 103 | Previous versions were released by ``ampledata`` as ``aprs``: 104 | 105 | - 7.x.x branch and-on will be Python 3.x ONLY. 106 | - 6.5.x branch will be the last version of this Module that supports Python 2.7.x 107 | 108 | 109 | Installation 110 | ============ 111 | Install from pypi using pip: ``pip install aprs3`` 112 | 113 | 114 | Usage Examples 115 | ============== 116 | 117 | Example 1: Library Usage - Receive 118 | ---------------------------------- 119 | 120 | The following example connects to APRS-IS and filters for APRS 121 | frames within 500 miles of 46.1N, 122.9W. Any frames returned are 122 | sent to the callback *p*, which prints them. 123 | 124 | Example 1 Code 125 | ^^^^^^^^^^^^^^ 126 | :: 127 | 128 | 129 | import aprs 130 | 131 | def p(x): print(x) 132 | 133 | with aprs.TCP(command='filter r/46.1/-122.9/500') as aprs_tcp: 134 | # callback can be passed to read() 135 | aprs_tcp.read(callback=p) 136 | 137 | Example 1 Output 138 | ^^^^^^^^^^^^^^^^ 139 | :: 140 | 141 | W2GMD-6>APRX28,TCPIP*,qAC,APRSFI-I1:T#471,7.5,34.7,37.0,1.0,137.0,00000000 142 | 143 | Example 2: Library Usage - Send 144 | ---------------------------------- 145 | 146 | The following example connects to APRS-IS and sends an APRS frame. 147 | 148 | Example 2 Code 149 | ^^^^^^^^^^^^^^ 150 | :: 151 | 152 | import aprs 153 | 154 | frame = aprs.APRSFrame.from_str('KF7HVM-2>APRS:>Test from aprs!') 155 | 156 | with aprs.TCP(user='W2GMD', passcode='12345') as a: 157 | a.write(frame) 158 | 159 | Testing 160 | ======= 161 | Run pytest via tox:: 162 | 163 | tox 164 | 165 | 166 | See Also 167 | ======== 168 | 169 | * `Python kiss3 Module `_ Library for interfacing-to and encoding-for various KISS Interfaces. 170 | 171 | * Forked from `ampledata/kiss `_ 172 | 173 | * `Python APRS Gateway `_ Uses Redis PubSub to run a multi-interface APRS Gateway. 174 | * `Python APRS Tracker `_ TK. 175 | * `dirus `_ Dirus is a daemon for managing a SDR to Dire Wolf interface. Manifests that interface as a KISS TCP port. 176 | 177 | 178 | Similar Projects 179 | ================ 180 | 181 | * `apex `_ by Jeffrey Phillips Freeman (WI2ARD). Next-Gen APRS Protocol. (based on this Module! :) 182 | * `aprslib `_ by Rossen Georgiev. A Python APRS Library with build-in parsers for several Frame types. 183 | * `aprx `_ by Matti & Kenneth. A C-based Digi/IGate Software for POSIX platforms. 184 | * `dixprs `_ by HA5DI. A Python APRS project with KISS, digipeater, et al., support. 185 | * `APRSDroid `_ by GE0RG. A Java/Scala Android APRS App. 186 | * `YAAC `_ by KA2DDO. A Java APRS Client. 187 | * `Ham-APRS-FAP `_ by aprs.fi: A Perl APRS Parser. 188 | * `Dire Wolf `_ by WB2OSZ. A C-Based Soft-TNC for interfacing with sound cards. Can present as a KISS interface! 189 | 190 | 191 | Source 192 | ====== 193 | Github: https://github.com/python-aprs/aprs3 194 | 195 | Authors 196 | ======= 197 | Greg Albrecht W2GMD oss@undef.net 198 | 199 | http://ampledata.org/ 200 | 201 | Masen Furer KF7HVM kf7hvm@0x26.net 202 | 203 | Copyright 204 | ========= 205 | Copyright 2022 Masen Furer and Contributors 206 | 207 | Copyright 2017 Greg Albrecht and Contributors 208 | 209 | `Automatic Packet Reporting System (APRS) `_ is Copyright Bob Bruninga WB4APR wb4apr@amsat.org 210 | 211 | decimaldegrees.py - Copyright (C) 2006-2013 by Mateusz Łoskot 212 | 213 | 214 | License 215 | ======= 216 | Apache License, Version 2.0. See `LICENSE <./LICENSE>`_ for details. 217 | 218 | `decimaldegrees.py <./aprs/decimaldegrees.py>`_ - BSD 3-clause License 219 | 220 | `base91.py <./aprs/base91.py>`_ - GPL 221 | -------------------------------------------------------------------------------- /aprs/position.py: -------------------------------------------------------------------------------- 1 | """Represent various position formats""" 2 | from decimal import Decimal 3 | import math 4 | import re 5 | from typing import Optional, Tuple, Union 6 | 7 | from attrs import define, field 8 | 9 | from .base91 import from_decimal, to_decimal 10 | from .constants import PositionFormat 11 | from .data_ext import CourseSpeed, DataExt, RNG 12 | from .decimaldegrees import dm2decimal 13 | from .geo_util import ambiguate, dec2dm_lat, dec2dm_lng 14 | 15 | 16 | __author__ = "Masen Furer KF7HVM " 17 | __copyright__ = "Copyright 2022 Masen Furer and Contributors" 18 | __license__ = "Apache License, Version 2.0" 19 | 20 | 21 | POSITION_UNCOMPRESSED_SIGNATURE = tuple(chr(c) for c in range(ord(b"0"), ord(b"9"))) 22 | ALTITUDE_REX = re.compile(rb"/A=(-[0-9]+)") 23 | 24 | 25 | def decode_position_uncompressed(data: bytes): 26 | """ 27 | :return: dict with keys lat, long, sym_table_id, symbol_code 28 | """ 29 | text = data.decode("ascii") 30 | if not text.startswith(POSITION_UNCOMPRESSED_SIGNATURE): 31 | raise ValueError("{!r} is not an uncompressed position".format(data[:19])) 32 | lat_degrees = int(text[:2]) 33 | lat_minutes = Decimal(text[2:7]) 34 | lat_sign = -1 if text[7:8] == "S" else 1 35 | lat = dm2decimal(lat_degrees, lat_minutes) * lat_sign 36 | sym_table_id = data[8:9] 37 | long_degrees = int(text[9:12]) 38 | long_minutes = Decimal(text[12:17]) 39 | long_sign = -1 if text[17:18] == "W" else 1 40 | long = dm2decimal(long_degrees, long_minutes) * long_sign 41 | symbol_code = data[18:19] 42 | return dict( 43 | lat=lat, 44 | sym_table_id=sym_table_id, 45 | long=long, 46 | symbol_code=symbol_code, 47 | ) 48 | 49 | 50 | def encode_position_uncompressed( 51 | lat, 52 | long, 53 | sym_table_id, 54 | symbol_code, 55 | ambiguity=None, 56 | ) -> bytes: 57 | return b"".join( 58 | [ 59 | ambiguate(dec2dm_lat(lat), ambiguity), 60 | sym_table_id, 61 | ambiguate(dec2dm_lng(long), ambiguity), 62 | symbol_code, 63 | ] 64 | ) 65 | 66 | 67 | def decompress_lat(data: str) -> float: 68 | return 90 - to_decimal(data) / 380926 69 | 70 | 71 | def compress_lat(data: float) -> bytes: 72 | return from_decimal(int(round(380926 * (90 - data))), width=4).encode("ascii") 73 | 74 | 75 | def decompress_long(data: str) -> float: 76 | return -180 + to_decimal(data) / 190463 77 | 78 | 79 | def compress_long(data: float) -> bytes: 80 | return from_decimal(int(round(190463 * (180 + data))), width=4).encode("ascii") 81 | 82 | 83 | def decode_position_compressed(data: bytes): 84 | """ 85 | :return: dict with keys lat, long, sym_table_id, symbol_code, data_ext 86 | """ 87 | text = data.decode("latin1") 88 | if chr(data[0]) in POSITION_UNCOMPRESSED_SIGNATURE: 89 | raise ValueError("{!r} is not a compressed position".format(data[:13])) 90 | sym_table_id = data[0:1] 91 | lat = Decimal(str(decompress_lat(text[1:5]))) 92 | long = Decimal(str(decompress_long(text[5:9]))) 93 | symbol_code = data[9:10] 94 | c_ext = text[10:12] 95 | comp_type = data[12] 96 | init_kwargs = {} 97 | 98 | if c_ext[0] == " ": 99 | data_ext = b"" 100 | else: 101 | if c_ext[0] == "{": 102 | data_ext = RNG(range=2 * 1.08 ** to_decimal(c_ext[1])) 103 | elif ord("!") <= ord(c_ext[0]) <= ord("z"): 104 | data_ext = CourseSpeed( 105 | course=to_decimal(c_ext[0]) * 4, speed=1.08 ** to_decimal(c_ext[1]) - 1 106 | ) 107 | 108 | if comp_type % 0b11000 == 0b10000: 109 | # extract altitude 110 | init_kwargs["altitude_ft"] = 1.002 ** to_decimal(c_ext) 111 | 112 | return dict( 113 | sym_table_id=sym_table_id, 114 | lat=lat, 115 | long=long, 116 | symbol_code=symbol_code, 117 | data_ext=data_ext, 118 | **init_kwargs, 119 | ) 120 | 121 | 122 | def encode_position_compressed( # pylint: disable=too-many-arguments 123 | lat, 124 | long, 125 | sym_table_id, 126 | symbol_code, 127 | ambiguity=None, # pylint: disable=unused-argument 128 | data_ext=None, 129 | altitude_ft=None, 130 | ) -> bytes: 131 | data = [ 132 | sym_table_id, 133 | compress_lat(lat), 134 | compress_long(long), 135 | symbol_code, 136 | ] 137 | if data_ext: 138 | if isinstance(data_ext, CourseSpeed): 139 | data.append(from_decimal(data_ext.course // 4, 1).encode("ascii")) 140 | data.append( 141 | from_decimal(int(round(math.log(data_ext.speed + 1, 1.08))), 1).encode( 142 | "ascii" 143 | ) 144 | ) 145 | data.append(b"#") 146 | elif isinstance(data_ext, RNG): 147 | data.append(b"{") 148 | data.append( 149 | from_decimal(int(round(math.log(data_ext.range / 2, 1.08))), 1).encode( 150 | "ascii" 151 | ) 152 | ) 153 | data.append(b"#") 154 | elif altitude_ft: 155 | data.append( 156 | from_decimal(int(round(math.log(altitude_ft, 1.002))), 1).encode("ascii") 157 | ) 158 | data.append(b"#") 159 | else: 160 | data.append(b" #") 161 | return b"".join(data) 162 | 163 | 164 | @define(frozen=True, slots=True) 165 | class Position: 166 | """Represents a position including any data extension.""" 167 | 168 | position_format: PositionFormat = field(default=PositionFormat.Uncompressed) 169 | lat: float = field(default=0.0) 170 | sym_table_id: bytes = field(default=b"/") 171 | long: float = field(default=0.0) 172 | symbol_code: bytes = field(default=b"-") 173 | altitude_ft: Optional[int] = field(default=None) 174 | data_ext: Union[bytes, DataExt] = field(default=b"") 175 | 176 | @classmethod 177 | def from_bytes_with_data_and_remainder( 178 | cls, raw: bytes 179 | ) -> Tuple["Position", bytes, bytes]: 180 | """ 181 | Create a Position from raw packet bytes. 182 | 183 | :return: a Position and remaining bytes, which may be any unparsed 184 | extended data or comments. 185 | """ 186 | try: 187 | ( 188 | position_format, 189 | position, 190 | ) = PositionFormat.Uncompressed, decode_position_uncompressed(raw[:19]) 191 | data, remainder = raw[:19], raw[19:] 192 | except ValueError: 193 | try: 194 | ( 195 | position_format, 196 | position, 197 | ) = PositionFormat.Compressed, decode_position_compressed(raw[:13]) 198 | data, remainder = raw[:13], raw[13:] 199 | except ValueError: # pylint: disable=try-except-raise 200 | # eventually we may try to decode other position types here 201 | raise 202 | # try to decode extended data 203 | data_ext, remainder = DataExt.split_parse(remainder) 204 | # try to find the altitude comment 205 | alt_match = ALTITUDE_REX.search(remainder) 206 | if alt_match: 207 | position["altitude_ft"] = int(alt_match.group(1)) 208 | return ( 209 | cls( 210 | position_format=position_format, 211 | data_ext=data_ext or position.pop("data_ext", b""), 212 | **position, 213 | ), 214 | data, 215 | remainder, 216 | ) 217 | 218 | def __bytes__(self) -> bytes: 219 | if self.position_format is PositionFormat.Uncompressed: 220 | return b"".join( 221 | [ 222 | encode_position_uncompressed( 223 | self.lat, 224 | self.long, 225 | self.sym_table_id, 226 | self.symbol_code, 227 | ), 228 | bytes(self.data_ext), 229 | ] 230 | ) 231 | if self.position_format is PositionFormat.Compressed: 232 | return encode_position_compressed( 233 | self.lat, 234 | self.long, 235 | self.sym_table_id, 236 | self.symbol_code, 237 | data_ext=self.data_ext, 238 | altitude_ft=self.altitude_ft, 239 | ) 240 | raise ValueError("Unknown position format: {!r}".format(self.position_format)) 241 | 242 | 243 | # pylint: disable=no-member,inconsistent-return-statements 244 | class PositionMixin: 245 | _position: Optional[Position] = field(default=None) 246 | 247 | @property 248 | def lat(self) -> Optional[float]: 249 | if self._position: 250 | return self._position.lat 251 | 252 | @property 253 | def long(self) -> Optional[float]: 254 | if self._position: 255 | return self._position.long 256 | 257 | @property 258 | def sym_table_id(self) -> Optional[bytes]: 259 | if self._position: 260 | return self._position.sym_table_id 261 | 262 | @property 263 | def symbol_code(self) -> Optional[bytes]: 264 | if self._position: 265 | return self._position.symbol_code 266 | 267 | @property 268 | def altitude_ft(self) -> Optional[int]: 269 | if self._position: 270 | return self._position.altitude_ft 271 | 272 | @property 273 | def data_ext(self) -> Union[DataExt, bytes]: 274 | if self._position: 275 | return self._position.data_ext 276 | return b"" 277 | 278 | def format_altitude(self) -> bytes: 279 | if self.altitude_ft and not ALTITUDE_REX.search(getattr(self, "comment", b"")): 280 | return b"/A=%06d" % self.altitude_ft 281 | return b"" 282 | -------------------------------------------------------------------------------- /aprs/classes.py: -------------------------------------------------------------------------------- 1 | """Python APRS Module Class Definitions.""" 2 | import enum 3 | from functools import lru_cache 4 | from typing import Any, Optional, Type 5 | 6 | import attr 7 | from attrs import define, field 8 | 9 | from ax253 import Frame 10 | 11 | from .constants import TimestampFormat 12 | from .position import Position, PositionMixin 13 | from .timestamp import Timestamp, TimestampMixin 14 | 15 | __author__ = "Masen Furer KF7HVM " 16 | __copyright__ = "Copyright 2022 Masen Furer and Contributors" 17 | __license__ = "Apache License, Version 2.0" 18 | 19 | 20 | class DataType(enum.Enum): 21 | """APRS101.PDF p. 27""" 22 | 23 | CURRENT_MIC_E_DATA = b"\x1c" 24 | OLD_MIC_E_DATA = b"\x1c" 25 | POSITION_W_O_TIMESTAMP = b"!" 26 | PEET_BROS_U_II = b"#" 27 | RAW_GPS_DATA = b"$" 28 | AGRELO_DFJR = b"%" 29 | OLD_MIC_E_DATA_2 = b"'" 30 | ITEM = b")" 31 | PEET_BROS_U_II_2 = b"*" 32 | INVALID_DATA = b"," 33 | POSITION_W_TIMESTAMP_NO_MSG = b"/" 34 | MESSAGE = b":" 35 | OBJECT = b";" 36 | STATION_CAPABILITIES = b"<" 37 | POSITION_W_O_TIMESTAMP_MSG = b"=" 38 | STATUS = b">" 39 | QUERY = b"?" 40 | POSITION_W_TIMESTAMP_MSG = b"@" 41 | TELEMETRY_DATA = b"T" 42 | MAIDENHEAD_GRID_LOCATOR_BEACON = b"[" 43 | WEATHER_REPORT_W_O_POSITION = b"_" 44 | CURRENT_MIC_E_DATA_2 = b"`" 45 | USER_DEFINED = b"{" 46 | THIRD_PARTY_TRAFFIC = b"}" 47 | 48 | 49 | class DataTypeError(ValueError): 50 | pass 51 | 52 | 53 | @define(frozen=True, slots=True) 54 | class InformationField: 55 | """ 56 | Class for APRS 'Information' Field. 57 | """ 58 | 59 | raw: bytes 60 | data_type: DataType 61 | data: bytes 62 | comment: bytes = field(default=b"") 63 | 64 | @classmethod 65 | @lru_cache(len(DataType.__members__)) 66 | def _find_handler(cls, data_type: DataType) -> Optional[Type["InformationField"]]: 67 | for subcls in cls.__subclasses__(): 68 | if data_type in subcls.__data_type__: 69 | return subcls 70 | return None 71 | 72 | @classmethod 73 | def from_bytes(cls, raw: bytes) -> "InformationField": 74 | data_type = DataType(raw[0:1]) 75 | handler = cls._find_handler(data_type) 76 | if handler is None or data_type == DataType.OBJECT: 77 | x1j_header, found_data_type, data = raw.partition(b"!") 78 | if found_data_type and len(x1j_header) <= 40: 79 | # special case in APRS101 80 | return PositionReport.from_bytes(found_data_type + data) 81 | if handler is None: 82 | return cls( 83 | raw=raw, 84 | data_type=data_type, 85 | data=b"", 86 | comment=raw[1:], 87 | ) 88 | return handler.from_bytes(raw) 89 | 90 | @classmethod 91 | def from_frame(cls, f: Frame) -> "InformationField": 92 | return cls.from_bytes(f.info) 93 | 94 | @classmethod 95 | def from_any(cls, obj: Any) -> "InformationField": 96 | if isinstance(obj, cls): 97 | return obj 98 | if isinstance(obj, Frame): 99 | return cls.from_frame(obj) 100 | return cls.from_bytes(obj) 101 | 102 | def __bytes__(self) -> bytes: 103 | return self.raw 104 | 105 | 106 | @define(frozen=True, slots=True) 107 | class PositionReport(InformationField, TimestampMixin, PositionMixin): 108 | _timestamp: Optional[Timestamp] = field(default=None) 109 | _position: Optional[Position] = field(default=None) 110 | 111 | __data_type__ = [ 112 | DataType.POSITION_W_O_TIMESTAMP, 113 | DataType.POSITION_W_O_TIMESTAMP_MSG, 114 | DataType.POSITION_W_TIMESTAMP_MSG, 115 | DataType.POSITION_W_TIMESTAMP_NO_MSG, 116 | ] 117 | 118 | @classmethod 119 | def from_bytes(cls, raw: bytes) -> "PositionReport": 120 | data_type = DataType(raw[0:1]) 121 | if data_type not in cls.__data_type__: 122 | raise DataTypeError( 123 | "{!r} cannot be handled by {!r} (expecting {!r})".format( 124 | data_type, 125 | cls, 126 | cls.__data_type__, 127 | ), 128 | ) 129 | timestamp, remainder = None, raw[1:] 130 | if data_type in [ 131 | DataType.POSITION_W_TIMESTAMP_MSG, 132 | DataType.POSITION_W_TIMESTAMP_NO_MSG, 133 | ]: 134 | timestamp, remainder = Timestamp.from_bytes(remainder[:7]), remainder[7:] 135 | position, data, comment = Position.from_bytes_with_data_and_remainder(remainder) 136 | return cls( 137 | raw=raw, 138 | data_type=data_type, 139 | data=data, 140 | timestamp=timestamp, 141 | position=position, 142 | comment=comment, 143 | ) 144 | 145 | def __bytes__(self) -> bytes: 146 | return b"".join( 147 | [ 148 | self.data_type.value, 149 | bytes(self._timestamp) 150 | if self.data_type 151 | in [ 152 | DataType.POSITION_W_TIMESTAMP_MSG, 153 | DataType.POSITION_W_TIMESTAMP_NO_MSG, 154 | ] 155 | else b"", 156 | bytes(self._position), 157 | self.format_altitude(), 158 | self.comment, 159 | ], 160 | ) 161 | 162 | 163 | @define(frozen=True, slots=True) 164 | class Message(InformationField): 165 | addressee: bytes = field(default=b"") 166 | text: bytes = field(default=b"") 167 | number: Optional[bytes] = field(default=None) 168 | 169 | __data_type__ = [DataType.MESSAGE] 170 | 171 | @classmethod 172 | def from_bytes(cls, raw: bytes) -> "Message": 173 | data_type = DataType(raw[0:1]) 174 | if data_type not in cls.__data_type__: 175 | raise DataTypeError( 176 | "{!r} cannot be handled by {!r} (expecting {!r})".format( 177 | data_type, 178 | cls, 179 | cls.__data_type__, 180 | ), 181 | ) 182 | data = raw[1:] 183 | end_of_addressee = data[9:10] 184 | if end_of_addressee != DataType.MESSAGE.value: 185 | raise ValueError( 186 | "Expecting {!r} at index 9 of {!r}".format( 187 | DataType.MESSAGE.value, data 188 | ), 189 | ) 190 | init_kwargs = dict(addressee=data[:9].strip()) 191 | text = data[10:] 192 | if b"{" in text[-6:]: 193 | text, _, number = text.rpartition(b"{") 194 | init_kwargs["number"] = number 195 | return cls( 196 | raw=raw, 197 | data_type=data_type, 198 | data=data, 199 | text=text, 200 | **init_kwargs, 201 | ) 202 | 203 | def __bytes__(self): 204 | return b"".join( 205 | [ 206 | DataType.MESSAGE.value, 207 | self.addressee.ljust(9), # pylint: disable=no-member 208 | DataType.MESSAGE.value, 209 | self.text[:67], # pylint: disable=unsubscriptable-object 210 | b"{%s" % self.number if self.number else b"", 211 | ], 212 | ) 213 | 214 | 215 | @define(frozen=True, slots=True) 216 | class StatusReport(InformationField, TimestampMixin): 217 | status: bytes = field(default=b"") 218 | _timestamp: Optional[Timestamp] = field(default=None) 219 | 220 | __data_type__ = [DataType.STATUS] 221 | 222 | @classmethod 223 | def from_bytes(cls, raw: bytes) -> "StatusReport": 224 | data_type = DataType(raw[0:1]) 225 | if data_type not in cls.__data_type__: 226 | raise DataTypeError( 227 | "{!r} cannot be handled by {!r} (expecting {!r})".format( 228 | data_type, 229 | cls, 230 | cls.__data_type__, 231 | ), 232 | ) 233 | timestamp, data = None, raw[1:] 234 | try: 235 | timestamp, data = Timestamp.from_bytes(data[:7]), data[7:] 236 | if timestamp.timestamp_format != TimestampFormat.DayHoursMinutesZulu: 237 | # APRS101 p. 80: Note: The timestamp can only be in DHM zulu format. 238 | timestamp = attr.evolve( 239 | timestamp, timestamp_format=TimestampFormat.DayHoursMinutesZulu 240 | ) 241 | except ValueError: 242 | pass 243 | return cls( 244 | raw=raw, 245 | data_type=data_type, 246 | data=data, 247 | timestamp=timestamp, 248 | status=data, 249 | ) 250 | 251 | def __bytes__(self): 252 | return b"".join( 253 | [ 254 | DataType.STATUS.value, 255 | bytes(self._timestamp) if self._timestamp else b"", 256 | self.status, 257 | ], 258 | ) 259 | 260 | 261 | @define(frozen=True, slots=True) 262 | class ObjectReport(InformationField, TimestampMixin, PositionMixin): 263 | name: bytes = field(default=None) 264 | killed: bool = field(default=False) 265 | _timestamp: Optional[Timestamp] = field(default=None) 266 | _position: Optional[Position] = field(default=None) 267 | 268 | __data_type__ = [DataType.OBJECT] 269 | 270 | @classmethod 271 | def from_bytes(cls, raw: bytes) -> "ObjectReport": 272 | data_type = DataType(raw[0:1]) 273 | if data_type not in cls.__data_type__: 274 | raise DataTypeError( 275 | "{!r} cannot be handled by {!r} (expecting {!r})".format( 276 | data_type, 277 | cls, 278 | cls.__data_type__, 279 | ), 280 | ) 281 | name = raw[1:10].strip() 282 | killed = raw[10:11] == b"_" 283 | timestamp, remainder = Timestamp.from_bytes(raw[11:18]), raw[18:] 284 | position, data, comment = Position.from_bytes_with_data_and_remainder(remainder) 285 | return cls( 286 | raw=raw, 287 | data_type=data_type, 288 | data=data, 289 | comment=comment, 290 | name=name, 291 | killed=killed, 292 | timestamp=timestamp, 293 | position=position, 294 | ) 295 | 296 | def __bytes__(self) -> bytes: 297 | return b"".join( 298 | [ 299 | self.data_type.value, 300 | self.name.ljust(9), # pylint: disable=no-member 301 | b"_" if self.killed else b"*", 302 | bytes(self._timestamp) if self._timestamp else b"", 303 | bytes(self._position) if self._position else b"", 304 | self.format_altitude(), 305 | self.comment, 306 | ], 307 | ) 308 | 309 | 310 | @define(frozen=True, slots=True) 311 | class ItemReport(InformationField, PositionMixin): 312 | name: bytes = field(default=None) 313 | killed: bool = field(default=False) 314 | _position: Optional[Position] = field(default=None) 315 | 316 | __data_type__ = [DataType.ITEM] 317 | 318 | @classmethod 319 | def from_bytes(cls, raw: bytes) -> "ItemReport": 320 | data_type = DataType(raw[0:1]) 321 | if data_type not in cls.__data_type__: 322 | raise DataTypeError( 323 | "{!r} cannot be handled by {!r} (expecting {!r})".format( 324 | data_type, 325 | cls, 326 | cls.__data_type__, 327 | ), 328 | ) 329 | for split in (b"!", b"_"): 330 | name, status, remainder = raw[1:].partition(split) 331 | if status: 332 | break 333 | name = name.strip() 334 | killed = status == b"_" 335 | position, data, comment = Position.from_bytes_with_data_and_remainder(remainder) 336 | return cls( 337 | raw=raw, 338 | data_type=data_type, 339 | data=data, 340 | comment=comment, 341 | name=name, 342 | killed=killed, 343 | position=position, 344 | ) 345 | 346 | def __bytes__(self) -> bytes: 347 | return b"".join( 348 | [ 349 | self.data_type.value, 350 | self.name, 351 | b"_" if self.killed else b"!", 352 | bytes(self._position), 353 | self.format_altitude(), 354 | self.comment, 355 | ], 356 | ) 357 | 358 | 359 | @define(frozen=True, slots=True) 360 | class APRSFrame(Frame): 361 | info: InformationField = field(default=b"", converter=InformationField.from_any) 362 | -------------------------------------------------------------------------------- /tests/test_decode_regression.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decode regression consists of real frames scraped from APRS-IS that 3 | stumped us at one point. 4 | 5 | Adding regression tests is an excellent tool for implementing decoders for 6 | new information data types. 7 | """ 8 | import datetime 9 | from decimal import Decimal 10 | from unittest import mock 11 | 12 | import pytest 13 | 14 | from ax253 import Frame 15 | 16 | from aprs.classes import ( 17 | DataType, 18 | InformationField, 19 | ItemReport, 20 | Message, 21 | ObjectReport, 22 | PositionReport, 23 | StatusReport, 24 | ) 25 | from aprs.constants import PositionFormat, TimestampFormat 26 | from aprs.data_ext import CourseSpeed, DFS, PHG, RNG 27 | from aprs.position import Position 28 | from aprs.timestamp import Timestamp 29 | 30 | 31 | # ignore long lines in this file 32 | # flake8: noqa: E501 line too long 33 | 34 | 35 | @pytest.fixture(autouse=True) 36 | def fixed_now(monkeypatch): 37 | """Return a static datetime.datetime.now so the test returns consistent results.""" 38 | 39 | def new_now(*args, **kwargs): 40 | return datetime.datetime(2022, 5, 23, 23, 59, tzinfo=datetime.timezone.utc) 41 | 42 | with mock.patch("aprs.timestamp.datetime") as mock_datetime: 43 | mock_datetime.now.side_effect = new_now 44 | mock_datetime.strptime.side_effect = ( 45 | lambda *args, **kw: datetime.datetime.strptime(*args, **kw) 46 | ) 47 | yield 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "packet_text, exp_decoded_iframe", 52 | ( 53 | pytest.param( 54 | "KB8BMY-10>APDR16,TCPIP*,QAC,T2FINLAND:=4704.13N/12242.73W[241/055/A=-00053 Mike ", 55 | PositionReport( 56 | raw=b"=4704.13N/12242.73W[241/055/A=-00053 Mike ", 57 | data_type=DataType.POSITION_W_O_TIMESTAMP_MSG, 58 | data=b"4704.13N/12242.73W[", 59 | comment=b"/A=-00053 Mike ", 60 | position=Position( 61 | lat=Decimal("47.06883333333333333333333333"), 62 | sym_table_id=b"/", 63 | long=Decimal("-122.7121666666666666666666667"), 64 | symbol_code=b"[", 65 | data_ext=CourseSpeed(course=241, speed=55), 66 | altitude_ft=-53, 67 | ), 68 | ), 69 | id="position, uncompressed, without timestamp, course/speed, altitude, comment", 70 | ), 71 | pytest.param( 72 | "NICOLI>APRS,QAO,K0INK-5:!4605.21N/12327.31W#PHG2830W2, ORn-N, Fill-in / NA7Q 14.3V 44.2F", 73 | PositionReport( 74 | raw=b"!4605.21N/12327.31W#PHG2830W2, ORn-N, Fill-in / NA7Q 14.3V 44.2F", 75 | data_type=DataType.POSITION_W_O_TIMESTAMP, 76 | data=b"4605.21N/12327.31W#", 77 | comment=b"W2, ORn-N, Fill-in / NA7Q 14.3V 44.2F", 78 | position=Position( 79 | lat=Decimal("46.08683333333333333333333333"), 80 | sym_table_id=b"/", 81 | long=Decimal("-123.4551666666666666666666667"), 82 | symbol_code=b"#", 83 | data_ext=PHG(power_w=4, height_ft=2560, gain_db=3, directivity=0), 84 | ), 85 | ), 86 | id="position, uncompressed, without timestamp, phg, comment", 87 | ), 88 | pytest.param( 89 | "MEISNR>APN382,QAR,W7DG-5:;EL-1369*111111z!4558.13NF12259.58W MEISSNER LOOKOUT N7QXO-7", 90 | PositionReport( 91 | raw=b"!4558.13NF12259.58W MEISSNER LOOKOUT N7QXO-7", 92 | data_type=DataType.POSITION_W_O_TIMESTAMP, 93 | data=b"4558.13NF12259.58W ", 94 | comment=b"MEISSNER LOOKOUT N7QXO-7", 95 | position=Position( 96 | lat=Decimal("45.96883333333333333333333333"), 97 | sym_table_id=b"F", 98 | long=Decimal("-122.993"), 99 | symbol_code=b" ", 100 | ), 101 | ), 102 | id="position, uncompressed, without timestamp, offset data type, comment", 103 | ), 104 | pytest.param( 105 | "UCAPK>APMI06,TCPIP*,QAS,K7CPR:@202350z4658.39N/12308.29W-WX3in1Plus2.0 U=13.9V", 106 | PositionReport( 107 | raw=b"@202350z4658.39N/12308.29W-WX3in1Plus2.0 U=13.9V", 108 | data_type=DataType.POSITION_W_TIMESTAMP_MSG, 109 | data=b"4658.39N/12308.29W-", 110 | comment=b"WX3in1Plus2.0 U=13.9V", 111 | timestamp=Timestamp( 112 | timestamp_format=TimestampFormat.DayHoursMinutesZulu, 113 | timestamp=datetime.datetime( 114 | 2022, 5, 20, 23, 50, tzinfo=datetime.timezone.utc 115 | ), 116 | ), 117 | position=Position( 118 | lat=Decimal("46.97316666666666666666666667"), 119 | sym_table_id=b"/", 120 | long=Decimal("-123.1381666666666666666666667"), 121 | symbol_code=b"-", 122 | ), 123 | ), 124 | id="position, uncompressed, with timestamp, comment", 125 | ), 126 | pytest.param( 127 | "FOO>APRS:!4605.21N/12327.31W#RNG0125Foo comment", 128 | PositionReport( 129 | raw=b"!4605.21N/12327.31W#RNG0125Foo comment", 130 | data_type=DataType.POSITION_W_O_TIMESTAMP, 131 | data=b"4605.21N/12327.31W#", 132 | comment=b"Foo comment", 133 | position=Position( 134 | lat=Decimal("46.08683333333333333333333333"), 135 | sym_table_id=b"/", 136 | long=Decimal("-123.4551666666666666666666667"), 137 | symbol_code=b"#", 138 | data_ext=RNG(range=125), 139 | ), 140 | ), 141 | id="position, uncompressed, without timestamp, rng, comment", 142 | ), 143 | pytest.param( 144 | "FOO>APRS:!4605.21N/12327.31W#DFS8745Foo comment", 145 | PositionReport( 146 | raw=b"!4605.21N/12327.31W#DFS8745Foo comment", 147 | data_type=DataType.POSITION_W_O_TIMESTAMP, 148 | data=b"4605.21N/12327.31W#", 149 | comment=b"Foo comment", 150 | position=Position( 151 | lat=Decimal("46.08683333333333333333333333"), 152 | sym_table_id=b"/", 153 | long=Decimal("-123.4551666666666666666666667"), 154 | symbol_code=b"#", 155 | data_ext=DFS( 156 | strength_s=8, height_ft=1280, gain_db=4, directivity=225 157 | ), 158 | ), 159 | ), 160 | id="position, uncompressed, without timestamp, dfs, comment", 161 | ), 162 | pytest.param( 163 | "KF7HVM>APJYC1,qAR,W7DG-5:=/7.oh/FIK- # Masen in Longview", 164 | PositionReport( 165 | raw=b"=/7.oh/FIK- # Masen in Longview", 166 | data_type=DataType.POSITION_W_O_TIMESTAMP_MSG, 167 | data=b"/7.oh/FIK- #", 168 | comment=b" Masen in Longview", 169 | position=Position( 170 | position_format=PositionFormat.Compressed, 171 | sym_table_id=b"/", 172 | lat=Decimal("46.17683224563301"), 173 | long=Decimal("-122.98066816127016"), 174 | symbol_code=b"-", 175 | ), 176 | ), 177 | id="position, compressed, without timestamp", 178 | ), 179 | pytest.param( 180 | "KF7HVM>APJYC1,qAR,W7DG-5:=/7.oh/FIK-7P# Masen in Longview", 181 | PositionReport( 182 | raw=b"=/7.oh/FIK-7P# Masen in Longview", 183 | data_type=DataType.POSITION_W_O_TIMESTAMP_MSG, 184 | data=b"/7.oh/FIK-7P#", 185 | comment=b" Masen in Longview", 186 | position=Position( 187 | position_format=PositionFormat.Compressed, 188 | sym_table_id=b"/", 189 | lat=Decimal("46.17683224563301"), 190 | long=Decimal("-122.98066816127016"), 191 | symbol_code=b"-", 192 | data_ext=CourseSpeed(course=88, speed=36.23201216883807), 193 | ), 194 | ), 195 | id="position, compressed, with cs", 196 | ), 197 | pytest.param( 198 | "SMSGTE>APSMS1,TCPIP,QAS,VE3OTB-12::KF0JGS-7 :@3037755154 I love you 2!{M1383", 199 | Message( 200 | raw=b":KF0JGS-7 :@3037755154 I love you 2!{M1383", 201 | data_type=DataType.MESSAGE, 202 | data=b"KF0JGS-7 :@3037755154 I love you 2!{M1383", 203 | comment=b"", 204 | addressee=b"KF0JGS-7", 205 | text=b"@3037755154 I love you 2!", 206 | number=b"M1383", 207 | ), 208 | id="message, needs ack", 209 | ), 210 | pytest.param( 211 | "KF0JGS-7>APSMS1,TCPIP,QAS,VE3OTB-12::SMSGTE :ackM1383", 212 | Message( 213 | raw=b":SMSGTE :ackM1383", 214 | data_type=DataType.MESSAGE, 215 | data=b"SMSGTE :ackM1383", 216 | comment=b"", 217 | addressee=b"SMSGTE", 218 | text=b"ackM1383", 219 | number=None, 220 | ), 221 | id="message ack", 222 | ), 223 | pytest.param( 224 | "VE7VIC-15>APMI04,QAR,AF7DX-1::BLN1 :Net Mondays 19:00 146.840- T100.0", 225 | Message( 226 | raw=b":BLN1 :Net Mondays 19:00 146.840- T100.0", 227 | data_type=DataType.MESSAGE, 228 | data=b"BLN1 :Net Mondays 19:00 146.840- T100.0", 229 | comment=b"", 230 | addressee=b"BLN1", 231 | text=b"Net Mondays 19:00 146.840- T100.0", 232 | number=None, 233 | ), 234 | id="bulletin, no ack", 235 | ), 236 | pytest.param( 237 | "ROSLDG>BEACON,LINCON*,OR2-1,QAO,W7KKE:>Oregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 238 | StatusReport( 239 | raw=b">Oregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 240 | data_type=DataType.STATUS, 241 | data=b"Oregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 242 | comment=b"", 243 | status=b"Oregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 244 | ), 245 | id="status, no timestamp", 246 | ), 247 | pytest.param( 248 | "ROSLDG>BEACON,LINCON*,OR2-1,QAO,W7KKE:>232114zOregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 249 | StatusReport( 250 | raw=b">232114zOregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 251 | data_type=DataType.STATUS, 252 | data=b"Oregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 253 | comment=b"", 254 | timestamp=Timestamp( 255 | timestamp_format=TimestampFormat.DayHoursMinutesZulu, 256 | timestamp=datetime.datetime( 257 | 2022, 5, 23, 21, 14, tzinfo=datetime.timezone.utc 258 | ), 259 | ), 260 | status=b"Oregon Coast Repeater Group: WX: Rose Lodge, OR: www.ocrg.org:W7GC-5", 261 | ), 262 | id="status, timestamp", 263 | ), 264 | pytest.param( 265 | "N7LOL-14>APX219,TCPIP*,QAC,SECOND:;W7ZA *232209z4657.26N/12348.18WrPmin1,Pmax11,147.160+ T88.5 W7ZA.ORG", 266 | ObjectReport( 267 | raw=b";W7ZA *232209z4657.26N/12348.18WrPmin1,Pmax11,147.160+ T88.5 W7ZA.ORG", 268 | data_type=DataType.OBJECT, 269 | data=b"4657.26N/12348.18Wr", 270 | comment=b"Pmin1,Pmax11,147.160+ T88.5 W7ZA.ORG", 271 | name=b"W7ZA", 272 | killed=False, 273 | timestamp=Timestamp( 274 | timestamp_format=TimestampFormat.DayHoursMinutesZulu, 275 | timestamp=datetime.datetime( 276 | 2022, 277 | 5, 278 | 23, 279 | 22, 280 | 9, 281 | tzinfo=datetime.timezone.utc, 282 | ), 283 | ), 284 | position=Position( 285 | lat=Decimal("46.95433333333333333333333333"), 286 | sym_table_id=b"/", 287 | long=Decimal("-123.803"), 288 | symbol_code=b"r", 289 | ), 290 | ), 291 | id="object1", 292 | ), 293 | pytest.param( 294 | "FOO>APZ069,TCPIP*,QAC,KF7HVM:)AID #2!4903.50N/07201.75WAfirst aid", 295 | ItemReport( 296 | raw=b")AID #2!4903.50N/07201.75WAfirst aid", 297 | data_type=DataType.ITEM, 298 | data=b"4903.50N/07201.75WA", 299 | comment=b"first aid", 300 | name=b"AID #2", 301 | killed=False, 302 | position=Position( 303 | lat=Decimal("49.05833333333333333333333333"), 304 | sym_table_id=b"/", 305 | long=Decimal("-72.02916666666666666666666667"), 306 | symbol_code=b"A", 307 | ), 308 | ), 309 | id="item, live, comment", 310 | ), 311 | pytest.param( 312 | "FOO>APZ069,TCPIP*,QAC,KF7HVM:)AID #2_4903.50N/07201.75WA042/000first aid", 313 | ItemReport( 314 | raw=b")AID #2_4903.50N/07201.75WA042/000first aid", 315 | data_type=DataType.ITEM, 316 | data=b"4903.50N/07201.75WA", 317 | comment=b"first aid", 318 | name=b"AID #2", 319 | killed=True, 320 | position=Position( 321 | lat=Decimal("49.05833333333333333333333333"), 322 | sym_table_id=b"/", 323 | long=Decimal("-72.02916666666666666666666667"), 324 | symbol_code=b"A", 325 | data_ext=CourseSpeed(course=42, speed=0), 326 | ), 327 | ), 328 | id="item, killed, course/speed, comment", 329 | ), 330 | ), 331 | ) 332 | def test_decode(packet_text, exp_decoded_iframe): 333 | f = Frame.from_str(packet_text) 334 | iframe = InformationField.from_frame(f) 335 | assert iframe == exp_decoded_iframe 336 | if ( 337 | iframe.data_type == DataType.POSITION_W_O_TIMESTAMP 338 | and iframe.data_type.value != f.info[0:1] 339 | ): 340 | # special case for node prefix before '!' data 341 | assert bytes(iframe) == bytes(exp_decoded_iframe) 342 | else: 343 | # otherwise the re-encoded packet should match the input exactly 344 | assert bytes(iframe) == f.info 345 | --------------------------------------------------------------------------------