Initial commit

This commit is contained in:
2026-01-17 09:53:08 +02:00
commit 159633e837
148 changed files with 42795 additions and 0 deletions

25
py_app/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Py Qt configurator for RX/TX firmware
Desktop GUI (PyQt6) that talks to the device over USB CDC (virtual COM). It sends line-delimited JSON commands and applies changes immediately when you edit controls.
## Setup
```
cd py_app
python3 -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt
python main.py
```
## Protocol expected on the device side
The firmware should read UTF-8 lines from the USB CDC serial port and handle JSON commands:
- `{"cmd": "set_params", "params": {freq_mhz, band, bw_khz, sf, cr, tx_power_dbm, period_ms, tx_enabled, payload}}` — apply and persist as needed.
- `{"cmd": "get_status"}` — respond with JSON status (e.g. metrics, current params).
- `{"cmd": "reboot_bootloader"}` — reboot into ROM bootloader for flashing.
The GUI logs any text received, so returning JSON or human-readable lines works.
## Notes
- Bands offered: 430/868/915/L/S/2.4G; frequency control is in MHz (1502500).
- Payload length is limited to 31 chars to match the firmware UI constraint.
- Changes are debounced by 150 ms; dragging a spinbox will send the last value when you stop.

426
py_app/main.py Normal file
View File

@@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""
PyQt6 desktop tool for configuring the RX/TX firmware over USB CDC.
Protocol: line-delimited JSON. Expected commands (device-side to implement):
{"cmd": "set_params", "params": {freq_mhz, bw_khz, sf, cr, tx_power_dbm, payload, period_ms, tx_enabled, band}}
{"cmd": "get_status"}
{"cmd": "reboot_bootloader"}
The GUI sends updates automatically when fields change.
"""
from __future__ import annotations
import json
import sys
import threading
from dataclasses import dataclass
from typing import Dict, Optional
from PyQt6.QtCore import QTimer, pyqtSignal, QObject
from PyQt6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSpinBox,
QTextEdit,
QVBoxLayout,
QWidget,
)
import serial
from serial.tools import list_ports
BANDS = [
("430", 430),
("868", 868),
("915", 915),
("L" , 1550), # MHz midpoint
("S" , 2000),
("2.4G", 2442),
]
@dataclass
class DeviceParams:
freq_mhz: int = 433
band: str = "430"
bw_khz: int = 125
sf: int = 7
cr: int = 5
tx_power_dbm: int = 14
period_ms: int = 1000
tx_enabled: bool = True
payload: str = "PING 1"
def to_dict(self) -> Dict[str, object]:
return {
"freq_mhz": self.freq_mhz,
"band": self.band,
"bw_khz": self.bw_khz,
"sf": self.sf,
"cr": self.cr,
"tx_power_dbm": self.tx_power_dbm,
"period_ms": self.period_ms,
"tx_enabled": self.tx_enabled,
"payload": self.payload,
}
class DeviceConnection(QObject):
status_changed = pyqtSignal(str)
data_received = pyqtSignal(str)
connected_changed = pyqtSignal(bool)
def __init__(self) -> None:
super().__init__()
self._ser: Optional[serial.Serial] = None
self._rx_thread: Optional[threading.Thread] = None
self._stop = threading.Event()
@staticmethod
def available_ports():
return list_ports.comports()
def connect(self, port: str, baud: int = 115200) -> None:
self.disconnect()
try:
self._ser = serial.Serial(port=port, baudrate=baud, timeout=0.1)
except serial.SerialException as exc:
self.status_changed.emit(f"Connect failed: {exc}")
self._ser = None
return
self._stop.clear()
self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True)
self._rx_thread.start()
self.status_changed.emit(f"Connected to {port} @ {baud}")
self.connected_changed.emit(True)
def disconnect(self) -> None:
self._stop.set()
if self._rx_thread and self._rx_thread.is_alive():
self._rx_thread.join(timeout=0.5)
self._rx_thread = None
if self._ser:
try:
self._ser.close()
except Exception:
pass
was_connected = self._ser is not None
self._ser = None
if was_connected:
self.connected_changed.emit(False)
self.status_changed.emit("Disconnected")
def is_connected(self) -> bool:
return self._ser is not None and self._ser.is_open
def send_json(self, obj: Dict[str, object]) -> None:
if not self.is_connected():
self.status_changed.emit("Not connected")
return
try:
line = json.dumps(obj) + "\n"
self._ser.write(line.encode("utf-8"))
except Exception as exc:
self.status_changed.emit(f"Send failed: {exc}")
def _rx_loop(self) -> None:
assert self._ser is not None
while not self._stop.is_set():
try:
raw = self._ser.readline()
except Exception as exc:
self.status_changed.emit(f"Read error: {exc}")
break
if not raw:
continue
try:
text = raw.decode("utf-8", errors="replace").strip()
except Exception:
continue
if text:
self.data_received.emit(text)
self.connected_changed.emit(False)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("LoRa RX/TX Config")
self.conn = DeviceConnection()
self.params = DeviceParams()
self._tx_widgets = []
self._apply_timer = QTimer(self)
self._apply_timer.setSingleShot(True)
self._apply_timer.timeout.connect(self.send_params)
self._build_ui()
self._wire()
def _build_ui(self) -> None:
container = QWidget()
main_layout = QVBoxLayout(container)
# Connection controls
conn_row = QHBoxLayout()
self.port_combo = QComboBox()
self.refresh_ports()
self.btn_refresh = QPushButton("Refresh")
self.btn_connect = QPushButton("Connect")
conn_row.addWidget(QLabel("Port:"))
conn_row.addWidget(self.port_combo, stretch=1)
conn_row.addWidget(self.btn_refresh)
conn_row.addWidget(self.btn_connect)
main_layout.addLayout(conn_row)
# Parameters
params_group = QGroupBox("Radio parameters")
grid = QGridLayout(params_group)
self.spin_freq = QSpinBox()
self.spin_freq.setRange(150, 2500)
self.spin_freq.setSuffix(" MHz")
self.spin_freq.setValue(self.params.freq_mhz)
self.combo_band = QComboBox()
for name, _ in BANDS:
self.combo_band.addItem(name)
self.combo_band.setCurrentText(self.params.band)
self.combo_bw = QComboBox()
for bw in (125, 250, 500):
self.combo_bw.addItem(f"{bw} kHz", bw)
self.combo_bw.setCurrentIndex(0)
self.combo_sf = QComboBox()
for sf in range(5, 13):
self.combo_sf.addItem(f"SF{sf}", sf)
self.combo_sf.setCurrentText("SF7")
self.combo_cr = QComboBox()
for cr in range(5, 9):
self.combo_cr.addItem(f"4/{cr}", cr)
self.combo_cr.setCurrentIndex(0)
self.spin_power = QSpinBox()
self.spin_power.setRange(-17, 22)
self.spin_power.setSuffix(" dBm")
self.spin_power.setValue(self.params.tx_power_dbm)
self.spin_period = QSpinBox()
self.spin_period.setRange(100, 60000)
self.spin_period.setSingleStep(100)
self.spin_period.setSuffix(" ms")
self.spin_period.setValue(self.params.period_ms)
self.chk_tx_enabled = QCheckBox("TX enabled")
self.chk_tx_enabled.setChecked(self.params.tx_enabled)
self.edit_payload = QLineEdit(self.params.payload)
self.edit_payload.setMaxLength(31)
grid.addWidget(QLabel("Band"), 0, 0)
grid.addWidget(self.combo_band, 0, 1)
grid.addWidget(QLabel("Frequency"), 1, 0)
grid.addWidget(self.spin_freq, 1, 1)
grid.addWidget(QLabel("Bandwidth"), 2, 0)
grid.addWidget(self.combo_bw, 2, 1)
grid.addWidget(QLabel("Spreading factor"), 3, 0)
grid.addWidget(self.combo_sf, 3, 1)
grid.addWidget(QLabel("Coding rate"), 4, 0)
grid.addWidget(self.combo_cr, 4, 1)
self.lbl_tx_power = QLabel("TX power")
grid.addWidget(self.lbl_tx_power, 5, 0)
grid.addWidget(self.spin_power, 5, 1)
self.lbl_payload = QLabel("Payload")
grid.addWidget(self.lbl_payload, 6, 0)
grid.addWidget(self.edit_payload, 6, 1)
self.lbl_period = QLabel("Period")
grid.addWidget(self.lbl_period, 7, 0)
grid.addWidget(self.spin_period, 7, 1)
grid.addWidget(self.chk_tx_enabled, 8, 1)
main_layout.addWidget(params_group)
# Actions
actions = QHBoxLayout()
self.btn_boot = QPushButton("Bootloader")
actions.addWidget(self.btn_boot)
main_layout.addLayout(actions)
# Status widgets for live metrics
metrics_row = QHBoxLayout()
self.lbl_role = QLabel("role: ?")
self.lbl_snr = QLabel("SNR: -")
self.lbl_rssi = QLabel("RSSI: -")
self.lbl_status = QLabel("IRQ: -")
metrics_row.addWidget(self.lbl_role)
metrics_row.addWidget(self.lbl_snr)
metrics_row.addWidget(self.lbl_rssi)
metrics_row.addWidget(self.lbl_status)
metrics_row.addStretch()
main_layout.addLayout(metrics_row)
self.lbl_rx_payload = QLabel("Payload: -")
self.lbl_rx_payload.setWordWrap(True)
main_layout.addWidget(self.lbl_rx_payload)
self.log_view = QTextEdit()
self.log_view.setReadOnly(True)
self.log_view.setPlaceholderText("Incoming lines...")
main_layout.addWidget(self.log_view)
self.status_label = QLabel("Idle")
main_layout.addWidget(self.status_label)
self.setCentralWidget(container)
self._tx_widgets = [
self.lbl_tx_power,
self.spin_power,
self.lbl_payload,
self.edit_payload,
self.lbl_period,
self.spin_period,
self.chk_tx_enabled,
]
self._set_tx_controls_visible(True)
def _wire(self) -> None:
self.btn_refresh.clicked.connect(self.refresh_ports)
self.btn_connect.clicked.connect(self.toggle_connect)
self.btn_boot.clicked.connect(self.request_bootloader)
for widget in (
self.combo_band,
self.combo_bw,
self.combo_sf,
self.combo_cr,
):
widget.currentIndexChanged.connect(self.schedule_apply)
for widget in (
self.spin_freq,
self.spin_power,
self.spin_period,
):
widget.valueChanged.connect(self.schedule_apply)
self.chk_tx_enabled.stateChanged.connect(self.schedule_apply)
self.edit_payload.textChanged.connect(self.schedule_apply)
self.conn.status_changed.connect(self.append_status)
self.conn.data_received.connect(self.append_rx)
self.conn.connected_changed.connect(self.on_connected_changed)
self.poll_timer = QTimer(self)
self.poll_timer.timeout.connect(self.request_status)
def refresh_ports(self) -> None:
current = self.port_combo.currentText()
self.port_combo.clear()
for p in self.conn.available_ports():
self.port_combo.addItem(p.device)
if current:
idx = self.port_combo.findText(current)
if idx >= 0:
self.port_combo.setCurrentIndex(idx)
def toggle_connect(self) -> None:
if self.conn.is_connected():
self.conn.disconnect()
return
port = self.port_combo.currentText()
if not port:
self.append_status("Select a port first")
return
self.conn.connect(port)
def on_connected_changed(self, connected: bool) -> None:
self.btn_connect.setText("Disconnect" if connected else "Connect")
if connected:
self.send_params()
self.poll_timer.start(1000)
else:
self.poll_timer.stop()
self._set_tx_controls_visible(True)
self.lbl_rx_payload.setText("Payload: -")
def schedule_apply(self) -> None:
# debounce rapid changes (spinner drags)
self._apply_timer.start(150)
def gather_params(self) -> DeviceParams:
p = DeviceParams()
p.band = self.combo_band.currentText()
p.freq_mhz = self.spin_freq.value()
p.bw_khz = int(self.combo_bw.currentData())
p.sf = int(self.combo_sf.currentData())
p.cr = int(self.combo_cr.currentData())
p.tx_power_dbm = self.spin_power.value()
p.period_ms = self.spin_period.value()
p.tx_enabled = self.chk_tx_enabled.isChecked()
p.payload = self.edit_payload.text()
return p
def send_params(self) -> None:
self.params = self.gather_params()
self.conn.send_json({"cmd": "set_params", "params": self.params.to_dict()})
def request_status(self) -> None:
self.conn.send_json({"cmd": "get_status"})
def request_bootloader(self) -> None:
self.conn.send_json({"cmd": "reboot_bootloader"})
def append_status(self, text: str) -> None:
self.status_label.setText(text)
def append_rx(self, text: str) -> None:
# Try to parse JSON status responses to populate labels
try:
obj = json.loads(text)
except Exception:
self.log_view.append(f"[rx] {text}")
return
if obj.get("resp") == "status":
role = obj.get("role", "?")
snr = obj.get("snr_db", None)
rssi = obj.get("rssi_dbm", None)
irq = obj.get("last_status", None)
payload = obj.get("payload", None)
self.lbl_role.setText(f"role: {role}")
self.lbl_snr.setText(f"SNR: {snr} dB" if snr is not None else "SNR: -")
self.lbl_rssi.setText(f"RSSI: {rssi} dBm" if rssi is not None else "RSSI: -")
self.lbl_status.setText(f"IRQ: 0x{irq:02X}" if irq is not None else "IRQ: -")
if payload is not None:
self.lbl_rx_payload.setText(f"Payload: {payload}")
else:
self.lbl_rx_payload.setText("Payload: -")
role_text = str(role).lower()
is_rx = role_text.startswith("rx") or role_text == "receiver"
self._set_tx_controls_visible(not is_rx)
else:
self.status_label.setText(text)
def _set_tx_controls_visible(self, visible: bool) -> None:
for widget in self._tx_widgets:
widget.setVisible(visible)
def main() -> int:
app = QApplication(sys.argv)
win = MainWindow()
win.setFixedSize(520, 520)
win.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())

2
py_app/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
PyQt6==6.6.1
pyserial==3.5