feat: upgrade and rewrite in go

This commit is contained in:
daltoncox committed 2026-10-27 17:40:39 -05:00
1 parent 3e90d301fe
commit ca8da96913
102 files changed
+4299 -31231

No files matched your search

+12 -159
View File
@@ -1,161 +1,14 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Build output
/lora-scanner
web/node_modules/
web/dist/*
!web/dist/.gitkeep
# C extensions
*.so
# Local config (the app writes its real config to the per-user config dir)
gateways.json
gateway_config.json
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
venv-lora/
# Editors / OS
.DS_Store
.idea/
.vscode/
+24
View File
@@ -0,0 +1,24 @@
.PHONY: web build dev test clean
BIN := lora-scanner
web:
cd web && bun install --frozen-lockfile && bun run build
touch web/dist/.gitkeep
# The web tests fail the build if the frontend references anything it doesn't embed (it must run offline).
build: web
go test ./web
go build -o $(BIN) .
test:
go vet ./... && go test -race ./...
# Run `make dev` for the Go API; in another shell run `cd web && bun run dev`
# (the Bun dev server proxies /api to :5000).
dev:
go run .
clean:
rm -rf $(BIN) web/dist/*
touch web/dist/.gitkeep
+57 -93
View File
@@ -1,130 +1,94 @@
# Skinny R&D Lora Scanner
# Skinny R&D Lora Scanner
![lora-main](./doc/img/lora-main.png)
A single-binary web application for monitoring and analyzing LoRa (Long Range) wireless traffic. The backend is Go ([gin](https://github.com/gin-gonic/gin)); the frontend is React with [MUI](https://mui.com), built with [bun](https://bun.sh) and embedded in the binary. It needs no internet access to run.
## Introduction
The Lora Scanner is a Flask web application integrated with SocketIO, designed for monitoring and analyzing LoRa (Long Range) wireless communication.
![Frames](./doc/img/frames.png)
## Features
* **Packet Streaming**: View a live stream of each raw LoRa packet captured and received by the LoRa Scanner with `Analysis Mode`.
* **Supported Frequencies**: Capture packets at 915, 868, or 433 MHz
* **Device Tracking**: Track down LoRa Devices in the area using `Tracking Mode`.
* **Packet Analysis**: View every packet captured by the LoRa scanner with `Survey Mode`.
* **Exporting Data**: Perform AI powered data analysis on LoRa packets by downloading them from the `Survey Mode` page.
* **LoRaWAN Support**: Integrated LoRaWAN support with the Dragino LPS8N Indoor LoRaWAN Gateway.
* **Hardware Support**: The LoRa Scanner is currently designed to work with an `Adafruit Feather M0` or `32u4`.
* **Live frames**: a Wireshark-style list of everything received (raw serial lines, decoded LoRa packets, LoRaWAN gateway traffic) with filtering, pause/follow and CSV export.
* **Devices**: a live table of every device heard, with frequency, signal strength, first/last seen and frame count.
* **Any number of transceivers**: add as many Feathers as you like; each one's frequency (433, 868, 915 MHz, ...) is detected automatically from the firmware's boot banner. View any device's raw serial output.
* **Transmit**: one-off messages and server-side beacons that keep running when the page is closed.
* **LoRaWAN gateways**: add any number of Dragino LPS8N gateways; their traffic logs are polled on a configurable interval.
* **Hardware**: an `Adafruit Feather M0` or `32u4` running the [Skinny LoRa](https://github.com/skinnyrad/Skinny-LoRa) firmware (steps 1-2 in its README). *For 433 MHz you need the `Adafruit Feather M0 LoRa 433 MHz`.*
## Installation
## Build and run
### Hardware Requirements
Requires Go 1.24+ and [bun](https://bun.sh).
Flash your Adafruit Feather M0 (or 32u4) with the Skinny LoRa firmware. You can attach up to three different receivers at a time. For instructions, please follow the steps 1-2 [here](https://github.com/skinnyrad/Skinny-LoRa).
```
make build # bun builds web/dist, then go build -> ./lora-scanner
./lora-scanner # http://localhost:5000
```
*Note: For 433 MHz analysis you need the `Adafruit Feather M0 LoRa 433 MHz`*
The result is one self-contained binary. The frontend uses no CDNs or web fonts, and `make build` fails if it ever references anything that isn't embedded. Every route under `/api` is the JSON/WebSocket API; any other URL serves the web interface.
### Prerequisites
- Python 3.x
- pip (Python package manager)
- Virtual environment (recommended)
Options:
### Setup For Linux, MacOS, and Windows
1. Navigate to the application directory:
```
cd lora-scanner
```
2. Create a virtual environment (optional):
```
python -m venv venv
```
3. Activate the virtual environment (optional):
| Option | Default | Purpose |
|---|---|---|
| `--addr` / `LORA_ADDR` | `:5000` | listen address |
| `--config` / `LORA_CONFIG` | `gateways.json` in the per-user config directory | gateway list and poll interval |
| `LORA_GATEWAY_USER` / `LORA_GATEWAY_PASS` | `root` / `dragino` | gateway web login |
- Windows: `venv\Scripts\activate`
- macOS/Linux: `source venv/bin/activate`
The config directory is `~/.config/lora-scanner/` on Linux, `~/Library/Application Support/lora-scanner/` on macOS and `%AppData%\lora-scanner\` on Windows, so the binary can live anywhere, including a read-only `bin`. On first run, an old `gateway_config.json` in the working directory is imported if present (and never modified).
4. Install the required packages:
```
pip install -r requirements.txt
```
Development: `make dev` runs the API; in another shell `cd web && bun run dev` (the Bun dev server on :5173 proxies `/api` to port 5000). Tests: `make test`.
## Usage
1. Run the application:
```bash
python app.py
```
2. Access the web interface at `http://localhost:5000`.
*The screenshots below were taken against simulated Feathers and a stand-in gateway.*
![scanner-main](./doc/img/lora-main.png)
### Configuration
![Configuration](./doc/img/configuration.png)
## Configuration
Click *Add Transceiver* and pick the serial port of a Feather. Its frequency shows up once the firmware reports it (the Feather prints it once at boot, so replug a board that shows "Detecting…"). Add as many as you like, including several on the same band.
![config](./doc/img/config.png)
*View Serial* shows everything a device prints, read-only:
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.
![Serial output](./doc/img/serial.png)
![config](./doc/img/select-device.png)
Add Dragino gateways by IP, with no limit. A gateway turns red after two failed polls in a row and is retried every 5 seconds until it answers; green means its last poll worked, and grey means it hasn't been polled yet. The poll interval (5-3600 seconds, default 30) is set here too.
### Frames
#### Configure LoRaWAN Gateways
![Frames](./doc/img/frames.png)
The 'Configure LoRaWAN Gateway' section allows you to set up to ten Dragino LPS8N LoRaWAN gateways.
Every frame as it arrives: raw serial lines, decoded LoRa packets and LoRaWAN gateway rows (the last 10,000 are kept). Filter by text or kind (LoRa, LoRaWAN, Raw), select a row for its details, pause the view, or download everything as CSV. *Follow* keeps the newest frame in view and switches itself off when you scroll up.
- Click the "Update Gateways" button to update each gateway's IP address.
- Skip configuring a gateway 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'.
### Devices
![Devices](./doc/img/devices.png)
![gateways](./doc/img/gateways.png)
A live table of every device heard: identifier, frequency, signal strength, first and last seen, and how many frames it has sent. Select a device to jump to its frames.
The gateway configuration will automatically be saved to a file named 'gateway_config.json'. This file gets loaded each time the application runs. If you would like to save multiple configurations, copy the 'gateway_config.json' to a new file for later use.
### Transmit
## Analysis Mode
![Transmit](./doc/img/transmit.png)
Analyze LoRa traffic received at 433, 868, or 915 MHz with ‘Analysis Mode’. Click the desired frequency to get started. 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.
The only place anything is transmitted. Send a message from a chosen transceiver, or start a beacon that repeats it every N milliseconds (minimum 1000) to probe nearby devices. Beacons run on the server and keep going if you close the page.
![analysis-mode](./doc/img/analysis-mode.png)
## API
![connect-main](./doc/img/connect-main.png)
| Endpoint | Purpose |
|---|---|
| `GET /api/ports` | serial ports on this machine |
| `GET/POST /api/transceivers` `{port}` | list / add a Feather; its frequency is detected from the firmware's `Freq set to:` boot line |
| `DELETE /api/transceivers/{id}` | remove |
| `POST /api/transceivers/{id}/transmit` `{text}` | send `TX:<text> ` |
| `GET/POST/DELETE /api/transceivers/{id}/beacon` | server-side periodic transmit (`{text, intervalMs>=1000}`) |
| `GET/PUT /api/gateways/poll` `{seconds}` | how often gateways are polled |
| `GET/POST /api/gateways` `{ip}`, `DELETE /api/gateways/{ip}`, `GET /api/gateways/status` | gateway list and each gateway's state from its last poll (`online`/`offline`/`unknown`, with the error) |
| `GET /api/frames?after=<id>`, `DELETE /api/frames`, `GET /api/frames.csv` | received frames (last 10,000 kept) |
| `GET /api/devices` | devices aggregated from frames |
| `GET /api/ws` | WebSocket: live `transceivers`, `gateways` (state changes), `frame` and `device` messages |
You can view and inspect any received packets from the analysis window in the appropriate section:
## SDR companion
![analysis-mode-2](./doc/img/analysis-mode-2.png)
`sdr-companion/` has GNU Radio tooling for capturing LoRa with an SDR; see [`sdr-companion/sdr-companion.md`](./sdr-companion/sdr-companion.md).
From analysis mode you can also transmit a message at the desired frequency using the ‘Transmit Data’ button.
![transmit](./doc/img/transmit.png)
## Tracking Mode
Tracking mode allows you to track down rogue LoRa transmitters by locking in on a particular LoRa device and receiving live updates of their RSSI values. From the main tracking mode screen, click the row containing the LoRa transmitter you are searching for and it will lock in on that particular device for seamless tracking.
![tracking-mode](./doc/img/tracking-mode.png)
If you toggle the beacon buttons at the bottom of the screen, you will find a similar interface to the ‘Analysis Mode’ page. From this window you will be able to see live traffic received from the LoRa transceivers you have attached to the scanner. If you would like to beacon a message at a desired frequency, you can use the ‘Start Beacon’ and ‘Stop Beacon’ buttons. This will allow you to transmit a custom LoRa message at any desired interval , in an attempt to probe any LoRa devices that might be listening for messages:
![transmit-interval](./doc/img/transmit-interval.png)
## Survey Mode
Survey mode allows you to view all of the devices and corresponding packets discovered by the LoRa scanner.
![survey](./doc/img/survey-mode.png)
From the main ‘Survey Mode’ screen, you can view plaintext messages that were successfully decoded by the LoRa scanner using the ‘Show Values’ button next to a desired LoRa device. After clicking the button, all of the messages captured from that particular device will be displayed. Each row represents a single message captured from that device.
![survey-packets](./doc/img/survey-packets.png)
To collapse the packet list, click the ‘Hide Values’ button.
![survey-collapse](./doc/img/survey-collapse.png)
## Exporting LoRa Traffic
To download all packets captured by the LoRa Scanner, click the ‘Download Packets’ button. This will export all packets into a CSV file.
![export-packets](./doc/img/download-packets.png)
## License
MIT, see [LICENSE](./LICENSE).
-819
View File
@@ -1,819 +0,0 @@
from flask import Flask, render_template, request, jsonify, send_file
from markupsafe import escape
from flask_socketio import SocketIO, emit
import serial
import threading
from threading import Timer
import time
from collections import deque
import re
import requests
from bs4 import BeautifulSoup
import ipaddress
from io import StringIO, BytesIO
import csv
import serial.tools.list_ports
from ipaddress import ip_address, AddressValueError
import os
import json
app = Flask(__name__)
socketio = SocketIO(app)
serial_threads = {}
serial_buffers = {}
port1_status = True
port2_status = True
port3_status = True
global ser1
global ser2
global ser3
gateway_ips = {
'gateway1': '',
'gateway2': '',
'gateway3': '',
'gateway4': '',
'gateway5': '',
'gateway6': '',
'gateway7': '',
'gateway8': '',
'gateway9': '',
'gateway10': ''
}
frequency = lambda port: {'port1': 433, 'port2': 868,'port3': 915}.get(port, None)
surveydata = {}
parsed_entries = set()
used_ports = set()
CONFIG_FILE = 'gateway_config.json'
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:
data = ser.readline().decode('utf-8').strip()
# Match RSSI
rssi_match = re.search(rssi_pattern, data)
if rssi_match:
rssi = int(rssi_match.group(1))
# Match Decoded Value
decoded_match = re.search(decoded_value_pattern, data)
if decoded_match:
decoded_value = decoded_match.group(1)
# Update dictionary only if both RSSI and Decoded Value are found
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] = []
# Append the new values to the list for this frequency
current_timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
surveydata[key].append([freq, rssi, f"{decoded_value} -- Timestamp:{current_timestamp}"])
# Reset rssi and decoded_value for next packet
rssi = None
decoded_value = None
buffer.append(data)
socketio.emit(f'serial_data_{port}', {'data': data})
# Check port status
if (port == 'port1' and not port1_status) or \
(port == 'port2' and not port2_status) or \
(port == 'port3' and not port3_status):
return
time.sleep(0.1)
except Exception as e:
#print("Could not access Serial Port")
#print(f"Error: {e}")
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
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
# Include the port number (8000) in your gateway URLs
gateway_urls = [
f"http://{gateway_ips[f'gateway{i}']}:8000/cgi-bin/log-traffic.has"
for i in range(1, 11)
if gateway_ips[f'gateway{i}'] and is_valid_ip(gateway_ips[f'gateway{i}'])
]
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:122.0) Gecko/20100101 Firefox/122.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"DNT": "1",
"Sec-GPC": "1",
"Authorization": "Basic cm9vdDpkcmFnaW5v", # Assumes the same credentials for both gateways
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1"
}
for url in gateway_urls:
try:
print(f"Fetching data from {url}")
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
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)
# Extract RSSI from the next hidden row
hidden_row = row.find_next_sibling('tr')
if hidden_row and 'display: none;' in hidden_row.get('style', ''):
hidden_data = hidden_row.td.text.strip()
rssi_match = re.search(r'"Rssi":(-?\d+)', hidden_data)
if rssi_match:
rssi = int(rssi_match.group(1))
else:
rssi = None
else:
rssi = None
# Save data into the surveydata structure
if dev_id and freq is not None:
entry_identifier = f"{dev_id}_{freq}_{formatted_row}"
if entry_identifier not in parsed_entries:
parsed_entries.add(entry_identifier)
if dev_id not in surveydata:
surveydata[dev_id] = []
surveydata[dev_id].append([freq, rssi, formatted_row])
print(f"Data parsed and stored from {url}.")
else:
print(f"Request to {url} failed with status code: {response.status_code}")
except Exception as e:
print(f"An error occurred while processing {url}: {e}")
# Schedule the next call to this function
Timer(30, parse_and_store_data).start() # Call this function every 30 seconds
def extract_dev_id(formatted_row):
"""
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
return dev_id
except Exception as e:
print(f"Error extracting DevEui/DevAddr: {e}")
return None # Return None or some default value if extraction fails
def extract_freq(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
return freq
except Exception as e:
print(f"Error extracting frequency: {e}")
return None # Return None or some default value if extraction fails
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
global used_ports
# Check if the port is already in use
if port in used_ports:
raise Exception(f"Port {port} is already in use for another frequency.")
if frequency == 433:
try:
ser1 = serial.Serial(port, baudrate=9600)
serial_buffers['port1'] = deque(maxlen=10)
serial_threads['port1'] = threading.Thread(target=read_serial_data, args=('port1', ser1, serial_buffers['port1']))
serial_threads['port1'].daemon = True
serial_threads['port1'].start()
used_ports.add(port) # Mark the port as used
except:
print("\n\nPort for 433 MHz not available\n\n")
if frequency == 868:
try:
ser2 = serial.Serial(port, baudrate=9600)
serial_buffers['port2'] = deque(maxlen=10)
serial_threads['port2'] = threading.Thread(target=read_serial_data, args=('port2', ser2, serial_buffers['port2']))
serial_threads['port2'].daemon = True
serial_threads['port2'].start()
used_ports.add(port) # Mark the port as used
except:
print("\n\nPort for 868 MHz not available\n\n")
if frequency == 915:
try:
ser3 = serial.Serial(port, baudrate=9600)
serial_buffers['port3'] = deque(maxlen=10)
serial_threads['port3'] = threading.Thread(target=read_serial_data, args=('port3', ser3, serial_buffers['port3']))
serial_threads['port3'].daemon = True
serial_threads['port3'].start()
used_ports.add(port) # Mark the port as used
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
global used_ports
try:
serial_threads[port].stop()
del serial_threads[port]
del serial_buffers[port]
except:
pass
if port == "port1":
ser1.close()
used_ports.remove(ser1.port) # Remove the port from the used set
elif port == "port2":
ser2.close()
used_ports.remove(ser2.port) # Remove the port from the used set
elif port == "port3":
ser3.close()
used_ports.remove(ser3.port) # Remove the port from the used set
else:
print("Unknown port, something went wrong...")
@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', gateway_ips=gateway_ips)
@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.
"""
try:
# Disconnect any existing connection for 433 MHz
disconnect_serial("port1")
print("Current port for 433 MHz disconnected")
except:
pass
user_input = escape(request.args.get('user_input'))
connect_serial(str(user_input), 433)
result = f'Serial Port Requested for 433 MHz'
return jsonify(result=result)
@app.route('/delete_serial_433', methods=['GET'])
def delete433():
"""
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.
"""
try:
# Disconnect any existing connection for 868 MHz
disconnect_serial("port2")
print("Current port for 868 MHz disconnected")
except:
pass
user_input = escape(request.args.get('user_input'))
connect_serial(str(user_input), 868)
result = f'Serial Port Requested for 868 MHz'
return jsonify(result=result)
@app.route('/delete_serial_868', methods=['GET'])
def delete868():
"""
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.
"""
try:
# Disconnect any existing connection for 915 MHz
disconnect_serial("port3")
print("Current port for 915 MHz disconnected")
except:
pass
user_input = escape(request.args.get('user_input'))
connect_serial(str(user_input), 915)
result = f'Serial Port Requested for 915 MHz'
return jsonify(result=result)
@app.route('/delete_serial_915', methods=['GET'])
def delete915():
"""
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
msg = "TX:"+user_input+" "
ser1.write(msg.encode())
return jsonify(result="Ok")
@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
msg = "TX:"+user_input+" "
ser2.write(msg.encode())
return jsonify(result="Ok")
@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
msg = "TX:"+user_input+" "
ser3.write(msg.encode())
return jsonify(result="Ok")
@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:
if ser1.is_open:
return jsonify(result="True")
except:
pass
elif data == 'port2':
try:
if ser2.is_open:
return jsonify(result="True")
except:
pass
elif data == 'port3':
try:
if ser3.is_open:
return jsonify(result="True")
except:
pass
return jsonify(result="False")
@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 = {}
for dev_id, data in surveydata.items():
if dev_id: # Check if dev_id is not empty
cleaned_data[dev_id] = data
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
# Update gateway IPs
ordered_gateways = {}
for i in range(1, 11):
key = f'gateway{i}'
input_ip = data.get(key, '').strip()
ordered_gateways[key] = input_ip if input_ip else ''
gateway_ips.update(ordered_gateways)
for gateway, ip_address in gateway_ips.items():
print(f"Gateway {gateway} has IP address: {ip_address}")
# Save configuration
if save_gateway_config():
return jsonify({"message": "Gateway IPs updated and saved successfully"}), 200
else:
return jsonify({"message": "Gateway IPs updated but failed to save configuration"}), 200
@app.route('/get_gateways', methods=['GET'])
def get_gateways():
"""
Retrieves the current IP addresses of the gateways.
This function handles the '/get_gateways' URL route and returns a JSON
response containing the IP addresses of the configured gateways.
Returns:
Response: A JSON response with the current gateway IPs, where each key is
a gateway identifier and the value is the corresponding IP address.
"""
return jsonify(gateway_ips)
def save_gateway_config():
"""
Saves the current gateway configuration to a JSON file.
"""
try:
with open(CONFIG_FILE, 'w') as f:
json.dump(gateway_ips, f)
return True
except Exception as e:
print(f"Error saving gateway configuration: {e}")
return False
def load_gateway_config():
"""
Loads the gateway configuration from a JSON file.
"""
global gateway_ips
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
loaded_config = json.load(f)
gateway_ips.update(loaded_config)
except Exception as e:
print(f"Error loading gateway configuration: {e}")
@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()
bytes_data.write(csv_data.getvalue().encode('utf-8'))
bytes_data.seek(0)
return send_file(bytes_data, mimetype='text/csv', as_attachment=True, download_name='surveydata.csv')
if __name__ == '__main__':
load_gateway_config()
Timer(30, parse_and_store_data).start()
socketio.run(app, debug=True)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 101 KiB

-57
View File
@@ -1,57 +0,0 @@
import requests
from bs4 import BeautifulSoup
def get_gateway_data(url, headers):
"""
Makes a request to the specified gateway URL and parses the HTML table content.
Returns a list of formatted strings for each row in the table.
"""
formatted_rows = []
response = requests.get(url, headers=headers)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
table = soup.find('table')
if table:
rows = table.find_all('tr')
headers = [header.text.strip() for header in rows[0].find_all('th')][1:]
for row in rows[1:]:
cells = row.find_all('td')
cell_data = [cell.text.strip() for cell in cells[1:] if cells.index(cell) < len(headers) + 1]
formatted_row = ' | '.join(cell_data)
formatted_rows.append(formatted_row)
else:
print(f"Request to {url} failed with status code:", response.status_code)
return formatted_rows
# Define URLs and headers for both gateways
gateway_1 = "http://192.168.1.23/cgi-bin/log-traffic.has"
gateway_2 = "http://192.168.1.24/cgi-bin/log-traffic.has"
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:122.0) Gecko/20100101 Firefox/122.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"DNT": "1",
"Sec-GPC": "1",
"Authorization": "Basic cm9vdDpkcmFnaW5v",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1"
}
# Fetch and print data from both gateways
print("Fetching data from Gateway 1 (192.168.1.23)...")
data_1 = get_gateway_data(gateway_1, headers)
for row in data_1:
print(row)
print("\nFetching data from Gateway 2 (192.168.1.24)...")
# Update the 'Host' header for the second gateway if necessary
headers["Host"] = "192.168.1.24"
data_2 = get_gateway_data(gateway_2, headers)
for row in data_2:
print(row)
+43
View File
@@ -0,0 +1,43 @@
module github.com/skinnyrad/Lora-Scanner
go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.13.0
github.com/gin-gonic/gin v1.12.0
github.com/gorilla/websocket v1.5.3
go.bug.st/serial v1.8.0
)
require (
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+97
View File
@@ -0,0 +1,97 @@
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.bug.st/serial v1.8.0 h1:ZtnmN8aYXtPlTghwSvDWPHKBHL9TM6oFDa+KpSn4SQE=
go.bug.st/serial v1.8.0/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
# Function to check if Python3 is installed
check_python_installed() {
if command -v python3 &> /dev/null; then
echo "Python3 is already installed."
return 0
else
return 1
fi
}
# Install Python3 and pip if not already installed
if ! check_python_installed; then
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
sudo apt update
sudo apt install -y python3 python3-pip
elif [[ "$OSTYPE" == "darwin"* ]]; then
# Check if Homebrew is installed, install if not
if ! command -v brew &> /dev/null; then
echo "Homebrew not found, installing..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
brew install python3
fi
else
echo "Skipping Python3 installation."
fi
# Create a requirements.txt file
cat <<EOT > requirements.txt
beautifulsoup4==4.12.3
Flask==2.3.2
Flask_SocketIO==5.3.6
MarkupSafe==2.1.3
pyserial==3.5
Requests==2.31.0
EOT
# Install required Python packages
pip3 install -r requirements.txt
# Inform the user that the installation is complete
echo "Installation complete. You can now run your application using: python3 app.py"
-39
View File
@@ -1,39 +0,0 @@
@echo off
:: Function to check if Python3 is installed
python --version >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
echo Python3 is already installed.
GOTO INSTALL_PACKAGES
)
echo Installing Python 3.12...
:: Download the most recent Python 3.12 installer
powershell -Command "Invoke-WebRequest -Uri https://www.python.org/ftp/python/3.12.0/python-3.12.0-amd64.exe -OutFile python-installer.exe"
:: Install Python silently
python-installer.exe /quiet InstallAllUsers=1 PrependPath=1
:: Clean up installer
del python-installer.exe
:INSTALL_PACKAGES
:: Check if pip is installed
pip --version >nul 2>&1
IF %ERRORLEVEL% NEQ 0 (
echo Installing pip...
python -m ensurepip --upgrade
)
:: Create a requirements.txt file
echo beautifulsoup4==4.12.3 > requirements.txt
echo Flask==2.3.2 >> requirements.txt
echo Flask_SocketIO==5.3.6 >> requirements.txt
echo MarkupSafe==2.1.3 >> requirements.txt
echo pyserial==3.5 >> requirements.txt
echo Requests==2.31.0 >> requirements.txt
:: Install required Python packages
pip install -r requirements.txt
:: Inform the user that the installation is complete
echo Installation complete. You can now run your application using: python app.py
pause
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
"github.com/skinnyrad/Lora-Scanner/internal/gateway"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
"github.com/skinnyrad/Lora-Scanner/internal/serial"
)
func newServer(t *testing.T) (http.Handler, *frames.Store) {
h := hub.New()
st := frames.NewStore(h, 0)
cfg := gateway.NewConfig(filepath.Join(t.TempDir(), "c.json"))
return (&Server{
Serial: serial.NewManager(h, st), Hub: h, Frames: st, Config: cfg,
Scraper: gateway.NewScraper(cfg, st, "u", "p"),
UI: fstest.MapFS{
"index.html": {Data: []byte("<html>spa</html>")},
"assets/app.js": {Data: []byte("console.log(1)")},
},
}).Router(), st
}
func do(h http.Handler, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
func TestRouting(t *testing.T) {
h, _ := newServer(t)
for _, p := range []string{"/", "/survey", "/some/deep/link"} {
if w := do(h, "GET", p, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "spa") {
t.Errorf("%s: %d %s", p, w.Code, w.Body)
}
}
if w := do(h, "GET", "/assets/app.js", ""); w.Code != 200 || !strings.Contains(w.Body.String(), "console") {
t.Errorf("asset: %d %s", w.Code, w.Body)
}
if w := do(h, "GET", "/api/nope", ""); w.Code != 404 || !strings.Contains(w.Header().Get("Content-Type"), "json") {
t.Errorf("api 404: %d %s", w.Code, w.Body)
}
}
func TestAPIBasics(t *testing.T) {
h, _ := newServer(t)
if w := do(h, "GET", "/api/transceivers", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
t.Errorf("list: %d %s", w.Code, w.Body)
}
if w := do(h, "POST", "/api/transceivers/1/transmit", `{"text":"x"}`); w.Code != 404 {
t.Errorf("transmit unknown: %d", w.Code)
}
if w := do(h, "DELETE", "/api/transceivers/1", ""); w.Code != 404 {
t.Errorf("remove unknown: %d", w.Code)
}
if w := do(h, "POST", "/api/transceivers", `{"port":""}`); w.Code != 500 && w.Code != 400 {
t.Errorf("empty port: %d", w.Code)
}
if w := do(h, "GET", "/api/serial", ""); w.Code != 404 {
t.Errorf("old serial routes should be gone: %d", w.Code)
}
if w := do(h, "POST", "/api/gateways", `{"ip":"nope"}`); w.Code != 400 {
t.Errorf("bad ip: %d", w.Code)
}
if w := do(h, "POST", "/api/gateways", `{"ip":"10.0.0.1"}`); w.Code != 201 {
t.Errorf("add: %d %s", w.Code, w.Body)
}
if w := do(h, "POST", "/api/gateways", `{"ip":"10.0.0.1"}`); w.Code != 409 {
t.Errorf("duplicate: %d", w.Code)
}
if w := do(h, "GET", "/api/gateways", ""); strings.TrimSpace(w.Body.String()) != `["10.0.0.1"]` {
t.Errorf("list: %s", w.Body)
}
if w := do(h, "GET", "/api/gateways/poll", ""); strings.TrimSpace(w.Body.String()) != `{"seconds":30}` {
t.Errorf("poll get: %s", w.Body)
}
if w := do(h, "PUT", "/api/gateways/poll", `{"seconds":2}`); w.Code != 400 {
t.Errorf("poll too small: %d", w.Code)
}
if w := do(h, "PUT", "/api/gateways/poll", `{"seconds":120}`); w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"seconds":120}` {
t.Errorf("poll put: %d %s", w.Code, w.Body)
}
if w := do(h, "GET", "/api/gateways/status", ""); !strings.Contains(w.Body.String(), `"10.0.0.1":{"state":"unknown","lastSuccess":null}`) {
t.Errorf("status before first poll: %s", w.Body)
}
if w := do(h, "DELETE", "/api/gateways/10.0.0.9", ""); w.Code != 404 {
t.Errorf("remove unknown: %d", w.Code)
}
if w := do(h, "DELETE", "/api/gateways/10.0.0.1", ""); w.Code != 204 {
t.Errorf("remove: %d", w.Code)
}
if w := do(h, "GET", "/api/survey", ""); w.Code != 404 {
t.Errorf("survey route should be gone: %d", w.Code)
}
if w := do(h, "POST", "/api/transceivers/1/beacon", `{"text":"x","intervalMs":10}`); w.Code != 400 {
t.Errorf("beacon interval: %d", w.Code)
}
}
func TestFramesAndDevices(t *testing.T) {
h, st := newServer(t)
rssi := -50
st.Add(frames.Frame{Kind: frames.KindRaw, Info: "noise"})
st.Add(frames.Frame{Kind: frames.KindLoRa, Identifier: "dev1", Freq: 915, RSSI: &rssi, Info: "hi"})
w := do(h, "GET", "/api/frames", "")
var fs []frames.Frame
if err := json.Unmarshal(w.Body.Bytes(), &fs); err != nil || len(fs) != 2 || fs[1].Identifier != "dev1" {
t.Fatalf("frames: %d %s", w.Code, w.Body)
}
w = do(h, "GET", "/api/frames?after=1", "")
if err := json.Unmarshal(w.Body.Bytes(), &fs); err != nil || len(fs) != 1 || fs[0].ID != 2 {
t.Fatalf("after: %s", w.Body)
}
if w := do(h, "GET", "/api/frames?after=x", ""); w.Code != 400 {
t.Errorf("bad after: %d", w.Code)
}
w = do(h, "GET", "/api/devices", "")
var ds []frames.Device
if err := json.Unmarshal(w.Body.Bytes(), &ds); err != nil || len(ds) != 1 || ds[0].Frames != 1 || ds[0].Identifier != "dev1" {
t.Fatalf("devices: %s", w.Body)
}
if w := do(h, "GET", "/api/frames.csv", ""); w.Code != 200 || !strings.HasPrefix(w.Body.String(), "No,Time,Kind,Source,Identifier,Frequency,RSSI,Info") {
t.Errorf("csv: %s", w.Body)
}
if w := do(h, "DELETE", "/api/frames", ""); w.Code != 204 {
t.Errorf("clear: %d", w.Code)
}
if w := do(h, "GET", "/api/devices", ""); strings.TrimSpace(w.Body.String()) != "[]" {
t.Errorf("devices after clear: %s", w.Body)
}
}
+289
View File
@@ -0,0 +1,289 @@
package api
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/skinnyrad/Lora-Scanner/internal/beacon"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
"github.com/skinnyrad/Lora-Scanner/internal/gateway"
"github.com/skinnyrad/Lora-Scanner/internal/serial"
)
func fail(c *gin.Context, code int, err error) {
c.JSON(code, gin.H{"error": err.Error()})
}
// serialErr maps manager errors to HTTP statuses.
func serialErr(c *gin.Context, err error) {
switch {
case errors.Is(err, serial.ErrNotFound):
fail(c, http.StatusNotFound, err)
case errors.Is(err, serial.ErrPortInUse):
fail(c, http.StatusConflict, err)
default:
fail(c, http.StatusInternalServerError, err)
}
}
func idParam(c *gin.Context) (int, bool) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, serial.ErrNotFound)
return 0, false
}
return id, true
}
func (s *Server) ports(c *gin.Context) {
ports, err := serial.ListPorts()
if err != nil {
fail(c, http.StatusInternalServerError, err)
return
}
if ports == nil {
ports = []string{}
}
c.JSON(http.StatusOK, gin.H{"ports": ports})
}
func (s *Server) transceiversList(c *gin.Context) { c.JSON(http.StatusOK, s.Serial.List()) }
func (s *Server) transceiverAdd(c *gin.Context) {
var body struct {
Port string `json:"port"`
}
if err := c.ShouldBindJSON(&body); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
t, err := s.Serial.Add(body.Port)
if err != nil {
serialErr(c, err)
return
}
c.JSON(http.StatusCreated, t)
}
func (s *Server) transceiverRemove(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
if err := s.Serial.Remove(id); err != nil {
serialErr(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (s *Server) transceiverTransmit(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
var body struct {
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&body); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
if err := s.Serial.Transmit(id, body.Text); err != nil {
serialErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"result": "ok"})
}
func (s *Server) beaconGet(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
st, err := s.Serial.BeaconState(id)
if err != nil {
serialErr(c, err)
return
}
c.JSON(http.StatusOK, st)
}
func (s *Server) beaconStart(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
var body struct {
Text string `json:"text"`
IntervalMs int64 `json:"intervalMs"`
}
if err := c.ShouldBindJSON(&body); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
d := time.Duration(body.IntervalMs) * time.Millisecond
if d < beacon.MinInterval {
fail(c, http.StatusBadRequest, errors.New("intervalMs must be at least 1000"))
return
}
if err := s.Serial.StartBeacon(id, body.Text, d); err != nil {
serialErr(c, err)
return
}
st, _ := s.Serial.BeaconState(id)
c.JSON(http.StatusOK, st)
}
func (s *Server) beaconStop(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
if err := s.Serial.StopBeacon(id); err != nil {
serialErr(c, err)
return
}
st, _ := s.Serial.BeaconState(id)
c.JSON(http.StatusOK, st)
}
func (s *Server) gatewaysGet(c *gin.Context) { c.JSON(http.StatusOK, s.Config.List()) }
func (s *Server) gatewayAdd(c *gin.Context) {
var body struct {
IP string `json:"ip"`
}
if err := c.ShouldBindJSON(&body); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
ip, err := s.Config.Add(body.IP)
switch {
case errors.Is(err, gateway.ErrDuplicate):
fail(c, http.StatusConflict, err)
case err != nil:
fail(c, http.StatusBadRequest, err)
default:
c.JSON(http.StatusCreated, gin.H{"ip": ip})
}
}
func (s *Server) gatewayRemove(c *gin.Context) {
if err := s.Config.Remove(c.Param("ip")); err != nil {
if errors.Is(err, gateway.ErrNotFound) {
fail(c, http.StatusNotFound, err)
} else {
fail(c, http.StatusInternalServerError, err)
}
return
}
c.Status(http.StatusNoContent)
}
type pollBody struct {
Seconds int `json:"seconds"`
}
func (s *Server) pollGet(c *gin.Context) {
c.JSON(http.StatusOK, pollBody{Seconds: int(s.Config.PollInterval() / time.Second)})
}
func (s *Server) pollPut(c *gin.Context) {
var body pollBody
if err := c.ShouldBindJSON(&body); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
switch err := s.Config.SetPollInterval(time.Duration(body.Seconds) * time.Second); {
case errors.Is(err, gateway.ErrBadInterval):
fail(c, http.StatusBadRequest, err)
case err != nil:
fail(c, http.StatusInternalServerError, err)
default:
s.pollGet(c)
}
}
func (s *Server) gatewaysStatus(c *gin.Context) {
c.JSON(http.StatusOK, s.Scraper.Statuses())
}
func (s *Server) framesGet(c *gin.Context) {
after, err := afterParam(c)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
c.JSON(http.StatusOK, s.Frames.Since(after))
}
func afterParam(c *gin.Context) (uint64, error) {
v := c.Query("after")
if v == "" {
return 0, nil
}
return strconv.ParseUint(v, 10, 64)
}
func (s *Server) framesClear(c *gin.Context) {
s.Frames.Clear()
c.Status(http.StatusNoContent)
}
func (s *Server) framesCSV(c *gin.Context) {
c.Header("Content-Disposition", `attachment; filename="frames.csv"`)
c.Header("Content-Type", "text/csv")
c.Status(http.StatusOK)
_ = frames.WriteCSV(c.Writer, s.Frames.Since(0))
}
func (s *Server) devicesGet(c *gin.Context) { c.JSON(http.StatusOK, s.Frames.Devices()) }
var upgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 4096}
// ws streams the transceiver list, then live frame/device/transceivers messages. Clients send nothing;
// they seed their state from GET /api/frames and /api/devices.
func (s *Server) ws(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// Subscribe first so nothing is missed between snapshot and stream.
ch, cancel := s.Hub.Subscribe()
defer cancel()
if conn.WriteJSON(serial.ListMsg{Type: "transceivers", Transceivers: s.Serial.List()}) != nil {
return
}
if conn.WriteJSON(s.Scraper.StatusMsg()) != nil {
return
}
closed := make(chan struct{})
go func() { // detect client close
defer close(closed)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
for {
select {
case msg, ok := <-ch:
if !ok || conn.WriteJSON(msg) != nil {
return
}
case <-closed:
return
}
}
}
+75
View File
@@ -0,0 +1,75 @@
// Package api wires the HTTP surface: /api/* JSON + WebSocket, everything
// else falls through to the embedded single-page app.
package api
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/gin-gonic/gin"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
"github.com/skinnyrad/Lora-Scanner/internal/gateway"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
"github.com/skinnyrad/Lora-Scanner/internal/serial"
)
type Server struct {
Serial *serial.Manager
Hub *hub.Hub
Frames *frames.Store
Config *gateway.Config
Scraper *gateway.Scraper
UI fs.FS // contents of dist/, rooted at index.html
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api")
api.GET("/ports", s.ports)
api.GET("/transceivers", s.transceiversList)
api.POST("/transceivers", s.transceiverAdd)
api.DELETE("/transceivers/:id", s.transceiverRemove)
api.POST("/transceivers/:id/transmit", s.transceiverTransmit)
api.GET("/transceivers/:id/beacon", s.beaconGet)
api.POST("/transceivers/:id/beacon", s.beaconStart)
api.DELETE("/transceivers/:id/beacon", s.beaconStop)
api.GET("/gateways", s.gatewaysGet)
api.POST("/gateways", s.gatewayAdd)
api.DELETE("/gateways/:ip", s.gatewayRemove)
api.GET("/gateways/status", s.gatewaysStatus)
api.GET("/gateways/poll", s.pollGet)
api.PUT("/gateways/poll", s.pollPut)
api.GET("/frames", s.framesGet)
api.DELETE("/frames", s.framesClear)
api.GET("/frames.csv", s.framesCSV)
api.GET("/devices", s.devicesGet)
api.GET("/ws", s.ws)
r.NoRoute(s.noRoute)
return r
}
func (s *Server) noRoute(c *gin.Context) {
p := c.Request.URL.Path
if p == "/api" || strings.HasPrefix(p, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
name := strings.TrimPrefix(path.Clean(p), "/")
if name != "" {
if info, err := fs.Stat(s.UI, name); err == nil && !info.IsDir() {
c.FileFromFS(name, http.FS(s.UI))
return
}
}
index, err := fs.ReadFile(s.UI, "index.html")
if err != nil {
c.String(http.StatusServiceUnavailable, "frontend not built: run `make web`")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
}
+80
View File
@@ -0,0 +1,80 @@
// Package beacon runs a periodic transmit on the server.
package beacon
import (
"context"
"sync"
"time"
)
const MinInterval = time.Second
type State struct {
Running bool `json:"running"`
Text string `json:"text"`
IntervalMs int64 `json:"intervalMs"`
}
type Beacon struct {
mu sync.Mutex
cancel context.CancelFunc
gen uint64
state State
}
// Start (re)starts the beacon, sending immediately and then every interval.
// It stops itself if send returns an error.
func (b *Beacon) Start(text string, interval time.Duration, send func(string) error) {
b.mu.Lock()
defer b.mu.Unlock()
b.stopLocked()
ctx, cancel := context.WithCancel(context.Background())
b.cancel = cancel
b.gen++
gen := b.gen
b.state = State{Running: true, Text: text, IntervalMs: interval.Milliseconds()}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
if send(text) != nil {
b.stopIfCurrent(gen)
return
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}()
}
func (b *Beacon) Stop() {
b.mu.Lock()
defer b.mu.Unlock()
b.stopLocked()
}
func (b *Beacon) State() State {
b.mu.Lock()
defer b.mu.Unlock()
return b.state
}
func (b *Beacon) stopLocked() {
if b.cancel != nil {
b.cancel()
b.cancel = nil
}
b.state.Running = false
}
// stopIfCurrent avoids a stale goroutine stopping a newer beacon.
func (b *Beacon) stopIfCurrent(gen uint64) {
b.mu.Lock()
defer b.mu.Unlock()
if b.gen == gen {
b.stopLocked()
}
}
+33
View File
@@ -0,0 +1,33 @@
package frames
import (
"encoding/csv"
"io"
"strconv"
"time"
)
// WriteCSV writes: No,Time,Kind,Source,Identifier,Frequency,RSSI,Info.
func WriteCSV(w io.Writer, fs []Frame) error {
cw := csv.NewWriter(w)
if err := cw.Write([]string{"No", "Time", "Kind", "Source", "Identifier", "Frequency", "RSSI", "Info"}); err != nil {
return err
}
for _, f := range fs {
rssi, freq := "", ""
if f.RSSI != nil {
rssi = strconv.Itoa(*f.RSSI)
}
if f.Freq != 0 {
freq = strconv.FormatFloat(f.Freq, 'f', -1, 64)
}
row := []string{
strconv.FormatUint(f.ID, 10), f.Time.Format(time.RFC3339Nano), f.Kind, f.Source, f.Identifier, freq, rssi, f.Info,
}
if err := cw.Write(row); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
+103
View File
@@ -0,0 +1,103 @@
package frames
import (
"bytes"
"sync"
"testing"
"time"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
)
func TestRingAndIDs(t *testing.T) {
s := NewStore(nil, 3)
for i := 0; i < 5; i++ {
s.Add(Frame{Kind: KindRaw, Info: "x"})
}
got := s.Since(0)
if len(got) != 3 || got[0].ID != 3 || got[2].ID != 5 {
t.Fatalf("ring: %+v", got)
}
if after := s.Since(4); len(after) != 1 || after[0].ID != 5 {
t.Fatalf("since: %+v", after)
}
s.Clear()
if f := s.Add(Frame{Kind: KindRaw}); f.ID != 6 {
t.Fatalf("ids must keep increasing after Clear, got %d", f.ID)
}
}
func TestDeviceAggregation(t *testing.T) {
s := NewStore(nil, 0)
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
t2 := t1.Add(time.Minute)
r1, r2 := -80, -70
s.Add(Frame{Kind: KindRaw, Info: "noise"})
s.Add(Frame{Kind: KindLoRaWAN, Identifier: "A", Freq: 914.1, RSSI: &r1, Time: t1})
s.Add(Frame{Kind: KindLoRaWAN, Identifier: "A", Freq: 914.9, RSSI: &r2, Time: t2})
ds := s.Devices()
if len(ds) != 1 {
t.Fatalf("raw frames must not create devices: %+v", ds)
}
d := ds[0]
if d.Frames != 2 || !d.FirstSeen.Equal(t1) || !d.LastSeen.Equal(t2) || d.Freq != 914.9 || *d.RSSI != -70 {
t.Fatalf("device: %+v", d)
}
s.Clear()
if len(s.Devices()) != 0 {
t.Fatal("clear should drop devices")
}
}
func TestPublishes(t *testing.T) {
h := hub.New()
ch, cancel := h.Subscribe()
defer cancel()
s := NewStore(h, 0)
s.Add(Frame{Kind: KindLoRa, Identifier: "d"})
if m, ok := (<-ch).(FrameMsg); !ok || m.Type != "frame" || m.Frame.ID != 1 {
t.Fatalf("frame msg: %+v", m)
}
if m, ok := (<-ch).(DeviceMsg); !ok || m.Type != "device" || m.Device.Frames != 1 {
t.Fatalf("device msg: %+v", m)
}
s.Add(Frame{Kind: KindRaw})
select {
case m := <-ch:
if _, ok := m.(FrameMsg); !ok {
t.Fatalf("raw frame should only publish a frame, got %T", m)
}
default:
t.Fatal("expected frame msg")
}
}
func TestCSV(t *testing.T) {
rssi := -7
var buf bytes.Buffer
WriteCSV(&buf, []Frame{
{ID: 1, Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), Kind: KindLoRa, Source: "Feather 915 MHz", Identifier: "d", Freq: 915, RSSI: &rssi, Info: "a, b"},
{ID: 2, Time: time.Date(2026, 1, 1, 0, 0, 1, 0, time.UTC), Kind: KindRaw, Info: "c"},
})
want := "No,Time,Kind,Source,Identifier,Frequency,RSSI,Info\n" +
"1,2026-01-01T00:00:00Z,lora,Feather 915 MHz,d,915,-7,\"a, b\"\n" +
"2,2026-01-01T00:00:01Z,raw,,,,,c\n"
if buf.String() != want {
t.Fatalf("got %q", buf.String())
}
}
func TestConcurrent(t *testing.T) {
s := NewStore(hub.New(), 100)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s.Add(Frame{Kind: KindLoRa, Identifier: "d"})
s.Since(0)
s.Devices()
}()
}
wg.Wait()
}
+135
View File
@@ -0,0 +1,135 @@
// Package frames holds received frames and the devices aggregated from them.
package frames
import (
"sort"
"sync"
"time"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
)
// DefaultMax is how many frames the ring keeps.
const DefaultMax = 10000
const (
KindLoRa = "lora" // packet decoded by a Feather
KindLoRaWAN = "lorawan" // row scraped from a gateway
KindRaw = "raw" // any serial line
)
type Frame struct {
ID uint64 `json:"id"`
Time time.Time `json:"time"`
Kind string `json:"kind"`
Source string `json:"source"`
// Transceiver is the ID of the attached Feather that produced the frame (0 for gateway frames).
Transceiver int `json:"transceiver"`
Identifier string `json:"identifier"`
Freq float64 `json:"freq"`
RSSI *int `json:"rssi"`
Info string `json:"info"`
Raw string `json:"raw"`
}
type Device struct {
Identifier string `json:"identifier"`
Freq float64 `json:"freq"`
RSSI *int `json:"rssi"`
FirstSeen time.Time `json:"firstSeen"`
LastSeen time.Time `json:"lastSeen"`
Frames int `json:"frames"`
}
// Messages broadcast on the hub.
type FrameMsg struct {
Type string `json:"type"` // "frame"
Frame Frame `json:"frame"`
}
type DeviceMsg struct {
Type string `json:"type"` // "device"
Device Device `json:"device"`
}
type Store struct {
mu sync.RWMutex
hub *hub.Hub
max int
nextID uint64
frames []Frame
devices map[string]*Device
}
// NewStore keeps at most max frames (DefaultMax if <= 0). hub may be nil.
func NewStore(h *hub.Hub, max int) *Store {
if max <= 0 {
max = DefaultMax
}
return &Store{hub: h, max: max, nextID: 1, devices: map[string]*Device{}}
}
// Add assigns the ID, stores the frame, updates its device (non-raw only)
// and publishes both to the hub. A zero Time is set to now.
func (s *Store) Add(f Frame) Frame {
if f.Time.IsZero() {
f.Time = time.Now()
}
s.mu.Lock()
f.ID = s.nextID
s.nextID++
s.frames = append(s.frames, f)
if len(s.frames) > s.max {
s.frames = append([]Frame(nil), s.frames[len(s.frames)-s.max:]...)
}
var dev *Device
if f.Kind != KindRaw && f.Identifier != "" {
d, ok := s.devices[f.Identifier]
if !ok {
d = &Device{Identifier: f.Identifier, FirstSeen: f.Time}
s.devices[f.Identifier] = d
}
d.Freq, d.RSSI, d.LastSeen = f.Freq, f.RSSI, f.Time
d.Frames++
cp := *d
dev = &cp
}
s.mu.Unlock()
if s.hub != nil {
s.hub.Broadcast(FrameMsg{Type: "frame", Frame: f})
if dev != nil {
s.hub.Broadcast(DeviceMsg{Type: "device", Device: *dev})
}
}
return f
}
// Since returns frames with ID > after, oldest first.
func (s *Store) Since(after uint64) []Frame {
s.mu.RLock()
defer s.mu.RUnlock()
i := sort.Search(len(s.frames), func(i int) bool { return s.frames[i].ID > after })
return append([]Frame{}, s.frames[i:]...)
}
// Devices returns a copy sorted by identifier.
func (s *Store) Devices() []Device {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Device, 0, len(s.devices))
for _, d := range s.devices {
out = append(out, *d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Identifier < out[j].Identifier })
return out
}
// Clear drops all frames and devices. IDs keep increasing so clients that
// resume with ?after= never see a repeated ID.
func (s *Store) Clear() {
s.mu.Lock()
s.frames = nil
s.devices = map[string]*Device{}
s.mu.Unlock()
}
+239
View File
@@ -0,0 +1,239 @@
// Package gateway stores gateway IPs and scrapes Dragino traffic logs.
package gateway
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
var (
ErrDuplicate = errors.New("gateway already added")
ErrNotFound = errors.New("gateway not found")
)
const (
DefaultPollInterval = 30 * time.Second
MinPollInterval = 5 * time.Second
MaxPollInterval = time.Hour
)
var ErrBadInterval = fmt.Errorf("poll interval must be between %d and %d seconds",
int(MinPollInterval/time.Second), int(MaxPollInterval/time.Second))
// Config is a mutex-guarded list of gateway IPv4 addresses plus the poll
// interval, persisted as {"gateways": ["192.168.8.101", ...], "pollIntervalSeconds": 30}.
type Config struct {
mu sync.RWMutex
path string
ips []string
interval time.Duration
}
func NewConfig(path string) *Config {
return &Config{path: path, interval: DefaultPollInterval}
}
// PollInterval is how often gateways are scraped.
func (c *Config) PollInterval() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
return c.interval
}
func (c *Config) SetPollInterval(d time.Duration) error {
if d < MinPollInterval || d > MaxPollInterval {
return ErrBadInterval
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.save(c.ips, d); err != nil {
return err
}
c.interval = d
return nil
}
// DefaultPath is <user config dir>/lora-scanner/gateways.json (e.g.
// ~/.config on Linux, ~/Library/Application Support on macOS, %AppData% on
// Windows), so the binary can live anywhere, such as a read-only bin directory.
// It falls back to the working directory if there is no user config dir.
func DefaultPath() string {
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "lora-scanner", "gateways.json")
}
return "gateways.json"
}
// Load reads the file. If it doesn't exist, the first existing legacyPaths
// entry (the Flask app's flat {"gateway1": ..., "gateway10": ...} file) is
// imported read-only: the old file is never modified. A missing file is not an error.
func (c *Config) Load(legacyPaths ...string) error {
b, err := os.ReadFile(c.path)
if err == nil {
return c.parse(b, c.path)
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
for _, p := range legacyPaths {
b, err := os.ReadFile(p)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
return c.parse(b, p)
}
return nil
}
// parse accepts {"gateways": [...]} or the legacy flat gateway1..N object.
func (c *Config) parse(b []byte, name string) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return fmt.Errorf("parse %s: %w", name, err)
}
if list, ok := raw["gateways"]; ok {
var ips []string
if err := json.Unmarshal(list, &ips); err != nil {
return fmt.Errorf("parse %s: %w", name, err)
}
if v, ok := raw["pollIntervalSeconds"]; ok {
var secs int
if err := json.Unmarshal(v, &secs); err != nil {
return fmt.Errorf("parse %s: %w", name, err)
}
if d := time.Duration(secs) * time.Second; d >= MinPollInterval && d <= MaxPollInterval {
c.mu.Lock()
c.interval = d
c.mu.Unlock()
}
}
return c.setAll(ips)
}
type kv struct {
n int
ip string
}
var found []kv
for k, v := range raw {
var ip string
n, err := strconv.Atoi(strings.TrimPrefix(k, "gateway"))
if err == nil && strings.HasPrefix(k, "gateway") && json.Unmarshal(v, &ip) == nil && strings.TrimSpace(ip) != "" {
found = append(found, kv{n, ip})
}
}
sort.Slice(found, func(i, j int) bool { return found[i].n < found[j].n })
ips := make([]string, 0, len(found))
for _, f := range found {
ips = append(ips, f.ip)
}
return c.setAll(ips)
}
// setAll validates and replaces the list in memory (no save).
func (c *Config) setAll(in []string) error {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, raw := range in {
ip, err := normalize(raw)
if err != nil {
return err
}
if !seen[ip] {
seen[ip] = true
out = append(out, ip)
}
}
c.mu.Lock()
c.ips = out
c.mu.Unlock()
return nil
}
func normalize(raw string) (string, error) {
raw = strings.TrimSpace(raw)
ip := net.ParseIP(raw)
if ip == nil || ip.To4() == nil {
return "", fmt.Errorf("%q is not a valid IPv4 address", raw)
}
return ip.To4().String(), nil
}
// List returns the gateway IPs in the order they were added.
func (c *Config) List() []string {
c.mu.RLock()
defer c.mu.RUnlock()
return append([]string{}, c.ips...)
}
// Add validates, de-duplicates and persists. It returns the normalized IP.
func (c *Config) Add(raw string) (string, error) {
ip, err := normalize(raw)
if err != nil {
return "", err
}
c.mu.Lock()
defer c.mu.Unlock()
for _, have := range c.ips {
if have == ip {
return "", ErrDuplicate
}
}
next := append(append([]string{}, c.ips...), ip)
if err := c.save(next, c.interval); err != nil {
return "", err
}
c.ips = next
return ip, nil
}
func (c *Config) Remove(raw string) error {
ip, err := normalize(raw)
if err != nil {
return ErrNotFound
}
c.mu.Lock()
defer c.mu.Unlock()
next := make([]string, 0, len(c.ips))
found := false
for _, have := range c.ips {
if have == ip {
found = true
continue
}
next = append(next, have)
}
if !found {
return ErrNotFound
}
if err := c.save(next, c.interval); err != nil {
return err
}
c.ips = next
return nil
}
func (c *Config) save(ips []string, interval time.Duration) error {
b, err := json.MarshalIndent(struct {
Gateways []string `json:"gateways"`
PollIntervalSeconds int `json:"pollIntervalSeconds"`
}{ips, int(interval / time.Second)}, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
return err
}
return os.WriteFile(c.path, append(b, '\n'), 0o644)
}
+440
View File
@@ -0,0 +1,440 @@
package gateway
import (
"context"
"errors"
"fmt"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
)
func TestParseFixture(t *testing.T) {
f, err := os.Open("testdata/log-traffic.html")
if err != nil {
t.Fatal(err)
}
defer f.Close()
rows, err := Parse(f)
if err != nil {
t.Fatal(err)
}
if len(rows) < 3 {
t.Fatalf("got %d rows", len(rows))
}
r := rows[0]
if r.DevID != "27FD3566" || r.Freq != 914.1 || r.RSSI == nil || *r.RSSI != -24 {
t.Fatalf("bad first row %+v", r)
}
want := "01/30-13:53:13 | Data Unconfirmed Up | LoRa | 914.1 | SF10 BW125 | 12 | Dev Addr: 27FD3566, Size: 20"
if r.Text != want {
t.Fatalf("text %q", r.Text)
}
if !strings.Contains(r.Detail, `"Rssi":-24`) {
t.Fatalf("detail %q", r.Detail)
}
if *rows[1].RSSI != -93 {
t.Fatalf("second rssi %v", *rows[1].RSSI)
}
}
func TestScrapeDedupesAndAuths(t *testing.T) {
page, _ := os.ReadFile("testdata/log-traffic.html")
var gotUser, gotPass string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser, gotPass, _ = r.BasicAuth()
w.Write(page)
}))
defer ts.Close()
cfg := NewConfig(filepath.Join(t.TempDir(), "c.json"))
if _, err := cfg.Add("127.0.0.1"); err != nil {
t.Fatal(err)
}
st := frames.NewStore(nil, 0)
sc := NewScraper(cfg, st, "root", "dragino")
sc.URLFor = func(string) string { return ts.URL }
sc.ScrapeAll(context.Background())
n := len(st.Since(0))
if n == 0 {
t.Fatal("no frames scraped")
}
f := st.Since(0)[0]
if f.Kind != frames.KindLoRaWAN || f.Source != "127.0.0.1" || f.Identifier != "27FD3566" ||
f.RSSI == nil || !strings.Contains(f.Raw, `"Rssi":`) {
t.Fatalf("frame: %+v", f)
}
if ds := st.Devices(); len(ds) != 1 || ds[0].Identifier != "27FD3566" || ds[0].Frames != n {
t.Fatalf("devices: %+v", ds)
}
sc.ScrapeAll(context.Background())
if len(st.Since(0)) != n {
t.Fatal("second scrape added duplicates")
}
if gotUser != "root" || gotPass != "dragino" {
t.Fatalf("auth %q:%q", gotUser, gotPass)
}
if st := sc.Statuses()["127.0.0.1"]; st.State != StateOnline || st.LastSuccess == nil || st.Error != "" {
t.Fatalf("status: %+v", st)
}
}
func TestConfigAddRemovePersist(t *testing.T) {
p := filepath.Join(t.TempDir(), "gateways.json")
c := NewConfig(p)
if err := c.Load(""); err != nil {
t.Fatal(err)
}
for _, bad := range []string{"", "999.1.1.1", "nope", "::1"} {
if _, err := c.Add(bad); err == nil {
t.Errorf("%q should be rejected", bad)
}
}
// More than the old cap of 10, with whitespace tolerated.
for i := 1; i <= 25; i++ {
if _, err := c.Add(fmt.Sprintf(" 10.0.0.%d ", i)); err != nil {
t.Fatal(err)
}
}
if _, err := c.Add("10.0.0.3"); !errors.Is(err, ErrDuplicate) {
t.Fatalf("want ErrDuplicate, got %v", err)
}
if err := c.Remove("10.0.0.2"); err != nil {
t.Fatal(err)
}
if err := c.Remove("10.0.0.2"); !errors.Is(err, ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
c2 := NewConfig(p)
if err := c2.Load(""); err != nil {
t.Fatal(err)
}
got := c2.List()
if len(got) != 24 || got[0] != "10.0.0.1" || got[1] != "10.0.0.3" || got[23] != "10.0.0.25" {
t.Fatalf("reload: %v", got)
}
}
func TestConfigCreatesMissingDirectory(t *testing.T) {
p := filepath.Join(t.TempDir(), "a", "b", "gateways.json")
c := NewConfig(p)
if _, err := c.Add("10.0.0.1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p); err != nil {
t.Fatalf("config not written: %v", err)
}
}
func TestDefaultPathIsAbsoluteUserDir(t *testing.T) {
if p := DefaultPath(); !filepath.IsAbs(p) || filepath.Base(p) != "gateways.json" || filepath.Base(filepath.Dir(p)) != "lora-scanner" {
t.Fatalf("default path %q", p)
}
}
func TestConfigImportsLegacyFlatFile(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "gateway_config.json")
body := `{"gateway1": "192.168.8.101", "gateway10": "192.168.8.110", "gateway2": "", "gateway3": "192.168.8.103"}`
os.WriteFile(legacy, []byte(body), 0o644)
c := NewConfig(filepath.Join(dir, "gateways.json"))
if err := c.Load(legacy); err != nil {
t.Fatal(err)
}
want := []string{"192.168.8.101", "192.168.8.103", "192.168.8.110"}
if got := c.List(); strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("import: %v", got)
}
if b, _ := os.ReadFile(legacy); string(b) != body {
t.Fatal("legacy file must not be modified")
}
if _, err := os.Stat(filepath.Join(dir, "gateways.json")); err == nil {
t.Fatal("import alone should not write the new file")
}
if _, err := c.Add("10.1.1.1"); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(legacy); string(b) != body {
t.Fatal("legacy file must not be modified after save")
}
// A legacy-format file at the main path loads too.
c3 := NewConfig(legacy)
if err := c3.Load(""); err != nil || len(c3.List()) != 3 {
t.Fatalf("flat at main path: %v %v", err, c3.List())
}
}
func TestPollIntervalPersistsAndValidates(t *testing.T) {
p := filepath.Join(t.TempDir(), "gateways.json")
c := NewConfig(p)
if c.PollInterval() != 30*time.Second {
t.Fatalf("default %v", c.PollInterval())
}
for _, bad := range []time.Duration{0, time.Second, 4 * time.Second, 2 * time.Hour} {
if err := c.SetPollInterval(bad); !errors.Is(err, ErrBadInterval) {
t.Errorf("%v: want ErrBadInterval, got %v", bad, err)
}
}
if err := c.SetPollInterval(90 * time.Second); err != nil {
t.Fatal(err)
}
// Adding a gateway must not reset the interval.
if _, err := c.Add("10.0.0.1"); err != nil {
t.Fatal(err)
}
c2 := NewConfig(p)
if err := c2.Load(); err != nil || c2.PollInterval() != 90*time.Second || len(c2.List()) != 1 {
t.Fatalf("reload: %v %v %v", err, c2.PollInterval(), c2.List())
}
// Legacy imports keep the default.
if c3 := NewConfig(filepath.Join(t.TempDir(), "x.json")); c3.PollInterval() != DefaultPollInterval {
t.Fatal("default lost")
}
}
func TestOfflineGatewayRetriesFastThenSettles(t *testing.T) {
page, _ := os.ReadFile("testdata/log-traffic.html")
var hits atomic.Int32
var failing atomic.Bool
failing.Store(true)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if failing.Load() {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
w.Write(page)
}))
defer ts.Close()
h := hub.New()
events, cancelSub := h.Subscribe()
defer cancelSub()
cfg := NewConfig(filepath.Join(t.TempDir(), "g.json"))
cfg.Add("127.0.0.1")
sc := NewScraper(cfg, frames.NewStore(nil, 0), "u", "p")
sc.Hub = h
sc.URLFor = func(string) string { return ts.URL }
sc.retry, sc.tick = 40*time.Millisecond, 10*time.Millisecond
if st := sc.Statuses()["127.0.0.1"]; st.State != StateUnknown {
t.Fatalf("before first poll: %+v", st)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sc.Run(ctx)
waitFor := func(what string, cond func() bool) {
t.Helper()
for i := 0; i < 200; i++ {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out: %s", what)
}
waitFor("first poll", func() bool { return hits.Load() >= 1 })
waitFor("offline", func() bool { return sc.Statuses()["127.0.0.1"].State == StateOffline })
if hits.Load() < FailThreshold {
t.Fatalf("went offline after %d poll(s), want %d", hits.Load(), FailThreshold)
}
if e := sc.Statuses()["127.0.0.1"].Error; !strings.Contains(e, "500") {
t.Fatalf("error should say why: %q", e)
}
// Offline gateways are retried on the short cadence, not the 30s poll interval.
waitFor("retries", func() bool { return hits.Load() >= 4 })
failing.Store(false)
waitFor("online", func() bool { return sc.Statuses()["127.0.0.1"].State == StateOnline })
settled := hits.Load()
time.Sleep(250 * time.Millisecond) // many retry periods
if got := hits.Load(); got > settled+1 {
t.Fatalf("online gateway still polled at retry cadence: %d -> %d", settled, got)
}
// Exactly two transitions were announced: unknown->offline, offline->online.
var states []string
for len(events) > 0 {
m := (<-events).(StatusMsg)
states = append(states, m.Gateways["127.0.0.1"].State)
}
if strings.Join(states, ",") != "offline,online" {
t.Fatalf("broadcasts: %v", states)
}
}
func TestRemovedGatewayStatusIsDropped(t *testing.T) {
cfg := NewConfig(filepath.Join(t.TempDir(), "g.json"))
cfg.Add("127.0.0.1")
sc := NewScraper(cfg, frames.NewStore(nil, 0), "u", "p")
sc.URLFor = func(string) string { return "http://127.0.0.1:1" } // refused
sc.ScrapeAll(context.Background())
sc.ScrapeAll(context.Background())
if sc.Statuses()["127.0.0.1"].State != StateOffline {
t.Fatal("expected offline")
}
cfg.Remove("127.0.0.1")
if len(sc.Statuses()) != 0 {
t.Fatal("removed gateway must not be reported")
}
}
func TestSlowGatewayDoesNotBlockOthersOrOverlap(t *testing.T) {
page, _ := os.ReadFile("testdata/log-traffic.html")
var slowHits, fastHits atomic.Int32
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slowHits.Add(1)
time.Sleep(600 * time.Millisecond)
w.Write(page)
}))
defer slow.Close()
fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fastHits.Add(1)
w.Write(page)
}))
defer fast.Close()
cfg := NewConfig(filepath.Join(t.TempDir(), "g.json"))
cfg.Add("10.0.0.1") // slow
cfg.Add("10.0.0.2") // fast
sc := NewScraper(cfg, frames.NewStore(nil, 0), "u", "p")
sc.URLFor = func(ip string) string {
if ip == "10.0.0.1" {
return slow.URL
}
return fast.URL
}
ctx := context.Background()
w1 := sc.launch(ctx, true)
time.Sleep(150 * time.Millisecond)
// Next cycle while the slow one is still running: only the fast gateway is scraped.
w2 := sc.launch(ctx, true)
w1()
w2()
if slowHits.Load() != 1 {
t.Errorf("slow gateway scraped %d times, want 1 (no overlap)", slowHits.Load())
}
if fastHits.Load() != 2 {
t.Errorf("fast gateway scraped %d times, want 2", fastHits.Load())
}
// Once finished it is scraped again.
sc.ScrapeAll(ctx)
if slowHits.Load() != 2 {
t.Errorf("slow gateway not rescheduled: %d", slowHits.Load())
}
}
func TestStalledBodyIsReportedAndRetriedCleanly(t *testing.T) {
page, _ := os.ReadFile("testdata/log-traffic.html")
var stall atomic.Bool
stall.Store(true)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if stall.Load() {
w.Write(page[:len(page)/2]) // headers + half the page, then hang
w.(http.Flusher).Flush()
time.Sleep(500 * time.Millisecond)
return
}
w.Write(page)
}))
defer ts.Close()
cfg := NewConfig(filepath.Join(t.TempDir(), "g.json"))
cfg.Add("127.0.0.1")
st := frames.NewStore(nil, 0)
sc := NewScraper(cfg, st, "u", "p")
sc.URLFor = func(string) string { return ts.URL }
sc.Client.Timeout = 150 * time.Millisecond
if err := sc.scrapeOne(context.Background(), "127.0.0.1"); err == nil || !strings.Contains(err.Error(), "reading page") {
t.Fatalf("want a reading-page error, got %v", err)
}
if len(st.Since(0)) != 0 {
t.Fatal("a truncated page must not produce frames")
}
stall.Store(false)
if err := sc.scrapeOne(context.Background(), "127.0.0.1"); err != nil || len(st.Since(0)) == 0 {
t.Fatalf("retry: %v, %d frames", err, len(st.Since(0)))
}
}
func TestSingleFailureDoesNotFlipOnlineGateway(t *testing.T) {
page, _ := os.ReadFile("testdata/log-traffic.html")
var hits atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits.Add(1) == 2 { // exactly one bad response
http.Error(w, "boom", http.StatusInternalServerError)
return
}
w.Write(page)
}))
defer ts.Close()
h := hub.New()
events, cancelSub := h.Subscribe()
defer cancelSub()
cfg := NewConfig(filepath.Join(t.TempDir(), "g.json"))
cfg.Add("127.0.0.1")
sc := NewScraper(cfg, frames.NewStore(nil, 0), "u", "p")
sc.Hub = h
sc.URLFor = func(string) string { return ts.URL }
sc.retry = 20 * time.Millisecond
ctx := context.Background()
sc.ScrapeAll(ctx) // ok -> online
if sc.Statuses()["127.0.0.1"].State != StateOnline {
t.Fatal("expected online")
}
sc.ScrapeAll(ctx) // the one failure
st := sc.Statuses()["127.0.0.1"]
if st.State != StateOnline || st.Error != "" {
t.Fatalf("a single failure must not change the state: %+v", st)
}
// The failed gateway is rechecked on the short cadence to confirm.
time.Sleep(40 * time.Millisecond)
sc.launch(ctx, false)()
if hits.Load() != 3 {
t.Fatalf("confirming poll not made on retry cadence: %d hits", hits.Load())
}
if sc.state["127.0.0.1"].fails != 0 {
t.Fatal("success must reset the failure count")
}
// Online->online: nothing was announced beyond the initial unknown->online.
n := 0
for len(events) > 0 {
<-events
n++
}
if n != 1 {
t.Fatalf("expected 1 broadcast, got %d", n)
}
}
func TestTwoFailuresInARowGoOffline(t *testing.T) {
cfg := NewConfig(filepath.Join(t.TempDir(), "g.json"))
cfg.Add("127.0.0.1")
sc := NewScraper(cfg, frames.NewStore(nil, 0), "u", "p")
sc.URLFor = func(string) string { return "http://127.0.0.1:1" } // refused
sc.ScrapeAll(context.Background())
if st := sc.Statuses()["127.0.0.1"]; st.State != StateUnknown {
t.Fatalf("after 1 failure: %+v", st)
}
sc.ScrapeAll(context.Background())
if st := sc.Statuses()["127.0.0.1"]; st.State != StateOffline || st.Error == "" {
t.Fatalf("after 2 failures: %+v", st)
}
}
+343
View File
@@ -0,0 +1,343 @@
package gateway
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
)
const (
// A dead gateway fails fast, but a slow one (the log page is large and
// the gateway's CGI is slow) gets time to finish sending it.
dialTimeout = 5 * time.Second
headerTimeout = 10 * time.Second
requestTimeout = 45 * time.Second
// Offline gateways are retried this often until they answer again.
RetryInterval = 5 * time.Second
// A gateway is only marked offline after this many failed polls in a row,
// so one slow or dropped response doesn't flip it.
FailThreshold = 2
tickEvery = time.Second
)
var (
rssiRe = regexp.MustCompile(`"Rssi":(-?\d+)`)
hiddenStyle = "display: none;"
)
// Row is one packet parsed from the gateway's log-traffic page.
type Row struct {
DevID string
Freq float64
RSSI *int
Text string
// Detail is the hidden row's JSON-ish text (address, size, RSSI, SNR, MIC...).
Detail string
}
// Parse extracts packet rows from a Dragino /cgi-bin/log-traffic.has page.
// Visible rows are summaries; each is followed by a hidden row holding JSON-ish
// details (not valid JSON, hence the regex for RSSI).
func Parse(r io.Reader) ([]Row, error) {
doc, err := goquery.NewDocumentFromReader(r)
if err != nil {
return nil, err
}
table := doc.Find("table").First()
var rows []Row
table.Find("tr").Each(func(i int, tr *goquery.Selection) {
if i == 0 || strings.Contains(tr.AttrOr("style", ""), hiddenStyle) {
return
}
var cells []string
tr.Find("td").Each(func(_ int, td *goquery.Selection) {
cells = append(cells, strings.TrimSpace(td.Text()))
})
if len(cells) < 2 {
return
}
fields := cells[1:] // first cell is the chevron
if len(fields) < 4 {
return
}
freq, err := strconv.ParseFloat(strings.TrimSpace(fields[3]), 64)
if err != nil {
return
}
devID := extractDevID(fields[len(fields)-1])
if devID == "" {
return
}
row := Row{DevID: devID, Freq: freq, Text: strings.Join(fields, " | ")}
hidden := tr.NextFiltered("tr")
if hidden.Length() > 0 && strings.Contains(hidden.AttrOr("style", ""), hiddenStyle) {
row.Detail = strings.Join(strings.Fields(hidden.Text()), " ")
if m := rssiRe.FindStringSubmatch(hidden.Text()); m != nil {
if v, err := strconv.Atoi(m[1]); err == nil {
row.RSSI = &v
}
}
}
rows = append(rows, row)
})
return rows, nil
}
// extractDevID turns "Dev Addr: 27FD3566, Size: 20" into "27FD3566".
func extractDevID(content string) string {
first := strings.SplitN(content, ",", 2)[0]
parts := strings.Split(first, ":")
return strings.TrimSpace(parts[len(parts)-1])
}
// Gateway states reported to the UI.
const (
StateUnknown = "unknown" // not polled yet
StateOnline = "online" // last poll succeeded
StateOffline = "offline" // last poll failed
)
// GatewayStatus is what the last poll of a gateway found.
type GatewayStatus struct {
State string `json:"state"`
Error string `json:"error,omitempty"`
LastSuccess *time.Time `json:"lastSuccess"`
}
// StatusMsg is broadcast on the hub whenever any gateway changes state.
type StatusMsg struct {
Type string `json:"type"` // "gateways"
Gateways map[string]GatewayStatus `json:"gateways"`
}
type gwState struct {
GatewayStatus
lastFinish time.Time
running bool
fails int // consecutive failed polls
}
type Scraper struct {
Config *Config
Frames *frames.Store
Hub *hub.Hub // optional: receives StatusMsg on state changes
User string
Pass string
Client *http.Client
URLFor func(ip string) string
mu sync.Mutex // guards seen and states
seen map[string]struct{}
state map[string]*gwState
retry time.Duration // offline retry cadence (RetryInterval; tests shorten it)
tick time.Duration
}
func NewScraper(cfg *Config, f *frames.Store, user, pass string) *Scraper {
return &Scraper{
Config: cfg, Frames: f, User: user, Pass: pass,
Client: &http.Client{
Timeout: requestTimeout,
Transport: &http.Transport{
// A host that silently drops packets must fail fast, not after the full request timeout.
DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext,
ResponseHeaderTimeout: headerTimeout,
},
},
URLFor: func(ip string) string { return fmt.Sprintf("http://%s:8000/cgi-bin/log-traffic.has", ip) },
seen: map[string]struct{}{},
state: map[string]*gwState{},
retry: RetryInterval,
tick: tickEvery,
}
}
// Run polls until ctx ends. Each gateway has its own schedule: every
// Config.PollInterval() while online, every RetryInterval while offline or
// confirming a failed poll. A gateway whose previous scrape is still running is skipped.
func (s *Scraper) Run(ctx context.Context) {
t := time.NewTicker(s.tick)
defer t.Stop()
for {
s.launch(ctx, false)
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// ScrapeAll scrapes every idle gateway now, regardless of schedule, and waits.
func (s *Scraper) ScrapeAll(ctx context.Context) { s.launch(ctx, true)() }
// launch starts a scrape for each gateway that is due (or all idle ones when
// force is set) and returns a func that waits for them.
func (s *Scraper) launch(ctx context.Context, force bool) (wait func()) {
ips := s.Config.List()
interval := s.Config.PollInterval()
now := time.Now()
s.mu.Lock()
configured := make(map[string]bool, len(ips))
for _, ip := range ips {
configured[ip] = true
}
for ip, st := range s.state {
if !configured[ip] && !st.running {
delete(s.state, ip) // removed from the list
}
}
var start []string
for _, ip := range ips {
st := s.state[ip]
if st == nil {
st = &gwState{GatewayStatus: GatewayStatus{State: StateUnknown}}
s.state[ip] = st
}
if st.running {
continue
}
wait := interval
if st.State != StateOnline || st.fails > 0 { // offline, or confirming a failure
wait = s.retry
}
if force || st.lastFinish.IsZero() || now.Sub(st.lastFinish) >= wait {
st.running = true
start = append(start, ip)
}
}
s.mu.Unlock()
var wg sync.WaitGroup
for _, ip := range start {
wg.Add(1)
go func(ip string) {
defer wg.Done()
began := time.Now()
s.finish(ip, s.scrapeOne(ctx, ip), time.Since(began))
}(ip)
}
return wg.Wait
}
// finish records a poll result and announces state changes.
func (s *Scraper) finish(ip string, err error, took time.Duration) {
s.mu.Lock()
st := s.state[ip]
if st == nil { // removed while the scrape was running
s.mu.Unlock()
return
}
prev := st.State
st.running = false
st.lastFinish = time.Now()
if err != nil {
st.fails++
// One failure is only a warning: the state flips after FailThreshold in a row.
if st.fails >= FailThreshold {
st.State, st.Error = StateOffline, err.Error()
}
} else {
now := time.Now()
st.fails = 0
st.State, st.Error, st.LastSuccess = StateOnline, "", &now
}
cur := st.State
changed := prev != cur
s.mu.Unlock()
if !changed {
return
}
if cur == StateOffline {
fmt.Printf("gateway %s offline after %d failed polls: %v (last took %s); retrying every %s\n",
ip, FailThreshold, err, took.Round(time.Millisecond), s.retry)
} else if prev == StateOffline {
fmt.Printf("gateway %s back online\n", ip)
}
if s.Hub != nil {
s.Hub.Broadcast(s.StatusMsg())
}
}
// Statuses reports the last poll result for every configured gateway.
func (s *Scraper) Statuses() map[string]GatewayStatus {
ips := s.Config.List()
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]GatewayStatus, len(ips))
for _, ip := range ips {
if st := s.state[ip]; st != nil {
out[ip] = st.GatewayStatus
} else {
out[ip] = GatewayStatus{State: StateUnknown}
}
}
return out
}
func (s *Scraper) StatusMsg() StatusMsg { return StatusMsg{Type: "gateways", Gateways: s.Statuses()} }
func (s *Scraper) request(ctx context.Context, method, ip string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, s.URLFor(ip), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(s.User, s.Pass)
return s.Client.Do(req)
}
func (s *Scraper) scrapeOne(ctx context.Context, ip string) error {
resp, err := s.request(ctx, http.MethodGet, ip)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %s", resp.Status)
}
// Read the whole page first so a stalled download is reported as such and
// nothing is half-parsed; the next poll sees the same rows again (deduped).
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading page (got %d bytes): %w", len(body), err)
}
rows, err := Parse(bytes.NewReader(body))
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
// Oldest first so survey order is chronological (the page lists newest first).
for i := len(rows) - 1; i >= 0; i-- {
r := rows[i]
key := fmt.Sprintf("%s_%v_%s", r.DevID, r.Freq, r.Text)
if _, dup := s.seen[key]; dup {
continue
}
s.seen[key] = struct{}{}
raw := r.Text
if r.Detail != "" {
raw += "\n" + r.Detail
}
s.Frames.Add(frames.Frame{
Kind: frames.KindLoRaWAN, Source: ip, Identifier: r.DevID,
Freq: r.Freq, RSSI: r.RSSI, Info: r.Text, Raw: raw,
})
}
return nil
}
File renamed without changes.
+46
View File
@@ -0,0 +1,46 @@
// Package hub fans messages out to WebSocket subscribers.
package hub
import "sync"
const clientBuffer = 256
type Hub struct {
mu sync.Mutex
clients map[chan any]struct{}
}
func New() *Hub { return &Hub{clients: map[chan any]struct{}{}} }
// Subscribe returns a message channel and a cancel func. The channel is
// closed if the subscriber falls too far behind or cancels.
func (h *Hub) Subscribe() (<-chan any, func()) {
ch := make(chan any, clientBuffer)
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
return ch, func() { h.drop(ch) }
}
func (h *Hub) drop(ch chan any) {
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.clients[ch]; ok {
delete(h.clients, ch)
close(ch)
}
}
// Broadcast never blocks; slow subscribers are dropped.
func (h *Hub) Broadcast(msg any) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
select {
case ch <- msg:
default:
delete(h.clients, ch)
close(ch)
}
}
}
+275
View File
@@ -0,0 +1,275 @@
package serial
import (
"bufio"
"errors"
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/skinnyrad/Lora-Scanner/internal/beacon"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
gs "go.bug.st/serial"
"go.bug.st/serial/enumerator"
)
const BaudRate = 9600
var (
ErrNotFound = errors.New("transceiver not found")
ErrPortInUse = errors.New("port already in use")
)
// Opener opens a serial device. It is a field so tests can inject a fake.
type Opener func(name string) (io.ReadWriteCloser, error)
func defaultOpener(name string) (io.ReadWriteCloser, error) {
return gs.Open(name, &gs.Mode{BaudRate: BaudRate})
}
// Transceiver is an attached Feather. Freq (MHz) is nil until detected from
// the firmware's boot banner.
type Transceiver struct {
ID int `json:"id"`
Port string `json:"port"`
Freq *float64 `json:"freq"`
}
// ListMsg is broadcast on the hub whenever the set of transceivers (or a
// detected frequency) changes.
type ListMsg struct {
Type string `json:"type"` // "transceivers"
Transceivers []Transceiver `json:"transceivers"`
}
type slot struct {
t Transceiver
conn io.ReadWriteCloser
done chan struct{}
beacon beacon.Beacon
writeMu sync.Mutex
}
type Manager struct {
mu sync.Mutex
slots map[int]*slot
used map[string]int // port -> id
lastFreq map[string]float64 // port -> last detected frequency
nextID int
hub *hub.Hub
frames *frames.Store
open Opener
}
func NewManager(h *hub.Hub, f *frames.Store) *Manager {
return &Manager{
slots: map[int]*slot{}, used: map[string]int{}, lastFreq: map[string]float64{},
nextID: 1, hub: h, frames: f, open: defaultOpener,
}
}
// ListPorts returns the serial device paths available on this machine.
func ListPorts() ([]string, error) {
details, err := enumerator.GetDetailedPortsList()
if err != nil {
return nil, err
}
ports := make([]string, 0, len(details))
for _, d := range details {
ports = append(ports, d.Name)
}
sort.Strings(ports)
return ports, nil
}
// Add opens the port and starts reading from it.
func (m *Manager) Add(port string) (Transceiver, error) {
port = strings.TrimSpace(port)
if port == "" {
return Transceiver{}, errors.New("port is required")
}
m.mu.Lock()
defer m.mu.Unlock()
if _, busy := m.used[port]; busy {
return Transceiver{}, ErrPortInUse
}
conn, err := m.open(port)
if err != nil {
return Transceiver{}, fmt.Errorf("open %s: %w", port, err)
}
s := &slot{conn: conn, done: make(chan struct{}), t: Transceiver{ID: m.nextID, Port: port}}
m.nextID++
// The firmware only prints its frequency at boot, so a reconnect to a
// board that is already running remembers what we saw last time.
if f, ok := m.lastFreq[port]; ok {
s.t.Freq = &f
}
m.slots[s.t.ID] = s
m.used[port] = s.t.ID
go m.read(s)
m.broadcastLocked()
return s.t, nil
}
func (m *Manager) Remove(id int) error {
m.mu.Lock()
s, ok := m.slots[id]
if !ok {
m.mu.Unlock()
return ErrNotFound
}
m.removeLocked(s)
m.mu.Unlock()
s.beacon.Stop()
s.conn.Close() // unblocks the reader
<-s.done
return nil
}
// removeLocked drops the slot from the registry and tells clients.
func (m *Manager) removeLocked(s *slot) {
delete(m.slots, s.t.ID)
delete(m.used, s.t.Port)
m.broadcastLocked()
}
func (m *Manager) listLocked() []Transceiver {
out := make([]Transceiver, 0, len(m.slots))
for _, s := range m.slots {
out = append(out, s.t)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (m *Manager) broadcastLocked() {
m.hub.Broadcast(ListMsg{Type: "transceivers", Transceivers: m.listLocked()})
}
func (m *Manager) List() []Transceiver {
m.mu.Lock()
defer m.mu.Unlock()
return m.listLocked()
}
func (m *Manager) get(id int) (*slot, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.slots[id]
if !ok {
return nil, ErrNotFound
}
return s, nil
}
// Transmit writes the legacy "TX:<text> " frame.
func (m *Manager) Transmit(id int, text string) error {
s, err := m.get(id)
if err != nil {
return err
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err = s.conn.Write([]byte("TX:" + text + " "))
return err
}
func (m *Manager) StartBeacon(id int, text string, interval time.Duration) error {
s, err := m.get(id)
if err != nil {
return err
}
s.beacon.Start(text, interval, func(t string) error { return m.Transmit(id, t) })
return nil
}
func (m *Manager) StopBeacon(id int) error {
s, err := m.get(id)
if err != nil {
return err
}
s.beacon.Stop()
return nil
}
func (m *Manager) BeaconState(id int) (beacon.State, error) {
s, err := m.get(id)
if err != nil {
return beacon.State{}, err
}
return s.beacon.State(), nil
}
// setFreq records a detected frequency and returns the slot's current label info.
func (m *Manager) setFreq(s *slot, f float64) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastFreq[s.t.Port] = f
if _, live := m.slots[s.t.ID]; !live {
return
}
s.t.Freq = &f
m.broadcastLocked()
}
func (m *Manager) snapshot(s *slot) Transceiver {
m.mu.Lock()
defer m.mu.Unlock()
return s.t
}
func sourceLabel(t Transceiver) string {
name := filepath.Base(t.Port)
if t.Freq != nil {
return fmt.Sprintf("%s (%s MHz)", name, fmtFreq(*t.Freq))
}
return name
}
func fmtFreq(f float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", f), "0"), ".")
}
func (m *Manager) read(s *slot) {
defer close(s.done)
var p Parser
sc := bufio.NewScanner(s.conn)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if f, ok := ParseFreq(line); ok {
m.setFreq(s, f)
}
t := m.snapshot(s)
source := sourceLabel(t)
m.frames.Add(frames.Frame{Kind: frames.KindRaw, Source: source, Transceiver: t.ID, Info: line, Raw: line})
if rssi, decoded, ok := p.Feed(line); ok {
id := "Raw LoRa Device (" + filepath.Base(t.Port) + ")"
var freq float64
if t.Freq != nil {
freq = *t.Freq
id = fmt.Sprintf("Raw LoRa Device %s MHz", fmtFreq(freq))
}
m.frames.Add(frames.Frame{
Kind: frames.KindLoRa, Source: source, Transceiver: t.ID, Identifier: id, Freq: freq, RSSI: &rssi,
Info: decoded, Raw: fmt.Sprintf("RSSI: %d\nDecoded Value: %s", rssi, decoded),
})
}
}
// The device vanished (unplugged) rather than an explicit Remove: release it.
m.mu.Lock()
if m.slots[s.t.ID] == s {
m.removeLocked(s)
m.mu.Unlock()
s.beacon.Stop()
s.conn.Close()
return
}
m.mu.Unlock()
}
+49
View File
@@ -0,0 +1,49 @@
// Package serial manages attached Feather transceivers.
package serial
import (
"regexp"
"strconv"
)
var (
rssiRe = regexp.MustCompile(`RSSI: (-?\d+)`)
decodedRe = regexp.MustCompile(`Decoded Value: (.+)`)
// The Skinny-LoRa firmware prints this once from setup(), e.g. "Freq set to: 915.00".
freqRe = regexp.MustCompile(`Freq set to: (\d+(?:\.\d+)?)`)
)
// ParseFreq extracts the radio frequency (MHz) from the firmware's boot banner.
func ParseFreq(line string) (float64, bool) {
m := freqRe.FindStringSubmatch(line)
if m == nil {
return 0, false
}
f, err := strconv.ParseFloat(m[1], 64)
return f, err == nil && f > 0
}
// Parser pairs the "RSSI:" and "Decoded Value:" lines the Skinny-LoRa
// firmware prints separately into one packet.
type Parser struct {
rssi *int
decoded *string
}
// Feed consumes one serial line and reports a completed packet, if any.
func (p *Parser) Feed(line string) (rssi int, decoded string, ok bool) {
if m := rssiRe.FindStringSubmatch(line); m != nil {
if v, err := strconv.Atoi(m[1]); err == nil {
p.rssi = &v
}
}
if m := decodedRe.FindStringSubmatch(line); m != nil {
d := m[1]
p.decoded = &d
}
if p.rssi != nil && p.decoded != nil {
rssi, decoded, ok = *p.rssi, *p.decoded, true
p.rssi, p.decoded = nil, nil
}
return
}
+181
View File
@@ -0,0 +1,181 @@
package serial
import (
"io"
"sync"
"testing"
"time"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
)
func TestParserPairsLines(t *testing.T) {
var p Parser
if _, _, ok := p.Feed("noise"); ok {
t.Fatal("unexpected packet")
}
if _, _, ok := p.Feed("RSSI: -80"); ok {
t.Fatal("rssi alone should not complete")
}
rssi, dec, ok := p.Feed("Decoded Value: hello world")
if !ok || rssi != -80 || dec != "hello world" {
t.Fatalf("got %v %q %v", rssi, dec, ok)
}
if _, _, ok := p.Feed("Decoded Value: again"); ok {
t.Fatal("state should reset after a packet")
}
}
// fakePort is a duplex pipe: device->app via r, app->device captured in written.
type fakePort struct {
r *io.PipeReader
w *io.PipeWriter
mu sync.Mutex
written []byte
}
func newFake() *fakePort {
r, w := io.Pipe()
return &fakePort{r: r, w: w}
}
func (f *fakePort) Read(b []byte) (int, error) { return f.r.Read(b) }
func (f *fakePort) Write(b []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.written = append(f.written, b...)
return len(b), nil
}
func (f *fakePort) Close() error { return f.r.Close() }
func eventually(t *testing.T, cond func() bool) {
t.Helper()
for i := 0; i < 200; i++ {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("condition not met")
}
func TestParseFreq(t *testing.T) {
for line, want := range map[string]float64{"Freq set to: 915.00": 915, "Freq set to: 433.0": 433, "Freq set to: 902.3": 902.3} {
if f, ok := ParseFreq(line); !ok || f != want {
t.Errorf("%q -> %v %v", line, f, ok)
}
}
for _, line := range []string{"Welcome to the skinny-lora transceiver", "Freq set to: abc", "RSSI: -80"} {
if _, ok := ParseFreq(line); ok {
t.Errorf("%q should not parse", line)
}
}
}
func newTestManager(t *testing.T, ports map[string]*fakePort) (*Manager, *frames.Store) {
h := hub.New()
st := frames.NewStore(h, 0)
m := NewManager(h, st)
m.open = func(name string) (io.ReadWriteCloser, error) {
if p, ok := ports[name]; ok {
return p, nil
}
return nil, io.ErrClosedPipe
}
return m, st
}
func TestManagerLifecycle(t *testing.T) {
fake := newFake()
m, st := newTestManager(t, map[string]*fakePort{"/dev/x": fake})
if _, err := m.Add("/dev/missing"); err == nil {
t.Fatal("expected open error")
}
tr, err := m.Add("/dev/x")
if err != nil || tr.ID != 1 || tr.Freq != nil {
t.Fatalf("add: %+v %v", tr, err)
}
if _, err := m.Add("/dev/x"); err != ErrPortInUse {
t.Fatalf("want ErrPortInUse, got %v", err)
}
// Frequency is detected from the firmware banner.
fake.w.Write([]byte("Welcome to the skinny-lora transceiver\nFreq set to: 915.00\n"))
eventually(t, func() bool { l := m.List(); return len(l) == 1 && l[0].Freq != nil })
if f := *m.List()[0].Freq; f != 915 {
t.Fatalf("freq %v", f)
}
fake.w.Write([]byte("RSSI: -42\nDecoded Value: hi\n"))
eventually(t, func() bool { return len(st.Since(0)) == 5 })
fs := st.Since(0)
if fs[0].Kind != frames.KindRaw || fs[0].Transceiver != 1 || fs[1].Info != "Freq set to: 915.00" || fs[1].Source != "x (915 MHz)" {
t.Fatalf("raw frames: %+v", fs)
}
lora := fs[4]
if lora.Kind != frames.KindLoRa || lora.Identifier != "Raw LoRa Device 915 MHz" ||
lora.RSSI == nil || *lora.RSSI != -42 || lora.Freq != 915 || lora.Info != "hi" {
t.Fatalf("lora frame: %+v", lora)
}
if ds := st.Devices(); len(ds) != 1 || ds[0].Frames != 1 {
t.Fatalf("devices: %+v", ds)
}
if err := m.Transmit(1, "ping"); err != nil {
t.Fatal(err)
}
if got := string(fake.written); got != "TX:ping " {
t.Fatalf("wrote %q", got)
}
if err := m.Transmit(9, "x"); err != ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
if err := m.StartBeacon(1, "b", time.Second); err != nil {
t.Fatal(err)
}
if err := m.Remove(1); err != nil {
t.Fatal(err)
}
if len(m.List()) != 0 {
t.Fatal("transceiver should be gone")
}
if err := m.Remove(1); err != ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestReaddRemembersFrequency(t *testing.T) {
fake := newFake()
m, _ := newTestManager(t, map[string]*fakePort{"/dev/x": fake})
if _, err := m.Add("/dev/x"); err != nil {
t.Fatal(err)
}
fake.w.Write([]byte("Freq set to: 868.0\n"))
eventually(t, func() bool { return m.List()[0].Freq != nil })
m.Remove(1)
// A board that is already running won't print the banner again.
fake2 := newFake()
m.open = func(string) (io.ReadWriteCloser, error) { return fake2, nil }
tr, err := m.Add("/dev/x")
if err != nil || tr.Freq == nil || *tr.Freq != 868 || tr.ID != 2 {
t.Fatalf("re-add: %+v %v", tr, err)
}
m.Remove(2)
}
func TestUnplugReleasesTransceiver(t *testing.T) {
fake := newFake()
m, _ := newTestManager(t, map[string]*fakePort{"/dev/x": fake})
if _, err := m.Add("/dev/x"); err != nil {
t.Fatal(err)
}
fake.w.Close() // EOF, like a vanished device
eventually(t, func() bool { return len(m.List()) == 0 })
if _, err := m.Add("/dev/x"); err != nil {
t.Fatalf("port should be free after unplug: %v", err)
}
m.Remove(2)
}
-122
View File
@@ -1,122 +0,0 @@
##!/usr/bin/env python3
import io
import sys
import time
import datetime
import argparse
from enum import IntEnum
import serial
from serial.threaded import LineReader, ReaderThread
parser = argparse.ArgumentParser(description='Connect to LoRaWAN network')
parser.add_argument('port', help="Serial port of LoStik")
parser.add_argument('--joinmode', '-j', help="otaa, abp", default="otaa")
# ABP credentials
parser.add_argument('--appskey', help="App Session Key", default="")
parser.add_argument('--nwkskey', help="Network Session Key", default="")
parser.add_argument('--devaddr', help="Device Address", default="")
# OTAA credentials
parser.add_argument('--appeui', help="App EUI", default="")
parser.add_argument('--appkey', help="App Key", default="")
parser.add_argument('--deveui', help="Device EUI", default="")
args = parser.parse_args()
OTAA_RETRIES = 5
class MaxRetriesError(Exception):
pass
class ConnectionState(IntEnum):
SUCCESS = 0
CONNECTING = 100
CONNECTED = 200
FAILED = 500
TO_MANY_RETRIES = 520
class PrintLines(LineReader):
retries = 0
state = ConnectionState.CONNECTING
def retry(self, action):
if(self.retries >= OTAA_RETRIES):
print("Too many retries, exiting")
self.state = ConnectionState.TO_MANY_RETRIES
return
self.retries = self.retries + 1
action()
def get_var(self, cmd):
self.send_cmd(cmd)
return self.transport.serial.readline()
def join(self):
if args.joinmode == "abp":
self.join_abp()
else:
self.join_otaa()
def join_otaa(self):
if len(args.appeui):
self.send_cmd('mac set appeui %s' % args.appeui)
if len(args.appkey):
self.send_cmd('mac set appkey %s' % args.appkey)
if len(args.deveui):
self.send_cmd('mac set deveui %s' % args.deveui)
self.send_cmd('mac join otaa')
def join_abp(self):
if len(args.devaddr):
self.send_cmd('mac set devaddr %s' % args.devaddr)
if len(args.appskey):
self.send_cmd('mac set appskey %s' % args.appskey)
if len(args.nwkskey):
self.send_cmd('mac set nwkskey %s' % args.nwkskey)
self.send_cmd('mac join abp')
def connection_made(self, transport):
"""
Fires when connection is made to serial port device
"""
print("Connection to LoStik established")
self.transport = transport
self.retry(self.join)
def handle_line(self, data):
# if data == "ok" or data == 'busy':
# return
print("STATUS: %s" % data)
if data.strip() == "denied" or data.strip() == "no_free_ch":
print("Retrying OTAA connection")
self.retry(self.join)
elif data.strip() == "accepted":
print("UPDATING STATE to connected")
self.state = ConnectionState.CONNECTED
def connection_lost(self, exc):
"""
Called when serial connection is severed to device
"""
if exc:
print(exc)
print("Lost connection to serial device")
def send_cmd(self, cmd, delay=.5):
print(cmd)
self.transport.write(('%s\r\n' % cmd).encode('UTF-8'))
time.sleep(delay)
ser = serial.Serial(args.port, baudrate=57600)
with ReaderThread(ser, PrintLines) as protocol:
while protocol.state < ConnectionState.FAILED:
if protocol.state != ConnectionState.CONNECTED:
time.sleep(1)
continue
protocol.send_cmd("mac tx uncnf 1 %d" % int(time.time()))
time.sleep(10)
exit(protocol.state)
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"errors"
"flag"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/skinnyrad/Lora-Scanner/internal/api"
"github.com/skinnyrad/Lora-Scanner/internal/frames"
"github.com/skinnyrad/Lora-Scanner/internal/gateway"
"github.com/skinnyrad/Lora-Scanner/internal/hub"
"github.com/skinnyrad/Lora-Scanner/internal/serial"
"github.com/skinnyrad/Lora-Scanner/web"
)
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
addr := flag.String("addr", env("LORA_ADDR", ":5000"), "listen address")
cfgPath := flag.String("config", env("LORA_CONFIG", gateway.DefaultPath()), "gateway list file")
flag.Parse()
gin.SetMode(gin.ReleaseMode)
h := hub.New()
store := frames.NewStore(h, frames.DefaultMax)
cfg := gateway.NewConfig(*cfgPath)
if err := cfg.Load("gateway_config.json", filepath.Join(filepath.Dir(*cfgPath), "gateway_config.json")); err != nil {
log.Fatalf("load config: %v", err)
}
scraper := gateway.NewScraper(cfg, store,
env("LORA_GATEWAY_USER", "root"), env("LORA_GATEWAY_PASS", "dragino"))
scraper.Hub = h
ui, err := fs.Sub(web.FS, "dist")
if err != nil {
log.Fatal(err)
}
srv := &api.Server{
Serial: serial.NewManager(h, store), Hub: h, Frames: store,
Config: cfg, Scraper: scraper, UI: ui,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go scraper.Run(ctx)
hs := &http.Server{Addr: *addr, Handler: srv.Router()}
go func() {
<-ctx.Done()
sctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
hs.Shutdown(sctx)
}()
log.Printf("Lora Scanner listening on %s (gateway list: %s)", *addr, *cfgPath)
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
-6
View File
@@ -1,6 +0,0 @@
beautifulsoup4==4.12.3
Flask==2.3.2
Flask_SocketIO==5.3.6
MarkupSafe==2.1.3
pyserial==3.5
Requests==2.31.0
-1
View File
@@ -1 +0,0 @@
python3 lorawan.py --joinmode otaa --appkey "814BFA6E589EE49376628B0CDD642E1F" --deveui "70B3D57ED80022D3" --appeui 0000000000000000 /dev/tty.usbserial-1140
-21
View File
@@ -1,21 +0,0 @@
@echo off
:: Check if Python3 is installed
python --version >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
echo Running application with Python...
python app.py
GOTO END
)
python3 --version >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
echo Running application with Python3...
python3 app.py
GOTO END
)
echo Python is not installed. Please install Python3 to run the application.
:END
pause
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-34
View File
@@ -1,34 +0,0 @@
from archlinux:latest
workdir /lib
# Update to latest arch
run pacman -Syu --noconfirm
# Install required dependencies
run pacman -S git python2-scipy swig cppunit fftw boost boost-libs gnuradio gnuradio-osmosdr libvolk log4cpp base-devel cmake wxgtk-common wxgtk2 wxgtk3 wxpython libuhd-firmware gnuradio-companion python2-requests --noconfirm
workdir /liquid
# Manual liquid-dsp install
run git clone https://github.com/jgaeddert/liquid-dsp.git . && \
sh ./bootstrap.sh && \
sh ./configure --prefix=/usr && \
make && \
make install
# Install gr-lora
workdir /src
arg CACHEBUST
run git clone https://github.com/rpp0/gr-lora.git . && \
mkdir build && \
cd build && \
cmake ../ -DCMAKE_INSTALL_PREFIX=/usr && \
make && \
make install && \
ldconfig
workdir /src/apps
expose 40868
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
LATEST_VERSION=`git ls-remote https://github.com/rpp0/gr-lora.git | grep HEAD | cut -f 1`
docker build -t rpp0/gr-lora --build-arg CACHEBUST=$LATEST_VERSION .
#docker tag rpp0/gr-lora:latest rpp0/gr-lora:$LATEST_VERSION
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
DOCKER_XAUTH=/tmp/.docker.xauth
touch /tmp/.docker.xauth
xauth nlist $DISPLAY | sed -e 's/^..../ffff/' | xauth -f $DOCKER_XAUTH nmerge -
docker run -i -t --rm --privileged -e DISPLAY=$DISPLAY -e XAUTHORITY=$DOCKER_XAUTH -v /tmp/.docker.xauth:/tmp/.docker.xauth:ro -v /tmp/.X11-unix:/tmp/.X11-unix:ro -v /dev/bus/usb:/dev/bus/usb --entrypoint /bin/bash rpp0/gr-lora:latest
-91
View File
@@ -1,91 +0,0 @@
#
# /etc/pacman.conf
#
# See the pacman.conf(5) manpage for option and repository directives
#
# GENERAL OPTIONS
#
[options]
# The following paths are commented out with their default values listed.
# If you wish to use different paths, uncomment and update the paths.
#RootDir = /
#DBPath = /var/lib/pacman/
#CacheDir = /var/cache/pacman/pkg/
#LogFile = /var/log/pacman.log
#GPGDir = /etc/pacman.d/gnupg/
HoldPkg = pacman glibc
#XferCommand = /usr/bin/curl -C - -f %u > %o
#XferCommand = /usr/bin/wget --passive-ftp -c -O %o %u
#CleanMethod = KeepInstalled
#UseDelta = 0.7
Architecture = auto
# Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup
#IgnorePkg =
#IgnoreGroup =
#NoUpgrade =
#NoExtract =
# Misc options
#UseSyslog
#Color
#TotalDownload
# We cannot check disk space from within a chroot environment
#CheckSpace
#VerbosePkgLists
# By default, pacman accepts packages signed by keys that its local keyring
# trusts (see pacman-key and its man page), as well as unsigned packages.
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
#RemoteFileSigLevel = Required
# NOTE: You must run `pacman-key --init` before first using pacman; the local
# keyring can then be populated with the keys of all official Arch Linux
# packagers with `pacman-key --populate archlinux`.
#
# REPOSITORIES
# - can be defined here or included from another file
# - pacman will search repositories in the order defined here
# - local/custom mirrors can be added here or in separate files
# - repositories listed first will take precedence when packages
# have identical names, regardless of version number
# - URLs will have $repo replaced by the name of the current repo
# - URLs will have $arch replaced by the name of the architecture
#
# Repository entries are of the format:
# [repo-name]
# Server = ServerName
# Include = IncludePath
#
# The header [repo-name] is crucial - it must be present and
# uncommented to enable the repo.
#
# The testing repositories are disabled by default. To enable, uncomment the
# repo name header and Include lines. You can add preferred servers immediately
# after the header, and they will be used before the default mirrors.
#[testing]
#Include = /etc/pacman.d/mirrorlist
[core]
Include = /etc/pacman.d/mirrorlist
[extra]
Include = /etc/pacman.d/mirrorlist
#[community-testing]
#Include = /etc/pacman.d/mirrorlist
[community]
Include = /etc/pacman.d/mirrorlist
# An example of a custom package repository. See the pacman manpage for
# tips on creating your own repositories.
#[custom]
#SigLevel = Optional TrustAll
#Server = file:///home/custompkgs
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env bash
# Generate a minimal filesystem for archlinux and load it into the local
# docker as "archlinux"
# requires root
set -e
hash pacstrap &>/dev/null || {
echo "Could not find pacstrap. Run pacman -S arch-install-scripts"
exit 1
}
hash expect &>/dev/null || {
echo "Could not find expect. Run pacman -S expect"
exit 1
}
export LANG="C.UTF-8"
ROOTFS=$(mktemp -d ${TMPDIR:-/var/tmp}/rootfs-archlinux-XXXXXXXXXX)
chmod 755 $ROOTFS
# packages to ignore for space savings
PKGIGNORE=(
cryptsetup
device-mapper
dhcpcd
iproute2
jfsutils
linux
lvm2
man-db
man-pages
mdadm
nano
netctl
openresolv
pciutils
pcmciautils
reiserfsprogs
s-nail
systemd-sysvcompat
usbutils
vi
xfsprogs
)
IFS=','
PKGIGNORE="${PKGIGNORE[*]}"
unset IFS
arch="$(uname -m)"
# Set to arch="armv7hf" in order to cross-compile
case "$arch" in
armv*)
if pacman -Q archlinuxarm-keyring >/dev/null 2>&1; then
pacman-key --init
pacman-key --populate archlinuxarm
else
echo "Could not find archlinuxarm-keyring. Please, install it and run pacman-key --populate archlinuxarm"
exit 1
fi
PACMAN_CONF=$(mktemp ${TMPDIR:-/var/tmp}/pacman-conf-archlinux-XXXXXXXXX)
version="$(echo $arch | cut -c 5)"
sed "s/Architecture = armv/Architecture = armv${version}h/g" './mkimage-archarm-pacman.conf' > "${PACMAN_CONF}"
PACMAN_MIRRORLIST='Server = http://mirror.archlinuxarm.org/$arch/$repo'
PACMAN_EXTRA_PKGS='archlinuxarm-keyring'
EXPECT_TIMEOUT=1800 # Most armv* based devices can be very slow (e.g. RPiv1)
ARCH_KEYRING=archlinuxarm
DOCKER_IMAGE_NAME="armv${version}h/archlinux"
;;
*)
PACMAN_CONF='./mkimage-arch-pacman.conf'
PACMAN_MIRRORLIST='Server = https://mirrors.kernel.org/archlinux/$repo/os/$arch'
PACMAN_EXTRA_PKGS=''
EXPECT_TIMEOUT=60
ARCH_KEYRING=archlinux
DOCKER_IMAGE_NAME=archlinux
;;
esac
export PACMAN_MIRRORLIST
expect <<EOF
set send_slow {1 .1}
proc send {ignore arg} {
sleep .1
exp_send -s -- \$arg
}
set timeout $EXPECT_TIMEOUT
spawn pacstrap -C $PACMAN_CONF -c -d -G -i $ROOTFS base haveged $PACMAN_EXTRA_PKGS --ignore $PKGIGNORE
expect {
-exact "anyway? \[Y/n\] " { send -- "n\r"; exp_continue }
-exact "(default=all): " { send -- "\r"; exp_continue }
-exact "installation? \[Y/n\]" { send -- "y\r"; exp_continue }
-exact "delete it? \[Y/n\]" { send -- "y\r"; exp_continue }
}
EOF
arch-chroot $ROOTFS /bin/sh -c 'rm -r /usr/share/man/*'
arch-chroot $ROOTFS /bin/sh -c "haveged -w 1024; pacman-key --init; pkill haveged; pacman -Rs --noconfirm haveged; pacman-key --populate $ARCH_KEYRING; pkill gpg-agent"
arch-chroot $ROOTFS /bin/sh -c "ln -s /usr/share/zoneinfo/UTC /etc/localtime"
echo 'en_US.UTF-8 UTF-8' > $ROOTFS/etc/locale.gen
arch-chroot $ROOTFS locale-gen
arch-chroot $ROOTFS /bin/sh -c 'echo $PACMAN_MIRRORLIST > /etc/pacman.d/mirrorlist'
# udev doesn't work in containers, rebuild /dev
DEV=$ROOTFS/dev
rm -rf $DEV
mkdir -p $DEV
mknod -m 666 $DEV/null c 1 3
mknod -m 666 $DEV/zero c 1 5
mknod -m 666 $DEV/random c 1 8
mknod -m 666 $DEV/urandom c 1 9
mkdir -m 755 $DEV/pts
mkdir -m 1777 $DEV/shm
mknod -m 666 $DEV/tty c 5 0
mknod -m 600 $DEV/console c 5 1
mknod -m 666 $DEV/tty0 c 4 0
mknod -m 666 $DEV/full c 1 7
mknod -m 600 $DEV/initctl p
mknod -m 666 $DEV/ptmx c 5 2
ln -sf /proc/self/fd $DEV/fd
tar --numeric-owner --xattrs --acls -C $ROOTFS -c . | docker import - $DOCKER_IMAGE_NAME
docker run --rm -t $DOCKER_IMAGE_NAME echo Success.
rm -rf $ROOTFS
-75
View File
@@ -1,75 +0,0 @@
# Introduction
In certain scenarios, traditional LoRa receivers, such as the Adafruit Feather LoRa or Lostik, may not adequately decode LoRa traffic. To address this, we introduce a Software Defined Radio (SDR) companion module designed to offer a more robust alternative for decoding when the conventional LoRa Scanner application falls short.
SDRs provide unparalleled flexibility compared to traditional hardware radios, which are often limited by their specific design and capabilities. With an SDR, you can decode a wide array of signals beyond LoRa, making it an invaluable tool for radio signal research and experimentation. The ability to tweak settings and experiment with different decoding algorithms allows for a deeper exploration into radio communications. This is especially beneficial in the evolving field of LoRa technology, where ongoing research and experimentation can yield significant insights. Projects like the HackRF demonstrate the power of a supportive community, offering extensive resources to enhance your SDR experience.
## gr-lora
The **gr-lora** project comprises GNU Radio blocks designed to decode LoRa-modulated radio messages using an SDR. LoRa (Long Range) is crucial for Internet of Things (IoT) applications due to its long-range, low-power, and low-bitrate communication capabilities.
While gr-lora supports the majority of LoRa's physical-layer modulation features, it does not facilitate CRC checks of payload and header or decode multiple channels simultaneously. Tested primarily with a USRP B201 and a Microchip RN2483 transmitter, it is also compatible with popular SDRs like the HackRF.
A standout feature of gr-lora is its clock recovery algorithm, introduced in version 0.6, enhancing the handling of long LoRa messages. Improvements in whitening, detection, and decoding further solidify its utility in IoT applications. This ongoing project exemplifies the collaborative spirit of the open-source community, continually evolving to meet users' needs.
## Running the sdr-companion
Ensure [Docker](https://www.docker.com/get-started/) is installed on your system to proceed with the sdr-companion setup.
1. **Download the Docker Image**: Begin by downloading the gr-lora image using the following command:
```bash
docker pull rpp0/gr-lora:latest
```
2. **Prepare Your SDR**: Connect your SDR (e.g., HackRF) to your machine. Ensure device permissions are correctly set to avoid detection issues.
3. **Run the Docker Container**: Execute the script to start the Docker container with gr-lora:
```bash
./docker_run_grlora.sh
```
Upon success, you'll enter the Docker container environment:
```bash
[root@9089ddfe32b5 apps]#
```
4. **Launch GNU Radio Companion**: Inside the container, open the lora_receive_realtime.grc flowgraph with GNU Radio Companion:
```bash
gnuradio-companion lora_receive_realtime.grc
```
5. **Configure and Execute**: Adjust the frequency, spreading factor, and other settings as needed at the gnuradio-companion interface. Start receiving and decoding by selecting *Run -> Execute*.
Decoded traffic will be displayed in hex format in the gnuradio-companion console.
## Prebuilt Flowgraphs
For convenience, prebuilt flowgraphs for specific frequencies are available:
- **915 MHz SF7 125k BW CR 4/5**: `915-sf7-125bw-cr4_5.grc`
- **868 MHz SF7 125k BW CR 4/5**: `868-sf7-125bw-cr4_5.grc`
To download these into the Docker container:
1. **Identify Host IP**: Use `ip addr` (or `ifconfig` if available) to find your host machine's IP address.
2. **Host Web Server**: On your host machine, in the directory with the flowgraphs, start a web server:
```bash
python3 -m http.server 80
```
3. **Download Flowgraphs**: Inside the container, use curl to download the desired flowgraph:
```bash
curl -O http://<your-host-ip>/915-sf7-125bw-cr4_5.grc
curl -O http://<your-host-ip>/868-sf7-125bw-cr4_5.grc
```
## Troubleshooting and Support
For troubleshooting common issues or seeking additional guidance, refer to the gr-lora project documentation and community forums. These resources can provide valuable support as you navigate installation and operation challenges.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-11114
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-6
View File
@@ -1,6 +0,0 @@
/*!
* Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
!function(){"use strict";var c={},l={};try{"undefined"!=typeof window&&(c=window),"undefined"!=typeof document&&(l=document)}catch(c){}var s=(c.navigator||{}).userAgent,a=void 0===s?"":s,z=c,e=l;z.document,e.documentElement&&e.head&&"function"==typeof e.addEventListener&&e.createElement,~a.indexOf("MSIE")||a.indexOf("Trident/");function H(l,c){var s,a=Object.keys(l);return Object.getOwnPropertySymbols&&(s=Object.getOwnPropertySymbols(l),c&&(s=s.filter(function(c){return Object.getOwnPropertyDescriptor(l,c).enumerable})),a.push.apply(a,s)),a}function t(l){for(var c=1;c<arguments.length;c++){var s=null!=arguments[c]?arguments[c]:{};c%2?H(Object(s),!0).forEach(function(c){V(l,c,s[c])}):Object.getOwnPropertyDescriptors?Object.defineProperties(l,Object.getOwnPropertyDescriptors(s)):H(Object(s)).forEach(function(c){Object.defineProperty(l,c,Object.getOwnPropertyDescriptor(s,c))})}return l}function V(c,l,s){return l in c?Object.defineProperty(c,l,{value:s,enumerable:!0,configurable:!0,writable:!0}):c[l]=s,c}function r(c,l){(null==l||l>c.length)&&(l=c.length);for(var s=0,a=new Array(l);s<l;s++)a[s]=c[s];return a}var M="___FONT_AWESOME___",h=function(){try{return!0}catch(c){return!1}}(),n="classic",i="sharp",m=[n,i];function o(c){return new Proxy(c,{get:function(c,l){return l in c?c[l]:c[n]}})}o((V(C={},n,{fa:"solid",fas:"solid","fa-solid":"solid",far:"regular","fa-regular":"regular",fal:"light","fa-light":"light",fat:"thin","fa-thin":"thin",fad:"duotone","fa-duotone":"duotone",fab:"brands","fa-brands":"brands",fak:"kit","fa-kit":"kit"}),V(C,i,{fa:"solid",fass:"solid","fa-solid":"solid",fasr:"regular","fa-regular":"regular"}),C));var f=o((V(v={},n,{solid:"fas",regular:"far",light:"fal",thin:"fat",duotone:"fad",brands:"fab",kit:"fak"}),V(v,i,{solid:"fass",regular:"fasr"}),v)),e=(o((V(s={},n,{fab:"fa-brands",fad:"fa-duotone",fak:"fa-kit",fal:"fa-light",far:"fa-regular",fas:"fa-solid",fat:"fa-thin"}),V(s,i,{fass:"fa-solid",fasr:"fa-regular"}),s)),o((V(c={},n,{"fa-brands":"fab","fa-duotone":"fad","fa-kit":"fak","fa-light":"fal","fa-regular":"far","fa-solid":"fas","fa-thin":"fat"}),V(c,i,{"fa-solid":"fass","fa-regular":"fasr"}),c)),o((V(l={},n,{900:"fas",400:"far",normal:"far",300:"fal",100:"fat"}),V(l,i,{900:"fass",400:"fasr"}),l)),[1,2,3,4,5,6,7,8,9,10]),a=e.concat([11,12,13,14,15,16,17,18,19,20]),C="duotone-group",v="swap-opacity",s="primary",c="secondary",l=new Set;Object.keys(f[n]).map(l.add.bind(l)),Object.keys(f[i]).map(l.add.bind(l));[].concat(m,function(c){if(Array.isArray(c))return r(c)}(l=l)||function(c){if("undefined"!=typeof Symbol&&null!=c[Symbol.iterator]||null!=c["@@iterator"])return Array.from(c)}(l)||function(c,l){if(c){if("string"==typeof c)return r(c,l);var s=Object.prototype.toString.call(c).slice(8,-1);return"Map"===(s="Object"===s&&c.constructor?c.constructor.name:s)||"Set"===s?Array.from(c):"Arguments"===s||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(s)?r(c,l):void 0}}(l)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}(),["2xs","xs","sm","lg","xl","2xl","beat","border","fade","beat-fade","bounce","flip-both","flip-horizontal","flip-vertical","flip","fw","inverse","layers-counter","layers-text","layers","li","pull-left","pull-right","pulse","rotate-180","rotate-270","rotate-90","rotate-by","shake","spin-pulse","spin-reverse","spin","stack-1x","stack-2x","stack","ul",C,v,s,c]).concat(e.map(function(c){return"".concat(c,"x")})).concat(a.map(function(c){return"w-".concat(c)}));z=z||{};z[M]||(z[M]={}),z[M].styles||(z[M].styles={}),z[M].hooks||(z[M].hooks={}),z[M].shims||(z[M].shims=[]);var L=z[M];function u(a){return Object.keys(a).reduce(function(c,l){var s=a[l];return!!s.icon?c[s.iconName]=s.icon:c[l]=s,c},{})}function d(c,l,s){var a=(2<arguments.length&&void 0!==s?s:{}).skipHooks,s=void 0!==a&&a,a=u(l);"function"!=typeof L.hooks.addPack||s?L.styles[c]=t(t({},L.styles[c]||{}),a):L.hooks.addPack(c,u(l)),"fas"===c&&d("fa",l)}var p={monero:[496,512,[],"f3d0","M352 384h108.4C417 455.9 338.1 504 248 504S79 455.9 35.6 384H144V256.2L248 361l104-105v128zM88 336V128l159.4 159.4L408 128v208h74.8c8.5-25.1 13.2-52 13.2-80C496 119 385 8 248 8S0 119 0 256c0 28 4.6 54.9 13.2 80H88z"],hooli:[640,512,[],"f427","M144.5 352l38.3.8c-13.2-4.6-26-10.2-38.3-16.8zm57.7-5.3v5.3l-19.4.8c36.5 12.5 69.9 14.2 94.7 7.2-19.9.2-45.8-2.6-75.3-13.3zm408.9-115.2c15.9 0 28.9-12.9 28.9-28.9s-12.9-24.5-28.9-24.5c-15.9 0-28.9 8.6-28.9 24.5s12.9 28.9 28.9 28.9zm-29 120.5H640V241.5h-57.9zm-73.7 0h57.9V156.7L508.4 184zm-31-119.4c-18.2-18.2-50.4-17.1-50.4-17.1s-32.3-1.1-50.4 17.1c-18.2 18.2-16.8 33.9-16.8 52.6s-1.4 34.3 16.8 52.5 50.4 17.1 50.4 17.1 32.3 1.1 50.4-17.1c18.2-18.2 16.8-33.8 16.8-52.5-.1-18.8 1.3-34.5-16.8-52.6zm-39.8 71.9c0 3.6-1.8 12.5-10.7 12.5s-10.7-8.9-10.7-12.5v-40.4c0-8.7 7.3-10.9 10.7-10.9s10.7 2.1 10.7 10.9zm-106.2-71.9c-18.2-18.2-50.4-17.1-50.4-17.1s-32.2-1.1-50.4 17.1c-1.9 1.9-3.7 3.9-5.3 6-38.2Line truncated
-7
View File
@@ -1,7 +0,0 @@
/*!
* Bootstrap v5.2.3 (https://getbootstrap.com/)
* Copyright 2011-2022 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},m=t=>{"function"==typeof t&&t()},_=(e,i,n=!0)=>{if(!n)return void m(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),m(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return C.has(o)||(o=t),[n,s,o]}function D(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return j(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return j(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function S(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const o of Object.keys(s))if(o.includes(n)){const n=s[o];S(t,e,i,n.callable,n.delegationSelector)}}function N(t){return t=t.replace(y,""),T[t]||t}const P={on(t,e,i,n){D(t,e,i,n,!1)},one(t,e,i,n){D(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))I(t,l,i,e.slice(1));for(const i of Object.keys(c)){const n=i.replace(w,"");if(!a||e.includes(n)){const Line truncated
//# sourceMappingURL=bootstrap.bundle.min.js.map
-2
View File
@@ -1,2 +0,0 @@
/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */
!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0<t&&t-1 in e)}S.fn=S.prototype={jquery:f,constructor:S,length:0,toArray:function(){return s.call(this)},get:function(e){return null==e?s.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=S.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return S.each(this,e)},map:function(n){return this.pushStack(S.map(this,function(e,t){return n.call(e,t,e)}))},slice:function(){return this.pushStack(s.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(S.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(S.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(0<=n&&n<t?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:u,sort:t.sort,splice:t.splice},S.extend=S.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||m(a)||(a={}),s===u&&(a=this,s--);s<u;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(S.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||S.isPlainObject(n)?n:{},i=!1,a[t]=S.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},S.extend({expando:"jQuery"+(f+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==o.call(e))&&(!(t=r(e))||"function"==typeof(n=v.call(t,"constructor")&&t.constructor)&&a.call(n)===l)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t,n){b(e,{nonce:t&&t.nonce},n)},each:function(e,t){var n,r=0;if(p(e)){for(n=e.length;r<n;r++)if(!1===t.call(e[r],r,e[r]))break}else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},makeArray:function(e,t){var n=t||[];return null!=e&&(p(Object(e))?S.merge(n,"string"==typeof e?[e]:e):u.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:i.call(t,e,n)},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;r<n;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;i<o;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,o=0,a=[];if(p(e))for(r=e.length;o<r;o++)null!=(i=t(e[o],o,n))&&a.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&a.push(i);return g(a)},guid:1,support:y}),"function"==typeof Symbol&&(S.fn[Symbol.iterator]=t[Symbol.iterator]),S.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){n["[object "+t+"]"]=t.toLowerCase()});var d=function(n){var e,d,b,o,i,h,f,g,w,u,l,T,C,a,E,v,s,c,y,S="sizzle"+1*new Date,p=n.document,k=0,r=0,m=ue(),x=ue(),A=ue(),N=ue(),j=function(e,t){return e===t&&(l=!0),0},D={}.hasOwnProperty,t=[],q=t.pop,L=t.push,H=t.push,O=t.slice,P=function(e,t){for(var n=0,r=e.length;n<r;n++)if(e[n]===t)return n;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",I="(?:\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+",W="\\["+M+"*("+I+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+I+"))|)"+M+"*\\]",F=":("+I+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+W+")*)|.*)\\)|)",B=new RegExp(M+"+","g"),$=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),_=new RegExp("^"+M+"*,"+M+"*"),z=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^Line truncated
-34
View File
@@ -1,34 +0,0 @@
/*!
* Start Bootstrap - Resume v7.0.6 (https://startbootstrap.com/theme/resume)
* Copyright 2013-2023 Start Bootstrap
* Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-resume/blob/master/LICENSE)
*/
//
// Scripts
//
window.addEventListener('DOMContentLoaded', event => {
// Activate Bootstrap scrollspy on the main nav element
const sideNav = document.body.querySelector('#sideNav');
if (sideNav) {
new bootstrap.ScrollSpy(document.body, {
target: '#sideNav',
rootMargin: '0px 0px -40%',
});
};
// Collapse responsive navbar when toggler is visible
const navbarToggler = document.body.querySelector('.navbar-toggler');
const responsiveNavItems = [].slice.call(
document.querySelectorAll('#navbarResponsive .nav-link')
);
responsiveNavItems.map(function (responsiveNavItem) {
responsiveNavItem.addEventListener('click', () => {
if (window.getComputedStyle(navbarToggler).display !== 'none') {
navbarToggler.click();
}
});
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-671
View File
@@ -1,671 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<script type="text/javascript" src="static/js/socket.io.js"></script>
<title>LoRa Scanner</title>
<link rel="icon" type="image/x-icon" href="assets/img/favicon.ico" />
<!-- Font Awesome icons (free version)-->
<script src="static/js/all.js" crossorigin="anonymous"></script>
<!-- Google fonts-->
<!-- Core theme CSS (includes Bootstrap)-->
<link href="static/css/styles.css" rel="stylesheet" />
<!-- Include SweetAlert2 CSS -->
<link rel="stylesheet" href="static/css/sweetalert2.css">
<!-- Include SweetAlert2 JS -->
<script src="static/js/sweetalert2.js"></script>
<script src="static/js/jquery.min.js"></script>
</head>
<body id="page-top">
<!-- Navigation-->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top" id="sideNav">
<a class="navbar-brand js-scroll-trigger" href="#page-top">
<span class="d-block d-lg-none">LoRa Scanner</span>
<span class="d-none d-lg-block"><img class="img-fluid img-profile rounded-circle mx-auto mb-2" src="static/assets/img/profile.jpg" alt="..." /></span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/">Home</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/#config">Configuration</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="#">Analysis Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/survey">Survey Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/tracking">Tracking Mode</a></li>
</ul>
</div>
</nav>
<!-- Page Content-->
<div class="container-fluid p-0">
<!-- About-->
<section class="resume-section" id="about">
<div class="resume-section-content">
<h1 class="mb-0">
Analysis Mode
</h1>
<h2 class="lead mb-5">Skinny Research and Development</h2>
<p>
Analyze LoRa traffic received at 433, 868, or 915 MHz. Click the desired frequency below to get started. Once you are on the appropriate page, click the 'Select Device' button to connect to a serial port on your computer. Once connected to your LoRa receiver, traffic will automatically be streamed to the web page for analysis. To disconnect a receiver, click the 'Disconnect Device' button.
</p>
<hr class="m-0" />
<br><br>
<!-- 433 -->
<div class="resume-section-content">
<!-- Collapsible content -->
<div class="collapse" id="collapse433">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">433 MHz LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port1">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser433()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial433()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit433()">Transmit Data</button><br><br>
<button class="transmit-button" onclick="clearDataPort1()">Clear Data</button>
<input type="checkbox" id="autoscroll-port1" checked>
<label for="autoscroll-port1">Autoscroll</label>
<p id="status-label433" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator433" class="indicator"></div>
</div>
</div>
<script>
function clearDataPort1() {
var serialDataDiv1 = document.getElementById('serial-data-port1');
serialDataDiv1.innerHTML = '';
}
function clearDataPort2() {
var serialDataDiv2 = document.getElementById('serial-data-port2');
serialDataDiv2.innerHTML = '';
}
function clearDataPort3() {
var serialDataDiv3 = document.getElementById('serial-data-port3');
serialDataDiv3.innerHTML = '';
}
</script>
<!-- JavaScript for updating the indicator -->
<script>
function updateIndicator433() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port1',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator433').removeClass('red').addClass('green');
$('#status-label433').text('Status: Connected');
} else {
$('#indicator433').removeClass('green').addClass('red');
$('#status-label433').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator433').removeClass('green').addClass('red');
$('#status-label433').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator433();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator433, 5000);
</script>
<script type="text/javascript">
var socket = io.connect('http://' + document.domain + ':' + location.port);
// For port1
socket.on('initial_serial_data_port1', function(data) {
var serialDataDiv = document.getElementById('serial-data-port1');
data.data.forEach(function(line) {
serialDataDiv.innerHTML += line + '<br>';
});
// Scroll to the bottom for new data
//serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
});
socket.on('serial_data_port1', function(data) {
var serialDataDiv = document.getElementById('serial-data-port1');
serialDataDiv.innerHTML += data.data + '<br>';
var autoscrollCheckbox = document.getElementById('autoscroll-port1');
if (autoscrollCheckbox.checked) {
serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
}
});
</script>
<script>
function promptUser433() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_433?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. The port might already be in use.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial433() {
Swal.fire({
title: 'Are you sure you want to disconnect the 433 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_433')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
function transmit433() {
Swal.fire({
title: 'Enter data to transmit:',
input: 'text',
inputAttributes: {
autocapitalize: 'off'
},
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: (value) => {
// Make an HTTP POST request to Flask
return fetch('/transmit433', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: value })
})
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. Maybe the serial port is disconnected?', 'error');
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({
title: 'Data Transmitted',
icon: 'info'
});
}
});
}
</script>
</div>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse433" role="button" aria-expanded="false" aria-controls="collapse433">
Toggle 433 MHz Section
</a>
</div>
<br><br>
<div class="resume-section-content">
<div class="collapse" id="collapse868">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">868 MHz LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port2">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser868()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial868()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit868()">Transmit Data</button><br><br>
<button class="transmit-button" onclick="clearDataPort2()">Clear Data</button>
<input type="checkbox" id="autoscroll-port2" checked>
<label for="autoscroll-port2">Autoscroll</label>
<p id="status-label868" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator868" class="indicator"></div>
</div>
</div>
</div>
<script>
function updateIndicator868() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port2',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator868').removeClass('red').addClass('green');
$('#status-label868').text('Status: Connected');
} else {
$('#indicator868').removeClass('green').addClass('red');
$('#status-label868').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator868').removeClass('green').addClass('red');
$('#status-label868a').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator868();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator868, 5000);
</script>
<script>
// For port2
var socket = io.connect('http://' + document.domain + ':' + location.port);
socket.on('initial_serial_data_port2', function(data) {
var serialDataDiv2 = document.getElementById('serial-data-port2');
data.data.forEach(function(line) {
serialDataDiv2.innerHTML += line + '<br>';
});
// Scroll to the bottom for new data
//serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
});
socket.on('serial_data_port2', function(data) {
var serialDataDiv2 = document.getElementById('serial-data-port2');
serialDataDiv2.innerHTML += data.data + '<br>';
var autoscrollCheckbox2 = document.getElementById('autoscroll-port2');
if (autoscrollCheckbox2.checked) {
serialDataDiv2.scrollTop = serialDataDiv2.scrollHeight;
}
});
</script>
<script>
function promptUser868() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_868?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. The port might already be in use.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial868() {
Swal.fire({
title: 'Are you sure you want to disconnect the 868 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_868')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
function transmit868() {
Swal.fire({
title: 'Enter data to transmit:',
input: 'text',
inputAttributes: {
autocapitalize: 'off'
},
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: (value) => {
// Make an HTTP POST request to Flask
return fetch('/transmit868', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: value })
})
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. Maybe the serial port is disconnected?', 'error');
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({
title: 'Data Transmitted',
icon: 'info'
});
}
});
}
</script>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse868" role="button" aria-expanded="false" aria-controls="collapse868">
Toggle 868 MHz Section
</a>
</div>
<br><br>
<div class="resume-section-content">
<div class="collapse" id="collapse915">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">915 MHZ LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port3">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser915()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial915()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit915()">Transmit Data</button><br><br>
<button class="transmit-button" onclick="clearDataPort3()">Clear Data</button>
<input type="checkbox" id="autoscroll-port3" checked>
<label for="autoscroll-port3">Autoscroll</label>
<p id="status-label915" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator915" class="indicator"></div>
</div>
</div>
</div>
<script>
function updateIndicator915() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port3',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator915').removeClass('red').addClass('green');
$('#status-label915').text('Status: Connected');
} else {
$('#indicator915').removeClass('green').addClass('red');
$('#status-label915').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator915').removeClass('green').addClass('red');
$('#status-label915').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator915();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator915, 5000);
</script>
<script>
// For port3
var socket = io.connect('http://' + document.domain + ':' + location.port);
socket.on('initial_serial_data_port3', function(data) {
var serialDataDiv3 = document.getElementById('serial-data-port3');
data.data.forEach(function(line) {
serialDataDiv3.innerHTML += line + '<br>';
});
// Scroll to the bottom for new data
//serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
});
socket.on('serial_data_port3', function(data) {
var serialDataDiv3 = document.getElementById('serial-data-port3');
serialDataDiv3.innerHTML += data.data + '<br>';
var autoscrollCheckbox3 = document.getElementById('autoscroll-port3');
if (autoscrollCheckbox3.checked) {
serialDataDiv3.scrollTop = serialDataDiv3.scrollHeight;
}
});
</script>
<script>
function promptUser915() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_915?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. The port might already be in use.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial915() {
Swal.fire({
title: 'Are you sure you want to disconnect the 915 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_915')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
function transmit915() {
Swal.fire({
title: 'Enter data to transmit:',
input: 'text',
inputAttributes: {
autocapitalize: 'off'
},
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: (value) => {
// Make an HTTP POST request to Flask
return fetch('/transmit915', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: value })
})
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. Maybe the serial port is disconnected?', 'error');
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({
title: 'Data Transmitted',
icon: 'info'
});
}
});
}
</script>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse915" role="button" aria-expanded="false" aria-controls="collapse915">
Toggle 915 MHz Section
</a>
</div>
</div>
</section>
</div>
<!-- Bootstrap core JS-->
<script src="static/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script src="static/js/scripts.js"></script>
</body>
</html>
-923
View File
@@ -1,923 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<script type="text/javascript" src="static/js/socket.io.js"></script>
<title>LoRa Scanner</title>
<link rel="icon" type="image/x-icon" href="assets/img/favicon.ico" />
<!-- Font Awesome icons (free version)-->
<script src="static/js/all.js" crossorigin="anonymous"></script>
<!-- Google fonts-->
<!-- Core theme CSS (includes Bootstrap)-->
<link href="static/css/styles.css" rel="stylesheet" />
<!-- Include SweetAlert2 CSS -->
<link rel="stylesheet" href="static/css/sweetalert2.css">
<!-- Include SweetAlert2 JS -->
<script src="static/js/sweetalert2.js"></script>
<script src="static/js/jquery.min.js"></script>
</head>
<body id="page-top">
<!-- Navigation-->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top" id="sideNav">
<a class="navbar-brand js-scroll-trigger" href="#page-top">
<span class="d-block d-lg-none">LoRa Scanner</span>
<span class="d-none d-lg-block"><img class="img-fluid img-profile rounded-circle mx-auto mb-2" src="static/assets/img/profile.jpg" alt="..." /></span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="#about">Home</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="#config">Configuration</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/analysis">Analysis Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/survey">Survey Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/tracking">Tracking Mode</a></li>
</ul>
</div>
</nav>
<!-- Page Content-->
<div class="container-fluid p-0">
<!-- About-->
<section class="resume-section" id="about">
<div class="resume-section-content">
<h1 class="mb-0">
LoRa Scanner
</h1>
<h2 class="lead mb-5">Skinny Research and Development</h2>
<p>LoRa, short for Long Range, is a wireless communication technology designed for long-range, low-power communication between devices in the Internet of Things (IoT) and machine-to-machine (M2M) applications. Developed to address the specific needs of IoT devices, LoRa technology enables efficient, low-cost, and long-range communication in various scenarios.
<br><br>LoRa operates on unlicensed radio frequencies, such as those in the Industrial, Scientific, and Medical (ISM) bands, providing the flexibility for deployment without the need for a dedicated spectrum license. It utilizes a modulation technique called Chirp Spread Spectrum (CSS), which allows for long-range communication with minimal power consumption.
<br><br>One of the key features of LoRa technology is its ability to provide extended communication ranges, often reaching several kilometers in open environments. This makes it suitable for applications where devices need to communicate over long distances while conserving battery life, such as in agriculture, smart cities, asset tracking, and environmental monitoring.
<br><br>LoRa devices typically consist of two main components: LoRa nodes (sensors or devices) and LoRa gateways. The nodes collect and transmit data, while gateways receive and forward this data to the central server or cloud infrastructure. The open LoRaWAN (Long Range Wide Area Network) protocol standardizes communication between devices and gateways, ensuring interoperability and facilitating the deployment of large-scale IoT networks.
<br><br>Due to its low power consumption, long-range capabilities, and suitability for diverse IoT applications, LoRa technology has gained popularity as a reliable and cost-effective solution for connecting a wide range of devices and sensors in the evolving landscape of the Internet of Things.
</p>
</div>
</section>
<!-- Add this form within the body where it fits best, possibly under the About section -->
<section class="resume-section" id="config">
<div class="resume-section-content">
<div class="config-section">
<h4>Configure Transceivers</h4>
<p>
The <strong>'Configure Transceiver'</strong> buttons allow you to attach to the serial port of an Adafruit Feather M0, enabling you to capture LoRa traffic on that frequency.
</p>
</div>
<!-- 433 -->
<div class="resume-section-content">
<div class="collapse" id="collapse433">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">433 MHz LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port1">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser433()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial433()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit433()">Transmit Data</button><br><br>
<button class="transmit-button" onclick="clearDataPort1()">Clear Data</button>
<input type="checkbox" id="autoscroll-port1" checked>
<label for="autoscroll-port1">Autoscroll</label>
<p id="status-label433" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator433" class="indicator"></div>
</div>
</div>
<script>
function clearDataPort1() {
var serialDataDiv1 = document.getElementById('serial-data-port1');
serialDataDiv1.innerHTML = '';
}
function clearDataPort2() {
var serialDataDiv2 = document.getElementById('serial-data-port2');
serialDataDiv2.innerHTML = '';
}
function clearDataPort3() {
var serialDataDiv3 = document.getElementById('serial-data-port3');
serialDataDiv3.innerHTML = '';
}
</script>
<!-- JavaScript for updating the indicator -->
<script>
function updateIndicator433() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port1',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator433').removeClass('red').addClass('green');
$('#status-label433').text('Status: Connected');
} else {
$('#indicator433').removeClass('green').addClass('red');
$('#status-label433').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator433').removeClass('green').addClass('red');
$('#status-label433').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator433();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator433, 5000);
</script>
<script type="text/javascript">
var socket = io.connect('http://' + document.domain + ':' + location.port);
// For port1
socket.on('initial_serial_data_port1', function(data) {
var serialDataDiv = document.getElementById('serial-data-port1');
data.data.forEach(function(line) {
serialDataDiv.innerHTML += line + '<br>';
});
// Scroll to the bottom for new data
//serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
});
socket.on('serial_data_port1', function(data) {
var serialDataDiv = document.getElementById('serial-data-port1');
serialDataDiv.innerHTML += data.data + '<br>';
var autoscrollCheckbox = document.getElementById('autoscroll-port1');
if (autoscrollCheckbox.checked) {
serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
}
});
</script>
<script>
function promptUser433() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_433?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial433() {
Swal.fire({
title: 'Are you sure you want to disconnect the 433 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_433')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
function transmit433() {
Swal.fire({
title: 'Enter data to transmit:',
input: 'text',
inputAttributes: {
autocapitalize: 'off'
},
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: (value) => {
// Make an HTTP POST request to Flask
return fetch('/transmit433', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: value })
})
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. Maybe the serial port is disconnected?', 'error');
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({
title: 'Data Transmitted',
icon: 'info'
});
}
});
}
</script>
</div>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse433" role="button" aria-expanded="false" aria-controls="collapse433">
Configure 433 MHz Transceiver
</a>
</div>
<br><br>
<div class="resume-section-content">
<div class="collapse" id="collapse868">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">868 MHz LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port2">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser868()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial868()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit868()">Transmit Data</button><br><br>
<button class="transmit-button" onclick="clearDataPort2()">Clear Data</button>
<input type="checkbox" id="autoscroll-port2" checked>
<label for="autoscroll-port2">Autoscroll</label>
<p id="status-label868" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator868" class="indicator"></div>
</div>
</div>
</div>
<script>
function updateIndicator868() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port2',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator868').removeClass('red').addClass('green');
$('#status-label868').text('Status: Connected');
} else {
$('#indicator868').removeClass('green').addClass('red');
$('#status-label868').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator868').removeClass('green').addClass('red');
$('#status-label868a').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator868();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator868, 5000);
</script>
<script>
// For port2
var socket = io.connect('http://' + document.domain + ':' + location.port);
socket.on('initial_serial_data_port2', function(data) {
var serialDataDiv2 = document.getElementById('serial-data-port2');
data.data.forEach(function(line) {
serialDataDiv2.innerHTML += line + '<br>';
});
// Scroll to the bottom for new data
//serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
});
socket.on('serial_data_port2', function(data) {
var serialDataDiv2 = document.getElementById('serial-data-port2');
serialDataDiv2.innerHTML += data.data + '<br>';
var autoscrollCheckbox2 = document.getElementById('autoscroll-port2');
if (autoscrollCheckbox2.checked) {
serialDataDiv2.scrollTop = serialDataDiv2.scrollHeight;
}
});
</script>
<script>
function promptUser868() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_868?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial868() {
Swal.fire({
title: 'Are you sure you want to disconnect the 868 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_868')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
function transmit868() {
Swal.fire({
title: 'Enter data to transmit:',
input: 'text',
inputAttributes: {
autocapitalize: 'off'
},
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: (value) => {
// Make an HTTP POST request to Flask
return fetch('/transmit868', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: value })
})
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. Maybe the serial port is disconnected?', 'error');
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({
title: 'Data Transmitted',
icon: 'info'
});
}
});
}
</script>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse868" role="button" aria-expanded="false" aria-controls="collapse868">
Configure 868 MHz Transceiver
</a>
</div>
<br><br>
<div class="resume-section-content">
<div class="collapse" id="collapse915">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">915 MHZ LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port3">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser915()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial915()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit915()">Transmit Data</button><br><br>
<button class="transmit-button" onclick="clearDataPort3()">Clear Data</button>
<input type="checkbox" id="autoscroll-port3" checked>
<label for="autoscroll-port3">Autoscroll</label>
<p id="status-label915" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator915" class="indicator"></div>
</div>
</div>
</div>
<script>
function updateIndicator915() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port3',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator915').removeClass('red').addClass('green');
$('#status-label915').text('Status: Connected');
} else {
$('#indicator915').removeClass('green').addClass('red');
$('#status-label915').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator915').removeClass('green').addClass('red');
$('#status-label915').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator915();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator915, 5000);
</script>
<script>
// For port3
var socket = io.connect('http://' + document.domain + ':' + location.port);
socket.on('initial_serial_data_port3', function(data) {
var serialDataDiv3 = document.getElementById('serial-data-port3');
data.data.forEach(function(line) {
serialDataDiv3.innerHTML += line + '<br>';
});
// Scroll to the bottom for new data
//serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
});
socket.on('serial_data_port3', function(data) {
var serialDataDiv3 = document.getElementById('serial-data-port3');
serialDataDiv3.innerHTML += data.data + '<br>';
var autoscrollCheckbox3 = document.getElementById('autoscroll-port3');
if (autoscrollCheckbox3.checked) {
serialDataDiv3.scrollTop = serialDataDiv3.scrollHeight;
}
});
</script>
<script>
function promptUser915() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_915?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial915() {
Swal.fire({
title: 'Are you sure you want to disconnect the 915 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_915')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
function transmit915() {
Swal.fire({
title: 'Enter data to transmit:',
input: 'text',
inputAttributes: {
autocapitalize: 'off'
},
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: (value) => {
// Make an HTTP POST request to Flask
return fetch('/transmit915', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: value })
})
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. Maybe the serial port is disconnected?', 'error');
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({
title: 'Data Transmitted',
icon: 'info'
});
}
});
}
</script>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse915" role="button" aria-expanded="false" aria-controls="collapse915">
Configure 915 MHz Transceiver
</a>
</div>
<br>
<br>
<section class="config-section">
<h4>Configure LoRaWAN Gateways</h4>
<div class="description">
<p>
The <strong>Configure LoRaWAN Gateway</strong> section allows you to set up to ten Dragino LPS8N LoRaWAN gateways.
</p>
<ul>
<li>Enter the IP address for each gateway you want to configure.</li>
<li>Leave the input field empty to keep the current IP or disconnect an existing one.</li>
<li>All entered IP addresses are validated for correct formatting.</li>
<li>Once configured, the application automatically retrieves and stores LoRaWAN traffic from each active gateway.</li>
<li>Access and analyze stored traffic in 'survey mode'.</li>
</ul>
<p class="note"><em>Empty values will erase that particular gateway IP address</em></p>
</div>
<form id="gatewayForm" class="mt-3">
<div id="gatewayInputs"></div>
<button type="button" class="btn btn-primary mt-3" onclick="submitGatewayForm()">Configure Gateways</button>
</form>
</section>
<style>
.gateway-container {
position: relative;
}
.status-indicator {
transition: background-color 0.3s ease;
box-shadow: 0 0 5px rgba(0,0,0,0.2);
}
.status-indicator[title] {
cursor: pointer;
}
.note {
font-style: italic;
color: #666;
margin-top: 1rem;
}
.config-section {
padding: 20px;
background-color: #f8f9fa;
border-radius: 5px;
margin-bottom: 20px;
}
.form-control {
margin-bottom: 0 !important;
}
</style>
<script>
class GatewayManager {
constructor() {
this.gatewayIPs = Object.fromEntries(
Array.from({length: 10}, (_, i) => [`gateway${i + 1}`, ''])
);
this.init();
}
createGatewayInputs() {
const container = document.getElementById('gatewayInputs');
container.innerHTML = Array.from({length: 10}, (_, i) => {
const key = `gateway${i + 1}`;
return `
<div class="gateway-container d-flex align-items-center mb-2">
<input type="text"
id="${key}"
class="form-control"
placeholder="Gateway ${i + 1} IP Address"
value="${this.gatewayIPs[key]}">
<div id="${key}-status" class="status-indicator ml-2"
style="width: 12px; height: 12px; border-radius: 50%; margin-left: 10px;">
</div>
</div>
`;
}).join('');
}
async checkGatewayStatus(gatewayIP) {
if (!gatewayIP) return false;
try {
const response = await fetch(`http://${gatewayIP}:8000/cgi-bin/log-traffic.has`, {
method: 'HEAD',
mode: 'no-cors',
timeout: 5000
});
return true;
} catch (error) {
return false;
}
}
updateStatusIndicator(gatewayKey, isOnline) {
const statusElement = document.getElementById(`${gatewayKey}-status`);
if (statusElement) {
statusElement.style.backgroundColor = isOnline ? '#4BD28F' : '#FF4D4D';
statusElement.title = isOnline ? 'Gateway Online' : 'Gateway Offline';
}
}
async checkAllGateways() {
for (const [key, ip] of Object.entries(this.gatewayIPs)) {
if (ip) {
const isOnline = await this.checkGatewayStatus(ip);
this.updateStatusIndicator(key, isOnline);
} else {
this.updateStatusIndicator(key, false);
}
}
}
validateIPAddress(ip) {
if (!ip) return true;
const regex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
if (!regex.test(ip)) return false;
return ip.split('.').every(octet => {
const num = parseInt(octet);
return num >= 0 && num <= 255;
});
}
async submitGatewayForm() {
const formData = new FormData();
let hasValidationError = false;
// Explicitly set each gateway value in the correct order
for (let i = 1; i <= 10; i++) {
const key = `gateway${i}`;
const input = document.getElementById(key);
const value = input.value.trim();
if (value && !this.validateIPAddress(value)) {
hasValidationError = true;
input.classList.add('is-invalid');
Swal.fire('Error', `Invalid IP address for ${key}`, 'error');
return;
}
// Always append the key to FormData, even if empty
formData.append(key, value);
if (value) {
input.classList.remove('is-invalid');
}
}
if (hasValidationError) return;
console.log(formData)
try {
const response = await fetch('/set_gateways', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Network response was not ok');
await response.json();
Swal.fire('Success', 'Gateway IPs updated successfully', 'success');
// Update local state with new values
for (let i = 1; i <= 10; i++) {
const key = `gateway${i}`;
this.gatewayIPs[key] = document.getElementById(key).value.trim();
}
// Check status of all gateways after successful update
await this.checkAllGateways();
} catch (error) {
console.error('Error:', error);
Swal.fire('Error', 'There was an issue updating the Gateway IPs', 'error');
}
}
setupEventListeners() {
document.querySelector('#gatewayForm button')
.addEventListener('click', () => this.submitGatewayForm());
Object.keys(this.gatewayIPs).forEach(key => {
const input = document.getElementById(key);
if (input) {
input.addEventListener('change', async () => {
const ip = input.value.trim();
if (ip) {
const isOnline = await this.checkGatewayStatus(ip);
this.updateStatusIndicator(key, isOnline);
} else {
this.updateStatusIndicator(key, false);
}
});
}
});
}
async loadGatewayIPs() {
try {
const response = await fetch('/get_gateways');
if (!response.ok) throw new Error('Failed to fetch gateway IPs');
const data = await response.json();
this.gatewayIPs = data;
} catch (error) {
console.error('Error loading gateway IPs:', error);
Swal.fire('Error', 'Failed to load gateway configurations', 'error');
}
}
async init() {
await this.loadGatewayIPs(); // Load IPs first
this.createGatewayInputs(); // Then create inputs with loaded values
this.setupEventListeners();
await this.checkAllGateways(); // Check gateway statuses
}
}
// Initialize the gateway manager
document.addEventListener('DOMContentLoaded', () => {
new GatewayManager();
});
</script>
</div>
</section>
</div>
<!-- Bootstrap core JS-->
<script src="static/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script src="static/js/scripts.js"></script>
</body>
</html>
-187
View File
@@ -1,187 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<script type="text/javascript" src="static/js/socket.io.js"></script>
<title>LoRa Scanner</title>
<link rel="icon" type="image/x-icon" href="assets/img/favicon.ico" />
<!-- Font Awesome icons (free version)-->
<script src="static/js/all.js" crossorigin="anonymous"></script>
<!-- Google fonts-->
<!-- Core theme CSS (includes Bootstrap)-->
<link href="static/css/styles.css" rel="stylesheet" />
<!-- Include SweetAlert2 CSS -->
<link rel="stylesheet" href="static/css/sweetalert2.css">
<!-- Include SweetAlert2 JS -->
<script src="static/js/sweetalert2.js"></script>
<script src="static/js/jquery.min.js"></script>
</head>
<body id="page-top">
<!-- Navigation-->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top" id="sideNav">
<a class="navbar-brand js-scroll-trigger" href="#page-top">
<span class="d-block d-lg-none">LoRa Scanner</span>
<span class="d-none d-lg-block"><img class="img-fluid img-profile rounded-circle mx-auto mb-2" src="static/assets/img/profile.jpg" alt="..." /></span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/">Home</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/#config">Configuration</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/analysis">Analysis Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="#">Survey Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/tracking">Tracking Mode</a></li>
</ul>
</div>
</nav>
<!-- Page Content-->
<div class="container-fluid p-0">
<!-- About-->
<section class="resume-section" id="about">
<div class="resume-section-content">
<h1 class="mb-0" style="text-align: center;">
Survey Mode
</h1>
<br>
<div class="scrollable-table">
<table id="data-table">
<thead>
<tr>
<th>Device Name</th>
<th>Frequency</th>
<th>Signal Strength</th>
<th>Decoded Values</th>
</tr>
</thead>
<tbody>
<!-- Rows will be inserted here -->
</tbody>
</table><br><br>
</div>
<div style="text-align:center;">
<button class="transmit-button" onclick="manualUpdateTable()">Refresh Table</button>
<button class="transmit-button" onclick="downloadPackets()">Download Packets</button>
</div>
<script>
function downloadPackets() {
fetch('/downloadPackets')
.then(response => {
if (response.ok) {
return response.blob();
}
throw new Error('Network response was not ok.');
})
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'surveydata.csv';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
})
.catch(error => console.error('There was a problem with the fetch operation:', error));
}
</script>
<script>
let expandedRows = {};
function manualUpdateTable() {
// Display a loading alert
Swal.fire({
title: 'Table Updated!',
icon: 'success'
});
updateTableData();
}
function updateTableData() {
fetch('/get_table_data')
.then(response => response.json())
.then(data => {
const tableBody = document.getElementById('data-table').getElementsByTagName('tbody')[0];
tableBody.innerHTML = ''; // Clear existing table rows
for (let key in data) {
let mainRow = tableBody.insertRow();
let cell1 = mainRow.insertCell();
let cell2 = mainRow.insertCell();
let cell3 = mainRow.insertCell();
let cell4 = mainRow.insertCell();
cell1.innerHTML = key;
let lastIndex = data[key].length - 1; // Get the index of the last entry
cell2.innerHTML = data[key][lastIndex][0]; // Frequency (from the last entry)
let rssiValue = data[key][lastIndex][1];
cell3.innerHTML = rssiValue === 0 ? 'unknown' : rssiValue; // Signal Strength (from the last entry)
//cell2.innerHTML = data[key][0][0]; // Frequency (from the first entry)
// Check for RSSI value and display 'unknown' if it is 0
//let rssiValue = data[key][0][1];
//cell3.innerHTML = rssiValue === 0 ? 'unknown' : rssiValue; // Signal Strength (from the first entry)
// Create a Bootstrap styled button
let expandBtn = document.createElement('button');
expandBtn.classList.add('btn', 'btn-secondary', 'btn-sm'); // Bootstrap button classes
expandBtn.innerHTML = 'Show Values';
expandBtn.onclick = function() {
let deviceKey = key; // Capture the current device key
let isExpanded = !expandedRows[deviceKey]; // Toggle the expanded state
// Show or hide the expandable rows
let nextRow = mainRow.nextSibling;
while (nextRow && nextRow.classList.contains('expandable-row')) {
nextRow.style.display = isExpanded ? 'table-row' : 'none';
nextRow = nextRow.nextSibling;
}
// Update the button text and expandedRows object
expandBtn.innerHTML = isExpanded ? 'Hide Values' : 'Show Values';
expandedRows[deviceKey] = isExpanded;
};
cell4.appendChild(expandBtn);
cell4.style.textAlign = 'center'; // Center align the button
// Add expandable rows for each decoded value
data[key].forEach(entry => {
let expandableRow = tableBody.insertRow();
expandableRow.classList.add('expandable-row');
expandableRow.style.display = 'none'; // Initially hidden
let cell = expandableRow.insertCell();
cell.colSpan = "4";
cell.innerHTML = `Decoded Value: ${entry[2]}`;
});
if (expandedRows[key]) {
let nextRow = mainRow.nextSibling;
while (nextRow && nextRow.classList.contains('expandable-row')) {
nextRow.style.display = 'table-row';
nextRow = nextRow.nextSibling;
}
}
}
})
.catch(error => console.error('Error:', error));
}
// Initial update of the table
updateTableData();
// Periodically update the table every 30 seconds (30000 milliseconds)
setInterval(updateTableData, 30000);
</script>
</section>
<hr class="m-0" />
</body>
</html>
-980
View File
@@ -1,980 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<script type="text/javascript" src="static/js/socket.io.js"></script>
<title>LoRa Scanner</title>
<link rel="icon" type="image/x-icon" href="assets/img/favicon.ico" />
<!-- Font Awesome icons (free version)-->
<script src="static/js/all.js" crossorigin="anonymous"></script>
<!-- Google fonts-->
<!-- Core theme CSS (includes Bootstrap)-->
<link href="static/css/styles.css" rel="stylesheet" />
<!-- Include SweetAlert2 CSS -->
<link rel="stylesheet" href="static/css/sweetalert2.css">
<!-- Include SweetAlert2 JS -->
<script src="static/js/sweetalert2.js"></script>
<script src="static/js/jquery.min.js"></script>
</head>
<body id="page-top">
<!-- Navigation-->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top" id="sideNav">
<a class="navbar-brand js-scroll-trigger" href="#page-top">
<span class="d-block d-lg-none">LoRa Scanner</span>
<span class="d-none d-lg-block"><img class="img-fluid img-profile rounded-circle mx-auto mb-2" src="static/assets/img/profile.jpg" alt="..." /></span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/">Home</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/#config">Configuration</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/analysis">Analysis Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="/survey">Survey Mode</a></li>
<li class="nav-item"><a class="nav-link js-scroll-trigger" href="#">Tracking Mode</a></li>
</ul>
</div>
</nav>
<!-- Page Content-->
<div class="container-fluid p-0">
<!-- About-->
<section class="resume-section" id="about">
<div class="resume-section-content">
<h1 class="mb-0" style="text-align: center;">
Tracking Mode
</h1>
<br>
<h2 class="lead mb-5" style="text-align: center;">Select A Device You Would Like To Track</h2>
<br>
<div class="scrollable-table">
<div class="resume-section-content">
<table id="data-table">
<thead>
<tr>
<th>Device Name</th>
<th>Frequency</th>
<th>Signal Strength</th>
</tr>
</thead>
<tbody>
<!-- Rows will be inserted here -->
</tbody>
</table>
</div>
<br>
<button id="clear-selection-button" class="btn btn-secondary btn-sm" style="display: none; display: block; margin: 0 auto; text-align: center;">Clear Filter</button>
<br>
<script>
function clearDataPort1() {
var serialDataDiv1 = document.getElementById('serial-data-port1');
serialDataDiv1.innerHTML = '';
}
function clearDataPort2() {
var serialDataDiv2 = document.getElementById('serial-data-port2');
serialDataDiv2.innerHTML = '';
}
function clearDataPort3() {
var serialDataDiv3 = document.getElementById('serial-data-port3');
serialDataDiv3.innerHTML = '';
}
</script>
<script>
let selectedDeviceName = null; // Global variable to store the selected device name
let dataCache = {}; // Cache for storing fetched data
function updateTableData() {
fetch('/get_table_data')
.then(response => response.json())
.then(data => {
const tableBody = document.getElementById('data-table').getElementsByTagName('tbody')[0];
tableBody.innerHTML = ''; // Clear existing table rows
let isRowSelected = selectedDeviceName !== null;
for (let key in data) {
let row = tableBody.insertRow();
row.addEventListener('click', function() {
// Handle row click event for selection
handleRowSelection(row, key);
});
let cell1 = row.insertCell();
let cell2 = row.insertCell();
let cell3 = row.insertCell();
cell1.innerHTML = key; // Device Name
// Assuming that the latest data is the most relevant
let latestData = data[key][data[key].length - 1];
cell2.innerHTML = latestData[0]; // Frequency
// Check for RSSI value and display 'unknown' if it is 0
let rssiValue = latestData[1];
cell3.innerHTML = rssiValue === 0 ? 'unknown' : rssiValue; // Signal Strength
// Apply hiding logic based on selection
if (isRowSelected) {
if (key !== selectedDeviceName) {
row.classList.add('hidden-row');
} else {
row.classList.add('selected-row');
}
}
}
updateSelectedDataDisplay(); // Update the selected data display after refreshing the table
})
.catch(error => console.error('Error:', error));
}
function handleRowSelection(row, key) {
// Reset all rows
const tableBody = document.getElementById('data-table').getElementsByTagName('tbody')[0];
const allRows = tableBody.getElementsByTagName('tr');
for (let i = 0; i < allRows.length; i++) {
allRows[i].classList.remove('hidden-row', 'selected-row');
}
// Highlight and show the clicked row
row.classList.add('selected-row');
// Display the "Clear Filter" button
const clearButton = document.getElementById('clear-selection-button');
clearButton.style.display = 'block';
selectedDeviceName = key; // Update the global variable with the selected device name
updateSelectedDataDisplay(); // Update the selected data display
}
// ... rest of your existing code for updateSelectedDataDisplay, clearButton event listener, etc.
// Initial update of the table
updateTableData();
// Periodically update the table every second
setInterval(updateTableData, 10000);
const clearButton = document.getElementById('clear-selection-button');
clearButton.addEventListener('click', function () {
const tableBody = document.getElementById('data-table').getElementsByTagName('tbody')[0];
const selectedRow = tableBody.getElementsByClassName('selected-row')[0];
if (selectedRow) {
selectedRow.classList.remove('selected-row');
}
// Show all rows
const allRows = tableBody.getElementsByTagName('tr');
for (let i = 0; i < allRows.length; i++) {
allRows[i].classList.remove('hidden-row');
}
// Hide the "Clear Filter" button
clearButton.style.display = 'none';
selectedDeviceName = null; // Reset the global variable when selection is cleared
updateSelectedDataDisplay(); // Clear the selected data display
});
</script>
</div>
<!-- 433 -->
<div class="resume-section-content">
<!-- Collapsible content -->
<div class="collapse" id="collapse433">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">433 MHz LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port1">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser433()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial433()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit433()">Start Beacon</button>
<button class="disconnect-button" onclick="confirmStopTransmission433()">Stop Beacon</button><br><br>
<button class="transmit-button" onclick="clearDataPort1()">Clear Data</button>
<input type="checkbox" id="autoscroll-port1" checked>
<label for="autoscroll-port1">Autoscroll</label>
<p id="status-label433" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator433" class="indicator"></div>
</div>
</div>
<!-- JavaScript for updating the indicator -->
<script>
function updateIndicator433() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port1',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator433').removeClass('red').addClass('green');
$('#status-label433').text('Status: Connected');
} else {
$('#indicator433').removeClass('green').addClass('red');
$('#status-label433').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator433').removeClass('green').addClass('red');
$('#status-label433').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator433();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator433, 5000);
</script>
<script type="text/javascript">
var socket = io.connect('http://' + document.domain + ':' + location.port);
// For port1
socket.on('initial_serial_data_port1', function(data) {
var serialDataDiv = document.getElementById('serial-data-port1');
data.data.forEach(function(line) {
serialDataDiv.innerHTML += line + '<br>';
});
// Scroll to the bottom for new data
//serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
});
socket.on('serial_data_port1', function(data) {
var serialDataDiv = document.getElementById('serial-data-port1');
serialDataDiv.innerHTML += data.data + '<br>';
var autoscrollCheckbox = document.getElementById('autoscroll-port1');
if (autoscrollCheckbox.checked) {
serialDataDiv.scrollTop = serialDataDiv.scrollHeight;
}
});
</script>
<script>
function promptUser433() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_433?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. The port might already be in use.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial433() {
Swal.fire({
title: 'Are you sure you want to disconnect the 433 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_433')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
var transmitIntervalId433 = null;
function transmit433() {
Swal.fire({
title: 'Enter data to transmit and set interval:',
html:
'<input id="swal-input1" class="swal2-input" placeholder="Enter data">' +
'<input id="swal-input2" class="swal2-input" placeholder="Interval in milliseconds">',
focusConfirm: false,
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
preConfirm: () => {
const data = document.getElementById('swal-input1').value;
const interval = document.getElementById('swal-input2').value;
if (!data || !interval) {
Swal.showValidationMessage('Please enter both data and interval');
} else if (isNaN(interval) || interval < 1000) { // Ensuring the interval is a number and at least 1 second (1000 milliseconds)
Swal.showValidationMessage('Please enter a valid interval (number >= 1000)');
} else {
return { data: data, interval: interval };
}
}
}).then((result) => {
if (result.isConfirmed && result.value) {
startTransmission433(result.value.data, result.value.interval);
Swal.fire({
title: 'Transmission Started!',
html: `Data: ${result.value.data}<br>Interval: ${result.value.interval} milliseconds`,
icon: 'success'
});
}
});
}
function startTransmission433(data, interval) {
if (transmitIntervalId433) {
clearInterval(transmitIntervalId433); // Clear existing interval
}
transmitIntervalId433 = setInterval(() => {
sendTransmissionData433(data);
}, parseInt(interval)); // Use the user-provided interval, ensuring it's an integer
// Send the first transmission immediately
sendTransmissionData433(data);
}
function sendTransmissionData433(data) {
return fetch('/transmit433', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: data })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Error', 'Transmission failed: ' + error.message, 'error');
clearInterval(transmitIntervalId433); // Stop the interval on error
});
}
function stopTransmission433() {
if (transmitIntervalId433) {
clearInterval(transmitIntervalId433);
transmitIntervalId433 = null;
Swal.fire('Transmission Stopped', '', 'info');
}
}
function confirmStopTransmission433() {
Swal.fire({
title: 'Are you sure?',
text: "Do you want to stop the transmission?",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes, stop it!'
}).then((result) => {
if (result.isConfirmed) {
stopTransmission433();
}
});
}
</script>
</div>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse433" role="button" aria-expanded="false" aria-controls="collapse433">
Toggle 433 MHz Beacon
</a>
</div>
<br><br>
<div class="resume-section-content">
<div class="collapse" id="collapse868">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">868 MHz LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port2">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser868()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial868()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit868()">Start Beacon</button>
<button class="disconnect-button" onclick="confirmStopTransmission868()">Stop Beacon</button><br><br>
<button class="transmit-button" onclick="clearDataPort2()">Clear Data</button>
<input type="checkbox" id="autoscroll-port2" checked>
<label for="autoscroll-port2">Autoscroll</label>
<p id="status-label868" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator868" class="indicator"></div>
</div>
</div>
</div>
<script>
function updateIndicator868() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port2',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator868').removeClass('red').addClass('green');
$('#status-label868').text('Status: Connected');
} else {
$('#indicator868').removeClass('green').addClass('red');
$('#status-label868').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator868').removeClass('green').addClass('red');
$('#status-label868a').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator868();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator868, 5000);
</script>
<script>
// For port2
var socket = io.connect('http://' + document.domain + ':' + location.port);
socket.on('initial_serial_data_port2', function(data) {
var serialDataDiv2 = document.getElementById('serial-data-port2');
data.data.forEach(function(line) {
serialDataDiv2.innerHTML += line + '<br>';
});
});
socket.on('serial_data_port2', function(data) {
var serialDataDiv2 = document.getElementById('serial-data-port2');
serialDataDiv2.innerHTML += data.data + '<br>';
var autoscrollCheckbox2 = document.getElementById('autoscroll-port2');
if (autoscrollCheckbox2.checked) {
serialDataDiv2.scrollTop = serialDataDiv2.scrollHeight;
}
});
</script>
<script>
function promptUser868() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_868?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. The port might already be in use.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial868() {
Swal.fire({
title: 'Are you sure you want to disconnect the 868 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_868')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
var transmitIntervalId868 = null;
function transmit868() {
Swal.fire({
title: 'Enter data to transmit and set interval:',
html:
'<input id="swal-input1-868" class="swal2-input" placeholder="Enter data">' +
'<input id="swal-input2-868" class="swal2-input" placeholder="Interval in milliseconds">',
focusConfirm: false,
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
preConfirm: () => {
const data = document.getElementById('swal-input1-868').value;
const interval = document.getElementById('swal-input2-868').value;
if (!data || !interval) {
Swal.showValidationMessage('Please enter both data and interval');
} else if (isNaN(interval) || interval < 1000) {
Swal.showValidationMessage('Please enter a valid interval (number >= 1000)');
} else {
return { data: data, interval: interval };
}
}
}).then((result) => {
if (result.isConfirmed && result.value) {
startTransmission868(result.value.data, result.value.interval);
Swal.fire({
title: 'Transmission Started!',
html: `Data: ${result.value.data}<br>Interval: ${result.value.interval} milliseconds`,
icon: 'success'
});
}
});
}
function startTransmission868(data, interval) {
if (transmitIntervalId868) {
clearInterval(transmitIntervalId868); // Clear existing interval
}
transmitIntervalId868 = setInterval(() => {
sendTransmissionData868(data);
}, parseInt(interval));
// Send the first transmission immediately
sendTransmissionData868(data);
}
function sendTransmissionData868(data) {
return fetch('/transmit868', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: data })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Error', 'Transmission failed: ' + error.message, 'error');
clearInterval(transmitIntervalId868); // Stop the interval on error
});
}
function stopTransmission868() {
if (transmitIntervalId868) {
clearInterval(transmitIntervalId868);
transmitIntervalId868 = null;
Swal.fire('Transmission Stopped', '', 'info');
}
}
function confirmStopTransmission868() {
Swal.fire({
title: 'Are you sure?',
text: "Do you want to stop the transmission?",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes, stop it!'
}).then((result) => {
if (result.isConfirmed) {
stopTransmission868();
}
});
}
</script>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse868" role="button" aria-expanded="false" aria-controls="collapse868">
Toggle 868 MHz Beacon
</a>
</div>
<br><br>
<!--915 MHz-->
<div class="resume-section-content">
<div class="collapse" id="collapse915">
<div class="d-flex flex-column flex-md-row justify-content-between mb-5">
<div class="flex-grow-1">
<h3 class="mb-0">915 MHZ LoRa Transceiver</h3><br>
<div class="serial-data" id="serial-data-port3">
</div>
<div style="text-align:center;">
<button class="styled-button" onclick="promptUser915()">Select Device</button>
<button class="disconnect-button" onclick="deleteSerial915()">Disconnect Device</button>
<button class="transmit-button" onclick="transmit915()">Start Beacon</button>
<button class="disconnect-button" onclick="confirmStopTransmission915()">Stop Beacon</button><br><br>
<button class="transmit-button" onclick="clearDataPort3()">Clear Data</button>
<input type="checkbox" id="autoscroll-port3" checked>
<label for="autoscroll-port3">Autoscroll</label>
<p id="status-label915" style="text-align: center">Status: Checking...</p>
<div class="indicator-container">
<div id="indicator915" class="indicator"></div>
</div>
</div>
</div>
<script>
function updateIndicator915() {
// Fetch the status from the Flask route
$.ajax({
url: '/checkSer',
type: 'GET',
data: {
port: 'port3',
},
success: function(response) {
if (response['result'] === 'True') {
$('#indicator915').removeClass('red').addClass('green');
$('#status-label915').text('Status: Connected');
} else {
$('#indicator915').removeClass('green').addClass('red');
$('#status-label915').text('Serial Port Status: Disconnected');
}
},
error: function() {
$('#indicator915').removeClass('green').addClass('red');
$('#status-label915').text('Serial Port Status: Disconnected');
}
});
}
// Initial update of the indicator
updateIndicator915();
// Periodically check and update the indicator (e.g., every 5 seconds)
setInterval(updateIndicator915, 5000);
</script>
<script>
// For port3
var socket = io.connect('http://' + document.domain + ':' + location.port);
socket.on('initial_serial_data_port3', function(data) {
var serialDataDiv3 = document.getElementById('serial-data-port3');
data.data.forEach(function(line) {
serialDataDiv3.innerHTML += line + '<br>';
});
});
socket.on('serial_data_port3', function(data) {
var serialDataDiv3 = document.getElementById('serial-data-port3');
serialDataDiv3.innerHTML += data.data + '<br>';
var autoscrollCheckbox3 = document.getElementById('autoscroll-port3');
if (autoscrollCheckbox3.checked) {
serialDataDiv3.scrollTop = serialDataDiv3.scrollHeight;
}
});
</script>
<script>
function promptUser915() {
// First, fetch the list of available ports
fetch('/get_serial_ports')
.then(response => response.json())
.then(data => {
const ports = data.ports;
// Create the select options
const selectOptions = ports.map(port => `<option value="${port}">${port}</option>`).join('');
Swal.fire({
title: 'Select Serial Port:',
html: `<select id="port-select" class="swal2-input">${selectOptions}</select>`,
showCancelButton: true,
confirmButtonText: 'Connect',
cancelButtonText: 'Cancel',
preConfirm: () => {
const selectedPort = document.getElementById('port-select').value;
return fetch(`/attach_serial_915?user_input=${encodeURIComponent(selectedPort)}`)
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong. The port might already be in use.', 'error');
});
}
});
})
.catch(error => {
console.error('Error fetching ports:', error);
Swal.fire('Oops!', 'Failed to fetch available ports.', 'error');
});
}
</script>
<script>
function deleteSerial915() {
Swal.fire({
title: 'Are you sure you want to disconnect the 915 MHz port?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
}).then((result) => {
if (result.isConfirmed) {
// User clicked "Yes" - Send HTTP GET request to Flask
fetch('/delete_serial_915')
.then(response => response.json())
.then(data => {
Swal.fire({
title: 'Confirmation Result',
text: data.result,
icon: 'info'
});
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Oops!', 'Something went wrong.', 'error');
});
}
});
}
</script>
<script>
var transmitIntervalId915 = null;
function transmit915() {
Swal.fire({
title: 'Enter data to transmit and set interval:',
html:
'<input id="swal-input1-915" class="swal2-input" placeholder="Enter data">' +
'<input id="swal-input2-915" class="swal2-input" placeholder="Interval in milliseconds">',
focusConfirm: false,
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
preConfirm: () => {
const data = document.getElementById('swal-input1-915').value;
const interval = document.getElementById('swal-input2-915').value;
if (!data || !interval) {
Swal.showValidationMessage('Please enter both data and interval');
} else if (isNaN(interval) || interval < 1000) {
Swal.showValidationMessage('Please enter a valid interval (number >= 1000)');
} else {
return { data: data, interval: interval };
}
}
}).then((result) => {
if (result.isConfirmed && result.value) {
startTransmission915(result.value.data, result.value.interval);
Swal.fire({
title: 'Transmission Started!',
html: `Data: ${result.value.data}<br>Interval: ${result.value.interval} milliseconds`,
icon: 'success'
});
}
});
}
function startTransmission915(data, interval) {
if (transmitIntervalId915) {
clearInterval(transmitIntervalId915); // Clear existing interval
}
transmitIntervalId915 = setInterval(() => {
sendTransmissionData915(data);
}, parseInt(interval));
// Send the first transmission immediately
sendTransmissionData915(data);
}
function sendTransmissionData915(data) {
return fetch('/transmit915', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ user_input: data })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Error', 'Transmission failed: ' + error.message, 'error');
clearInterval(transmitIntervalId915); // Stop the interval on error
});
}
function stopTransmission915() {
if (transmitIntervalId915) {
clearInterval(transmitIntervalId915);
transmitIntervalId915 = null;
Swal.fire('Transmission Stopped', '', 'info');
}
}
function confirmStopTransmission915() {
Swal.fire({
title: 'Are you sure?',
text: "Do you want to stop the transmission?",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes, stop it!'
}).then((result) => {
if (result.isConfirmed) {
stopTransmission915();
}
});
}
</script>
</div>
</div>
<!-- Collapsible button -->
<a class="btn btn-primary" data-bs-toggle="collapse" href="#collapse915" role="button" aria-expanded="false" aria-controls="collapse915">
Toggle 915 MHz Beacon
</a>
</div>
</section>
</div>
<!-- Bootstrap core JS-->
<script src="static/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script src="static/js/scripts.js"></script>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
import socket
# Define the IP address and the port number to listen on.
# '' means the script will listen on all available IPs.
IP = '10.130.1.235'
PORT = 1700
def main():
# Create a UDP socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
# Bind the socket to the address and port
s.bind((IP, PORT))
print(f"Listening for UDP traffic on port {PORT}...")
# Continuously listen for UDP packets
while True:
# Receive data from the client
data, addr = s.recvfrom(1024) # buffer size is 1024 bytes
print(f"Received message from {addr}: {data}")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+272
View File
@@ -0,0 +1,272 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "lora-scanner-web",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@mui/x-data-grid": "^9.16.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-router-dom": "^7.18.4",
},
"devDependencies": {
"@types/bun": "^1.4.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"typescript": "^7.0.2",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.11", "", {}, "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.11", "", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag=="],
"@babel/runtime": ["@babel/runtime@7.29.10", "", {}, "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="],
"@babel/types": ["@babel/types@7.29.11", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
"@base-ui/utils": ["@base-ui/utils@0.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.0", "@floating-ui/utils": "^0.2.12", "reselect": "^5.3.0", "use-sync-external-store": "^1.7.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A=="],
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
"@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
"@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="],
"@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="],
"@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "@types/react": "*", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="],
"@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="],
"@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="],
"@emotion/styled": ["@emotion/styled@11.14.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/is-prop-valid": "^1.3.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", "@types/react": "*", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw=="],
"@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
"@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="],
"@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="],
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@9.5.0", "", {}, "sha512-PErEpl3Lp+1ZqWYKI7GQU8XSiciAFmLbUAZ7IoaUL598HLy1UAFuEUMEJu8yAS1v8vkXYzWprMVZzky4UwXoOA=="],
"@mui/icons-material": ["@mui/icons-material@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.5" }, "peerDependencies": { "@mui/material": "^9.5.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-dahtVaqnRUIBgaZ4WC9EdP+LgJ5d2rslASYIX+nDFG6+ssUiM11jQ+Gd1wH8vhUGoNA9ES40MYics5BI8zJFXQ=="],
"@mui/material": ["@mui/material@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.5", "@mui/core-downloads-tracker": "^9.5.0", "@mui/system": "^9.5.0", "@mui/types": "^9.5.0", "@mui/utils": "^9.5.0", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1", "react-is": "^19.3.0", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^9.5.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@mui/material-pigment-css", "@types/react"] }, "sha512-QgqvdVjZoKxP8E8SBxYY6LrdV+kw00moXweZQWBbMMdifPKZAGfUaaXhGKIca6fjKnCL6HJIe/AWWCpqNvyHVw=="],
"@mui/private-theming": ["@mui/private-theming@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.5", "@mui/utils": "^9.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-UHZKySlr4ZJeA5HEhe/Tq1fhsR5573ViKYn7JV80dCVJtgqRQIplRSer1DHPg2Yi22llX7QRhgM0Vtrx4kZHSA=="],
"@mui/styled-engine": ["@mui/styled-engine@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-IX1ceHnZ0bJgzJRWO/Ayl4ME1rNmpWVYwV28yb089UAMR17rJkV1qfr7ApsWQXXPi1oi+2JapJrWQAiAnTlJHQ=="],
"@mui/system": ["@mui/system@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.5", "@mui/private-theming": "^9.5.0", "@mui/styled-engine": "^9.5.0", "@mui/types": "^9.5.0", "@mui/utils": "^9.5.0", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@types/react"] }, "sha512-OZuAwvoiaaHpvn0a540VH1DUwwmojJubGfNmO2g+nb2ybNDP4/gF7t7aA6AMduELSuJ5yxwlzyFvUO3CfLv1Yg=="],
"@mui/types": ["@mui/types@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.5" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-O0S6xS80qEQqW2CYNrd4GlBEQ1xpMOHZ5b4zO/DaLu6NZmtthM6smEg0Gz/u9gX5vL+8NB3EiBIxmxSCcG590Q=="],
"@mui/utils": ["@mui/utils@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.5", "@mui/types": "^9.5.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.3.0" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-8m0LfSgE2pj58znY3kZ+anwRKbnBkcu/7AJPA8L/77wEjN0QnpX4/zRAccyplp1VEUnMsUBADeTRin5Mkf7epA=="],
"@mui/x-data-grid": ["@mui/x-data-grid@9.16.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.0", "@base-ui/utils": "^0.5.0", "@mui/utils": "^9.5.0", "@mui/x-internals": "9.16.0", "@mui/x-virtualizer": "0.7.4", "clsx": "^2.1.1", "prop-types": "^15.8.1", "use-sync-external-store": "^1.7.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-xgTG4DZ8Y3LV0dx3zw4NmyJfi4qk6KqgaQNEBDwku9bpAsXsrob7Tcgg+LECPc3ldbtTfCDon/v1yyt9bfDNyQ=="],
"@mui/x-internals": ["@mui/x-internals@9.16.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.0", "@base-ui/utils": "^0.5.0", "@mui/utils": "^9.5.0", "reselect": "^5.3.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ImvD4yi8J/Tip60OnPntw55xG1JlsqxABVlO1XEe1lypg4rUUyR9o7S29FHzOshBPeMVigd9izBtNIdioh1Djw=="],
"@mui/x-virtualizer": ["@mui/x-virtualizer@0.7.4", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.0", "@base-ui/utils": "^0.5.0", "@mui/utils": "^9.5.0", "@mui/x-internals": "9.16.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-bAvkhC6I4D+53Jjb+rWd1OgDq8SBC2WyChyWeMG02TugCIsR2LnSXio3OzE5GyXWi4e6Ay85SrV8kWckPXMxDw=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
"bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
"react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="],
"react-is": ["react-is@19.3.1", "", {}, "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q=="],
"react-router": ["react-router@7.18.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ=="],
"react-router-dom": ["react-router-dom@7.18.4", "", { "dependencies": { "react-router": "7.18.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="],
"resolve": ["resolve@1.22.13", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.17.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.3", "", {}, "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
}
}
+48
View File
@@ -0,0 +1,48 @@
// Dev server: Bun's HTML bundler with HMR, proxying /api (HTTP + WebSocket) to the Go server.
import index from './index.html'
const API = process.env.LORA_API ?? 'localhost:5000'
type Payload = Parameters<WebSocket['send']>[0]
type WsData = { path: string; upstream?: WebSocket; queue: Payload[] }
Bun.serve<WsData>({
port: 5173,
routes: {
'/api/*': (req: Request, server: Bun.Server<WsData>) => {
const url = new URL(req.url)
if (req.headers.get('upgrade') === 'websocket') {
return server.upgrade(req, { data: { path: url.pathname + url.search, queue: [] } })
? undefined
: new Response('upgrade failed', { status: 400 })
}
return fetch(`http://${API}${url.pathname}${url.search}`, {
method: req.method,
headers: req.headers,
body: req.body,
})
},
'/*': index,
},
development: true,
websocket: {
open(ws) {
const up = new WebSocket(`ws://${API}${ws.data.path}`)
ws.data.upstream = up
up.onopen = () => ws.data.queue.splice(0).forEach((m) => up.send(m))
up.onmessage = (e) => ws.send(e.data)
up.onclose = () => ws.close()
},
message(ws, msg) {
const up = ws.data.upstream
const payload = msg as Payload
if (up?.readyState === WebSocket.OPEN) up.send(payload)
else ws.data.queue.push(payload)
},
close(ws) {
ws.data.upstream?.close()
},
},
})
console.log('dev server on http://localhost:5173 (proxying /api to ' + API + ')')
View File
Whitespace-only changes.
+9
View File
@@ -0,0 +1,9 @@
// Package web embeds the built React frontend.
package web
import "embed"
// FS holds web/dist; run `make web` to populate it.
//
//go:embed all:dist
var FS embed.FS
+74
View File
@@ -0,0 +1,74 @@
package web
import (
"io/fs"
"regexp"
"strings"
"testing"
)
// The app must work with no internet (it can run on an isolated network), so
// the built frontend may not reference anything outside the embedded files.
// Skipped until `make web` has produced dist/index.html.
func dist(t *testing.T) fs.FS {
t.Helper()
sub, err := fs.Sub(FS, "dist")
if err != nil {
t.Fatal(err)
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
t.Skip("web/dist not built; run `make web`")
}
return sub
}
var (
attrRe = regexp.MustCompile(`(?:src|href)="([^"]+)"`)
assetRe = regexp.MustCompile(`"(/[A-Za-z0-9_.-]+\.(?:svg|png|ico|jpe?g|webp|gif|woff2?|ttf|otf|css|js|json|webmanifest))"`)
// Hosts that would mean a runtime fetch of a font, script or stylesheet.
remoteRe = regexp.MustCompile(`fonts\.(?:googleapis|gstatic)\.com|cdn\.jsdelivr|unpkg\.com|cdnjs\.|use\.typekit|@import\s+url\(\s*['"]?https?:`)
)
func exists(t *testing.T, root fs.FS, ref string) {
t.Helper()
if _, err := fs.Stat(root, strings.TrimPrefix(ref, "/")); err != nil {
t.Errorf("%q is referenced but not in the embedded dist", ref)
}
}
func TestIndexOnlyReferencesEmbeddedFiles(t *testing.T) {
root := dist(t)
b, _ := fs.ReadFile(root, "index.html")
refs := attrRe.FindAllStringSubmatch(string(b), -1)
if len(refs) == 0 {
t.Fatal("index.html references no assets")
}
for _, m := range refs {
ref := m[1]
if !strings.HasPrefix(ref, "/") || strings.HasPrefix(ref, "//") {
t.Errorf("index.html reference %q is not a local absolute path", ref)
continue
}
exists(t, root, ref)
}
}
func TestBundlesHaveNoRemoteResourcesAndAssetsExist(t *testing.T) {
root := dist(t)
err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !(strings.HasSuffix(p, ".js") || strings.HasSuffix(p, ".css") || strings.HasSuffix(p, ".html")) {
return err
}
b, _ := fs.ReadFile(root, p)
if m := remoteRe.Find(b); m != nil {
t.Errorf("%s loads a remote resource: %q", p, m)
}
for _, m := range assetRe.FindAllStringSubmatch(string(b), -1) {
exists(t, root, m[1])
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 501 501" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><circle cx="250.4" cy="250.4" r="250" style="fill:#ffffff;"/><g transform="translate(250.4 250.4) scale(0.72) translate(-250.4 -250.4)"><rect id="Brand-Icon" serif:id="Brand Icon" x="0" y="0.093" width="500.713" height="500" style="fill:none;"/><clipPath id="_clip1"><rect x="0" y="0.093" width="500.713" height="500"/></clipPath><g clip-path="url(#_clip1)"><g><path d="M26.555,44.002c9.432,0 17.089,-7.657 17.089,-17.089c-0,-9.431 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.658 -17.089,17.089c-0,9.432 7.658,17.089 17.089,17.089Z"/><path d="M475.882,131.914c9.432,-0 17.089,-7.658 17.089,-17.089c-0,-9.431 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.658 -17.089,17.089c-0,9.431 7.658,17.089 17.089,17.089Z"/><path d="M475.882,406.112c9.432,0 17.089,-7.657 17.089,-17.088c-0,-9.432 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.657 -17.089,17.089c-0,9.431 7.658,17.088 17.089,17.088Z"/><path d="M475.882,493.003c9.432,0 17.089,-7.657 17.089,-17.088c-0,-9.432 -7.657,-17.089 -17.089,-17.089c-9.431,-0 -17.089,7.657 -17.089,17.089c-0,9.431 7.658,17.088 17.089,17.088Z"/><path d="M26.555,130.892c9.432,0 17.089,-7.657 17.089,-17.088c-0,-9.432 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.657 -17.089,17.089c-0,9.431 7.658,17.088 17.089,17.088Z"/><path d="M477.079,49.553c-13.474,-0 -24.413,-10.939 -24.413,-24.413c-0,-13.473 10.939,-24.412 24.413,-24.412c13.472,-0 24.411,10.939 24.411,24.412c0,13.474 -10.939,24.413 -24.411,24.413Z" style="fill:#42c1c7;"/><path d="M25.191,451.902c13.472,-0 24.411,10.94 24.411,24.413c0,13.473 -10.939,24.413 -24.411,24.413c-13.474,-0 -24.414,-10.94 -24.414,-24.413c0,-13.473 10.94,-24.413 24.414,-24.413Z" style="fill:#42c1c7;"/><path d="M26.555,370.914c9.432,-0 17.089,7.657 17.089,17.088c-0,9.432 -7.657,17.089 -17.089,17.089c-9.431,0 -17.089,-7.657 -17.089,-17.089c-0,-9.431 7.658,-17.088 17.089,-17.088Z"/><path d="M363.935,156.059c-3.745,-0.012 -6.777,-3.063 -6.765,-6.808c0.013,-3.745 3.063,-6.777 6.808,-6.764l31.085,0.099l75.563,-118.305c2.016,-3.156 6.215,-4.082 9.373,-2.066c3.156,2.017 4.082,6.215 2.065,9.373l-77.572,121.448c-1.25,1.959 -3.417,3.141 -5.741,3.134l-34.816,-0.111Zm0.068,68.474c-3.745,0.025 -6.807,-2.995 -6.833,-6.741c-0.024,-3.745 2.996,-6.806 6.741,-6.831l43.322,-0.29l63.389,-99.493c2.012,-3.158 6.212,-4.089 9.369,-2.076c3.159,2.013 4.089,6.21 2.078,9.37l-65.374,102.607c-1.238,1.941 -3.375,3.124 -5.677,3.139l-47.015,0.315Zm-0.092,68.355c-3.745,-0.026 -6.765,-3.087 -6.741,-6.832c0.026,-3.745 3.088,-6.766 6.833,-6.74l47.015,0.315c2.302,0.015 4.439,1.197 5.677,3.139l65.374,102.608c2.011,3.158 1.081,7.357 -2.078,9.369c-3.157,2.012 -7.357,1.082 -9.369,-2.077l-63.389,-99.492l-43.322,-0.29Zm0.067,68.474c-3.745,0.012 -6.795,-3.019 -6.808,-6.764c-0.012,-3.746 3.02,-6.796 6.765,-6.808l34.816,-0.111c2.324,-0.007 4.491,1.175 5.74,3.133l77.573,121.45c2.017,3.156 1.091,7.355 -2.065,9.371c-3.158,2.016 -7.357,1.091 -9.373,-2.066l-75.563,-118.304l-31.085,0.099Zm-225.013,-14.593c3.745,0.012 6.777,3.062 6.765,6.807c-0.012,3.746 -3.063,6.777 -6.808,6.765l-31.084,-0.099l-75.563,118.304c-2.016,3.157 -6.216,4.082 -9.373,2.066c-3.156,-2.015 -4.082,-6.215 -2.066,-9.371l77.573,-121.45c1.25,-1.958 3.416,-3.14 5.74,-3.134l34.816,0.112Zm-0.067,-68.474c3.745,-0.026 6.806,2.995 6.832,6.74c0.025,3.745 -2.995,6.806 -6.74,6.832l-43.323,0.29l-63.389,99.492c-2.012,3.159 -6.211,4.089 -9.369,2.076c-3.159,-2.011 -4.089,-6.21 -2.077,-9.368l65.373,-102.608c1.238,-1.942 3.376,-3.125 5.678,-3.139l47.015,-0.315Zm0.092,-68.355c3.745,0.025 6.765,3.086 6.74,6.831c-0.026,3.745 -3.087,6.766 -6.832,6.741l-47.015,-0.315c-2.303,-0.015 -4.44,-1.197 -5.678,-3.14l-65.373,-102.607c-2.012,-3.159 -1.082,-7.356 2.077,-9.369c3.158,-2.013 7.357,-1.082 9.369,2.076l63.389,99.492l43.323,0.291Zm-0.068,-68.475c3.745,-0.012 6.796,3.02 6.808,6.765c0.012,3.745 -3.02,6.796 -6.765,6.807l-34.816,0.112c-2.324,0.006 -4.49,-1.175 -5.74,-3.134l-77.573,-121.449c-2.016,-3.157 -1.09,-7.356 2.066,-9.372c3.157,-2.016 7.357,-1.09 9.373,2.066l75.563,118.304l31.084,-0.099Z"/><path d="M376.712,100.295c0,-10.688 -8.664,-19.352 -19.352,-19.352c-46.737,-0 -165.351,-0 -212.087,-0c-10.688,-0 -19.353,8.664 -19.353,19.352c0,60.084 0,242.988 0,303.072c0,10.688 8.665,19.353 19.353,19.353c46.736,-0 165.35,-0 212.087,-0c10.688,-0 19.352,-8.665 19.352,-19.353l0,-303.072Z"/><path d="M174.745,173.814c-13.474,0 -24.413,-10.938 -24.413,-24.412c0,-13.474 10.939,-24.413 24.413,-24.413c13.474,0 24.413,10.939 24.413,24.413c0,13.474 -10.939,24.412 -24.413,24.412Z" style="fill:#42c1c7;"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LoRa Scanner</title>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="icon" type="image/x-icon" href="./favicon.ico" sizes="48x48 32x32 16x16" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "lora-scanner-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "bun run dev.ts",
"build": "tsc --noEmit && rm -rf dist && bun build ./index.html --outdir=dist --public-path=/ --minify"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@mui/x-data-grid": "^9.16.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@types/bun": "^1.4.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"typescript": "^7.0.2"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { CssBaseline, ThemeProvider } from '@mui/material'
import { theme } from './theme'
import { LiveProvider } from './hooks/useLive'
import { NotifyProvider } from './hooks/useNotify'
import Layout from './components/Layout'
import Configuration from './pages/Configuration'
import Frames from './pages/Frames'
import Devices from './pages/Devices'
import Transmit from './pages/Transmit'
export default function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<NotifyProvider>
<LiveProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<Configuration />} />
<Route path="frames" element={<Frames />} />
<Route path="devices" element={<Devices />} />
<Route path="transmit" element={<Transmit />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</LiveProvider>
</NotifyProvider>
</ThemeProvider>
)
}
+79
View File
@@ -0,0 +1,79 @@
export interface Transceiver {
id: number
port: string
freq: number | null // MHz; null until the firmware's boot banner has been seen
}
export interface GatewayStatus {
state: 'online' | 'offline' | 'unknown'
error?: string
lastSuccess: string | null
}
export interface BeaconState {
running: boolean
text: string
intervalMs: number
}
export type FrameKind = 'lora' | 'lorawan' | 'raw'
export interface Frame {
id: number
transceiver: number // 0 for gateway frames
time: string // ISO
kind: FrameKind
source: string
identifier: string
freq: number
rssi: number | null
info: string
raw: string
}
export interface Device {
identifier: string
freq: number
rssi: number | null
firstSeen: string
lastSeen: string
frames: number
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 204) return undefined as T
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error ?? `${res.status} ${res.statusText}`)
return data as T
}
export const api = {
ports: () => request<{ ports: string[] }>('GET', '/api/ports').then((r) => r.ports),
transceivers: () => request<Transceiver[]>('GET', '/api/transceivers'),
addTransceiver: (port: string) => request<Transceiver>('POST', '/api/transceivers', { port }),
removeTransceiver: (id: number) => request<void>('DELETE', `/api/transceivers/${id}`),
transmit: (id: number, text: string) => request<unknown>('POST', `/api/transceivers/${id}/transmit`, { text }),
beacon: (id: number) => request<BeaconState>('GET', `/api/transceivers/${id}/beacon`),
startBeacon: (id: number, text: string, intervalMs: number) =>
request<BeaconState>('POST', `/api/transceivers/${id}/beacon`, { text, intervalMs }),
stopBeacon: (id: number) => request<BeaconState>('DELETE', `/api/transceivers/${id}/beacon`),
gateways: () => request<string[]>('GET', '/api/gateways'),
addGateway: (ip: string) => request<{ ip: string }>('POST', '/api/gateways', { ip }),
removeGateway: (ip: string) => request<void>('DELETE', `/api/gateways/${encodeURIComponent(ip)}`),
pollInterval: () => request<{ seconds: number }>('GET', '/api/gateways/poll').then((r) => r.seconds),
setPollInterval: (seconds: number) =>
request<{ seconds: number }>('PUT', '/api/gateways/poll', { seconds }).then((r) => r.seconds),
frames: (after = 0) => request<Frame[]>('GET', `/api/frames?after=${after}`),
clearFrames: () => request<void>('DELETE', '/api/frames'),
devices: () => request<Device[]>('GET', '/api/devices'),
}
export const freqLabel = (f: number | null) => (f === null ? 'Detecting…' : `${Number(f.toFixed(3))} MHz`)
export const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e))
+9
View File
@@ -0,0 +1,9 @@
declare module '*.png' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 501 501" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Brand-Icon" serif:id="Brand Icon" x="0" y="0.093" width="500.713" height="500" style="fill:none;"/><clipPath id="_clip1"><rect x="0" y="0.093" width="500.713" height="500"/></clipPath><g clip-path="url(#_clip1)"><g><path d="M26.555,44.002c9.432,0 17.089,-7.657 17.089,-17.089c-0,-9.431 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.658 -17.089,17.089c-0,9.432 7.658,17.089 17.089,17.089Z"/><path d="M475.882,131.914c9.432,-0 17.089,-7.658 17.089,-17.089c-0,-9.431 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.658 -17.089,17.089c-0,9.431 7.658,17.089 17.089,17.089Z"/><path d="M475.882,406.112c9.432,0 17.089,-7.657 17.089,-17.088c-0,-9.432 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.657 -17.089,17.089c-0,9.431 7.658,17.088 17.089,17.088Z"/><path d="M475.882,493.003c9.432,0 17.089,-7.657 17.089,-17.088c-0,-9.432 -7.657,-17.089 -17.089,-17.089c-9.431,-0 -17.089,7.657 -17.089,17.089c-0,9.431 7.658,17.088 17.089,17.088Z"/><path d="M26.555,130.892c9.432,0 17.089,-7.657 17.089,-17.088c-0,-9.432 -7.657,-17.089 -17.089,-17.089c-9.431,0 -17.089,7.657 -17.089,17.089c-0,9.431 7.658,17.088 17.089,17.088Z"/><path d="M477.079,49.553c-13.474,-0 -24.413,-10.939 -24.413,-24.413c-0,-13.473 10.939,-24.412 24.413,-24.412c13.472,-0 24.411,10.939 24.411,24.412c0,13.474 -10.939,24.413 -24.411,24.413Z" style="fill:#42c1c7;"/><path d="M25.191,451.902c13.472,-0 24.411,10.94 24.411,24.413c0,13.473 -10.939,24.413 -24.411,24.413c-13.474,-0 -24.414,-10.94 -24.414,-24.413c0,-13.473 10.94,-24.413 24.414,-24.413Z" style="fill:#42c1c7;"/><path d="M26.555,370.914c9.432,-0 17.089,7.657 17.089,17.088c-0,9.432 -7.657,17.089 -17.089,17.089c-9.431,0 -17.089,-7.657 -17.089,-17.089c-0,-9.431 7.658,-17.088 17.089,-17.088Z"/><path d="M363.935,156.059c-3.745,-0.012 -6.777,-3.063 -6.765,-6.808c0.013,-3.745 3.063,-6.777 6.808,-6.764l31.085,0.099l75.563,-118.305c2.016,-3.156 6.215,-4.082 9.373,-2.066c3.156,2.017 4.082,6.215 2.065,9.373l-77.572,121.448c-1.25,1.959 -3.417,3.141 -5.741,3.134l-34.816,-0.111Zm0.068,68.474c-3.745,0.025 -6.807,-2.995 -6.833,-6.741c-0.024,-3.745 2.996,-6.806 6.741,-6.831l43.322,-0.29l63.389,-99.493c2.012,-3.158 6.212,-4.089 9.369,-2.076c3.159,2.013 4.089,6.21 2.078,9.37l-65.374,102.607c-1.238,1.941 -3.375,3.124 -5.677,3.139l-47.015,0.315Zm-0.092,68.355c-3.745,-0.026 -6.765,-3.087 -6.741,-6.832c0.026,-3.745 3.088,-6.766 6.833,-6.74l47.015,0.315c2.302,0.015 4.439,1.197 5.677,3.139l65.374,102.608c2.011,3.158 1.081,7.357 -2.078,9.369c-3.157,2.012 -7.357,1.082 -9.369,-2.077l-63.389,-99.492l-43.322,-0.29Zm0.067,68.474c-3.745,0.012 -6.795,-3.019 -6.808,-6.764c-0.012,-3.746 3.02,-6.796 6.765,-6.808l34.816,-0.111c2.324,-0.007 4.491,1.175 5.74,3.133l77.573,121.45c2.017,3.156 1.091,7.355 -2.065,9.371c-3.158,2.016 -7.357,1.091 -9.373,-2.066l-75.563,-118.304l-31.085,0.099Zm-225.013,-14.593c3.745,0.012 6.777,3.062 6.765,6.807c-0.012,3.746 -3.063,6.777 -6.808,6.765l-31.084,-0.099l-75.563,118.304c-2.016,3.157 -6.216,4.082 -9.373,2.066c-3.156,-2.015 -4.082,-6.215 -2.066,-9.371l77.573,-121.45c1.25,-1.958 3.416,-3.14 5.74,-3.134l34.816,0.112Zm-0.067,-68.474c3.745,-0.026 6.806,2.995 6.832,6.74c0.025,3.745 -2.995,6.806 -6.74,6.832l-43.323,0.29l-63.389,99.492c-2.012,3.159 -6.211,4.089 -9.369,2.076c-3.159,-2.011 -4.089,-6.21 -2.077,-9.368l65.373,-102.608c1.238,-1.942 3.376,-3.125 5.678,-3.139l47.015,-0.315Zm0.092,-68.355c3.745,0.025 6.765,3.086 6.74,6.831c-0.026,3.745 -3.087,6.766 -6.832,6.741l-47.015,-0.315c-2.303,-0.015 -4.44,-1.197 -5.678,-3.14l-65.373,-102.607c-2.012,-3.159 -1.082,-7.356 2.077,-9.369c3.158,-2.013 7.357,-1.082 9.369,2.076l63.389,99.492l43.323,0.291Zm-0.068,-68.475c3.745,-0.012 6.796,3.02 6.808,6.765c0.012,3.745 -3.02,6.796 -6.765,6.807l-34.816,0.112c-2.324,0.006 -4.49,-1.175 -5.74,-3.134l-77.573,-121.449c-2.016,-3.157 -1.09,-7.356 2.066,-9.372c3.157,-2.016 7.357,-1.09 9.373,2.066l75.563,118.304l31.084,-0.099Z"/><path d="M376.712,100.295c0,-10.688 -8.664,-19.352 -19.352,-19.352c-46.737,-0 -165.351,-0 -212.087,-0c-10.688,-0 -19.353,8.664 -19.353,19.352c0,60.084 0,242.988 0,303.072c0,10.688 8.665,19.353 19.353,19.353c46.736,-0 165.35,-0 212.087,-0c10.688,-0 19.352,-8.665 19.352,-19.353l0,-303.072Z"/><path d="M174.745,173.814c-13.474,0 -24.413,-10.938 -24.413,-24.412c0,-13.474 10.939,-24.413 24.413,-24.413c13.474,0 24.413,10.939 24.413,24.413c0,13.474 -10.939,24.412 -24.413,24.412Z" style="fill:#42c1c7;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react'
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, TextField } from '@mui/material'
import AddIcon from '@mui/icons-material/AddRounded'
import { api, errMsg } from '../api/client'
import { useLive } from '../hooks/useLive'
import { useNotify } from '../hooks/useNotify'
export default function AddTransceiver() {
const { transceivers } = useLive()
const notify = useNotify()
const [open, setOpen] = useState(false)
const [ports, setPorts] = useState<string[]>([])
const [port, setPort] = useState('')
const show = async () => {
try {
const inUse = new Set(transceivers.map((t) => t.port))
const free = (await api.ports()).filter((p) => !inUse.has(p))
setPorts(free)
setPort(free[0] ?? '')
setOpen(true)
} catch (e) {
notify(errMsg(e), 'error')
}
}
const add = async () => {
setOpen(false)
try {
await api.addTransceiver(port)
notify(`Added ${port}`)
} catch (e) {
notify(errMsg(e), 'error')
}
}
return (
<>
<Button variant="contained" startIcon={<AddIcon />} onClick={show}>Add Transceiver</Button>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Add transceiver</DialogTitle>
<DialogContent>
<TextField select fullWidth margin="dense" label="Serial port" value={port} onChange={(e) => setPort(e.target.value)}
helperText={ports.length ? 'The frequency is detected automatically.' : 'No unused serial ports found'}>
{ports.map((p) => <MenuItem key={p} value={p}>{p}</MenuItem>)}
</TextField>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" disabled={!port} onClick={add}>Add</Button>
</DialogActions>
</Dialog>
</>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from 'react'
import { Button, Chip, Stack, TextField, Typography } from '@mui/material'
import { api, errMsg, type BeaconState } from '../api/client'
import { useNotify } from '../hooks/useNotify'
export default function BeaconControl({ id }: { id: number }) {
const notify = useNotify()
const [state, setState] = useState<BeaconState>({ running: false, text: '', intervalMs: 0 })
const [text, setText] = useState('')
const [interval, setIntervalMs] = useState('5000')
const refresh = useCallback(() => api.beacon(id).then(setState).catch(() => {}), [id])
// The server owns the timer, so re-sync after reloads and on connection changes.
useEffect(() => {
refresh()
const t = window.setInterval(refresh, 5000)
return () => window.clearInterval(t)
}, [refresh])
const ms = Number(interval)
const valid = text.length > 0 && Number.isFinite(ms) && ms >= 1000
const act = async (fn: () => Promise<BeaconState>) => {
try {
setState(await fn())
} catch (e) {
notify(errMsg(e), 'error')
}
}
return (
<Stack spacing={1}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="subtitle2">Beacon</Typography>
<Chip size="small" color={state.running ? 'success' : 'default'}
label={state.running ? `Every ${state.intervalMs} ms: ${state.text}` : 'Stopped'} />
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
<TextField size="small" label="Beacon data" value={text} onChange={(e) => setText(e.target.value)} sx={{ flex: 1 }} />
<TextField size="small" label="Interval (ms)" value={interval} onChange={(e) => setIntervalMs(e.target.value)}
error={interval !== '' && !(ms >= 1000)} helperText={ms >= 1000 ? undefined : 'Minimum 1000'} />
<Button variant="contained" disabled={!valid} onClick={() => act(() => api.startBeacon(id, text, ms))}>
Start
</Button>
<Button variant="outlined" color="error" disabled={!state.running} onClick={() => act(() => api.stopBeacon(id))}>
Stop
</Button>
</Stack>
</Stack>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Box } from '@mui/material'
import { clockTime, rssiLabel } from '../lib/format'
import type { Frame } from '../api/client'
const ROW_H = 28
const OVERSCAN = 12
const COLS = '70px 110px 190px 220px 80px 90px 80px minmax(120px, 1fr)'
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
// Wireshark-style row tints.
const tint = { raw: '#f1f3f4', lora: '#dff3f3', lorawan: '#e4eefb' } as const
const cell = { px: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } as const
interface Props {
rows: Frame[]
selectedId: number | null
onSelect: (id: number) => void
follow: boolean
onUserScrolledAway: () => void // the user scrolled off the newest row
emptyText: string
}
// A windowed list: only the visible rows exist in the DOM, so the whole
// 10,000-frame buffer scrolls smoothly. (MUI's community DataGrid caps a page at 100 rows.)
export default function FrameTable({ rows, selectedId, onSelect, follow, onUserScrolledAway, emptyText }: Props) {
const ref = useRef<HTMLDivElement>(null)
const [scrollTop, setScrollTop] = useState(0)
const [height, setHeight] = useState(400)
const [gutter, setGutter] = useState(0) // scrollbar width, so the header lines up with the rows
useLayoutEffect(() => {
const el = ref.current!
const measure = () => {
setHeight(el.clientHeight)
setGutter(el.offsetWidth - el.clientWidth)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Follow: pin to the newest row after every change.
useLayoutEffect(() => {
const el = ref.current
if (follow && el) {
el.scrollTop = el.scrollHeight
setScrollTop(el.scrollTop)
}
}, [rows, follow])
useEffect(() => {
const el = ref.current!
const onScroll = () => {
setScrollTop(el.scrollTop)
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - ROW_H
if (!atBottom) onUserScrolledAway()
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
}, [onUserScrolledAway])
const first = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN)
const last = Math.min(rows.length, Math.ceil((scrollTop + height) / ROW_H) + OVERSCAN)
const visible = rows.slice(first, last)
return (
<Box role="table" aria-rowcount={rows.length} sx={{
height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden',
border: 1, borderColor: 'divider', borderRadius: 1, bgcolor: 'background.paper', fontFamily: MONO, fontSize: 13,
}}>
{/* The header is a plain sibling above the scroller (not sticky), so nothing can scroll under or around it. */}
<Box role="row" sx={{
display: 'grid', gridTemplateColumns: COLS, flex: 'none', height: ROW_H + 4, alignItems: 'center', pr: `${gutter}px`,
borderBottom: 1, borderColor: 'divider', fontFamily: 'inherit', fontWeight: 600,
}}>
{['No.', 'Time', 'Source', 'Identifier', 'Freq', 'RSSI', 'Kind', 'Info'].map((h) => <Box key={h} role="columnheader" sx={cell}>{h}</Box>)}
</Box>
<Box ref={ref} sx={{ flex: 1, minHeight: 0, overflowY: 'scroll', overflowX: 'hidden' }}>
{rows.length === 0 ? (
<Box sx={{ p: 2, color: 'text.secondary', fontFamily: 'inherit' }}>{emptyText}</Box>
) : (
<Box sx={{ height: rows.length * ROW_H, position: 'relative' }}>
<Box sx={{ position: 'absolute', top: first * ROW_H, left: 0, right: 0 }}>
{visible.map((f) => {
const sel = f.id === selectedId
return (
<Box key={f.id} role="row" data-id={f.id} data-kind={f.kind} aria-selected={sel} onClick={() => onSelect(f.id)} sx={{
display: 'grid', gridTemplateColumns: COLS, height: ROW_H, alignItems: 'center', cursor: 'pointer',
bgcolor: sel ? 'primary.main' : tint[f.kind], color: sel ? 'primary.contrastText' : f.kind === 'raw' ? 'text.secondary' : 'text.primary',
'&:hover': { filter: 'brightness(0.96)' },
}}>
<Box sx={cell}>{f.id}</Box>
<Box sx={cell}>{clockTime(f.time)}</Box>
<Box sx={cell} title={f.source}>{f.source}</Box>
<Box sx={cell} title={f.identifier}>{f.identifier}</Box>
<Box sx={cell}>{f.freq ? f.freq : ''}</Box>
<Box sx={cell}>{f.kind === 'raw' ? '' : rssiLabel(f.rssi)}</Box>
<Box sx={cell}>{f.kind}</Box>
<Box sx={cell} title={f.info}>{f.info}</Box>
</Box>
)
})}
</Box>
</Box>
)}
</Box>
</Box>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react'
import {
Box, Button, Card, CardContent, CardHeader, IconButton, List, ListItem, ListItemIcon, ListItemText, Stack, TextField, Tooltip, Typography,
} from '@mui/material'
import CircleIcon from '@mui/icons-material/CircleRounded'
import AddIcon from '@mui/icons-material/AddRounded'
import DeleteIcon from '@mui/icons-material/DeleteRounded'
import { api, errMsg, type GatewayStatus } from '../api/client'
import { useLive } from '../hooks/useLive'
import { useNotify } from '../hooks/useNotify'
const MIN_POLL = 5
const MAX_POLL = 3600
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/
// Green = online, red = two polls in a row failed (retried every 5s), grey = not polled yet.
function StatusDot({ status }: { status?: GatewayStatus }) {
const state = status?.state ?? 'unknown'
const color = state === 'online' ? 'success' : state === 'offline' ? 'error' : 'disabled'
const title =
state === 'online' ? `Online. Last poll ${new Date(status!.lastSuccess!).toLocaleTimeString()}`
: state === 'offline' ? `Offline: ${status!.error ?? 'poll failed'}. Retrying every 5 seconds.`
: 'Not polled yet'
return <CircleIcon fontSize="small" color={color} titleAccess={title} />
}
export default function GatewayConfig() {
const notify = useNotify()
const { gatewayStatus } = useLive()
const [ips, setIps] = useState<string[]>([])
const [input, setInput] = useState('')
const [saved, setSaved] = useState<number | null>(null) // interval on the server, seconds
const [poll, setPoll] = useState('')
useEffect(() => {
api.gateways().then(setIps).catch((e) => notify(errMsg(e), 'error'))
api.pollInterval().then((s) => { setSaved(s); setPoll(String(s)) }).catch(() => {})
}, [notify])
const ip = input.trim()
const invalid = ip !== '' && !IPV4.test(ip)
const duplicate = ips.includes(ip)
const add = async () => {
try {
const added = await api.addGateway(ip)
setIps((l) => [...l, added.ip])
setInput('')
notify(`Added ${added.ip}`)
} catch (e) {
notify(errMsg(e), 'error')
}
}
const pollNum = Number(poll)
const pollValid = poll !== '' && Number.isInteger(pollNum) && pollNum >= MIN_POLL && pollNum <= MAX_POLL
const savePoll = async () => {
try {
const s = await api.setPollInterval(pollNum)
setSaved(s)
setPoll(String(s))
notify(`Polling every ${s} seconds`)
} catch (e) {
notify(errMsg(e), 'error')
}
}
const remove = async (target: string) => {
try {
await api.removeGateway(target)
setIps((l) => l.filter((x) => x !== target))
notify(`Removed ${target}`)
} catch (e) {
notify(errMsg(e), 'error')
}
}
return (
<Card variant="outlined">
<CardHeader title="LoRaWAN Gateways" slotProps={{ title: { variant: 'h6' } }} subheader="Dragino gateways are polled for new traffic on an interval" />
<CardContent sx={{ pt: 0 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start', mb: 3 }}>
<TextField size="small" type="number" label="Poll interval (seconds)" value={poll}
onChange={(e) => setPoll(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && pollValid && poll !== String(saved) && savePoll()}
slotProps={{ htmlInput: { min: MIN_POLL, max: MAX_POLL } }}
error={poll !== '' && !pollValid}
helperText={poll !== '' && !pollValid ? `Whole seconds, ${MIN_POLL} to ${MAX_POLL}` : undefined}
sx={{ width: 220 }} />
<Button variant="outlined" disabled={!pollValid || poll === String(saved)} onClick={savePoll} sx={{ mt: '1px' }}>
Save
</Button>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
<TextField size="small" label="Gateway IP" placeholder="192.168.1.100" value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && ip && !invalid && !duplicate && add()}
error={invalid || duplicate}
helperText={invalid ? 'Invalid IPv4 address' : duplicate ? 'Already added' : undefined} />
<Button variant="contained" startIcon={<AddIcon />} disabled={!ip || invalid || duplicate} onClick={add} sx={{ mt: '1px' }}>
Add Gateway
</Button>
</Stack>
{ips.length === 0 ? (
<Typography color="text.secondary" sx={{ mt: 2 }}>No gateways added.</Typography>
) : (
<Box sx={{ mt: 1 }}>
<List dense disablePadding>
{ips.map((g) => (
<ListItem key={g} divider
secondaryAction={
<Tooltip title="Remove">
<IconButton edge="end" aria-label={`Remove ${g}`} onClick={() => remove(g)}><DeleteIcon /></IconButton>
</Tooltip>
}>
<ListItemIcon sx={{ minWidth: 32 }}>
<StatusDot status={gatewayStatus[g]} />
</ListItemIcon>
<ListItemText primary={g} slotProps={{ primary: { sx: { fontFamily: 'ui-monospace, Menlo, Consolas, monospace' } } }} />
</ListItem>
))}
</List>
</Box>
)}
</CardContent>
</Card>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { Avatar, Box, Chip, Collapse, IconButton, List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material'
import MenuIcon from '@mui/icons-material/MenuRounded'
import SettingsIcon from '@mui/icons-material/SettingsRounded'
import ViewListIcon from '@mui/icons-material/ViewListRounded'
import SensorsIcon from '@mui/icons-material/SensorsRounded'
import SendIcon from '@mui/icons-material/SendRounded'
import { useLive } from '../hooks/useLive'
import { SIDEBAR, SIDEBAR_WIDTH } from '../theme'
import brandIcon from '../assets/brand-icon.svg'
const NAV = [
{ to: '/', label: 'Configuration', icon: <SettingsIcon />, end: true },
{ to: '/frames', label: 'Frames', icon: <ViewListIcon />, end: false },
{ to: '/devices', label: 'Devices', icon: <SensorsIcon />, end: false },
{ to: '/transmit', label: 'Transmit', icon: <SendIcon />, end: false },
]
// Same look as the Flask "sideNav": dark teal bar, uppercase links that
// brighten on hover/active, circular logo on desktop.
const linkSx = {
justifyContent: 'flex-start',
py: 1,
px: 2,
color: 'rgba(255,255,255,.55)',
'&:hover': { color: 'rgba(255,255,255,.75)', bgcolor: 'transparent' },
'&.active': { color: '#fff' },
'& .MuiListItemIcon-root': { color: 'inherit', minWidth: 36 },
'& .MuiListItemText-primary': { fontWeight: 800, letterSpacing: '0.05rem', textTransform: 'uppercase', fontSize: '0.9rem' },
} as const
export default function Layout() {
const [open, setOpen] = useState(false)
const { connected } = useLive()
const close = () => setOpen(false)
const links = (
<List sx={{ width: '100%' }}>
{NAV.map((n) => (
<ListItemButton
key={n.label}
component={NavLink}
end={n.end}
to={n.to}
onClick={close}
sx={linkSx}
>
<ListItemIcon>{n.icon}</ListItemIcon>
<ListItemText primary={n.label} />
</ListItemButton>
))}
</List>
)
const live = (
<Chip size="small" label={connected ? 'Live' : 'Reconnecting…'}
sx={{ bgcolor: connected ? 'rgba(255,255,255,.2)' : 'warning.main', color: connected ? '#fff' : '#212529' }} />
)
return (
<Box sx={{ display: { md: 'flex' } }}>
{/* Desktop sidebar */}
<Box component="nav" sx={{
display: { xs: 'none', md: 'flex' }, flexDirection: 'column', alignItems: 'center',
position: 'fixed', top: 0, left: 0, width: SIDEBAR_WIDTH, height: '100vh', bgcolor: SIDEBAR,
}}>
<Avatar src={brandIcon} alt="Skinny R&D" sx={{
width: 112, height: 112, mt: 'auto', mb: 1, bgcolor: '#fff', border: '0.4rem solid rgba(255,255,255,.2)', boxSizing: 'content-box',
// The artwork reaches the corners of its square, so inset it to stay inside the circle.
'& img': { objectFit: 'contain', p: '16px', boxSizing: 'border-box' },
}} />
<Box sx={{ width: '100%', mb: 'auto' }}>{links}</Box>
<Box sx={{ pb: 2 }}>{live}</Box>
</Box>
{/* Mobile top bar */}
<Box component="nav" sx={{ display: { xs: 'block', md: 'none' }, position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1100, bgcolor: SIDEBAR }}>
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1 }}>
<Typography sx={{ flex: 1, color: '#fff', fontSize: '1.25rem' }}>LoRa Scanner</Typography>
<Box sx={{ mr: 1 }}>{live}</Box>
<IconButton aria-label="Toggle navigation" onClick={() => setOpen(!open)}
sx={{ color: 'rgba(255,255,255,.55)', border: '1px solid rgba(255,255,255,.1)', borderRadius: 1 }}>
<MenuIcon />
</IconButton>
</Box>
<Collapse in={open}>{links}</Collapse>
</Box>
<Box component="main" sx={{
flex: 1, minWidth: 0, ml: { md: `${SIDEBAR_WIDTH}px` },
px: { xs: 2, md: 3 }, pt: { xs: 9, md: 3 }, pb: 4,
}}>
<Outlet />
</Box>
</Box>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Switch } from '@mui/material'
import { freqLabel, type Transceiver } from '../api/client'
import { useLive } from '../hooks/useLive'
// Read-only view of everything the device has printed, taken from the live frame store.
export default function SerialViewer({ transceiver, open, onClose }: { transceiver: Transceiver; open: boolean; onClose: () => void }) {
const { frames } = useLive()
const [clearedAt, setClearedAt] = useState(0) // hide frames up to this id
const [autoscroll, setAutoscroll] = useState(true)
const ref = useRef<HTMLDivElement>(null)
const lines = useMemo(
() => frames.filter((f) => f.kind === 'raw' && f.transceiver === transceiver.id && f.id > clearedAt),
[frames, transceiver.id, clearedAt],
)
useEffect(() => {
if (open && autoscroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight
}, [lines, autoscroll, open])
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle sx={{ fontSize: '1.1rem', wordBreak: 'break-all' }}>Serial output: {transceiver.port} ({freqLabel(transceiver.freq)})</DialogTitle>
<DialogContent>
<Box ref={ref} sx={{
height: 420, overflowY: 'auto', p: 1, borderRadius: 1, bgcolor: 'action.hover',
fontFamily: 'ui-monospace, Menlo, Consolas, monospace', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{lines.length === 0 && <Box sx={{ color: 'text.secondary' }}>Nothing received yet.</Box>}
{lines.map((f) => <div key={f.id}>{f.info}</div>)}
</Box>
</DialogContent>
<DialogActions>
<FormControlLabel control={<Switch checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} />} label="Autoscroll" />
<Box sx={{ flex: 1 }} />
<Button onClick={() => setClearedAt(lines.length ? lines[lines.length - 1].id : clearedAt)}>Clear View</Button>
<Button variant="contained" onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useState } from 'react'
import { Button, Card, CardContent, CardHeader, Chip, Stack, Typography } from '@mui/material'
import { api, errMsg, freqLabel, type Transceiver } from '../api/client'
import { useNotify } from '../hooks/useNotify'
import { portTitle } from '../theme'
import SerialViewer from './SerialViewer'
// Connection management only; transmitting lives on the Transmit page.
export default function TransceiverPanel({ transceiver: t }: { transceiver: Transceiver }) {
const notify = useNotify()
const [viewing, setViewing] = useState(false)
const remove = async () => {
if (!window.confirm(`Remove ${t.port}?`)) return
try {
await api.removeTransceiver(t.id)
notify('Transceiver removed')
} catch (e) {
notify(errMsg(e), 'error')
}
}
return (
<Card variant="outlined">
<CardHeader
title={t.port}
slotProps={{ title: portTitle }}
action={<Chip size="small" color={t.freq === null ? 'warning' : 'success'} label={freqLabel(t.freq)} />}
/>
<CardContent sx={{ pt: 0 }}>
{t.freq === null && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
The Feather reports its frequency once at boot. Unplug and replug it (with this page open) to detect it.
</Typography>
)}
<Stack direction="row" spacing={1}>
<Button variant="contained" onClick={() => setViewing(true)}>View Serial</Button>
<Button variant="outlined" color="error" onClick={remove}>Remove</Button>
</Stack>
</CardContent>
<SerialViewer transceiver={t} open={viewing} onClose={() => setViewing(false)} />
</Card>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { api, type Device, type Frame, type GatewayStatus, type Transceiver } from '../api/client'
const MAX_FRAMES = 10000
const FLUSH_MS = 250
interface Live {
connected: boolean // websocket is up
transceivers: Transceiver[]
gatewayStatus: Record<string, GatewayStatus> // keyed by gateway IP; missing means not polled yet
frames: Frame[]
devices: Record<string, Device>
clearFrames: () => Promise<void>
}
const Ctx = createContext<Live | null>(null)
export function LiveProvider({ children }: { children: ReactNode }) {
const [connected, setConnected] = useState(false)
const [transceivers, setTransceivers] = useState<Transceiver[]>([])
const [gatewayStatus, setGatewayStatus] = useState<Record<string, GatewayStatus>>({})
const [frames, setFrames] = useState<Frame[]>([])
const [devices, setDevices] = useState<Record<string, Device>>({})
// Frames and device updates arrive in bursts; buffer them and render on a timer.
const pendingFrames = useRef<Frame[]>([])
const pendingDevices = useRef<Record<string, Device>>({})
const lastId = useRef(0)
const retry = useRef(1000)
useEffect(() => {
const flush = () => {
if (pendingFrames.current.length) {
const batch = pendingFrames.current.sort((a, b) => a.id - b.id)
pendingFrames.current = []
// ws and the catch-up fetch can both deliver a frame; keep each id once.
const fresh = batch.filter((f, i) => f.id > lastId.current && (i === 0 || f.id !== batch[i - 1].id))
if (fresh.length) {
lastId.current = fresh[fresh.length - 1].id
setFrames((prev) => {
const next = prev.concat(fresh)
return next.length > MAX_FRAMES ? next.slice(next.length - MAX_FRAMES) : next
})
}
}
const pd = pendingDevices.current
if (Object.keys(pd).length) {
pendingDevices.current = {}
setDevices((prev) => ({ ...prev, ...pd }))
}
}
const timer = window.setInterval(flush, FLUSH_MS)
let ws: WebSocket | undefined
let reconnect: number | undefined
let stopped = false
const sync = async () => {
try {
const [fs, ds] = await Promise.all([api.frames(lastId.current), api.devices()])
pendingFrames.current.push(...fs)
setDevices(Object.fromEntries(ds.map((d) => [d.identifier, d])))
} catch {
/* the next reconnect retries */
}
}
const open = () => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
ws = new WebSocket(`${proto}://${location.host}/api/ws`)
ws.onopen = () => {
retry.current = 1000
setConnected(true)
sync()
}
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data)
if (m.type === 'transceivers') {
setTransceivers(m.transceivers)
} else if (m.type === 'gateways') {
setGatewayStatus(m.gateways)
} else if (m.type === 'frame') {
pendingFrames.current.push(m.frame)
} else if (m.type === 'device') {
pendingDevices.current[m.device.identifier] = m.device
}
}
ws.onclose = () => {
setConnected(false)
if (stopped) return
reconnect = window.setTimeout(open, retry.current)
retry.current = Math.min(retry.current * 2, 10000)
}
}
open()
return () => {
stopped = true
window.clearInterval(timer)
window.clearTimeout(reconnect)
ws?.close()
}
}, [])
const clearFrames = useCallback(async () => {
await api.clearFrames()
pendingFrames.current = []
pendingDevices.current = {}
setFrames([])
setDevices({})
}, [])
const value = useMemo(() => ({ connected, transceivers, gatewayStatus, frames, devices, clearFrames }), [connected, transceivers, gatewayStatus, frames, devices, clearFrames])
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export function useLive() {
const v = useContext(Ctx)
if (!v) throw new Error('useLive outside LiveProvider')
return v
}
+24
View File
@@ -0,0 +1,24 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import { Alert, Snackbar, type AlertColor } from '@mui/material'
type Notify = (message: string, severity?: AlertColor) => void
const Ctx = createContext<Notify>(() => {})
export function NotifyProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<{ message: string; severity: AlertColor } | null>(null)
const notify = useCallback<Notify>((message, severity = 'success') => setToast({ message, severity }), [])
return (
<Ctx.Provider value={notify}>
{children}
<Snackbar open={!!toast} autoHideDuration={4000} onClose={() => setToast(null)}>
{toast ? (
<Alert severity={toast.severity} variant="filled" onClose={() => setToast(null)}>
{toast.message}
</Alert>
) : undefined}
</Snackbar>
</Ctx.Provider>
)
}
export const useNotify = () => useContext(Ctx)
+11
View File
@@ -0,0 +1,11 @@
export const rssiLabel = (r: number | null | undefined) => (r === null || r === undefined || r === 0 ? 'unknown' : `${r} dBm`)
const pad = (n: number, w = 2) => String(n).padStart(w, '0')
/** HH:MM:SS.mmm, like Wireshark's time column. */
export function clockTime(iso: string) {
const d = new Date(iso)
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
}
export const dateTime = (iso: string) => new Date(iso).toLocaleString()
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+23
View File
@@ -0,0 +1,23 @@
import { Stack, Typography } from '@mui/material'
import TransceiverPanel from '../components/TransceiverPanel'
import AddTransceiver from '../components/AddTransceiver'
import GatewayConfig from '../components/GatewayConfig'
import { useLive } from '../hooks/useLive'
export default function Configuration() {
const { transceivers } = useLive()
return (
<Stack spacing={3}>
<Typography>
Attach a Feather running the Skinny-LoRa firmware to receive raw LoRa packets, and add Dragino LoRaWAN
gateways below to collect their traffic logs. Received frames show up on the Frames and Devices pages.
</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<AddTransceiver />
{transceivers.length === 0 && <Typography color="text.secondary">No transceivers attached.</Typography>}
</Stack>
{transceivers.map((t) => <TransceiverPanel key={t.id} transceiver={t} />)}
<GatewayConfig />
</Stack>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Stack, Typography } from '@mui/material'
import { DataGrid, type GridColDef } from '@mui/x-data-grid'
import { useLive } from '../hooks/useLive'
import { dateTime, rssiLabel } from '../lib/format'
import type { Device } from '../api/client'
const columns: GridColDef<Device>[] = [
{ field: 'identifier', headerName: 'Identifier', flex: 1, minWidth: 200 },
{ field: 'freq', headerName: 'Frequency', width: 120, type: 'number', align: 'left', headerAlign: 'left', valueFormatter: (v: number) => `${v} MHz` },
{
field: 'rssi', headerName: 'Signal Strength', width: 150,
valueGetter: (_v, row) => row.rssi ?? 0, valueFormatter: (_v, row) => rssiLabel(row.rssi),
},
{ field: 'firstSeen', headerName: 'First Seen', width: 190, valueGetter: (v: string) => new Date(v), valueFormatter: (_v, row) => dateTime(row.firstSeen), type: 'dateTime' },
{ field: 'lastSeen', headerName: 'Last Seen', width: 190, valueGetter: (v: string) => new Date(v), valueFormatter: (_v, row) => dateTime(row.lastSeen), type: 'dateTime' },
{ field: 'frames', headerName: 'Frames Seen', width: 130, type: 'number', align: 'left', headerAlign: 'left' },
]
export default function Devices() {
const { devices } = useLive()
const navigate = useNavigate()
const rows = useMemo(() => Object.values(devices), [devices])
return (
<Stack spacing={2}>
<Typography color="text.secondary">Every device heard so far, updated live. Select a row to see its frames.</Typography>
<DataGrid
rows={rows}
columns={columns}
getRowId={(r) => r.identifier}
density="compact"
autoHeight
disableColumnMenu
hideFooter={rows.length <= 100}
initialState={{ sorting: { sortModel: [{ field: 'lastSeen', sort: 'desc' }] } }}
onRowClick={(p) => navigate(`/frames?device=${encodeURIComponent(p.row.identifier)}`)}
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' }, bgcolor: 'background.paper' }}
localeText={{ noRowsLabel: 'No devices yet' }}
/>
</Stack>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
Box, Button, Chip, FormControlLabel, Paper, Stack, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography,
} from '@mui/material'
import FrameTable from '../components/FrameTable'
import PauseIcon from '@mui/icons-material/PauseRounded'
import PlayIcon from '@mui/icons-material/PlayArrowRounded'
import DownloadIcon from '@mui/icons-material/DownloadRounded'
import ClearIcon from '@mui/icons-material/DeleteSweepRounded'
import { useLive } from '../hooks/useLive'
import { useNotify } from '../hooks/useNotify'
import { dateTime, rssiLabel } from '../lib/format'
import { errMsg, type Frame, type FrameKind } from '../api/client'
const KINDS: { kind: FrameKind; label: string }[] = [
{ kind: 'lora', label: 'LoRa' },
{ kind: 'lorawan', label: 'LoRaWAN' },
{ kind: 'raw', label: 'Raw' },
]
function matches(f: Frame, tokens: string[]) {
if (tokens.length === 0) return true
const hay = `${f.id} ${f.kind} ${f.source} ${f.identifier} ${f.freq || ''} ${f.rssi ?? ''} ${f.info}`.toLowerCase()
return tokens.every((t) => hay.includes(t))
}
export default function Frames() {
const { frames, clearFrames } = useLive()
const notify = useNotify()
const [params, setParams] = useSearchParams()
const [filter, setFilter] = useState(params.get('device') ?? '')
const [kinds, setKinds] = useState<FrameKind[]>(['lora', 'lorawan', 'raw'])
const [follow, setFollow] = useState(true)
const [frozen, setFrozen] = useState<Frame[] | null>(null) // non-null while paused
const [selectedId, setSelectedId] = useState<number | null>(null)
// Arriving via /frames?device=X pre-fills the filter once, then the URL is just the page.
useEffect(() => {
if (params.has('device')) setParams({}, { replace: true })
}, [params, setParams])
const source = frozen ?? frames
const rows = useMemo(() => {
const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean)
return source.filter((f) => kinds.includes(f.kind) && matches(f, tokens))
}, [source, filter, kinds])
const stopFollowing = useCallback(() => setFollow(false), [])
const selected = useMemo(() => source.find((f) => f.id === selectedId) ?? null, [source, selectedId])
const clear = async () => {
try {
await clearFrames()
setFrozen(null)
setSelectedId(null)
} catch (e) {
notify(errMsg(e), 'error')
}
}
return (
<Stack spacing={2}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1} sx={{ alignItems: { md: 'center' } }}>
<TextField size="small" label="Filter" placeholder="e.g. 27FD3566 lorawan" value={filter}
onChange={(e) => setFilter(e.target.value)} sx={{ minWidth: 260 }} />
<ToggleButtonGroup size="small" value={kinds} onChange={(_e, v: FrameKind[]) => setKinds(v)}>
{KINDS.map((k) => <ToggleButton key={k.kind} value={k.kind}>{k.label}</ToggleButton>)}
</ToggleButtonGroup>
<FormControlLabel control={<Switch checked={follow} onChange={(e) => setFollow(e.target.checked)} />} label="Follow" />
<Box sx={{ flex: 1 }} />
<Chip size="small" label={`${rows.length} shown / ${frames.length} total`} />
<Button size="small" startIcon={frozen ? <PlayIcon /> : <PauseIcon />} onClick={() => setFrozen(frozen ? null : frames)}>
{frozen ? 'Resume' : 'Pause'}
</Button>
<Button size="small" component="a" href="/api/frames.csv" download="frames.csv" startIcon={<DownloadIcon />}>CSV</Button>
<Button size="small" color="error" startIcon={<ClearIcon />}
onClick={() => window.confirm('Clear all frames and devices?') && clear()}>Clear</Button>
</Stack>
<Box sx={{ height: 'calc(100vh - 300px)', minHeight: 280 }}>
<FrameTable
rows={rows}
selectedId={selectedId}
onSelect={setSelectedId}
follow={follow}
onUserScrolledAway={stopFollowing}
emptyText={frozen ? 'No frames in the paused view' : 'No frames yet'}
/>
</Box>
<Paper variant="outlined" sx={{ p: 2, minHeight: 150 }}>
{selected ? (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 2, rowGap: 0.5, alignContent: 'start' }}>
{([
['Frame', selected.id],
['Received', dateTime(selected.time)],
['Kind', selected.kind],
['Source', selected.source],
['Identifier', selected.identifier || '—'],
['Frequency', selected.freq ? `${selected.freq} MHz` : '—'],
['Signal', selected.kind === 'raw' ? '—' : rssiLabel(selected.rssi)],
] as const).map(([k, v]) => (
<Box key={k} sx={{ display: 'contents' }}>
<Typography variant="body2" color="text.secondary">{k}</Typography>
<Typography variant="body2">{v}</Typography>
</Box>
))}
</Box>
<Box component="pre" sx={{
flex: 1, m: 0, p: 1, borderRadius: 1, bgcolor: 'action.hover', overflow: 'auto', maxHeight: 220,
fontFamily: 'ui-monospace, Menlo, Consolas, monospace', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{selected.raw || selected.info}
</Box>
</Stack>
) : (
<Typography color="text.secondary">Select a frame to see its details.</Typography>
)}
</Paper>
</Stack>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Button, Card, CardContent, CardHeader, Chip, Divider, Stack, TextField, Typography } from '@mui/material'
import { api, errMsg, freqLabel, type Transceiver } from '../api/client'
import { useLive } from '../hooks/useLive'
import { useNotify } from '../hooks/useNotify'
import BeaconControl from '../components/BeaconControl'
import { portTitle } from '../theme'
function TransmitCard({ t }: { t: Transceiver }) {
const notify = useNotify()
const [text, setText] = useState('')
const send = async () => {
try {
await api.transmit(t.id, text)
notify(`Transmitted on ${t.port}`)
} catch (e) {
notify(errMsg(e), 'error')
}
}
return (
<Card variant="outlined">
<CardHeader title={t.port} slotProps={{ title: portTitle }} action={<Chip size="small" color={t.freq === null ? 'warning' : 'success'} label={freqLabel(t.freq)} />} />
<CardContent sx={{ pt: 0 }}>
<Stack spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
<TextField size="small" label="Data" value={text} onChange={(e) => setText(e.target.value)} sx={{ flex: 1 }}
onKeyDown={(e) => e.key === 'Enter' && text && send()} />
<Button variant="contained" disabled={!text} onClick={send}>Send</Button>
</Stack>
<Divider />
<BeaconControl id={t.id} />
</Stack>
</CardContent>
</Card>
)
}
export default function Transmit() {
const { transceivers } = useLive()
return (
<Stack spacing={3}>
<Typography>Send a one-off message, or start a beacon that repeats it on an interval. Beacons run on the server and keep going if you close this page.</Typography>
{transceivers.length === 0 && <Typography color="text.secondary">No transceivers attached. Add one on the Configuration page.</Typography>}
{transceivers.map((t) => <TransmitCard key={t.id} t={t} />)}
</Stack>
)
}
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more