feat: upgrade and rewrite in go
No files matched your search
@@ -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/
|
||||
@@ -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
|
||||
@@ -1,130 +1,94 @@
|
||||
# Skinny R&D Lora Scanner
|
||||
# Skinny R&D Lora Scanner
|
||||
|
||||

|
||||
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.
|
||||

|
||||
|
||||
## 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.*
|
||||
|
||||

|
||||
### Configuration
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
*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.
|
||||

|
||||
|
||||

|
||||
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
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||

|
||||
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
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
## API
|
||||
|
||||

|
||||
| 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
|
||||
|
||||

|
||||
`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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
## Survey Mode
|
||||
|
||||
Survey mode allows you to view all of the devices and corresponding packets discovered by the LoRa scanner.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
To collapse the packet list, click the ‘Hide Values’ button.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
## License
|
||||
|
||||
MIT, see [LICENSE](./LICENSE).
|
||||
@@ -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)
|
||||
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 101 KiB |
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
python3 lorawan.py --joinmode otaa --appkey "814BFA6E589EE49376628B0CDD642E1F" --deveui "70B3D57ED80022D3" --appeui 0000000000000000 /dev/tty.usbserial-1140
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 775 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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 + ')')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
@@ -0,0 +1,9 @@
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||