Skip to content

Commit

Permalink
Add APRS device tracker component (#22469)
Browse files Browse the repository at this point in the history
* Add APRS device tracker component

This component keeps open a connection to the APRS-IS infrastructure so
messages generated by filtered callsigns can be immediately acted upon.
Any messages with certain values for the 'format' key are position
reports and are parsed into device tracker entities.

* Log errors and return if startup failure

* Fix unit tests
  • Loading branch information
PhilRW authored and balloob committed Jun 11, 2019
1 parent 0a7919a commit 8fcfcc4
Show file tree
Hide file tree
Showing 9 changed files with 566 additions and 0 deletions.
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ homeassistant/components/amazon_polly/* @robbiet480
homeassistant/components/ambiclimate/* @danielhiversen
homeassistant/components/ambient_station/* @bachya
homeassistant/components/api/* @home-assistant/core
homeassistant/components/aprs/* @PhilRW
homeassistant/components/arduino/* @fabaff
homeassistant/components/arest/* @fabaff
homeassistant/components/asuswrt/* @kennedyshead
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/aprs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The APRS component."""
187 changes: 187 additions & 0 deletions homeassistant/components/aprs/device_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Support for APRS device tracking."""

import logging
import threading

import voluptuous as vol

from homeassistant.components.device_tracker import (
ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, PLATFORM_SCHEMA)
from homeassistant.const import (
CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP)
import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify

DOMAIN = 'aprs'

_LOGGER = logging.getLogger(__name__)

ATTR_ALTITUDE = 'altitude'
ATTR_COURSE = 'course'
ATTR_COMMENT = 'comment'
ATTR_FROM = 'from'
ATTR_FORMAT = 'format'
ATTR_POS_AMBIGUITY = 'posambiguity'
ATTR_SPEED = 'speed'

CONF_CALLSIGNS = 'callsigns'

DEFAULT_HOST = 'rotate.aprs2.net'
DEFAULT_PASSWORD = '-1'
DEFAULT_TIMEOUT = 30.0

FILTER_PORT = 14580

MSG_FORMATS = ['compressed', 'uncompressed', 'mic-e']

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_CALLSIGNS): cv.ensure_list,
vol.Required(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD,
default=DEFAULT_PASSWORD): cv.string,
vol.Optional(CONF_HOST,
default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_TIMEOUT,
default=DEFAULT_TIMEOUT): vol.Coerce(float),
})


def make_filter(callsigns: list) -> str:
"""Make a server-side filter from a list of callsigns."""
return ' '.join('b/{0}'.format(cs.upper()) for cs in callsigns)


def gps_accuracy(gps, posambiguity: int) -> int:
"""Calculate the GPS accuracy based on APRS posambiguity."""
import geopy.distance

pos_a_map = {0: 0,
1: 1 / 600,
2: 1 / 60,
3: 1 / 6,
4: 1}
if posambiguity in pos_a_map:
degrees = pos_a_map[posambiguity]

gps2 = (gps[0], gps[1] + degrees)
dist_m = geopy.distance.distance(gps, gps2).m

accuracy = round(dist_m)
else:
message = "APRS position ambiguity must be 0-4, not '{0}'.".format(
posambiguity)
raise ValueError(message)

return accuracy


def setup_scanner(hass, config, see, discovery_info=None):
"""Set up the APRS tracker."""
callsigns = config.get(CONF_CALLSIGNS)
server_filter = make_filter(callsigns)

callsign = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
host = config.get(CONF_HOST)
timeout = config.get(CONF_TIMEOUT)
aprs_listener = AprsListenerThread(
callsign, password, host, server_filter, see)

def aprs_disconnect(event):
"""Stop the APRS connection."""
aprs_listener.stop()

aprs_listener.start()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, aprs_disconnect)

if not aprs_listener.start_event.wait(timeout):
_LOGGER.error("Timeout waiting for APRS to connect.")
return

if not aprs_listener.start_success:
_LOGGER.error(aprs_listener.start_message)
return

_LOGGER.debug(aprs_listener.start_message)
return True


class AprsListenerThread(threading.Thread):
"""APRS message listener."""

def __init__(self, callsign: str, password: str, host: str,
server_filter: str, see):
"""Initialize the class."""
super().__init__()

import aprslib

self.callsign = callsign
self.host = host
self.start_event = threading.Event()
self.see = see
self.server_filter = server_filter
self.start_message = ""
self.start_success = False

self.ais = aprslib.IS(
self.callsign, passwd=password, host=self.host, port=FILTER_PORT)

def start_complete(self, success: bool, message: str):
"""Complete startup process."""
self.start_message = message
self.start_success = success
self.start_event.set()

def run(self):
"""Connect to APRS and listen for data."""
self.ais.set_filter(self.server_filter)
from aprslib import ConnectionError as AprsConnectionError
from aprslib import LoginError

try:
_LOGGER.info("Opening connection to %s with callsign %s.",
self.host, self.callsign)
self.ais.connect()
self.start_complete(
True,
"Connected to {0} with callsign {1}.".format(
self.host, self.callsign))
self.ais.consumer(callback=self.rx_msg, immortal=True)
except (AprsConnectionError, LoginError) as err:
self.start_complete(False, str(err))
except OSError:
_LOGGER.info("Closing connection to %s with callsign %s.",
self.host, self.callsign)

def stop(self):
"""Close the connection to the APRS network."""
self.ais.close()

def rx_msg(self, msg: dict):
"""Receive message and process if position."""
_LOGGER.debug("APRS message received: %s", str(msg))
if msg[ATTR_FORMAT] in MSG_FORMATS:
dev_id = slugify(msg[ATTR_FROM])
lat = msg[ATTR_LATITUDE]
lon = msg[ATTR_LONGITUDE]

attrs = {}
if ATTR_POS_AMBIGUITY in msg:
pos_amb = msg[ATTR_POS_AMBIGUITY]
try:
attrs[ATTR_GPS_ACCURACY] = gps_accuracy((lat, lon),
pos_amb)
except ValueError:
_LOGGER.warning(
"APRS message contained invalid posambiguity: %s",
str(pos_amb))
for attr in [ATTR_ALTITUDE,
ATTR_COMMENT,
ATTR_COURSE,
ATTR_SPEED]:
if attr in msg:
attrs[attr] = msg[attr]

self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs)
11 changes: 11 additions & 0 deletions homeassistant/components/aprs/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "aprs",
"name": "APRS",
"documentation": "https://www.home-assistant.io/components/aprs",
"dependencies": [],
"codeowners": ["@PhilRW"],
"requirements": [
"aprslib==0.6.46",
"geopy==1.19.0"
]
}
6 changes: 6 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ apcaccess==0.0.13
# homeassistant.components.apns
apns2==0.3.0

# homeassistant.components.aprs
aprslib==0.6.46

# homeassistant.components.aqualogic
aqualogic==1.0

Expand Down Expand Up @@ -498,6 +501,9 @@ geniushub-client==0.4.11
# homeassistant.components.usgs_earthquakes_feed
geojson_client==0.3

# homeassistant.components.aprs
geopy==1.19.0

# homeassistant.components.geo_rss_events
georss_generic_client==0.2

Expand Down
6 changes: 6 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ ambiclimate==0.1.3
# homeassistant.components.apns
apns2==0.3.0

# homeassistant.components.aprs
aprslib==0.6.46

# homeassistant.components.stream
av==6.1.2

Expand Down Expand Up @@ -123,6 +126,9 @@ gTTS-token==1.1.3
# homeassistant.components.usgs_earthquakes_feed
geojson_client==0.3

# homeassistant.components.aprs
geopy==1.19.0

# homeassistant.components.geo_rss_events
georss_generic_client==0.2

Expand Down
2 changes: 2 additions & 0 deletions script/gen_requirements_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
'aiounifi',
'aioswitcher',
'apns2',
'aprslib',
'av',
'axis',
'caldav',
Expand All @@ -66,6 +67,7 @@
'feedparser-homeassistant',
'foobot_async',
'geojson_client',
'geopy',
'georss_generic_client',
'georss_ign_sismologia_client',
'google-api-python-client',
Expand Down
1 change: 1 addition & 0 deletions tests/components/aprs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the APRS component."""
Loading

0 comments on commit 8fcfcc4

Please sign in to comment.