diff --git a/.coveragerc b/.coveragerc index 5dd2c66b56eca2..b2ad0fa68074c7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -438,7 +438,6 @@ omit = homeassistant/components/device_tracker/keenetic_ndms2.py homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/linksys_smart.py - homeassistant/components/device_tracker/luci.py homeassistant/components/device_tracker/mikrotik.py homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index f479dea184bfbf..f4d0b6607cc964 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -36,6 +36,12 @@ class InvalidLuciTokenError(HomeAssistantError): pass +class LuciRpcMethodNotFoundError(HomeAssistantError): + """When an invalid method is called.""" + + pass + + def get_scanner(hass, config): """Validate the configuration and return a Luci scanner.""" scanner = LuciDeviceScanner(config[DOMAIN]) @@ -60,6 +66,7 @@ def __init__(self, config): self.refresh_token() self.mac2name = None self.success_init = self.token is not None + self.use_ip_neighbors_table = False def refresh_token(self): """Get a new token.""" @@ -98,11 +105,28 @@ def _update_info(self): _LOGGER.info("Checking ARP") - url = '{}/cgi-bin/luci/rpc/sys'.format(self.origin) + sys_url = '{}/cgi-bin/luci/rpc/sys'.format(self.origin) + ip_url = '{}/cgi-bin/luci/rpc/ip'.format(self.origin) try: - result = _req_json_rpc( - url, 'net.arptable', params={'auth': self.token}) + if self.use_ip_neighbors_table: + # Newer OpenWRT releases (18.06+) + result = _req_json_rpc( + ip_url, 'neighbors', {"family": 4}, + params={'auth': self.token}) + else: + try: + # Older OpenWRT releases (pre-18.06) + result = _req_json_rpc( + sys_url, 'net.arptable', params={ + 'auth': self.token}) + except LuciRpcMethodNotFoundError: + # This is normal for newer OpenWRT (18.06+) + _LOGGER.error('method net.arptable not found. ' + 'Will try ip neighbors instead') + self.use_ip_neighbors_table = True + self._update_info() + return False except InvalidLuciTokenError: _LOGGER.info("Refreshing token") self.refresh_token() @@ -111,10 +135,24 @@ def _update_info(self): if result: self.last_results = [] for device_entry in result: - # Check if the Flags for each device contain - # NUD_REACHABLE and if so, add it to last_results - if int(device_entry['Flags'], 16) & 0x2: - self.last_results.append(device_entry['HW address']) + _LOGGER.debug('device_entry. %s', device_entry) + + if "Flags" in device_entry: + # Older OpenWRT releases (pre-18.06) + # + # Check if the Flags for each device contain + # NUD_REACHABLE and if so, add it to last_results + if device_entry['Flags'] == "0x2": + self.last_results.append(device_entry['HW address']) + elif "mac" in device_entry: + # Newer OpenWRT releases (18.06+) + # + # Do not use `reachable` or `stale` values + # as they flap constantly even + # when the device is inside the network. + # The very existence of the mac in the results + # is enough to determine the "device is home" + self.last_results.append(device_entry['mac']) return True @@ -138,7 +176,20 @@ def _req_json_rpc(url, method, *args, **kwargs): _LOGGER.exception("Failed to parse response from luci") return try: - return result['result'] + result_value = result['result'] + + if result_value is not None: + return result_value + + # On 18.06, we want to check for error 'Method not Found' + error_message = result['error']['message'] + error_code = result['error']['code'] + _LOGGER.error( + "method: '%s' returned an " + "error '%s' (code: '%s).", + method, error_message, error_code) + if error_code == -32601: + raise LuciRpcMethodNotFoundError except KeyError: _LOGGER.exception("No result in response from luci") return diff --git a/tests/components/device_tracker/test_luci.py b/tests/components/device_tracker/test_luci.py new file mode 100644 index 00000000000000..90258e17c671d8 --- /dev/null +++ b/tests/components/device_tracker/test_luci.py @@ -0,0 +1,109 @@ +"""The tests for Efergy sensor platform.""" +import unittest +import requests_mock +from homeassistant.components import device_tracker + +from homeassistant.setup import setup_component +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL) + +from tests.common import load_fixture, get_test_home_assistant + +TOKEN = 'bf13be9ca4cea446c49410963492282a' + +LUCI_CONFIG = { + 'platform': 'luci', + CONF_HOST: "127.0.0.1", + CONF_USERNAME: 'blahuser', + CONF_PASSWORD: "blahpass", + CONF_SSL: False +} + + +def mock_responses_version_pre_18(mock): + """Mock responses for Luci RPC version pre-18.""" + base_url = 'http://{}'.format(LUCI_CONFIG[CONF_HOST]) + mock.post( + '{}/cgi-bin/luci/rpc/auth'.format(base_url), + text=load_fixture('luci_auth.json')) + mock.post( + '{}/cgi-bin/luci/rpc/sys?auth={}'.format(base_url, TOKEN), + text=load_fixture('luci_arptable_legacy.json')) + mock.post( + '{}/cgi-bin/luci/rpc/uci?auth={}'.format(base_url, TOKEN), + text=load_fixture('luci_dhcp.json')) + + +def mock_responses_version_v18(mock): + """Mock responses for Luci RPC version v18.""" + base_url = 'http://{}'.format(LUCI_CONFIG[CONF_HOST]) + mock.post( + '{}/cgi-bin/luci/rpc/auth'.format(base_url), + text=load_fixture('luci_auth.json')) + mock.post( + '{}/cgi-bin/luci/rpc/sys?auth={}'.format(base_url, TOKEN), + text=load_fixture('luci_arptable_v18.json')) + mock.post( + '{}/cgi-bin/luci/rpc/ip?auth={}'.format(base_url, TOKEN), + text=load_fixture('luci_neighbors.json')) + mock.post( + '{}/cgi-bin/luci/rpc/uci?auth={}'.format(base_url, TOKEN), + text=load_fixture('luci_dhcp.json')) + + +class TestLuciDeviceScanner(unittest.TestCase): + """Tests the Luci device scanner platform.""" + + DEVICES = [] + + def setUp(self): + """Initialize values for this test case class.""" + self.hass = get_test_home_assistant() + self.config = LUCI_CONFIG + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def setup_luci_component(self): + """Set up with basic config.""" + self.assertTrue( + setup_component(self.hass, + device_tracker.DOMAIN, { + device_tracker.DOMAIN: LUCI_CONFIG + })) + self.hass.block_till_done() + + @requests_mock.Mocker() + def test_pre_v18_success(self, mock): + """Test for platform on pre-v18.""" + mock_responses_version_pre_18(mock) + + self.setup_luci_component() + + self.assertEqual('home', self.hass.states.get( + 'device_tracker.raspberrypi3').state) + self.assertIsNone(self.hass.states.get( + 'device_tracker.b8e8995c6e12')) + self.assertIsNone(self.hass.states.get( + 'device_tracker.b8e8995c6e10')) + + self.assertEqual('home', self.hass.states.get( + 'device_tracker.b8e8995c6e11').state) + + @requests_mock.Mocker() + def test_v18_or_newer_success(self, mock): + """Test for platform on v18.06+.""" + mock_responses_version_v18(mock) + + self.setup_luci_component() + + self.assertIsNone(self.hass.states.get( + 'device_tracker.188b400814b2')) + self.assertEqual('home', self.hass.states.get( + 'device_tracker.mydevicename').state) + + self.assertEqual('home', self.hass.states.get( + 'device_tracker.b8e8995c6e15').state) + self.assertEqual('home', self.hass.states.get( + 'device_tracker.b827eb117c22').state) diff --git a/tests/fixtures/luci_arptable_legacy.json b/tests/fixtures/luci_arptable_legacy.json new file mode 100644 index 00000000000000..8a51e0cb89d34a --- /dev/null +++ b/tests/fixtures/luci_arptable_legacy.json @@ -0,0 +1,30 @@ +{ + "id": null, + "result": [ + { + "IP address": "10.10.10.249", + "HW address": "B8:E8:99:5C:6E:10", + "HW type": "0x1", + "Flags": "0x6", + "Mask": "*", + "Device": "br-lan" + }, + { + "IP address": "10.10.10.250", + "HW address": "B8:E8:99:5C:6E:11", + "HW type": "0x1", + "Flags": "0x2", + "Mask": "*", + "Device": "br-lan" + }, + { + "IP address": "10.10.10.251", + "HW address": "B8:E8:99:5C:6E:12", + "HW type": "0x1", + "Flags": "0x2", + "Mask": "*", + "Device": "br-lan" + } + ], + "error": null +} \ No newline at end of file diff --git a/tests/fixtures/luci_arptable_v18.json b/tests/fixtures/luci_arptable_v18.json new file mode 100644 index 00000000000000..fa58db526fd75d --- /dev/null +++ b/tests/fixtures/luci_arptable_v18.json @@ -0,0 +1,8 @@ +{ + "id": null, + "result": null, + "error": { + "message": "Method not found.", + "code": -32601 + } +} \ No newline at end of file diff --git a/tests/fixtures/luci_auth.json b/tests/fixtures/luci_auth.json new file mode 100644 index 00000000000000..15e1200efa6165 --- /dev/null +++ b/tests/fixtures/luci_auth.json @@ -0,0 +1,5 @@ +{ + "id": null, + "result": "bf13be9ca4cea446c49410963492282a", + "error": null +} \ No newline at end of file diff --git a/tests/fixtures/luci_dhcp.json b/tests/fixtures/luci_dhcp.json new file mode 100644 index 00000000000000..c441c82c5bab4b --- /dev/null +++ b/tests/fixtures/luci_dhcp.json @@ -0,0 +1,26 @@ +{ + "id": null, + "result": { + "cfg21fe63": { + ".name": "cfg21fe63", + ".type": "host", + "name": "raspberrypi3", + ".index": 32, + "mac": "b8:e8:99:5c:6e:12", + "dns": "1", + ".anonymous": true, + "ip": "10.10.10.251" + }, + "cfg21fe62": { + ".name": "cfg21fe62", + ".type": "host", + "name": "mydevicename", + ".index": 32, + "mac": "18:8b:40:08:14:b2", + "dns": "1", + ".anonymous": true, + "ip": "10.10.10.25" + } + }, + "error": null +} \ No newline at end of file diff --git a/tests/fixtures/luci_neighbors.json b/tests/fixtures/luci_neighbors.json new file mode 100644 index 00000000000000..fcd41f4ff37763 --- /dev/null +++ b/tests/fixtures/luci_neighbors.json @@ -0,0 +1,117 @@ +{ + "id": null, + "result": [ + { + "dev": "br-lan", + "stale": true, + "mac": "B8:E8:99:5C:6E:15", + "noarp": false, + "dest": "10.10.10.249", + "permanent": false, + "failed": false, + "family": 4, + "proxy": false, + "router": false, + "reachable": false, + "probe": false, + "delay": false, + "incomplete": false + }, + { + "dev": "br-lan", + "stale": false, + "mac": "B8:27:EB:11:7C:22", + "noarp": false, + "dest": "10.10.10.8", + "permanent": false, + "failed": false, + "family": 4, + "proxy": false, + "router": false, + "reachable": true, + "probe": false, + "delay": false, + "incomplete": false + }, + { + "dev": "br-lan", + "stale": true, + "mac": "18:8B:40:08:14:B2", + "noarp": false, + "dest": "10.10.10.155", + "permanent": false, + "failed": false, + "family": 4, + "proxy": false, + "router": false, + "reachable": false, + "probe": false, + "delay": false, + "incomplete": false + }, + { + "dev": "br-lan", + "stale": true, + "mac": "C8:69:CD:12:B3:32", + "noarp": false, + "dest": "10.10.10.113", + "permanent": false, + "failed": false, + "family": 4, + "proxy": false, + "router": false, + "reachable": false, + "probe": false, + "delay": false, + "incomplete": false + }, + { + "dev": "br-lan", + "stale": false, + "noarp": false, + "dest": "10.10.10.199", + "permanent": false, + "failed": true, + "family": 4, + "proxy": false, + "router": false, + "reachable": false, + "probe": false, + "delay": false, + "incomplete": false + }, + { + "dev": "br-lan", + "stale": false, + "mac": "34:CE:00:50:1B:A0", + "noarp": false, + "dest": "10.10.10.175", + "permanent": false, + "failed": false, + "family": 4, + "proxy": false, + "router": false, + "reachable": true, + "probe": false, + "delay": false, + "incomplete": false + }, + { + "dev": "br-lan", + "stale": true, + "mac": "00:2D:EE:06:00:A0", + "noarp": false, + "dest": "10.10.10.5", + "permanent": false, + "failed": false, + "family": 4, + "proxy": false, + "router": false, + "reachable": false, + "probe": false, + "delay": false, + "incomplete": false + } + ], + "error": null +} \ No newline at end of file