diff --git a/.gitignore b/.gitignore index 942c1d5..7a84d65 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ redis-server.log redis-server/ .python-version __pycache__ +config.ini +private_key.pem +Pipfile diff --git a/README.rst b/README.rst index a785e40..4d82d12 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -SFPD CAD to Cursor-On-Target Gateway. +PulsePoint to Cursor-On-Target Gateway. ************************************* .. image:: https://raw.githubusercontent.com/ampledata/pulsecot/main/docs/Screenshot_20201026-142037_ATAK-25p.jpg @@ -8,8 +8,7 @@ SFPD CAD to Cursor-On-Target Gateway. Example agencies: * DC Fire & EMS: EMS1205 -The SFPD CAD to Cursor-On-Target Gateway (SFPDCADCOT) transforms SFPD Computer -Aided Dispatch (CAD) calls for service to Cursor-On-Target (COT) Events for +The PulsePoint to Cursor-On-Target Gateway (PulseCOT) transforms PulsePoint incidents/AEDs to Cursor-On-Target (COT) Events for display on Situational Awareness applications such as the Android Team Awareness Kit (ATAK), WinTAK, RaptorX, TAKX, iTAK, et al. More information on the TAK suite of tools cal be found at: https://www.tak.gov/ @@ -32,7 +31,7 @@ efforts is greatly appreciated. Installation ============ -SFPDCADCOT's functionality provided by a command-line program called `pulsecot`. +PulseCOT's functionality provided by a command-line program called `pulsecot`. Installing as a Debian / Ubuntu Package [Recommended]:: @@ -75,7 +74,7 @@ a INI-stile configuration file. Parameters: -* **CAD_URL**: (*optional*) SFPD CAD Data URL. +* **AGENCY_IDS**: (*optional*) PulsePoint agency ID for displaying incidents/AEDs. * **COT_URL**: (*optional*) Destination for Cursor-On-Target messages. See `PyTAK `_ for options. * **POLL_INTERVAL**: (*optional*) Period in seconds to poll API. Default: 30 @@ -90,19 +89,19 @@ Configuration parameters are imported in the following priority order: Source ====== -SFPDCADCOT source can be found on Github: https://github.com/ampledata/pulsecot +PulseCOT source can be found on Github: https://github.com/ampledata/pulsecot Author ====== -SFPDCADCOT is written and maintained by Greg Albrecht W2GMD oss@undef.net +PulseCOT is written and maintained by Greg Albrecht W2GMD oss@undef.net https://ampledata.org/ Copyright ========= -SFPDCADCOT is Copyright 2022 Greg Albrecht +PulseCOT is Copyright 2022 Greg Albrecht License diff --git a/pulsecot/__init__.py b/pulsecot/__init__.py index b7b02ff..870ba4b 100644 --- a/pulsecot/__init__.py +++ b/pulsecot/__init__.py @@ -32,9 +32,12 @@ DEFAULT_AGENCY_IDS, PP_CALL_TYPES, DEFAULT_PP_URL, + DEFAULT_PP_AED_URL, + DEFAULT_PP_AED_API_USERNAME, + DEFAULT_PP_AED_API_PASSWORD ) -from .functions import incident_to_cot, create_tasks # NOQA +from .functions import incident_to_cot, create_tasks, aed_to_cot # NOQA from .classes import CADWorker # NOQA diff --git a/pulsecot/classes.py b/pulsecot/classes.py index 6164492..a665728 100644 --- a/pulsecot/classes.py +++ b/pulsecot/classes.py @@ -41,34 +41,58 @@ class CADWorker(pytak.QueueWorker): agency_details: dict = {} - async def handle_data(self, data: list) -> None: + async def handle_data(self, data: list, type) -> None: """ Transforms Data to COT and puts it onto TX queue. """ if not data: return + if type != "incident": + for aed in data.get('aeds',[]): + event: Union[str, None] = pulsecot.aed_to_cot( + aed, config=self.config, agency=data["agency"] + ) + if not event: + self._logger.debug("Empty CoT") + continue + await self.put_queue(event) + else: + for incident in data.get("incidents", []): + event: Union[str, None] = pulsecot.incident_to_cot( + incident, config=self.config, agency=data["agency"] + ) - for incident in data.get("incidents", []): - event: Union[str, None] = pulsecot.incident_to_cot( - incident, config=self.config, agency=data["agency"] - ) - - if not event: - self._logger.debug("Empty CoT") - continue + if not event: + self._logger.debug("Empty CoT") + continue - await self.put_queue(event) + await self.put_queue(event) async def get_pp_feed(self, agency_id: str) -> dict: if not agency_id: self._logger.warning("No agency_id specified, try `find_agency()`?") return + agency_id = agency_id.replace("\"","") + aed_url: str = f"{pulsecot.DEFAULT_PP_AED_URL}{agency_id}" + async with self.session.get(aed_url, auth=aiohttp.BasicAuth(pulsecot.DEFAULT_PP_AED_API_USERNAME, pulsecot.DEFAULT_PP_AED_API_PASSWORD)) as resp: + if resp.status != 200: + response_content = await resp.text() + self._logger.error("Received HTTP Status %s for %s", resp.status, aed_url) + self._logger.error(response_content) + return + json_resp = await resp.json(content_type="application/json") + if json_resp == None: + return - url: str = f"{pulsecot.DEFAULT_PP_URL}{agency_id}" - async with self.session.get(url) as resp: + aeds: Union[dict, None] = json_resp.get("aeds", {}) + if aeds: + data = {"aeds": aeds, "agency": self.agency_details[agency_id]} + await self.handle_data(data, "aeds") + inc_url: str = f"{pulsecot.DEFAULT_PP_URL}{agency_id}" + async with self.session.get(inc_url) as resp: if resp.status != 200: response_content = await resp.text() - self._logger.error("Received HTTP Status %s for %s", resp.status, url) + self._logger.error("Received HTTP Status %s for %s", resp.status, inc_url) self._logger.error(response_content) return @@ -84,7 +108,7 @@ async def get_pp_feed(self, agency_id: str) -> dict: data = {"incidents": active, "agency": self.agency_details[agency_id]} - await self.handle_data(data) + await self.handle_data(data, "incident") async def run(self, number_of_iterations=-1) -> None: """Runs this Thread, Reads from Pollers.""" @@ -98,6 +122,7 @@ async def run(self, number_of_iterations=-1) -> None: # Populate the agency hints: agencies = pulsecot.gnu.get_agencies() for agency_id in agency_ids.split(","): + agency_id = agency_id.replace("\"","") self.agency_details[agency_id] = agencies[agency_id] async with aiohttp.ClientSession() as self.session: diff --git a/pulsecot/constants.py b/pulsecot/constants.py index c2a3f98..87714b4 100644 --- a/pulsecot/constants.py +++ b/pulsecot/constants.py @@ -31,6 +31,10 @@ DEFAULT_COT_STALE: str = "130" DEFAULT_AGENCY_IDS: str = "21105,EMS1384,41000" DEFAULT_PP_URL: str = "https://web.pulsepoint.org/DB/giba.php?agency_id=" +DEFAULT_PP_AED_URL: str = "https://api.pulsepoint.org/v2/aed?apikey=mSwngLqWvSrQEVWu4eFF62Z2fZgsnCD5ZTaA8ZV&agencyid=" +DEFAULT_PP_AED_API_USERNAME: str = "aedviewer" +DEFAULT_PP_AED_API_PASSWORD: str = "1ScOvupxfZ" + DEFAULT_PP_CALL_TYPES_FILE: str = os.getenv( "PP_CALL_TYPES_FILE", diff --git a/pulsecot/functions.py b/pulsecot/functions.py index 5771ffc..4b86ab1 100644 --- a/pulsecot/functions.py +++ b/pulsecot/functions.py @@ -52,6 +52,96 @@ def create_tasks( """ return set([pulsecot.CADWorker(clitool.tx_queue, config)]) +def aed_to_cot_xml( + aed: dict, + config: Union[SectionProxy, None] = None, + agency: Union[dict, None] = None, +) -> Union[ET.Element, None]: + """ + Serializes a PulsePoint AEDs as Cursor-On-Target XML. + + Parameters + ---------- + incident : `dict` + Key/Value data struct of PulsePoint Incident. + config : `configparser.SectionProxy` + Configuration options and values. + + Returns + ------- + `xml.etree.ElementTree.Element` + Cursor-On-Target XML ElementTree object. + """ + config: dict = config or {} + locString = aed['LatitudeLongitude'].split(",") + lat = locString[0] + lon = locString[1] + + if lat is None or lon is None: + return None + + if lat == "0.0000000000" or lon == "0.0000000000": + return None + + remarks_fields = [] + + pp_id: str = aed["ID"] + cot_stale: int = int(config.get("COT_STALE")) + cot_host_id: str = config.get("COT_HOST_ID", pytak.DEFAULT_HOST_ID) + cot_uid: str = f"PulsePoint-{agency['agency_initials']}-AED-{pp_id}" + cot_type: str = "a-u-G" + + callsign = f"AED - {aed['AEDLocationName']}" + iconsetpath = "f7f71666-8b28-4b57-9fbb-e38e61d33b79/Google/earthquake.png" + if aed['AEDIsPrivate'] == True: + remarks_fields.append('Open to Public: No') + else: + remarks_fields.append('Open to Public: Yes') + remarks_fields.append(f"AED Location: {aed['AEDLocationDescription']}") + remarks_fields.append(f"Agency: {agency['short_agencyname']}") + + remarks_fields.append(f"PPID: {pp_id}") + + point = ET.Element("point") + point.set("lat", str(lat)) + point.set("lon", str(lon)) + + point.set("ce", str("9999999.0")) + point.set("le", str("9999999.0")) + point.set("hae", str("9999999.0")) + + contact = ET.Element("contact") + contact.set("callsign", str(callsign)) + + usericon = ET.Element("usericon") + usericon.set("iconsetpath", iconsetpath) + + detail = ET.Element("detail") + detail.append(contact) + detail.append(usericon) + + remarks = ET.Element("remarks") + + remarks_fields.append(f"{cot_host_id}") + + _remarks = "\n".join(list(filter(None, remarks_fields))) + + remarks.text = _remarks + detail.append(remarks) + + root = ET.Element("event") + root.set("version", "2.0") + root.set("type", cot_type) + root.set("uid", cot_uid) + root.set("how", "m-g") + root.set("time", pytak.cot_time()) + root.set("start", pytak.cot_time()) + root.set("stale", pytak.cot_time(cot_stale)) + + root.append(point) + root.append(detail) + + return root def incident_to_cot_xml( incident: dict, @@ -172,6 +262,15 @@ def incident_to_cot( b"\n".join([pytak.DEFAULT_XML_DECLARATION, ET.tostring(cot)]) if cot else None ) +def aed_to_cot( + call: dict, config: Union[dict, None] = None, agency: Union[dict, None] = None +) -> Union[bytes, None]: + """Wrapper that returns COT as an XML string.""" + cot: Union[ET.Element, None] = aed_to_cot_xml(call, config, agency) + return ( + b"\n".join([pytak.DEFAULT_XML_DECLARATION, ET.tostring(cot)]) if cot else None + ) + # [{'AddressTruncated': '1', # 'AgencyID': 'EMS1384', diff --git a/setup.py b/setup.py index a21feec..c87acfc 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ def publish(): description="PulsePoint to Cursor-On-Target Gateway for ATAK and other TAK systems.", author="Greg Albrecht", author_email="oss@undef.net", - package_data={"": ["LICENSE"]}, + package_data={"": ["LICENSE", "data/*.json"]}, license="Apache License, Version 2.0", long_description=open("README.rst").read(), long_description_content_type="text/x-rst", @@ -64,6 +64,5 @@ def publish(): "Programming Language :: Python", "License :: OSI Approved :: Apache Software License", ], - keywords=["CAD", "Cursor on Target", "ATAK", "TAK", "COT"], - package_data={'': ['data/*.json']}, + keywords=["CAD", "Cursor on Target", "ATAK", "TAK", "COT"] )