From def3d13b3743b40d1bf8ac863c9c33b296d34746 Mon Sep 17 00:00:00 2001 From: Greg Albrecht Date: Mon, 30 Aug 2021 11:27:50 -0700 Subject: [PATCH] initial commit --- .github/workflows/debian.yml | 61 ++++++++++++ .github/workflows/pylint.yml | 22 +++++ .github/workflows/python-app.yml | 37 ++++++++ .github/workflows/python-package.yml | 41 ++++++++ .github/workflows/python-publish.yml | 31 ++++++ .gitignore | 36 +++++++ LICENSE | 13 +++ MANIFEST.in | 1 + Makefile | 71 ++++++++++++++ README.rst | 134 ++++++++++++++++++++++++++ example-config.ini | 12 +++ inrcot/__init__.py | 25 +++++ inrcot/classes.py | 84 +++++++++++++++++ inrcot/commands.py | 83 ++++++++++++++++ inrcot/constants.py | 31 ++++++ inrcot/functions.py | 107 +++++++++++++++++++++ requirements.txt | 1 + requirements_test.txt | 6 ++ setup.py | 63 +++++++++++++ tests/test.kml | 135 +++++++++++++++++++++++++++ tests/test_functions.py | 42 +++++++++ 21 files changed, 1036 insertions(+) create mode 100644 .github/workflows/debian.yml create mode 100644 .github/workflows/pylint.yml create mode 100644 .github/workflows/python-app.yml create mode 100644 .github/workflows/python-package.yml create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.rst create mode 100644 example-config.ini create mode 100644 inrcot/__init__.py create mode 100644 inrcot/classes.py create mode 100644 inrcot/commands.py create mode 100644 inrcot/constants.py create mode 100644 inrcot/functions.py create mode 100644 requirements.txt create mode 100644 requirements_test.txt create mode 100644 setup.py create mode 100644 tests/test.kml create mode 100644 tests/test_functions.py diff --git a/.github/workflows/debian.yml b/.github/workflows/debian.yml new file mode 100644 index 0000000..4e25105 --- /dev/null +++ b/.github/workflows/debian.yml @@ -0,0 +1,61 @@ +name: Build Debian package + +on: + push: + tags: + - '*' + +env: + DEB_BUILD_OPTIONS: nocheck + +jobs: + build-artifact: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@master + + - name: Install packaging dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y \ + python3 python3-dev python3-pip python3-venv python3-all \ + dh-python debhelper devscripts dput software-properties-common \ + python3-distutils python3-setuptools python3-wheel python3-stdeb + + - name: Build Debian/Apt sdist_dsc + run: | + rm -Rf deb_dist/* + python3 setup.py --command-packages=stdeb.command sdist_dsc + + - name: Build Debian/Apt bdist_deb + run: | + python3 setup.py --command-packages=stdeb.command bdist_deb + ls -al deb_dist/ + + - uses: actions/upload-artifact@master + with: + name: artifact-deb + path: | + deb_dist/*.deb + + - name: Create Release + id: create_release + uses: actions/create-release@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + + - name: Upload Release Asset + id: upload-release-asset + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: deb_dist/*.deb + tag: ${{ github.ref }} + overwrite: true + file_glob: true \ No newline at end of file diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..c16ba22 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,22 @@ +name: Pylint + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + - name: Analysing the code with pylint + run: | + pylint `ls -R|grep .py$|xargs` diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..74a085a --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,37 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install -e . + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..25d5236 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,41 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python package + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install -e . + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..1a03a7b --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70b6ce1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +*.deb +*.egg +*.egg-info/ +*.egg/ +*.ignore +*.py[co] +*.py[oc] +*.spl +*.vagrant +.DS_Store +.coverage +.eggs/ +.eggs/* +.idea +.idea/ +.pt +.vagrant/ +RELEASE-VERSION.txt +build/ +cover/ +dist/ +dump.rdb +flake8.log +local/ +local_* +metadata/ +nosetests.xml +output.xml +pylint.log +redis-server.log +redis-server/ +.python-version +venv/ +config.ini + +*/__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad1faa2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2021 Greg Albrecht + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..645a28c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.rst LICENSE requirements.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..306f4f8 --- /dev/null +++ b/Makefile @@ -0,0 +1,71 @@ +# Makefile for Python Programs + +.DEFAULT_GOAL := all + + +all: develop + +install_requirements: + pip install -r requirements.txt + +install_requirements_test: + pip install -r requirements_test.txt + +develop: remember + python setup.py develop + +install: remember + python setup.py install + +uninstall: + pip uninstall -y inrcot + +reinstall: uninstall install + +remember: + @echo + @echo "Hello from the Makefile..." + @echo "Don't forget to run: 'make install_requirements'" + @echo + +remember_test: + @echo + @echo "Hello from the Makefile..." + @echo "Don't forget to run: 'make install_requirements_test'" + @echo + +clean: + @rm -rf *.egg* build dist *.py[oc] */*.py[co] cover doctest_pypi.cfg \ + nosetests.xml pylint.log output.xml flake8.log tests.log \ + test-result.xml htmlcov fab.log .coverage */__pycache__/ + +# Publishing: + +build: remember_test + python3 -m build --sdist --wheel + +twine_check: remember_test build + twine check dist/* + +upload: remember_test build + twine upload dist/* + +publish: build twine_check upload + +# Tests: + +pep8: remember_test + flake8 --max-complexity 12 --exit-zero *.py */*.py + +flake8: pep8 + +lint: remember_test + pylint --msg-template="{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}" \ + -r n *.py */*.py || exit 0 + +pylint: lint + +pytest: remember_test + pytest + +test: lint pep8 pytest diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..c41af43 --- /dev/null +++ b/README.rst @@ -0,0 +1,134 @@ +inrcot - Garmin inReach to Cursor-on-Target Gateway. +**************************************************** + +IF YOU HAVE AN URGENT OPERATIONAL NEED: Email ops@undef.net or call/sms +1-415-598-8226 + +.. image:: docs/ScreenShot2021-01-08at4.18.37PM.png + :alt: Screenshot of inReach CoT PLI Point in ATAK + :target: docs/ScreenShot2021-01-08at4.18.37PM.png + +The ``inrcot`` inReach to Cursor-on-Target Gateway transforms Garmin inReach +position messages into Cursor on Target (CoT) Position Location Information +(PLI) Points for display on Situational Awareness (SA) applications such as the +Android Team Awareness Kit (ATAK), WinTAK, RaptorX, COPERS, et al. + +Possible use-cases include tracking Search & Rescue (SAR) operators, or +integrating Partner Forces location data into existing SA infrastructure +without exposing private network elements. + +``inrcot`` can be run as a foreground command line application, but should be +run as a service with tools like systemd or `supervisor `_ + +Usage of this program requires a `Garmin iNReach `_ device with service. + +Wildland Firefighting +===================== +``inrcot`` may also be of use in wildland firefighting, see Section 1114.d of the `Dingell Act `_:: + + Location Systems for Wildland Firefighters.-- + (1) In general.--Not later than 2 years after the date of + enactment of this Act, subject to the availability of + appropriations, the Secretaries, in coordination with State + wildland firefighting agencies, shall jointly develop and + operate a tracking system (referred to in this subsection as the + ``system'') to remotely locate the positions of fire resources + for use by wildland firefighters, including, at a minimum, any + fire resources assigned to Federal type 1 wildland fire incident + management teams. + + +Installation +============ + +To install from this source tree:: + + $ git checkout https://github.com/ampledata/inrcot.git + $ cd inrcot/ + $ python setup.py install + +To install from PyPI:: + + $ pip install inrcot + + +Setup +===== + +``inrcot`` uses the Garmin inReach **KML Feed** feature to retrieve Spot location +messages from the Spot API. + +To enable the **XML Feed** feature: + +1. Login to your Spot account at: https://login.findmespot.com/spot-main-web/auth/login.html +2. In the navigation bar, click **XML Feed**, then **Create XML Feed**. +3. Enter any value for **XML Feed Name**. +4. *[Optional]* If you select **Make XML page private**, chose and record a password. +5. Click **Create**, record the **XML Feed ID**. + +Usage +===== + +The `inrcot` program has one command-line argument:: + + $ inrcot -h + usage: inrcot [-h] [-c CONFIG_FILE] + + optional arguments: + -h, --help show this help message and exit + -c CONFIG_FILE, --CONFIG_FILE CONFIG_FILE + +You must create a configuration file, see `example-config.ini` in the source +respository. + +An example config:: + + [inrcot] + COT_URL = tcp:takserver.example.com:8088 + POLL_INTERVAL = 120 + + [inrcot_feed_aaa] + FEED_URL = https://share.garmin.com/Feed/Share/aaa + +Multiple feeds can be added by creating multiple `inrcot_feed` sections:: + + [inrcot] + COT_URL = tcp:takserver.example.com:8088 + POLL_INTERVAL = 120 + + [inrcot_feed_xxx] + FEED_URL = https://share.garmin.com/Feed/Share/xxx + + [inrcot_feed_yyy] + FEED_URL = https://share.garmin.com/Feed/Share/yyy + +Individual feeds CoT output can be customized as well:: + + [inrcot] + COT_URL = tcp:takserver.example.com:8088 + POLL_INTERVAL = 120 + + [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 + + +Source +====== +Github: https://github.com/ampledata/inrcot + +Author +====== +Greg Albrecht W2GMD oss@undef.net + +https://ampledata.org/ + +Copyright +========= +Copyright 2021 Greg Albrecht + +License +======= +Apache License, Version 2.0. See LICENSE for details. diff --git a/example-config.ini b/example-config.ini new file mode 100644 index 0000000..93505ec --- /dev/null +++ b/example-config.ini @@ -0,0 +1,12 @@ +[inrcot] +; ^-- Always make sure to include this section header somewhere. + +COT_URL = tcp:172.17.2.135:28087 + +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 + diff --git a/inrcot/__init__.py b/inrcot/__init__.py new file mode 100644 index 0000000..1bb1285 --- /dev/null +++ b/inrcot/__init__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# inReach to Cursor-on-Target Gateway. + +""" +inReach to Cursor-on-Target Gateway. +~~~~ + +:author: Greg Albrecht W2GMD +:copyright: Copyright 2021 Greg Albrecht +:license: Apache License, Version 2.0 +:source: +""" + +from .constants import (LOG_FORMAT, LOG_LEVEL, DEFAULT_POLL_INTERVAL, # NOQA + DEFAULT_COT_STALE, DEFAULT_COT_TYPE) + +from .functions import inreach_to_cot # NOQA + +from .classes import InrWorker # NOQA + +__author__ = "Greg Albrecht W2GMD " +__copyright__ = "Copyright 2021 Greg Albrecht" +__license__ = "Apache License, Version 2.0" diff --git a/inrcot/classes.py b/inrcot/classes.py new file mode 100644 index 0000000..f3a2756 --- /dev/null +++ b/inrcot/classes.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""inReach Cursor-on-Target Class Definitions.""" + +import asyncio + +import aiohttp + +import pytak + +import inrcot + +__author__ = "Greg Albrecht W2GMD " +__copyright__ = "Copyright 2021 Greg Albrecht" +__license__ = "Apache License, Version 2.0" + + +class InrWorker(pytak.MessageWorker): + + """Reads inReach Data, renders to CoT, and puts on queue.""" + + def __init__(self, event_queue: asyncio.Queue, config) -> None: + super().__init__(event_queue) + + # self.cot_stale = opts.get("COT_STALE") + # self.cot_type = opts.get("COT_TYPE") or inrcot.DEFAULT_COT_TYPE + + self.poll_interval: int = int(config["inrcot"].get( + "POLL_INTERVAL", inrcot.DEFAULT_POLL_INTERVAL)) + + # Used by inreach_to_cot function: + self.cot_renderer = inrcot.inreach_to_cot + + self.inreach_feeds: list = [] + + for feed in config.sections(): + if "inrcot_feed_" in feed: + self.inreach_feeds.append({ + "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"), + }) + + async def handle_response(self, content: str, feed_conf: dict) -> None: + """Handles the response from the inReach API.""" + event: str = inrcot.inreach_to_cot(content, feed_conf) + if event: + await self._put_event_queue(event) + else: + self._logger.debug("Empty CoT Event") + + async def _get_inreach_feeds(self): + """Gets inReach Feed from API.""" + self._logger.debug("Polling inReach API") + + for feed_conf in self.inreach_feeds: + async with aiohttp.ClientSession() as session: + try: + response = await session.request( + method="GET", url=feed_conf.get("feed_url")) + except Exception as exc: # NOQA pylint: disable=broad-except + self._logger.error( + "Exception raised while polling inReach API.") + self._logger.exception(exc) + return + + if response.status == 200: + await self.handle_response(await response.content.read(), + feed_conf) + else: + self._logger.error("No valid response from inReach API.") + + async def run(self) -> None: + """Runs this Worker, Reads from Pollers.""" + self._logger.info("Running InrWorker") + + while 1: + await self._get_inreach_feeds() + await asyncio.sleep(self.poll_interval) diff --git a/inrcot/commands.py b/inrcot/commands.py new file mode 100644 index 0000000..d37ec08 --- /dev/null +++ b/inrcot/commands.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""inReach Cursor-on-Target Gateway Commands.""" + +import asyncio +import argparse +import configparser +import logging +import sys +import urllib + +import pytak + +import inrcot + +# Python 3.6 support: +if sys.version_info[:2] >= (3, 7): + from asyncio import get_running_loop +else: + from asyncio import _get_running_loop as get_running_loop # NOQA pylint: disable=no-name-in-module + + +__author__ = "Greg Albrecht W2GMD " +__copyright__ = "Copyright 2021 Greg Albrecht." +__license__ = "Apache License, Version 2.0" + + +async def main(config): + """Main program function, executes workers, et al.""" + tx_queue: asyncio.Queue = asyncio.Queue() + rx_queue: asyncio.Queue = asyncio.Queue() + + cot_url: urllib.parse.ParseResult = urllib.parse.urlparse( + config["inrcot"].get("COT_URL")) + + # Create our CoT Event Queue Worker + reader, writer = await pytak.protocol_factory(cot_url) + write_worker = pytak.EventTransmitter(tx_queue, writer) + read_worker = pytak.EventReceiver(rx_queue, reader) + + message_worker = inrcot.InrWorker(tx_queue, config) + + await tx_queue.put(pytak.hello_event("inrcot")) + + done, _ = await asyncio.wait( + {message_worker.run(), read_worker.run(), write_worker.run()}, + return_when=asyncio.FIRST_COMPLETED) + + for task in done: + print(f"Task completed: {task}") + + +def cli(): + """Command Line interface for inReach Cursor-on-Target Gateway.""" + + # Get cli arguments: + parser = argparse.ArgumentParser() + + parser.add_argument("-c", "--CONFIG_FILE", dest="CONFIG_FILE", + default="config.ini", type=str) + namespace = parser.parse_args() + cli_args = {k: v for k, v in vars(namespace).items() if v is not None} + + # Read config file: + config = configparser.ConfigParser() + + config_file = cli_args.get("CONFIG_FILE") + logging.info("Reading configuration from %s", config_file) + config.read(config_file) + + if sys.version_info[:2] >= (3, 7): + asyncio.run(main(config), debug=config["inrcot"].getboolean("DEBUG")) + else: + loop = get_running_loop() + try: + loop.run_until_complete(main(config)) + finally: + loop.close() + + +if __name__ == '__main__': + cli() diff --git a/inrcot/constants.py b/inrcot/constants.py new file mode 100644 index 0000000..1fb4a05 --- /dev/null +++ b/inrcot/constants.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""inReach to Cursor-on-Target Constants.""" + +import logging +import os + +__author__ = "Greg Albrecht W2GMD " +__copyright__ = "Copyright 2021 Greg Albrecht" +__license__ = "Apache License, Version 2.0" + + +if bool(os.environ.get('DEBUG')): + LOG_LEVEL = logging.DEBUG + LOG_FORMAT = logging.Formatter( + ('%(asctime)s inrcot %(levelname)s %(name)s.%(funcName)s:%(lineno)d ' + ' - %(message)s')) + logging.debug('inrcot Debugging Enabled via DEBUG Environment Variable.') +else: + LOG_LEVEL = logging.INFO + LOG_FORMAT = logging.Formatter('%(asctime)s inrcot - %(message)s') + +# How long between checking for new messages at the Spot API? +DEFAULT_POLL_INTERVAL: int = 120 + +# How longer after producting the CoT Event is the Event 'stale' (seconds) +DEFAULT_COT_STALE: int = 600 + +# CoT Event Type / 2525 type / SIDC-like +DEFAULT_COT_TYPE: str = "a-.-G-E-V-C" diff --git a/inrcot/functions.py b/inrcot/functions.py new file mode 100644 index 0000000..fedd035 --- /dev/null +++ b/inrcot/functions.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Spot Cursor-on-Target Gateway Functions.""" + +import datetime +import io + +import xml.etree.ElementTree + +import pytak + +import inrcot.constants + +__author__ = "Greg Albrecht W2GMD " +__copyright__ = "Copyright 2021 Greg Albrecht" +__license__ = "Apache License, Version 2.0" + + +def inreach_to_cot_xml( + content: str, feed_conf: dict = None) -> [xml.etree.ElementTree, None]: + """ + Converts an inReach Response to a Cursor-on-Target Event, as an XML Obj. + """ + feed_conf = feed_conf or {} + tree = xml.etree.ElementTree.parse(io.StringIO(content)) + + document = tree.find('{http://www.opengis.net/kml/2.2}Document') + folder = document.find("{http://www.opengis.net/kml/2.2}Folder") + + placemarks = folder.find("{http://www.opengis.net/kml/2.2}Placemark") + _point = placemarks.find("{http://www.opengis.net/kml/2.2}Point") + coordinates = _point.find( + "{http://www.opengis.net/kml/2.2}coordinates").text + _name = placemarks.find("{http://www.opengis.net/kml/2.2}name").text + + ts = placemarks.find("{http://www.opengis.net/kml/2.2}TimeStamp") + when = ts.find("{http://www.opengis.net/kml/2.2}when").text + + lon, lat, alt = coordinates.split(",") + if lat is None or lon is None: + return None + + time = when + + # We want to use localtime + stale instead of lastUpdate time + stale + # This means a device could go offline and we might not know it? + _cot_stale = feed_conf.get("cot_stale", inrcot.DEFAULT_COT_STALE) + cot_stale = (datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta( + seconds=int(_cot_stale))).strftime(pytak.ISO_8601_UTC) + + cot_type = feed_conf.get("cot_type", inrcot.DEFAULT_COT_TYPE) + + name = feed_conf.get("cot_name") or _name + callsign = name + + point = xml.etree.ElementTree.Element("point") + point.set("lat", str(lat)) + point.set("lon", str(lon)) + point.set("hae", "9999999.0") + point.set("ce", "9999999.0") + point.set("le", "9999999.0") + + uid = xml.etree.ElementTree.Element("UID") + uid.set("Droid", f"{name} (inReach)") + + contact = xml.etree.ElementTree.Element("contact") + contact.set("callsign", f"{callsign} (inReach)") + + track = xml.etree.ElementTree.Element("track") + track.set("course", "9999999.0") + + detail = xml.etree.ElementTree.Element("detail") + detail.set("uid", name) + detail.append(uid) + detail.append(contact) + detail.append(track) + + remarks = xml.etree.ElementTree.Element("remarks") + + _remarks = f"Garmin inReach User.\r\n Name: {name}" + + detail.set("remarks", _remarks) + remarks.text = _remarks + detail.append(remarks) + + root = xml.etree.ElementTree.Element("event") + root.set("version", "2.0") + root.set("type", cot_type) + root.set("uid", f"Garmin-inReach.{name}".replace(" ", "")) + root.set("how", "m-g") + root.set("time", time) # .strftime(pytak.ISO_8601_UTC)) + root.set("start", time) # .strftime(pytak.ISO_8601_UTC)) + root.set("stale", cot_stale) + root.append(point) + root.append(detail) + + return root + + +def inreach_to_cot(content: str, feed_conf: dict = None) -> str: + """ + Converts an inReach Response to a Cursor-on-Target Event, as a String. + """ + cot_xml: xml.etree.ElementTree = inreach_to_cot_xml(content, feed_conf) + return xml.etree.ElementTree.tostring(cot_xml) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c307495 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# Python Distribution Package Requirements \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..fc4f7e3 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,6 @@ +# Python Distribution Package Requirements for Tests + +build +flake8 +pylint +twine diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cf566b8 --- /dev/null +++ b/setup.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Setup for the inReach Cursor-on-Target Gateway. + +:author: Greg Albrecht W2GMD +:copyright: Copyright 2021 Greg Albrecht +:license: Apache License, Version 2.0 +:source: https://github.com/ampledata/inrcot +""" + +import os +import sys + +import setuptools + +__title__ = "inrcot" +__version__ = "1.0.0b1" +__author__ = "Greg Albrecht W2GMD " +__copyright__ = "Copyright 2021 Greg Albrecht" +__license__ = "Apache License, Version 2.0" + + +def publish(): + """Function for publishing package to pypi.""" + if sys.argv[-1] == "publish": + os.system("python setup.py sdist") + os.system("twine upload dist/*") + sys.exit() + + +publish() + + +setuptools.setup( + version=__version__, + name=__title__, + packages=[__title__], + package_dir={__title__: __title__}, + url=f"https://github.com/ampledata/{__title__}", + 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_content_type="text/x-rst", + zip_safe=False, + include_package_data=True, + install_requires=[ + "pytak >= 3.0.0", + "aiohttp" + ], + classifiers=[ + "Programming Language :: Python", + "License :: OSI Approved :: Apache Software License" + ], + keywords=[ + "Satellite", "Cursor on Target", "ATAK", "TAK", "CoT" + ], + entry_points={"console_scripts": ["inrcot = inrcot.commands:cli"]} +) diff --git a/tests/test.kml b/tests/test.kml new file mode 100644 index 0000000..479055b --- /dev/null +++ b/tests/test.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/test_functions.py b/tests/test_functions.py new file mode 100644 index 0000000..7446d77 --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""inReach to Cursor-on-Target Gateway Function Tests.""" + +import unittest + +import inrcot.functions + +__author__ = "Greg Albrecht W2GMD " +__copyright__ = "Copyright 2021 Greg Albrecht" +__license__ = "Apache License, Version 2.0" + + +class FunctionsTestCase(unittest.TestCase): + """ + Tests for Functions. + """ + + def test_inreach_to_cot_xml(self): + with open("tests/test.kml", "r") as test_kml_fd: + test_kml = test_kml_fd.read() + + test_cot = inrcot.functions.inreach_to_cot_xml(test_kml, {}) + point = test_cot.find("point") + + self.assertEqual(test_cot.get("type"), "a-.-G-E-V-C") + 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): + with open("tests/test.kml", "r") as test_kml_fd: + test_kml = test_kml_fd.read() + + test_cot = inrcot.functions.inreach_to_cot(test_kml, {}) + + self.assertIn(b"Greg Albrecht (inReach)", test_cot) + + +if __name__ == "__main__": + unittest.main()