From 5b1b4ff54c317374a5bab90f40737a5637a6b932 Mon Sep 17 00:00:00 2001 From: Greg Albrecht Date: Fri, 27 Jan 2023 23:41:49 -0800 Subject: [PATCH] major updates --- CHANGELOG.md | 10 ++ LICENSE | 2 +- Makefile | 8 +- README.rst | 76 ++++++----- docs/{inrcot-conops.png => inrcot-conop.png} | Bin inrcot/__init__.py | 19 ++- inrcot/classes.py | 109 +++++++-------- inrcot/commands.py | 56 +++++--- inrcot/constants.py | 12 +- inrcot/functions.py | 89 ++++++++---- setup.py | 50 ++++--- tests/data/bad-data.kml | 135 +++++++++++++++++++ tests/data/bad-data2.kml | 135 +++++++++++++++++++ tests/data/bad.kml | 135 +++++++++++++++++++ tests/data/test-config.ini | 32 +++++ tests/{ => data}/test.kml | 0 tests/test_functions.py | 127 +++++++++++++++-- 17 files changed, 804 insertions(+), 191 deletions(-) create mode 100644 CHANGELOG.md rename docs/{inrcot-conops.png => inrcot-conop.png} (100%) create mode 100644 tests/data/bad-data.kml create mode 100644 tests/data/bad-data2.kml create mode 100644 tests/data/bad.kml create mode 100644 tests/data/test-config.ini rename tests/{ => data}/test.kml (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3b01c27 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +INRCOT 5.2.0 +------------ +Major updates. +- Updated copyrights, etc. +- Documentation & Readme updates. +- Now supports Data Package server configuration. +- Fixed custom user icon support. +- Added unit tests. +- Now requires PyTAK >= 5.6.1. +- Refactoring, style improvements, linting, black. diff --git a/LICENSE b/LICENSE index 24b9c09..382902d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2022 Greg Albrecht +Copyright 2023 Greg Albrecht Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index 09596e3..f855718 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # -# Copyright 2022 Greg Albrecht +# Copyright 2023 Greg Albrecht # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,9 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Author:: Greg Albrecht W2GMD -# Copyright:: Copyright 2022 Greg Albrecht -# License:: Apache License, Version 2.0 +# Author:: Greg Albrecht # this_app = inrcot @@ -72,7 +70,7 @@ pytest: test: editable install_test_requirements pytest test_cov: - pytest --cov=$(this_app) + pytest --cov=$(this_app) --cov-report term-missing black: black . diff --git a/README.rst b/README.rst index cdda292..a21ffff 100644 --- a/README.rst +++ b/README.rst @@ -8,29 +8,25 @@ Garmin inReach to Cursor on Target Gateway * Pictured: Screenshot of INRCOT being used on a Search & Rescue mission in Arizona. The inReach to Cursor on Target Gateway (INRCOT) transforms Garmin inReach -position messages into Cursor on Target (CoT) Points for display on TAK Products -like ATAK, WinTAK, iTAK, et al. Single or multi-device feeds are supported. +position messages into Cursor on Target (CoT) for display on TAK Products such as +ATAK, WinTAK, iTAK, et al. Single or multi-device feeds are supported. -Other situational awareness products, including as RaptorX, TAKX & COPERS have been tested. +Other situational awareness products, including as RaptorX, TAKX & COPERS have been +tested. -INRCOT requires a `Garmin inReach `_ device with service. +INRCOT requires a `Garmin inReach `_ +device with service. -.. image:: https://raw.githubusercontent.com/ampledata/inrcot/main/docs/inrcot-conops.png - :alt: Diagram of INRCOT's Concept of Operations (CONOPS). - :target: https://raw.githubusercontent.com/ampledata/inrcot/main/docs/inrcot-conops.png +.. image:: https://raw.githubusercontent.com/ampledata/inrcot/main/docs/inrcot-conop.png + :alt: Diagram of INRCOT's Concept of Operations (CONOP). + :target: https://raw.githubusercontent.com/ampledata/inrcot/main/docs/inrcot-conop.png + +* Pictured: Diagram of INRCOT's Concept of Operations (CONOP). -* Pictured: Diagram of INRCOT's Concept of Operations (CONOPS). Support Development =================== -**Tech Support**: Email support@undef.net or Signal/WhatsApp: +1-310-621-9598 - -This tool has been developed for the Disaster Response, Public Safety and -Frontline Healthcare community. This software is currently provided at no-cost -to users. Any contribution you can make to further this project's development -efforts is greatly appreciated. - .. image:: https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png :target: https://www.buymeacoffee.com/ampledata :alt: Support Development: Buy me a coffee! @@ -42,14 +38,14 @@ Use Cases There are numerous applications for satellite based position location information, including: -1. Blue Force Tracking -2. Search & Rescue (SAR) -3. Partner Forces PLI -4. Asset Tracking -5. Data diode, CDS & cybersecurity considerations +1. Wildland fire unit tracking +2. Blue Force Tracking +3. Search & Rescue (SAR) +4. Partner Forces PLI +5. Asset Tracking +6. Data diode, CDS & cybersecurity considerations -INRCOT may also be of use in wildland firefighting, see Section 1114.d of -the `Dingell Act `_:: +See also Section 1114.d of the `Dingell Act `_:: Location Systems for Wildland Firefighters.-- (1) In general.--Not later than 2 years after the date of @@ -108,24 +104,38 @@ Usage The ``inrcot`` program has two command-line arguments:: $ inrcot -h - usage: inrcot [-h] [-c CONFIG_FILE] + usage: inrcot [-h] [-c CONFIG_FILE] [-p PREF_PACKAGE] optional arguments: - -h, --help show this help message and exit - -c CONFIG_FILE, --CONFIG_FILE Sets the path to a config file. Default: config.ini + -h, --help show this help message and exit + -c CONFIG_FILE, --CONFIG_FILE CONFIG_FILE + Optional configuration file. Default: config.ini + -p PREF_PACKAGE, --PREF_PACKAGE PREF_PACKAGE + Optional connection preferences package zip file (aka data package). + Configuration ============= Configuration parameters can be specified either via environment variables or in -a INI-stile configuration file. You must create a configuration file, see -`example-config.ini` in the source respository. +a INI-stile configuration file. An example configuration file, click here for an +example configuration file `example-config.ini `_. + +Global Config Parameters: + +* **POLL_INTERVAL**: How many seconds between checking for new messages at the Spot API? Default: ``120`` (seconds). +* **COT_STALE**: How many seconds until CoT is stale? Default: ``600`` (seconds) +* **COT_TYPE**: CoT Type. Default: ``a-f-g-e-s`` -Parameters: +For each feed (1 inReach = 1 feed, multiple feeds supported), these config params can be set: -* **DEFAULT_POLL_INTERVAL**: How many seconds between checking for new messages at the Spot API? Default: 120 (seconds). -* **DEFAULT_COT_STALE**: How many seconds until CoT is stale? Default: 600 (seconds) -* **DEFAULT_COT_TYPE**: CoT Event Type / 2525 type / SIDC-like. Default: neutral ground +* **FEED_URL**: URL to the MapShare KML. +* **COT_STALE**: How many seconds until CoT is stale? Default: ``600`` (seconds) +* **COT_TYPE**: CoT Type. Default: ``a-f-g-e-s`` +* **COT_NAME**: CoT Callsign. Defaults to the MapShare KML Placemark name. +* **COT_ICON**: CoT User Icon. If set, will set the CoT ``usericon`` element, for use with custom TAK icon sets. +* **FEED_USERNAME**: MapShare username, for use with protected MapShare. +* **FEED_PASSWORD**: MapShare password, for use with protected MapShare. TLS & other configuration parameters available via `PyTAK `_. @@ -194,12 +204,12 @@ https://ampledata.org/ Copyright ========= -INRCOT is Copyright 2022 Greg Albrecht +INRCOT is Copyright 2023 Greg Albrecht License ======= -Copyright 2022 Greg Albrecht +Copyright 2023 Greg Albrecht Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/docs/inrcot-conops.png b/docs/inrcot-conop.png similarity index 100% rename from docs/inrcot-conops.png rename to docs/inrcot-conop.png diff --git a/inrcot/__init__.py b/inrcot/__init__.py index b913c1f..e4c8e62 100644 --- a/inrcot/__init__.py +++ b/inrcot/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Copyright 2022 Greg Albrecht +# Copyright 2023 Greg Albrecht # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,25 +13,22 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Author:: Greg Albrecht W2GMD -# -""" -inReach to Cursor-on-Target Gateway. -~~~~ -:author: Greg Albrecht W2GMD -:copyright: Copyright 2022 Greg Albrecht +"""inReach to Cursor on Target Gateway. + +:author: Greg Albrecht +:copyright: Copyright 2023 Greg Albrecht :license: Apache License, Version 2.0 :source: """ from .constants import DEFAULT_POLL_INTERVAL, DEFAULT_COT_STALE, DEFAULT_COT_TYPE -from .functions import create_tasks, inreach_to_cot, split_feed +from .functions import create_tasks, inreach_to_cot, split_feed, create_feeds from .classes import Worker -__author__ = "Greg Albrecht W2GMD " -__copyright__ = "Copyright 2022 Greg Albrecht" +__author__ = "Greg Albrecht " +__copyright__ = "Copyright 2023 Greg Albrecht" __license__ = "Apache License, Version 2.0" diff --git a/inrcot/classes.py b/inrcot/classes.py index 66298ba..6b71096 100644 --- a/inrcot/classes.py +++ b/inrcot/classes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Copyright 2022 Greg Albrecht +# Copyright 2023 Greg Albrecht # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,94 +13,85 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Author:: Greg Albrecht W2GMD -# + """INRCOT Class Definitions.""" import asyncio +from typing import Optional + import aiohttp import pytak import inrcot -__author__ = "Greg Albrecht W2GMD " -__copyright__ = "Copyright 2022 Greg Albrecht" +__author__ = "Greg Albrecht " +__copyright__ = "Copyright 2023 Greg Albrecht" __license__ = "Apache License, Version 2.0" class Worker(pytak.QueueWorker): + """Read inReach Feed, renders to CoT, and puts on a TX queue.""" - """Reads inReach Feed, renders to CoT, and puts on a TX queue.""" - - def __init__(self, queue: asyncio.Queue, config, original_config) -> None: + def __init__(self, queue: asyncio.Queue, config, orig_config) -> None: super().__init__(queue, config) - self.inreach_feeds: list = [] - self._create_feeds(original_config) - - def _create_feeds(self, config: dict = None) -> None: - """Creates a list of feed configurations.""" - for feed in config.sections(): - if "inrcot_feed_" in feed: - feed_conf = { - "feed_url": config[feed].get("FEED_URL"), - "cot_stale": config[feed].get( - "COT_STALE", inrcot.DEFAULT_COT_STALE - ), - "cot_type": config[feed].get("COT_TYPE", inrcot.DEFAULT_COT_TYPE), - "cot_icon": config[feed].get("COT_ICON"), - "cot_name": config[feed].get("COT_NAME"), - } - - # Support "private" MapShare feeds: - if config[feed].get("FEED_PASSWORD") and config[feed].get( - "FEED_USERNAME" - ): - feed_conf["feed_auth"] = aiohttp.BasicAuth( - config[feed].get("FEED_USERNAME"), - config[feed].get("FEED_PASSWORD"), - ) - - self.inreach_feeds.append(feed_conf) - - async def handle_data(self, data: str, feed_conf: dict) -> None: - """Handles the response from the inReach API.""" - for feed in inrcot.split_feed(data): - event: str = inrcot.inreach_to_cot(feed, feed_conf) - if event: - await self.put_queue(event) - else: - self._logger.debug("Empty COT Event") - - async def get_inreach_feeds(self): - """Gets inReach Feed from API.""" + self.inreach_feeds: list = inrcot.create_feeds(orig_config) + + async def handle_data(self, data: bytes, feed_conf: dict) -> None: + """Handle the response from the inReach API.""" + feeds: Optional[list] = inrcot.split_feed(data) + if not feeds: + return None + for feed in feeds: + event: Optional[bytes] = inrcot.inreach_to_cot(feed, feed_conf) + if not event: + self._logger.debug("Empty CoT Event") + continue + await self.put_queue(event) + + async def get_inreach_feeds(self) -> None: + """Get inReach Feed from API.""" for feed_conf in self.inreach_feeds: feed_auth = feed_conf.get("feed_auth") + if not feed_auth: + self._logger.warning("No feed_auth specified.") + continue + + feed_url = feed_conf.get("feed_url") + if not feed_url: + self._logger.warning("No feed_url specified.") + continue + async with aiohttp.ClientSession() as session: try: response = await session.request( - method="GET", auth=feed_auth, url=feed_conf.get("feed_url") + method="GET", auth=feed_auth, url=feed_url ) except Exception as exc: # NOQA pylint: disable=broad-except - self._logger.error("Exception raised while polling inReach API.") + self._logger.warning("Exception raised while polling inReach API.") self._logger.exception(exc) - return + continue + + status: int = response.status + if status != 200: + self._logger.warning( + "No valid response from inReach API: status=%s", status + ) + self._logger.debug(response) + continue - if response.status == 200: - await self.handle_data(await response.content.read(), feed_conf) - else: - self._logger.error("No valid response from inReach API.") + await self.handle_data(await response.content.read(), feed_conf) - async def run(self) -> None: - """Runs this Worker, Reads from Pollers.""" + async def run(self, number_of_iterations=-1) -> None: + """Run this Worker, Reads from Pollers.""" self._logger.info("Run: %s", self.__class__) - poll_interval: str = self.config.get( - "POLL_INTERVAL", inrcot.DEFAULT_POLL_INTERVAL + poll_interval: int = int( + self.config.get("POLL_INTERVAL", inrcot.DEFAULT_POLL_INTERVAL) ) while 1: await self.get_inreach_feeds() - await asyncio.sleep(int(poll_interval)) + await asyncio.sleep(poll_interval) diff --git a/inrcot/commands.py b/inrcot/commands.py index ce0e489..95bcaad 100644 --- a/inrcot/commands.py +++ b/inrcot/commands.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Copyright 2022 Greg Albrecht +# Copyright 2023 Greg Albrecht # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,8 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Author:: Greg Albrecht W2GMD -# """PyTAK Command Line.""" @@ -24,23 +22,25 @@ import logging import os import platform +import pprint import sys +import warnings from configparser import ConfigParser, SectionProxy import pytak -__author__ = "Greg Albrecht W2GMD " -__copyright__ = "Copyright 2022 Greg Albrecht" -__license__ = "Apache License, Version 2.0" - - # Python 3.6 support: if sys.version_info[:2] >= (3, 7): from asyncio import get_running_loop else: + warnings.warn("Using Python < 3.7, consider upgrading Python.") from asyncio import get_event_loop as get_running_loop +__author__ = "Greg Albrecht " +__copyright__ = "Copyright 2023 Greg Albrecht" +__license__ = "Apache License, Version 2.0" + async def main( app_name: str, config: SectionProxy, original_config: ConfigParser @@ -83,41 +83,55 @@ def cli(app_name: str = "inrcot") -> None: type=str, help="Optional configuration file. Default: config.ini", ) + parser.add_argument( + "-p", + "--PREF_PACKAGE", + dest="PREF_PACKAGE", + required=False, + type=str, + help="Optional connection preferences package zip file (aka data package).", + ) namespace = parser.parse_args() cli_args = {k: v for k, v in vars(namespace).items() if v is not None} # Read config: env_vars = os.environ + + # Remove env vars that contain '%', which ConfigParser or pprint barf on: + env_vars = {key: val for key, val in env_vars.items() if "%" not in val} + env_vars["COT_URL"] = env_vars.get("COT_URL", pytak.DEFAULT_COT_URL) env_vars["COT_HOST_ID"] = f"{app_name}@{platform.node()}" env_vars["COT_STALE"] = getattr(app, "DEFAULT_COT_STALE", pytak.DEFAULT_COT_STALE) - config: ConfigParser = ConfigParser(env_vars) + + orig_config: ConfigParser = ConfigParser(env_vars) config_file = cli_args.get("CONFIG_FILE") - if os.path.exists(config_file): + if config_file and os.path.exists(config_file): logging.info("Reading configuration from %s", config_file) - config.read(config_file) + orig_config.read(config_file) else: - config.add_section(app_name) + orig_config.add_section(app_name) + + config: SectionProxy = orig_config[app_name] - original_config: ConfigParser = config - config: SectionProxy = config[app_name] + pref_package: str = config.get("PREF_PACKAGE", cli_args.get("PREF_PACKAGE")) + if pref_package and os.path.exists(pref_package): + pref_config = pytak.read_pref_package(pref_package) + config.update(pref_config) debug = config.getboolean("DEBUG") if debug: - import pprint # pylint: disable=import-outside-toplevel - - # FIXME: This doesn't work with weird bash escape stuff. - print("Showing Config: %s", config_file) + print(f"Showing Config: {config_file}") print("=" * 10) - pprint.pprint(config) + pprint.pprint(dict(config)) print("=" * 10) if sys.version_info[:2] >= (3, 7): - asyncio.run(main(app_name, config, original_config), debug=debug) + asyncio.run(main(app_name, config, orig_config), debug=debug) else: loop = get_running_loop() try: - loop.run_until_complete(main(app_name, config, original_config)) + loop.run_until_complete(main(app_name, config, orig_config)) finally: loop.close() diff --git a/inrcot/constants.py b/inrcot/constants.py index e95f60b..1e71822 100644 --- a/inrcot/constants.py +++ b/inrcot/constants.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Copyright 2022 Greg Albrecht +# Copyright 2023 Greg Albrecht # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,13 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Author:: Greg Albrecht W2GMD -# """INRCOT Constants.""" -__author__ = "Greg Albrecht W2GMD " -__copyright__ = "Copyright 2021 Greg Albrecht" +__author__ = "Greg Albrecht " +__copyright__ = "Copyright 2023 Greg Albrecht" __license__ = "Apache License, Version 2.0" @@ -29,5 +27,5 @@ # How longer after producting the CoT Event is the Event 'stale' (seconds) DEFAULT_COT_STALE: str = "600" -# CoT Event Type / 2525 type / SIDC-like -DEFAULT_COT_TYPE: str = "a-n-G-E-V-C" +# Default CoT type. 'a-f-g-e-s' works in iTAK, WinTAK & ATAK... +DEFAULT_COT_TYPE: str = "a-f-g-e-s" diff --git a/inrcot/functions.py b/inrcot/functions.py index 2f7fe3f..57615a6 100644 --- a/inrcot/functions.py +++ b/inrcot/functions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Copyright 2022 Greg Albrecht +# Copyright 2023 Greg Albrecht # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,32 +13,31 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Author:: Greg Albrecht W2GMD -# -"""SpotCOT Gateway Functions.""" +"""INRCOT Gateway Functions.""" import datetime import io import xml.etree.ElementTree as ET from configparser import ConfigParser -from typing import Union, Set +from typing import Optional, Set + +from aiohttp import BasicAuth import pytak import inrcot -__author__ = "Greg Albrecht W2GMD " -__copyright__ = "Copyright 2022 Greg Albrecht" +__author__ = "Greg Albrecht " +__copyright__ = "Copyright 2023 Greg Albrecht" __license__ = "Apache License, Version 2.0" def create_tasks( config: ConfigParser, clitool: pytak.CLITool, original_config: ConfigParser ) -> Set[pytak.Worker,]: - """ - Creates specific coroutine task set for this application. + """Create specific coroutine task set for this application. Parameters ---------- @@ -55,18 +54,52 @@ def create_tasks( return set([inrcot.Worker(clitool.tx_queue, config, original_config)]) -def split_feed(content: str) -> list: - """Splits an inReach MapShare KML feed by 'Folder'""" +def split_feed(content: bytes) -> Optional[list]: + """Split an inReach MapShare KML feed by 'Folder'.""" tree = ET.parse(io.BytesIO(content)) document = tree.find("{http://www.opengis.net/kml/2.2}Document") + if not document: + return None folder = document.findall("{http://www.opengis.net/kml/2.2}Folder") return folder -def inreach_to_cot_xml(feed: str, feed_conf: dict = None) -> Union[ET.Element, None]: - """ - Converts an inReach Response to a Cursor-on-Target Event, as an XML Obj. - """ +def make_feed_conf(section) -> dict: + """Make a feed conf dictionary from a conf.""" + feed_conf: dict = { + "feed_url": section.get("FEED_URL"), + "cot_stale": section.get("COT_STALE", inrcot.DEFAULT_COT_STALE), + "cot_type": section.get("COT_TYPE", inrcot.DEFAULT_COT_TYPE), + "cot_icon": section.get("COT_ICON"), + "cot_name": section.get("COT_NAME"), + } + # Support "private" MapShare feeds: + feed_pass: str = section.get("FEED_PASSWORD") + feed_user: str = section.get("FEED_USERNAME") + if feed_pass and feed_user: + feed_auth: BasicAuth = BasicAuth(feed_user, feed_pass) + feed_conf["feed_auth"] = str(feed_auth) + + return feed_conf + + +def create_feeds(config: ConfigParser) -> list: + """Create a list of feed configurations.""" + feeds: list = [] + for feed in config.sections(): + if not "inrcot_feed_" in feed: + continue + config_section = config[feed] + feed_conf: dict = make_feed_conf(config_section) + feed_conf["feed_name"] = feed + feeds.append(feed_conf) + return feeds + + +def inreach_to_cot_xml( + feed: str, feed_conf: Optional[dict] = None +) -> Optional[ET.Element]: + """Convert an inReach Response to a Cursor-on-Target Event, as an XML Obj.""" feed_conf = feed_conf or {} placemarks = feed.find("{http://www.opengis.net/kml/2.2}Placemark") @@ -77,8 +110,11 @@ def inreach_to_cot_xml(feed: str, feed_conf: dict = None) -> Union[ET.Element, N ts = placemarks.find("{http://www.opengis.net/kml/2.2}TimeStamp") when = ts.find("{http://www.opengis.net/kml/2.2}when").text + if not "," in coordinates or coordinates.count(",") != 2: + return None + lon, lat, alt = coordinates.split(",") - if lat is None or lon is None: + if not all([lat, lon]): return None time = when @@ -103,20 +139,11 @@ def inreach_to_cot_xml(feed: str, feed_conf: dict = None) -> Union[ET.Element, N point.set("ce", "9999999.0") point.set("le", "9999999.0") - uid = ET.Element("UID") - uid.set("Droid", f"{name} (inReach)") - contact = ET.Element("contact") contact.set("callsign", f"{callsign} (inReach)") - track = ET.Element("track") - track.set("course", "9999999.0") - detail = ET.Element("detail") - detail.set("uid", name) - detail.append(uid) detail.append(contact) - detail.append(track) remarks = ET.Element("remarks") @@ -126,6 +153,12 @@ def inreach_to_cot_xml(feed: str, feed_conf: dict = None) -> Union[ET.Element, N remarks.text = _remarks detail.append(remarks) + cot_icon: Optional[str] = feed_conf.get("cot_icon") + if cot_icon: + usericon = ET.Element("usericon") + usericon.set("iconsetpath", cot_icon) + detail.append(usericon) + root = ET.Element("event") root.set("version", "2.0") root.set("type", cot_type) @@ -140,7 +173,7 @@ def inreach_to_cot_xml(feed: str, feed_conf: dict = None) -> Union[ET.Element, N return root -def inreach_to_cot(content: str, feed_conf: dict = None) -> Union[bytes, None]: - """Wrapper that returns COT as an XML string.""" - cot: Union[ET.Element, None] = inreach_to_cot_xml(content, feed_conf) +def inreach_to_cot(content: str, feed_conf: Optional[dict] = None) -> Optional[bytes]: + """Render a CoT XML as a string.""" + cot: Optional[ET.Element] = inreach_to_cot_xml(content, feed_conf) return ET.tostring(cot) if cot else None diff --git a/setup.py b/setup.py index 10acb41..d598b2a 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Copyright 2022 Greg Albrecht +# Copyright 2023 Greg Albrecht # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,14 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Author:: Greg Albrecht W2GMD -# -""" -Setup for the inReach Cursor-on-Target Gateway. +"""Setup for the inReach Cursor-on-Target Gateway. -:author: Greg Albrecht W2GMD -:copyright: Copyright 2022 Greg Albrecht +:author: Greg Albrecht +:copyright: Copyright 2023 Greg Albrecht :license: Apache License, Version 2.0 :source: https://github.com/ampledata/inrcot """ @@ -31,14 +28,14 @@ import setuptools __title__ = "inrcot" -__version__ = "5.0.2" -__author__ = "Greg Albrecht W2GMD " -__copyright__ = "Copyright 2022 Greg Albrecht" +__version__ = "5.2.0" +__author__ = "Greg Albrecht " +__copyright__ = "Copyright 2023 Greg Albrecht" __license__ = "Apache License, Version 2.0" def publish(): - """Function for publishing package to pypi.""" + """Publish this package to pypi.""" if sys.argv[-1] == "publish": os.system("python setup.py sdist") os.system("twine upload dist/*") @@ -48,6 +45,15 @@ def publish(): publish() +def read_readme(readme_file="README.rst") -> str: + """Read the contents of the README file for use as a long_description.""" + readme: str = "" + this_directory = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.join(this_directory, readme_file), encoding="UTF-8") as rmf: + readme = rmf.read() + return readme + + setuptools.setup( version=__version__, name=__title__, @@ -55,19 +61,31 @@ def publish(): package_dir={__title__: __title__}, url=f"https://github.com/ampledata/{__title__}", entry_points={"console_scripts": [f"{__title__} = {__title__}.commands:cli"]}, - description="inReach Cursor-on-Target Gateway.", + description="inReach Cursor on Target Gateway.", author="Greg Albrecht", author_email="oss@undef.net", package_data={"": ["LICENSE"]}, license="Apache License, Version 2.0", - long_description=open("README.rst").read(), + long_description=read_readme(), long_description_content_type="text/x-rst", zip_safe=False, include_package_data=True, - install_requires=["pytak >= 5.0.0", "aiohttp"], + install_requires=["pytak >= 5.6.1", "aiohttp"], classifiers=[ - "Programming Language :: Python", + "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + ], + keywords=[ + "Satellite", + "SAR", + "Search and Rescue", + "Cursor on Target", + "ATAK", + "TAK", + "CoT", + "WinTAK", + "iTAK", + "TAK Server", ], - keywords=["Satellite", "Cursor on Target", "ATAK", "TAK", "CoT"], ) diff --git a/tests/data/bad-data.kml b/tests/data/bad-data.kml new file mode 100644 index 0000000..cefa44d --- /dev/null +++ b/tests/data/bad-data.kml @@ -0,0 +1,135 @@ + + + + KML Export 8/30/2021 5:13:22 PM + + + + + + Greg Albrecht + + Greg Albrecht + true + + + 2021-07-22T15:22:30Z + + #style_1658884 + + + 207049997 + + + 7/22/2021 3:22:30 PM + + + 7/22/2021 8:22:30 AM + + + Greg Albrecht + + + Greg Albrecht + + + inReach Mini + + + 300434033719020 + + + + + + 33.874926 + + + -118.346915 + + + 22.63 m from MSL + + + 0.0 km/h + + + 0.00 ° True + + + True + + + False + + + + + + Tracking interval received. + + + + + + WGS84 + + + + false + absolute + -118.346915,,22.63 + + + + Greg Albrecht + true + Greg Albrecht's track log + #linestyle_1658884 + + true + -118.346915,33.874926,22.63 + + + + + \ No newline at end of file diff --git a/tests/data/bad-data2.kml b/tests/data/bad-data2.kml new file mode 100644 index 0000000..c5ac7ec --- /dev/null +++ b/tests/data/bad-data2.kml @@ -0,0 +1,135 @@ + + + + KML Export 8/30/2021 5:13:22 PM + + + + + + Greg Albrecht + + Greg Albrecht + true + + + 2021-07-22T15:22:30Z + + #style_1658884 + + + 207049997 + + + 7/22/2021 3:22:30 PM + + + 7/22/2021 8:22:30 AM + + + Greg Albrecht + + + Greg Albrecht + + + inReach Mini + + + 300434033719020 + + + + + + 33.874926 + + + -118.346915 + + + 22.63 m from MSL + + + 0.0 km/h + + + 0.00 ° True + + + True + + + False + + + + + + Tracking interval received. + + + + + + WGS84 + + + + false + absolute + -118.346915,22.63 + + + + Greg Albrecht + true + Greg Albrecht's track log + #linestyle_1658884 + + true + -118.346915,33.874926,22.63 + + + + + \ No newline at end of file diff --git a/tests/data/bad.kml b/tests/data/bad.kml new file mode 100644 index 0000000..5bc62df --- /dev/null +++ b/tests/data/bad.kml @@ -0,0 +1,135 @@ + + + + KML Export 8/30/2021 5:13:22 PM + + + + + + Greg Albrecht + + Greg Albrecht + true + + + 2021-07-22T15:22:30Z + + #style_1658884 + + + 207049997 + + + 7/22/2021 3:22:30 PM + + + 7/22/2021 8:22:30 AM + + + Greg Albrecht + + + Greg Albrecht + + + inReach Mini + + + 300434033719020 + + + + + + 33.874926 + + + -118.346915 + + + 22.63 m from MSL + + + 0.0 km/h + + + 0.00 ° True + + + True + + + False + + + + + + Tracking interval received. + + + + + + WGS84 + + + + false + absolute + -118.346915,33.874926,22.63 + + + + Greg Albrecht + true + Greg Albrecht's track log + #linestyle_1658884 + + true + -118.346915,33.874926,22.63 + + + + + \ No newline at end of file diff --git a/tests/data/test-config.ini b/tests/data/test-config.ini new file mode 100644 index 0000000..053d821 --- /dev/null +++ b/tests/data/test-config.ini @@ -0,0 +1,32 @@ +[inrcot] + +COT_URL = udp://239.2.3.1:6969 + +POLL_INTERVAL = 120 + +[inrcot_feed_1] +FEED_URL = https://share.garmin.com/Feed/Share/ampledata +COT_TYPE = a-f-G-U-C +COT_STALE = 600 +COT_ICON = TACOS/taco.png + +[inrcot_feed_aaa] +FEED_URL = https://share.garmin.com/Feed/Share/aaa + +[inrcot_feed_xxx] +FEED_URL = https://share.garmin.com/Feed/Share/xxx + +[inrcot_feed_yyy] +FEED_URL = https://share.garmin.com/Feed/Share/yyy + +[inrcot_feed_zzz] +FEED_URL = https://share.garmin.com/Feed/Share/zzz +COT_TYPE = a-f-G-U-C +COT_STALE = 600 +COT_NAME = Team Lead +COT_ICON = my_package/team_lead.png + +[inrcot_feed_ppp] +FEED_URL = https://share.garmin.com/Feed/Share/ppp +FEED_USERNAME = secretsquirrel +FEED_PASSWORD = supersecret \ No newline at end of file diff --git a/tests/test.kml b/tests/data/test.kml similarity index 100% rename from tests/test.kml rename to tests/data/test.kml diff --git a/tests/test_functions.py b/tests/test_functions.py index 5015fc9..1639354 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -3,23 +3,24 @@ """inReach to Cursor-on-Target Gateway Function Tests.""" +from configparser import ConfigParser, SectionProxy +from aiohttp import BasicAuth + import unittest import inrcot.functions -__author__ = "Greg Albrecht W2GMD " -__copyright__ = "Copyright 2021 Greg Albrecht" +__author__ = "Greg Albrecht " +__copyright__ = "Copyright 2023 Greg Albrecht" __license__ = "Apache License, Version 2.0" class FunctionsTestCase(unittest.TestCase): - """ - Tests for inrcot Functions. - """ + """Test for inrcot Functions.""" def test_inreach_to_cot_xml(self): - """Tests rendering inReach KML a Python XML Object.""" - with open("tests/test.kml", "rb") as test_kml_fd: + """Test rendering inReach KML a Python XML Object.""" + with open("tests/data/test.kml", "rb") as test_kml_fd: test_kml_feed = test_kml_fd.read() test_kml = inrcot.functions.split_feed(test_kml_feed)[0] @@ -27,14 +28,14 @@ def test_inreach_to_cot_xml(self): point = test_cot.find("point") - self.assertEqual(test_cot.get("type"), "a-n-G-E-V-C") + self.assertEqual(test_cot.get("type"), "a-f-g-e-s") self.assertEqual(test_cot.get("uid"), "Garmin-inReach.GregAlbrecht") self.assertEqual(point.get("lat"), "33.874926") self.assertEqual(point.get("lon"), "-118.346915") def test_inreach_to_cot(self): - """Tests rendering inReach KML as a Python XML String.""" - with open("tests/test.kml", "rb") as test_kml_fd: + """Test rendering inReach KML as a Python XML String.""" + with open("tests/data/test.kml", "rb") as test_kml_fd: test_kml_feed = test_kml_fd.read() test_kml = inrcot.functions.split_feed(test_kml_feed)[0] @@ -42,6 +43,112 @@ def test_inreach_to_cot(self): self.assertIn(b"Greg Albrecht (inReach)", test_cot) + def test_inreach_to_cot_xml_from_config(self): + """Test rendering inReach KML a Python XML Object.""" + with open("tests/data/test.kml", "rb") as test_kml_fd: + test_kml_feed = test_kml_fd.read() + + test_config_file = "tests/data/test-config.ini" + orig_config: ConfigParser = ConfigParser() + orig_config.read(test_config_file) + feeds = inrcot.functions.create_feeds(orig_config) + + test_kml = inrcot.functions.split_feed(test_kml_feed)[0] + test_cot = inrcot.functions.inreach_to_cot_xml(test_kml, feeds[0]) + + point = test_cot.find("point") + detail = test_cot.find("detail") + usericon = detail.find("usericon") + + self.assertEqual(test_cot.get("type"), "a-f-G-U-C") + self.assertEqual(test_cot.get("uid"), "Garmin-inReach.GregAlbrecht") + self.assertEqual(point.get("lat"), "33.874926") + self.assertEqual(point.get("lon"), "-118.346915") + self.assertEqual(usericon.get("iconsetpath"), "TACOS/taco.png") + + def test_inreach_to_cot_from_config(self): + """Test rendering inReach KML as a Python XML String.""" + with open("tests/data/test.kml", "rb") as test_kml_fd: + test_kml_feed = test_kml_fd.read() + + test_config_file = "tests/data/test-config.ini" + orig_config: ConfigParser = ConfigParser() + orig_config.read(test_config_file) + feeds = inrcot.functions.create_feeds(orig_config) + + test_kml = inrcot.functions.split_feed(test_kml_feed)[0] + test_cot = inrcot.functions.inreach_to_cot(test_kml, feeds[0]) + + self.assertIn(b"Greg Albrecht (inReach)", test_cot) + + def test_create_feeds(self): + """Test creating feeds from config.""" + test_config_file = "tests/data/test-config.ini" + orig_config: ConfigParser = ConfigParser() + orig_config.read(test_config_file) + + # config: SectionProxy = orig_config["inrcot"] + + feeds = inrcot.functions.create_feeds(orig_config) + self.assertTrue(len(feeds) == 6) + feed = feeds[0] + self.assertEqual(feed["feed_name"], "inrcot_feed_1") + self.assertEqual(feed.get("cot_type"), "a-f-G-U-C") + self.assertEqual(feed["cot_type"], "a-f-G-U-C") + self.assertEqual( + feed["feed_url"], "https://share.garmin.com/Feed/Share/ampledata" + ) + self.assertEqual(feed["cot_stale"], "600") + self.assertEqual(feed["cot_icon"], "TACOS/taco.png") + self.assertEqual(feed["cot_name"], None) + + def test_create_feeds_with_auth(self): + """Test creating feeds with auth from config.""" + test_config_file = "tests/data/test-config.ini" + orig_config: ConfigParser = ConfigParser() + orig_config.read(test_config_file) + + feeds = inrcot.functions.create_feeds(orig_config) + self.assertTrue(len(feeds) == 6) + feed = feeds[5] + self.assertEqual(feed["feed_name"], "inrcot_feed_ppp") + self.assertEqual(feed.get("cot_type"), "a-f-g-e-s") + self.assertEqual(feed["cot_type"], "a-f-g-e-s") + self.assertEqual(feed["feed_url"], "https://share.garmin.com/Feed/Share/ppp") + self.assertEqual(feed["cot_stale"], "600") + self.assertEqual(feed["cot_icon"], None) + self.assertEqual(feed["cot_name"], None) + self.assertEqual( + feed["feed_auth"], + "BasicAuth(login='secretsquirrel', password='supersecret', encoding='latin1')", + ) + + def test_inreach_to_cot_xml_bad_kml(self): + """Test rendering bad KML.""" + with open("tests/data/bad.kml", "rb") as test_kml_fd: + test_kml_feed = test_kml_fd.read() + + test_kml = inrcot.functions.split_feed(test_kml_feed) + self.assertEqual(test_kml, None) + + def test_inreach_to_cot_xml_bad_data(self): + """Test rendering bad KML.""" + with open("tests/data/bad-data.kml", "rb") as test_kml_fd: + test_kml_feed = test_kml_fd.read() + + test_kml = inrcot.functions.split_feed(test_kml_feed)[0] + test_cot = inrcot.functions.inreach_to_cot(test_kml, {}) + self.assertEqual(test_cot, None) + + def test_inreach_to_cot_xml_bad_data2(self): + """Test rendering bad KML.""" + with open("tests/data/bad-data2.kml", "rb") as test_kml_fd: + test_kml_feed = test_kml_fd.read() + + test_kml = inrcot.functions.split_feed(test_kml_feed)[0] + test_cot = inrcot.functions.inreach_to_cot(test_kml, {}) + self.assertEqual(test_cot, None) + if __name__ == "__main__": unittest.main()