Skip to content

Commit

Permalink
Add APRS device tracker component
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
PhilRW committed Apr 23, 2019
1 parent 2871a65 commit 3763da3
Show file tree
Hide file tree
Showing 9 changed files with 552 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 @@ -23,6 +23,7 @@ homeassistant/components/alpha_vantage/* @fabaff
homeassistant/components/amazon_polly/* @robbiet480
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."""
186 changes: 186 additions & 0 deletions homeassistant/components/aprs/device_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""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)
from homeassistant.exceptions import PlatformNotReady
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):
raise PlatformNotReady("Timeout waiting for APRS to connect.")

if not aprs_listener.start_success:
raise PlatformNotReady(aprs_listener.start_message)

_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 @@ -178,6 +178,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 @@ -475,6 +478,9 @@ geniushub-client==0.3.6
# 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 @@ -60,6 +60,9 @@ aiounifi==4
# 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 @@ -111,6 +114,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 @@ -50,6 +50,7 @@
'aiounifi',
'aioswitcher',
'apns2',
'aprslib',
'av',
'axis',
'caldav',
Expand All @@ -63,6 +64,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 3763da3

Please sign in to comment.