diff --git a/README.md b/README.md index 26c0763..335042a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,27 @@ python app.py ![scanner-main](./doc/img/lora-main.png) +## Configuration + +![config](./doc/img/config.png) + +From the configuration page, you can connect to a LoRa transmitter to start sending and receiving messages. Select the desired frequency to begin. Click the 'Select Device' button to connect to a serial port on your computer (where your Feather is attached). Once connected, traffic from your LoRa receiver will automatically stream to the web page for analysis. To disconnect a receiver, click the 'Disconnect Device' button. You can connect up to three LoRa transmitters at once. + +![config](./doc/img/select-device.png) + + +#### Configure LoRaWAN Gateways + +The 'Configure LoRaWAN Gateway' section allows you to set up to ten Dragino LPS8N LoRaWAN gateways. + +- Click the "Configure Gateway" button to start configuring each gateway's IP address. +- Skip configuring a gateway or disconnect an existing one by leaving the input field empty. +- All entered IP addresses are validated for correct formatting. +- Once configured, the application automatically retrieves and stores LoRaWAN traffic from each active gateway. +- Access and analyze stored traffic in 'survey mode'. + +![gateways](./doc/img/gateways.png) + ## Analysis Mode Analyze LoRa traffic received at 433, 868, or 915 MHz with ‘Analysis Mode’. Click the desired frequency to get started. Once you are on the appropriate page, click the 'Connect Serial Port' button to connect to a serial port on your computer (the one your Feather is attached to). Once connected to your LoRa receiver, traffic will automatically be streamed to the web page for analysis. To disconnect a receiver, click the 'Disconnect Serial Port' button. @@ -106,10 +127,3 @@ To download all packets captured by the LoRa Scanner, click the ‘Download Pack ![export-packets](./doc/img/download-packets.png) -## LoRaWAN - -To capture LoRaWAN traffic, you have to connect to an active Dragino LPS8N Indoor LoRaWAN gateway (915 or 868 MHz). You can connect to your gateway from the main screen by entering in the IP address of the gateway. Currently the application allows you to simultaneously connect to three gateways at once. - -![gateway](./doc/img/lorawan-gateway.png) - -![gateway-config](./doc/img/lorawan-gateway-configure.png) diff --git a/app.py b/app.py index d04c8da..456ee89 100644 --- a/app.py +++ b/app.py @@ -42,12 +42,23 @@ surveydata = {} parsed_entries = set() def read_serial_data(port, ser, buffer): + """ + This function reads data from a serial port and processes it. + + Parameters: + port (str): The name of the serial port (e.g., 'port1', 'port2', 'port3'). + ser (Serial): The serial object representing the serial port. + buffer (deque): A deque object to store the received data. + + Returns: + None + """ global surveydata rssi_pattern = r"RSSI: (-?\d+)" decoded_value_pattern = r"Decoded Value: (.+)" rssi = None decoded_value = None - + while True: try: if ser.in_waiting > 0: @@ -67,7 +78,7 @@ def read_serial_data(port, ser, buffer): if rssi is not None and decoded_value is not None: freq = frequency(port) key = f'Raw LoRa Device {freq} MHz' - + # Initialize the list for the frequency if not already done if key not in surveydata: surveydata[key] = [] @@ -97,28 +108,61 @@ def read_serial_data(port, ser, buffer): pass def convert_dict_to_csv(data): + """ + Converts a dictionary of survey data into a CSV format. + + Parameters: + data (dict): A dictionary where keys are device identifiers and values are lists of data entries. + Each data entry is a list containing frequency, RSSI, and a decoded value. + + Returns: + StringIO: A StringIO object containing the CSV representation of the input data. + """ output = StringIO() writer = csv.writer(output) - + # Write the header writer.writerow(['Device', 'Frequency', 'RSSI', 'Decoded Value']) - + # Write the data for key, values in data.items(): for value in values: writer.writerow([key] + value) - + output.seek(0) return output def is_valid_ip(ip): + """ + Validates whether a given string is a valid IP address. + + Parameters: + ip (str): The string representation of the IP address to validate. + + Returns: + bool: True if the input string is a valid IP address, False otherwise. + """ try: ip_address(ip) return True except AddressValueError: - return False + return False def parse_and_store_data(): + """ + Fetches data from configured gateway URLs, parses the data, and stores it in a global survey data structure. + + This function constructs URLs for gateways, sends HTTP GET requests to fetch data, parses the HTML response + to extract relevant information, and stores the parsed data in a global dictionary. It also schedules itself + to run periodically. + + Global Variables: + - surveydata (dict): A dictionary to store parsed survey data. + - parsed_entries (set): A set to keep track of already parsed entries to avoid duplicates. + - gateway_ips (dict): A dictionary containing IP addresses of gateways. + + The function does not take any parameters or return any values. + """ global surveydata global parsed_entries global gateway_ips @@ -151,19 +195,19 @@ def parse_and_store_data(): table = soup.find('table') if table: rows = table.find_all('tr')[1:] # Skip the header row - + for row in rows: # Skip hidden rows in this iteration if row.get('style') == 'display: none;': continue - + cells = row.find_all('td') if not cells: continue - + # Prepare formatted_row from visible cells, skipping the first cell for Chevron icon formatted_row = ' | '.join(cell.text.strip() for cell in cells[1:]) - + # Extract dev_id and freq from the visible row dev_id = extract_dev_id(formatted_row) freq = extract_freq(formatted_row) @@ -200,8 +244,19 @@ def parse_and_store_data(): def extract_dev_id(formatted_row): - # Assuming DevEui or DevAddr is in the 'Content' part of the formatted_row - # and it's formatted like 'Dev Addr: {DevEui}, Size: {Size}' + """ + Extracts the device identifier (DevEui or DevAddr) from a formatted row string. + + The function assumes that the device identifier is located in the 'Content' part of the formatted row, + formatted as 'Dev Addr: {DevEui}, Size: {Size}'. + + Parameters: + formatted_row (str): A string representing a row of data, where the last part contains the 'Content' + with the device identifier. + + Returns: + str or None: The extracted device identifier (DevEui or DevAddr) if successful, or None if extraction fails. + """ try: content_part = formatted_row.split('|')[-1].strip() # Get the last part of the formatted_row, which is 'Content' dev_id = content_part.split(',')[0].split(':')[-1].strip() # Extract the DevEui or DevAddr @@ -212,7 +267,18 @@ def extract_dev_id(formatted_row): def extract_freq(formatted_row): - # Assuming 'Freq' is a standalone field in the formatted_row + """ + Extracts the frequency value from a formatted row string. + + The function assumes that the frequency is a standalone field within the formatted row, + specifically located at a fixed position. + + Parameters: + formatted_row (str): A string representing a row of data, where fields are separated by '|'. + + Returns: + float or None: The extracted frequency as a float if successful, or None if extraction fails. + """ try: freq_part = formatted_row.split('|')[3].strip() # Get the 'Freq' part (assuming it's the fifth field) freq = float(freq_part) # Convert the frequency to float @@ -222,7 +288,18 @@ def extract_freq(formatted_row): return None # Return None or some default value if extraction fails -def connect_serial(port,frequency): +def connect_serial(port, frequency): + """ + Establishes a serial connection to a specified port and frequency, and starts a thread to read data. + + Parameters: + port (str): The name of the serial port to connect to. + frequency (int): The frequency in MHz for which the serial connection is being established. + Supported frequencies are 433, 868, and 915 MHz. + + Returns: + None + """ global ser1 global ser2 global ser3 @@ -252,12 +329,21 @@ def connect_serial(port,frequency): serial_threads['port3'] = threading.Thread(target=read_serial_data, args=('port3', ser3, serial_buffers['port3'])) serial_threads['port3'].daemon = True serial_threads['port3'].start() - + except: print('\n\nPort for 915 MHz not available\n\n') def disconnect_serial(port): + """ + Disconnects the serial connection for a specified port and cleans up associated resources. + + Parameters: + port (str): The name of the serial port to disconnect. Expected values are 'port1', 'port2', or 'port3'. + + Returns: + None + """ global ser1 global ser2 global ser3 @@ -279,74 +365,194 @@ def disconnect_serial(port): @app.route('/') def index(): + """ + Renders the main index page of the application. + + This function handles the root URL of the application and returns the rendered HTML + template for the index page. + + Returns: + str: The rendered HTML content for the index page. + """ return render_template('index.html') @app.route('/analysis') def analysis(): + """ + Renders the analysis page of the application with initial serial data. + + This function handles the '/analysis' URL route and returns the rendered HTML + template for the analysis page. It includes initial data from the serial buffers + for each port. + + Returns: + str: The rendered HTML content for the analysis page, with initial data for each port. + """ return render_template('analysis.html', initial_data={port: list(buffer) for port, buffer in serial_buffers.items()}) + @app.route('/survey') def survey(): + """ + Renders the survey page of the application. + + This function handles the '/survey' URL route and returns the rendered HTML + template for the survey page. + + Returns: + str: The rendered HTML content for the survey page. + """ return render_template('survey.html') @app.route('/tracking') def tracking(): + """ + Renders the tracking page of the application with initial serial data. + + This function handles the '/tracking' URL route and returns the rendered HTML + template for the tracking page. It includes initial data from the serial buffers + for each port. + + Returns: + str: The rendered HTML content for the tracking page, with initial data for each port. + """ return render_template('tracking.html', initial_data={port: list(buffer) for port, buffer in serial_buffers.items()}) @app.route('/attach_serial_433', methods=['GET']) def attach_serial_433(): + """ + Attaches a serial connection to the 433 MHz port. + + This function handles the '/attach_serial_433' URL route and establishes a serial + connection to the specified port for 433 MHz frequency. + + Parameters: + None + + Returns: + Response: A JSON response indicating the result of the operation. + """ user_input = escape(request.args.get('user_input')) port1_status = True connect_serial(str(user_input), 433) - # Process the input as needed result = f'Serial Port Requested for 433 MHz' return jsonify(result=result) @app.route('/delete_serial_433', methods=['GET']) def delete433(): - # Add your logic here to handle the confirmation + """ + Disconnects the serial connection for the 433 MHz port. + + This function handles the '/delete_serial_433' URL route and disconnects + the serial connection associated with the 433 MHz frequency. + + Returns: + Response: A JSON response indicating the result of the disconnection operation. + """ disconnect_serial("port1") result = "Port Disconnected!" return jsonify(result=result) @app.route('/attach_serial_868', methods=['GET']) def attach_serial_868(): + """ + Attaches a serial connection to the 868 MHz port. + + This function handles the '/attach_serial_868' URL route and establishes a serial + connection to the specified port for 868 MHz frequency. + + Parameters: + None + + Returns: + Response: A JSON response indicating the result of the operation. + """ user_input = escape(request.args.get('user_input')) port2_status = True connect_serial(str(user_input), 868) - # Process the input as needed result = f'Serial Port Requested for 868 MHz' return jsonify(result=result) @app.route('/delete_serial_868', methods=['GET']) def delete868(): - # Add your logic here to handle the confirmation + """ + Disconnects the serial connection for the 868 MHz port. + + This function handles the '/delete_serial_868' URL route and disconnects + the serial connection associated with the 868 MHz frequency. + + Returns: + Response: A JSON response indicating the result of the disconnection operation. + """ disconnect_serial("port2") result = "Port Disconnected!" return jsonify(result=result) @app.route('/attach_serial_915', methods=['GET']) def attach_serial_915(): + """ + Attaches a serial connection to the 915 MHz port. + + This function handles the '/attach_serial_915' URL route and establishes a serial + connection to the specified port for 915 MHz frequency. + + Parameters: + None + + Returns: + Response: A JSON response indicating the result of the operation. + """ user_input = escape(request.args.get('user_input')) connect_serial(str(user_input), 915) - # Process the input as needed result = f'Serial Port Requested for 915 MHz' return jsonify(result=result) @app.route('/delete_serial_915', methods=['GET']) def delete915(): - # Add your logic here to handle the confirmation + """ + Disconnects the serial connection for the 915 MHz port. + + This function handles the '/delete_serial_915' URL route and disconnects + the serial connection associated with the 915 MHz frequency. + + Returns: + Response: A JSON response indicating the result of the disconnection operation. + """ disconnect_serial("port3") result = "Port Disconnected!" return jsonify(result=result) @socketio.on('connect') def handle_connect(): + """ + Handles a new client connection to the SocketIO server. + + This function is triggered when a client connects to the server via SocketIO. + It emits the initial serial data for each port to the connected client. + + Parameters: + None + + Returns: + None + """ for port, buffer in serial_buffers.items(): emit(f'initial_serial_data_{port}', {'data': list(buffer)}) @app.route('/transmit433', methods=['POST']) def transmit433(): + """ + Transmits a message over the 433 MHz serial connection. + + This function handles the '/transmit433' URL route and sends a user-provided + message to the connected serial device operating at 433 MHz frequency. + + Parameters: + None + + Returns: + Response: A JSON response indicating the result of the transmission operation. + """ global ser1 data = request.json # Get the data from the POST request user_input = data.get('user_input') # Extract the user input @@ -356,11 +562,32 @@ def transmit433(): @app.route('/get_serial_ports') def get_serial_ports(): + """ + Retrieves a list of available serial ports on the system. + + This function handles the '/get_serial_ports' URL route and returns a JSON + response containing a list of serial port device names available on the system. + + Returns: + Response: A JSON response with a list of available serial port device names. + """ ports = [port.device for port in serial.tools.list_ports.comports()] return jsonify(ports=ports) @app.route('/transmit868', methods=['POST']) def transmit868(): + """ + Transmits a message over the 868 MHz serial connection. + + This function handles the '/transmit868' URL route and sends a user-provided + message to the connected serial device operating at 868 MHz frequency. + + Parameters: + None + + Returns: + Response: A JSON response indicating the result of the transmission operation. + """ global ser2 data = request.json # Get the data from the POST request user_input = data.get('user_input') # Extract the user input @@ -370,6 +597,18 @@ def transmit868(): @app.route('/transmit915', methods=['POST']) def transmit915(): + """ + Transmits a message over the 915 MHz serial connection. + + This function handles the '/transmit915' URL route and sends a user-provided + message to the connected serial device operating at 915 MHz frequency. + + Parameters: + None + + Returns: + Response: A JSON response indicating the result of the transmission operation. + """ global ser3 data = request.json # Get the data from the POST request user_input = data.get('user_input') # Extract the user input @@ -379,6 +618,19 @@ def transmit915(): @app.route('/checkSer', methods=['GET']) def checkSer(): + """ + Checks the status of a specified serial port to determine if it is open. + + This function handles the '/checkSer' URL route and checks whether the specified + serial port is currently open. It returns a JSON response indicating the status. + + Parameters: + None + + Returns: + Response: A JSON response with the result "True" if the specified port is open, + otherwise "False". + """ data = request.args.get('port') if data == 'port1': try: @@ -402,6 +654,19 @@ def checkSer(): @app.route('/get_table_data') def get_table_data(): + """ + Retrieves and returns the cleaned survey data in JSON format. + + This function processes the global survey data to remove any entries with empty device identifiers + and returns the cleaned data as a JSON response. + + Parameters: + None + + Returns: + Response: A JSON response containing the cleaned survey data, where each key is a device identifier + and the value is a list of associated data entries. + """ global surveydata cleaned_data = {} @@ -409,12 +674,26 @@ def get_table_data(): if dev_id: # Check if dev_id is not empty cleaned_data[dev_id] = data - #print(cleaned_data) # For debugging return jsonify(cleaned_data) @app.route('/set_gateways', methods=['POST']) def set_gateways(): + """ + Updates the IP addresses of the gateways based on the provided form data. + + This function processes a POST request containing IP addresses for up to 10 gateways. + It validates each IP address and updates the global `gateway_ips` dictionary with the + new values if they are valid. + + Parameters: + None + + Returns: + Response: A JSON response indicating the success or failure of the operation. + - On success, returns a message confirming the update of gateway IPs with a 200 status code. + - On failure, returns an error message specifying the invalid IP address with a 400 status code. + """ global gateway_ips data = request.form for key in [f'gateway{i}' for i in range(1, 11)]: @@ -434,6 +713,19 @@ def set_gateways(): @app.route('/downloadPackets', methods=['GET']) def downloadPackets(): + """ + Initiates the download of survey data in CSV format. + + This function handles the '/downloadPackets' URL route and converts the global + survey data into a CSV format. It then prepares the CSV data for download as a file. + + Parameters: + None + + Returns: + Response: A Flask response object that sends the CSV file as an attachment to the client. + The file is named 'surveydata.csv' and is sent with a 'text/csv' MIME type. + """ csv_data = convert_dict_to_csv(surveydata) # Convert StringIO to BytesIO for send_file compatibility bytes_data = BytesIO() diff --git a/doc/img/analysis-mode.png b/doc/img/analysis-mode.png index 746ff0b..15e6bc3 100644 Binary files a/doc/img/analysis-mode.png and b/doc/img/analysis-mode.png differ diff --git a/doc/img/config.png b/doc/img/config.png new file mode 100644 index 0000000..a6800b2 Binary files /dev/null and b/doc/img/config.png differ diff --git a/doc/img/gateways.png b/doc/img/gateways.png new file mode 100644 index 0000000..e32ca57 Binary files /dev/null and b/doc/img/gateways.png differ diff --git a/doc/img/lora-main.png b/doc/img/lora-main.png index 47557c3..d16144a 100644 Binary files a/doc/img/lora-main.png and b/doc/img/lora-main.png differ diff --git a/doc/img/lorawan-gateway-configure.png b/doc/img/lorawan-gateway-configure.png deleted file mode 100644 index 8a4c0ea..0000000 Binary files a/doc/img/lorawan-gateway-configure.png and /dev/null differ diff --git a/doc/img/lorawan-gateway.png b/doc/img/lorawan-gateway.png deleted file mode 100644 index 95e91f5..0000000 Binary files a/doc/img/lorawan-gateway.png and /dev/null differ diff --git a/doc/img/select-device.png b/doc/img/select-device.png new file mode 100644 index 0000000..9aa2c05 Binary files /dev/null and b/doc/img/select-device.png differ