diff --git a/CODEOWNERS b/CODEOWNERS index e0756e41932..40d41b28790 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/aprs/__init__.py b/homeassistant/components/aprs/__init__.py new file mode 100644 index 00000000000..20a023166ae --- /dev/null +++ b/homeassistant/components/aprs/__init__.py @@ -0,0 +1 @@ +"""The APRS component.""" diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py new file mode 100644 index 00000000000..905eb360bdf --- /dev/null +++ b/homeassistant/components/aprs/device_tracker.py @@ -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) diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json new file mode 100644 index 00000000000..fbe13ca8578 --- /dev/null +++ b/homeassistant/components/aprs/manifest.json @@ -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" + ] +} diff --git a/requirements_all.txt b/requirements_all.txt index 29bd06de1d8..8607dbef478 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7d683ccf6f..d45f6cd70fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 05fa9ed3ac6..4b3e2de3e42 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -52,6 +52,7 @@ TEST_REQUIREMENTS = ( 'aiounifi', 'aioswitcher', 'apns2', + 'aprslib', 'av', 'axis', 'caldav', @@ -66,6 +67,7 @@ TEST_REQUIREMENTS = ( 'feedparser-homeassistant', 'foobot_async', 'geojson_client', + 'geopy', 'georss_generic_client', 'georss_ign_sismologia_client', 'google-api-python-client', diff --git a/tests/components/aprs/__init__.py b/tests/components/aprs/__init__.py new file mode 100644 index 00000000000..c3e9dddb37f --- /dev/null +++ b/tests/components/aprs/__init__.py @@ -0,0 +1 @@ +"""Tests for the APRS component.""" diff --git a/tests/components/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py new file mode 100644 index 00000000000..a90f11a01bc --- /dev/null +++ b/tests/components/aprs/test_device_tracker.py @@ -0,0 +1,351 @@ +"""Test APRS device tracker.""" +from unittest.mock import Mock, patch + +import aprslib + +import homeassistant.components.aprs.device_tracker as device_tracker +from homeassistant.const import EVENT_HOMEASSISTANT_START + +from tests.common import get_test_home_assistant + +DEFAULT_PORT = 14580 + +TEST_CALLSIGN = 'testcall' +TEST_COORDS_NULL_ISLAND = (0, 0) +TEST_FILTER = 'testfilter' +TEST_HOST = 'testhost' +TEST_PASSWORD = 'testpass' + + +def test_make_filter(): + """Test filter.""" + callsigns = [ + 'CALLSIGN1', + 'callsign2' + ] + res = device_tracker.make_filter(callsigns) + assert res == "b/CALLSIGN1 b/CALLSIGN2" + + +def test_gps_accuracy_0(): + """Test GPS accuracy level 0.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 0) + assert acc == 0 + + +def test_gps_accuracy_1(): + """Test GPS accuracy level 1.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 1) + assert acc == 186 + + +def test_gps_accuracy_2(): + """Test GPS accuracy level 2.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 2) + assert acc == 1855 + + +def test_gps_accuracy_3(): + """Test GPS accuracy level 3.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 3) + assert acc == 18553 + + +def test_gps_accuracy_4(): + """Test GPS accuracy level 4.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 4) + assert acc == 111319 + + +def test_gps_accuracy_invalid_int(): + """Test GPS accuracy with invalid input.""" + level = 5 + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_gps_accuracy_invalid_string(): + """Test GPS accuracy with invalid input.""" + level = "not an int" + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_gps_accuracy_invalid_float(): + """Test GPS accuracy with invalid input.""" + level = 1.2 + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_aprs_listener(): + """Test listener thread.""" + with patch('aprslib.IS') as mock_ais: + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + port = DEFAULT_PORT + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + mock_ais.assert_called_with( + callsign, passwd=password, host=host, port=port) + + +def test_aprs_listener_start_fail(): + """Test listener thread start failure.""" + with patch('aprslib.IS.connect', + side_effect=aprslib.ConnectionError("Unable to connect.")): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert not listener.start_success + assert listener.start_message == "Unable to connect." + + +def test_aprs_listener_stop(): + """Test listener thread stop.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.ais.close = Mock() + listener.run() + listener.stop() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + assert listener.start_success + listener.ais.close.assert_called_with() + + +def test_aprs_listener_rx_msg(): + """Test rx_msg.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_ALTITUDE: 0 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={"altitude": 0}) + + +def test_aprs_listener_rx_msg_ambiguity(): + """Test rx_msg with posambiguity.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_POS_AMBIGUITY: 1 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={device_tracker.ATTR_GPS_ACCURACY: 186}) + + +def test_aprs_listener_rx_msg_ambiguity_invalid(): + """Test rx_msg with invalid posambiguity.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_POS_AMBIGUITY: 5 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={}) + + +def test_aprs_listener_rx_msg_no_position(): + """Test rx_msg with non-position report.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "invalid" + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_not_called() + + +def test_setup_scanner(): + """Test setup_scanner.""" + with patch('homeassistant.components.' + 'aprs.device_tracker.AprsListenerThread') as listener: + hass = get_test_home_assistant() + hass.start() + + config = { + 'username': TEST_CALLSIGN, + 'password': TEST_PASSWORD, + 'host': TEST_HOST, + 'callsigns': [ + 'XX0FOO*', + 'YY0BAR-1'] + } + + see = Mock() + res = device_tracker.setup_scanner(hass, config, see) + hass.bus.fire(EVENT_HOMEASSISTANT_START) + hass.stop() + + assert res + listener.assert_called_with( + TEST_CALLSIGN, TEST_PASSWORD, TEST_HOST, + 'b/XX0FOO* b/YY0BAR-1', see) + + +def test_setup_scanner_timeout(): + """Test setup_scanner failure from timeout.""" + hass = get_test_home_assistant() + hass.start() + + config = { + 'username': TEST_CALLSIGN, + 'password': TEST_PASSWORD, + 'host': "localhost", + 'timeout': 0.01, + 'callsigns': [ + 'XX0FOO*', + 'YY0BAR-1'] + } + + see = Mock() + try: + assert not device_tracker.setup_scanner(hass, config, see) + finally: + hass.stop()