Skip to content

Commit 2911d6a

Browse files
committed
Fix #256 Use async process to query Device API
1 parent 2c4b693 commit 2911d6a

File tree

3 files changed

+115
-80
lines changed

3 files changed

+115
-80
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ authors = [
1616
{ name = "Danny Huang", email = "[email protected]" },
1717
{ name = "Andrew Quijano", email = "[email protected]" }
1818
]
19-
license = "MIT"
19+
license = "Apache-2.0"
2020
classifiers = [
2121
"Programming Language :: Python :: 3",
2222
"Operating System :: OS Independent",

src/libinspector/common.py

Lines changed: 0 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import datetime
2-
import os
32
import time
43
import threading
54
import json
65
import functools
7-
import requests
86
import libinspector.global_state
97
from libinspector.privacy import is_ad_tracked
108
import pandas as pd
@@ -217,74 +215,6 @@ def get_remote_hostnames(mac_address: str):
217215
return remote_hostnames
218216

219217

220-
@st.cache_data(show_spinner=False)
221-
def call_predict_api(dhcp_hostname: str, oui_vendor: str, remote_hostnames: str,
222-
mac_address: str, url="https://dev-id-1.tailcedbd.ts.net/predict") -> dict:
223-
"""
224-
Call the predicting API with the given fields.
225-
This takes the MAC Address of an inspected device
226-
and checks the `devices` table, where iot inspector core collected meta-data
227-
based on SSDP discovery.
228-
Please see Page 11 Table A.1. We explain how to get the data from IoT Inspector:
229-
1. oui_friendly: we use the OUI database from IoT Inspector Core
230-
2. dhcp_hostname: this is extracted from the 'devices' table, check meta-data and look for 'dhcp_hostname' key.
231-
3. remote_hostnames: IoT Inspector collects this information the DHCP hostname via either DNS or SNI
232-
Args:
233-
dhcp_hostname (str): The DHCP hostname of the device we want to use AI to get more info about
234-
oui_vendor (str): The OUI vendor of the device we want to use AI to get more info about
235-
remote_hostnames (str): The remote hostnames the device has contacted
236-
mac_address (str): The MAC address of the device we want to use AI to get more info about
237-
url (str): The API endpoint.
238-
Returns:
239-
dict: The response text from the API.
240-
"""
241-
api_key = os.environ.get("API_KEY", "momo")
242-
device_tracked_key = f'tracked@{mac_address}'
243-
244-
headers = {
245-
"Content-Type": "application/json",
246-
"x-api-key": api_key
247-
}
248-
data = {
249-
"prolific_id": config_get("prolific_id", ""),
250-
"mac_address": mac_address,
251-
"fields": {
252-
"oui_friendly": oui_vendor,
253-
"dhcp_hostname": dhcp_hostname,
254-
"remote_hostnames": remote_hostnames,
255-
"user_agent_info": "",
256-
"netdisco_info": "",
257-
"user_labels": "",
258-
"talks_to_ads": config_get(device_tracked_key, False)
259-
}
260-
}
261-
non_empty_field_values = [
262-
field_value
263-
for field_name, field_value in data["fields"].items()
264-
if field_name != "talks_to_ads" and bool(field_value)
265-
]
266-
# TODO: We should make this 2 fields eventually...
267-
if len(non_empty_field_values) < 1:
268-
logger.warning(
269-
"[Device ID API] Fewer than two string fields in data are non-empty; refusing to call API. Wait until IoT Inspector collects more data.")
270-
raise RuntimeError(
271-
"Fewer than two string fields in data are non-empty; refusing to call API. Wait until IoT Inspector collects more data.")
272-
273-
logger.info("[Device ID API] Calling API with data: %s", json.dumps(data, indent=4))
274-
275-
try:
276-
response = requests.post(url, headers=headers, json=data, timeout=10)
277-
response.raise_for_status()
278-
result = response.json()
279-
except (requests.exceptions.RequestException, ValueError) as e:
280-
logger.error(f"[Device ID API] API request failed: {e}")
281-
raise RuntimeError("API request failed, not caching this result.")
282-
283-
logger.info("[Device ID API] API query successful!")
284-
config_set(f'device_details@{mac_address}', result)
285-
return result
286-
287-
288218
def get_human_readable_time(timestamp=None):
289219
"""
290220
Convert a timestamp to a human-readable time format.

src/libinspector/device_list_page.py

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
import libinspector.global_state
55
import common
66
import logging
7+
import functools
8+
import threading
9+
import os
10+
import requests
11+
712

813
logger = logging.getLogger("client")
914

@@ -14,6 +19,112 @@ def show():
1419
toast_obj = st.toast('Discovering devices...')
1520
show_list(toast_obj)
1621

22+
@functools.lru_cache(maxsize=1)
23+
def start_inspector_once():
24+
"""Initialize the Inspector core only once."""
25+
with st.spinner("Starting API Thread..."):
26+
threading.Thread(
27+
target=worker_thread,
28+
daemon=True,
29+
)
30+
31+
32+
def worker_thread():
33+
"""
34+
A worker thread to periodically clear the cache of call_predict_api.
35+
"""
36+
while True:
37+
time.sleep(15)
38+
db_conn, rwlock = libinspector.global_state.db_conn_and_lock
39+
# Get the list of devices from the database
40+
sql = """
41+
SELECT * \
42+
FROM devices
43+
WHERE is_gateway = 0 \
44+
"""
45+
device_list = []
46+
with rwlock:
47+
for device_dict in db_conn.execute(sql):
48+
device_list.append(dict(device_dict))
49+
50+
# Getting inputs and calling API
51+
for device_dict in device_list:
52+
dhcp_hostname, oui_vendor = common.get_device_metadata(device_dict['mac_address'])
53+
remote_hostnames = common.get_remote_hostnames(device_dict['mac_address'])
54+
try:
55+
api_output = call_predict_api(dhcp_hostname, oui_vendor, remote_hostnames, device_dict['mac_address'])
56+
common.config_set(f'device_details@{device_dict["mac_address"]}', api_output)
57+
except RuntimeError:
58+
continue
59+
60+
61+
@functools.cache
62+
def call_predict_api(dhcp_hostname: str, oui_vendor: str, remote_hostnames: str,
63+
mac_address: str, url="https://dev-id-1.tailcedbd.ts.net/predict") -> dict:
64+
"""
65+
Call the predicting API with the given fields.
66+
This takes the MAC Address of an inspected device
67+
and checks the `devices` table, where iot inspector core collected meta-data
68+
based on SSDP discovery.
69+
Please see Page 11 Table A.1. We explain how to get the data from IoT Inspector:
70+
1. oui_friendly: we use the OUI database from IoT Inspector Core
71+
2. dhcp_hostname: this is extracted from the 'devices' table, check meta-data and look for 'dhcp_hostname' key.
72+
3. remote_hostnames: IoT Inspector collects this information the DHCP hostname via either DNS or SNI
73+
Args:
74+
dhcp_hostname (str): The DHCP hostname of the device we want to use AI to get more info about
75+
oui_vendor (str): The OUI vendor of the device we want to use AI to get more info about
76+
remote_hostnames (str): The remote hostnames the device has contacted
77+
mac_address (str): The MAC address of the device we want to use AI to get more info about
78+
url (str): The API endpoint.
79+
Returns:
80+
dict: The response text from the API.
81+
"""
82+
api_key = os.environ.get("API_KEY", "momo")
83+
device_tracked_key = f'tracked@{mac_address}'
84+
85+
headers = {
86+
"Content-Type": "application/json",
87+
"x-api-key": api_key
88+
}
89+
data = {
90+
"prolific_id": common.config_get("prolific_id", ""),
91+
"mac_address": mac_address,
92+
"fields": {
93+
"oui_friendly": oui_vendor,
94+
"dhcp_hostname": dhcp_hostname,
95+
"remote_hostnames": remote_hostnames,
96+
"user_agent_info": "",
97+
"netdisco_info": "",
98+
"user_labels": "",
99+
"talks_to_ads": common.config_get(device_tracked_key, False)
100+
}
101+
}
102+
non_empty_field_values = [
103+
field_value
104+
for field_name, field_value in data["fields"].items()
105+
if field_name != "talks_to_ads" and bool(field_value)
106+
]
107+
# TODO: We should make this 2 fields eventually...
108+
if len(non_empty_field_values) < 1:
109+
logger.warning(
110+
"[Device ID API] Fewer than two string fields in data are non-empty; refusing to call API. Wait until IoT Inspector collects more data.")
111+
raise RuntimeError(
112+
"Fewer than two string fields in data are non-empty; refusing to call API. Wait until IoT Inspector collects more data.")
113+
114+
# TODO: Used for debugging, risk is minimal since this is client-facing code. Should have some flag for production.
115+
logger.info("[Device ID API] Calling API with data: %s", json.dumps(data, indent=4))
116+
117+
try:
118+
response = requests.post(url, headers=headers, json=data, timeout=10)
119+
response.raise_for_status()
120+
result = response.json()
121+
except (requests.exceptions.RequestException, ValueError) as e:
122+
logger.error(f"[Device ID API] API request failed: {e}")
123+
raise RuntimeError("API request failed, not caching this result.")
124+
125+
logger.info("[Device ID API] API query successful!")
126+
return result
127+
17128

18129
@st.fragment(run_every=1)
19130
def show_list(toast_obj: st.toast):
@@ -58,7 +169,6 @@ def show_device_card(device_dict: dict):
58169
Args:
59170
device_dict (dict): information about the device, from the 'devices' table
60171
"""
61-
62172
# Check if the user has previously inspected this device
63173
device_inspected_config_key = f'device_is_inspected_{device_dict["mac_address"]}'
64174
is_inspected = common.config_get(device_inspected_config_key, False)
@@ -90,18 +200,13 @@ def show_device_card(device_dict: dict):
90200
caption = f'{device_dict["ip_address"]} | {device_dict["mac_address"]}'
91201
if "oui_vendor" in metadata_dict:
92202
caption += f' | {metadata_dict["oui_vendor"]}'
93-
try:
94-
dhcp_hostname, oui_vendor = common.get_device_metadata(device_dict['mac_address'])
95-
remote_hostnames = common.get_remote_hostnames(device_dict['mac_address'])
96-
api_output = common.call_predict_api(dhcp_hostname, oui_vendor, remote_hostnames, device_dict['mac_address'])
203+
204+
api_output = common.config_get(f'device_details@{device_dict["mac_address"]}', default={})
205+
if "Vendor" in api_output or "Explanation" in api_output:
97206
vendor = api_output.get("Vendor", "")
98207
explanation = api_output.get("Explanation", "")
99208
if vendor or explanation:
100209
caption += f"| Vendor: {vendor} | Explanation: {explanation}"
101-
# Use run-time error to avoid caching if API call failed
102-
except RuntimeError as e:
103-
logger.info(f"Device ID API failed: {e}")
104-
105210
st.caption(caption, help='IP address, MAC address, manufacturer OUI, and Device Identification API output')
106211

107212
# --- Add bar charts for upload/download ---

0 commit comments

Comments
 (0)