core/homeassistant/components/aprs/device_tracker.py

186 lines
5.5 KiB
Python
Raw Normal View History

"""Support for APRS device tracking."""
import logging
import threading
2019-10-12 20:04:42 +00:00
import geopy.distance
import aprslib
from aprslib import ConnectionError as AprsConnectionError
from aprslib import LoginError
import voluptuous as vol
2019-06-11 22:57:29 +00:00
from homeassistant.components.device_tracker import PLATFORM_SCHEMA
from homeassistant.const import (
2019-07-31 19:25:30 +00:00
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_HOST,
CONF_PASSWORD,
CONF_TIMEOUT,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify
2019-07-31 19:25:30 +00:00
DOMAIN = "aprs"
_LOGGER = logging.getLogger(__name__)
2019-07-31 19:25:30 +00:00
ATTR_ALTITUDE = "altitude"
ATTR_COURSE = "course"
ATTR_COMMENT = "comment"
ATTR_FROM = "from"
ATTR_FORMAT = "format"
ATTR_POS_AMBIGUITY = "posambiguity"
ATTR_SPEED = "speed"
2019-07-31 19:25:30 +00:00
CONF_CALLSIGNS = "callsigns"
2019-07-31 19:25:30 +00:00
DEFAULT_HOST = "rotate.aprs2.net"
DEFAULT_PASSWORD = "-1"
DEFAULT_TIMEOUT = 30.0
FILTER_PORT = 14580
2019-07-31 19:25:30 +00:00
MSG_FORMATS = ["compressed", "uncompressed", "mic-e"]
2019-07-31 19:25:30 +00:00
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."""
2019-07-31 19:25:30 +00:00
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."""
2019-07-31 19:25:30 +00:00
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 = f"APRS position ambiguity must be 0-4, not '{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)
2019-07-31 19:25:30 +00:00
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."""
2019-07-31 19:25:30 +00:00
def __init__(
self, callsign: str, password: str, host: str, server_filter: str, see
):
"""Initialize the class."""
super().__init__()
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(
2019-07-31 19:25:30 +00:00
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)
try:
2019-07-31 19:25:30 +00:00
_LOGGER.info(
"Opening connection to %s with callsign %s.", self.host, self.callsign
)
self.ais.connect()
self.start_complete(
True, f"Connected to {self.host} with callsign {self.callsign}."
2019-07-31 19:25:30 +00:00
)
self.ais.consumer(callback=self.rx_msg, immortal=True)
except (AprsConnectionError, LoginError) as err:
self.start_complete(False, str(err))
except OSError:
2019-07-31 19:25:30 +00:00
_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:
2019-07-31 19:25:30 +00:00
attrs[ATTR_GPS_ACCURACY] = gps_accuracy((lat, lon), pos_amb)
except ValueError:
_LOGGER.warning(
2019-07-31 19:25:30 +00:00
"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)