Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions html/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function _(el) {
function init() {
initAat();
initMavLink();
initBleTrainer();

// sends XMLHttpRequest, so do it last
initOptions();
Expand Down Expand Up @@ -47,6 +48,55 @@ function initMavLink() {
xmlhttp.open('GET', '/mavlink', true);
xmlhttp.send();
}

function initBleTrainer() {
const form = _('bt_form');
if (!form)
return;
form.addEventListener('submit', (e) => { e.preventDefault(); btSubmit(false); });
_('bt_forget').addEventListener('click', (e) => { e.preventDefault(); btSubmit(true); });
_('bt_source').addEventListener('change', () => btToggleBleSettings(_('bt_source').value));
}

// Posts the FrSky BLE Trainer form (or a Forget request) and updates the page
// in place, so no manual reload is needed.
function btSubmit(clearPeer) {
const body = clearPeer ? new URLSearchParams({ forget: 1 })
: new URLSearchParams(new FormData(_('bt_form')));
const xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function () {
if (this.readyState != 4)
return;
if (this.status == 200) {
if (clearPeer) {
_('bt_status').textContent = 'Not paired';
_('bt_mac').textContent = '-';
}
cuteAlert({
type: 'success',
title: 'Bluetooth Trainer',
message: 'Bluetooth trainer settings saved'
});
} else {
cuteAlert({
type: 'error',
title: 'Bluetooth Trainer',
message: 'An error occurred saving the Bluetooth trainer settings'
});
}
};
xmlhttp.open('POST', '/settrainer', true);
xmlhttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xmlhttp.send(body);
}

// Bluetooth-only fields are hidden when the native source is selected.
function btToggleBleSettings(source) {
const el = _('bt_ble_settings');
if (el)
el.style.display = (source === 'native') ? 'none' : 'block';
}

function initOptions() {
const xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
Expand Down Expand Up @@ -136,6 +186,15 @@ function updateConfig(data) {
if (config['head-tracking']) {
if (_('httab')) _('httab').style.display = 'table-cell';
}
if (config['trainer']) {
if (_('bttab')) _('bttab').style.display = 'table-cell';
const bt = config.trainer;
_('bt_status').textContent = bt.paired ? 'Paired' : 'Not paired';
_('bt_mac').textContent = bt.paired ? bt.mac : '-';
_('bt_interval').value = bt.interval;
_('bt_source').value = bt.source;
btToggleBleSettings(bt.source);
}
if((!data.stm32 || data.stm32==="no") && _('tx_tab')) {
mui.tabs.activate('pane-justified-2');
_('tx_tab').style.display = 'none';
Expand Down
43 changes: 43 additions & 0 deletions html/txbp_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ <h1><b>ExpressLRS</b></h1>
<li><a data-mui-toggle="tab" data-mui-controls="pane-justified-2">Backpack</a></li>
<li><a data-mui-toggle="tab" data-mui-controls="pane-justified-3">Network</a></li>
<li><a data-mui-toggle="tab" data-mui-controls="pane-justified-4">MavLink</a></li>
<li id="bttab" style="display:none;"><a data-mui-toggle="tab" data-mui-controls="pane-bt">FrSky BLE Trainer</a></li>
</ul>

<div class="mui-tabs__pane mui--is-active" id="pane-justified-1">
Expand Down Expand Up @@ -146,6 +147,48 @@ <h3>MavLink Statistics</h3>
</tr>
</table>
</div>

<div class="mui-tabs__pane" id="pane-bt">
<h2>FrSky BLE Trainer</h2>
<p>The backpack forwards trainer channels to EdgeTX (as trainer input) from one of
two sources: the <b>native</b> system (ESP-NOW, e.g. a VRX or goggles with IMU) or a
<b>Bluetooth</b> device advertising the FrSky trainer service (e.g. an Arduino-based
HeadTracker). Both respect the radio's HT Enable; use the selector to choose which one
is used.</p>
<form id="bt_form" class="mui-form">
<div class="mui-select">
<select id="bt_source" name="source">
<option value="auto">Auto (Bluetooth if connected, else native)</option>
<option value="native">Native (ESP-NOW / VRX)</option>
<option value="ble">Bluetooth (BLE)</option>
</select>
<label>Trainer source</label>
</div>
<div id="bt_ble_settings">
<h3>Bluetooth device</h3>
<table class="mui-table">
<tr>
<td>Status:</td>
<td id="bt_status">-</td>
</tr>
<tr>
<td>Paired address:</td>
<td id="bt_mac">-</td>
</tr>
</table>
<div class="mui-textfield">
Connection interval (ms)
<input type="number" id="bt_interval" name="interval" min="7" max="1000">
</div>
<p>The trainer sends one frame per Bluetooth connection event, so a low
interval is what keeps the movement fluid. 8 ms suits a HeadTracker; higher
values only slow the stream down.</p>
<button type="button" id="bt_forget" class="mui-btn mui-btn--danger">Forget (re-pair on next boot)</button>
<br/><br/>
</div>
<input type="submit" value="Save" class="mui-btn mui-btn--primary">
</form>
</div>
</div>
</div>
<footer>
Expand Down
26 changes: 26 additions & 0 deletions include/BleTrainer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#pragma once

#include "device.h"

// Bluetooth trainer bridge: the backpack acts as a BLE central and connects to
// a device that advertises the FrSky trainer service (0xFFF0/0xFFF6), such as a
// HeadTracker, parses the FrSky trainer frame and emits
// MSP_ELRS_BACKPACK_SET_PTR over Serial towards the ELRS module.
//
// Compiled only on BLE-capable targets (ESP32-C3/S3/ESP32) with HAS_BLE_TRAINER.

#if defined(HAS_BLE_TRAINER)

extern device_t BleTrainer_device;

// True while a trainer is connected over BLE.
bool bleTrainerConnected();

// Forget the paired peer and go back to pairing mode (button long press).
void bleTrainerForgetPeer();

// Retained-RAM diagnostic: number of forwarded trainer packets, read from /config
// (the Nomad backpack has no USB console).
uint32_t bleTrainerSetPtrCount();

#endif
13 changes: 13 additions & 0 deletions lib/BUTTON/devButton.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#if defined(PIN_BUTTON)
#include "logging.h"
#include "button.h"
#include "BleTrainer.h"

static Button<PIN_BUTTON, false> button;

Expand All @@ -24,9 +25,21 @@ static void shortPress()
}
}

#if defined(HAS_BLE_TRAINER)
// Long press: forget the paired Bluetooth trainer so it can be re-paired.
static void longPress()
{
DBGLN("Button long: forget Bluetooth trainer peer");
bleTrainerForgetPeer();
}
#endif

static void initialize()
{
button.OnShortPress = shortPress;
#if defined(HAS_BLE_TRAINER)
button.OnLongPress = longPress;
#endif
}

static int start()
Expand Down
138 changes: 128 additions & 10 deletions lib/WIFI/devWIFI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ extern wifi_service_t wifiService;
#if defined(MAVLINK_ENABLED)
extern MAVLink mavlink;
#endif
#if defined(HAS_BLE_TRAINER)
#include <esp_attr.h>
#include <esp_system.h>
#include "BleTrainer.h"
#endif
#elif defined(TARGET_TIMER_BACKPACK)
extern TimerBackpackConfig config;
#else
Expand Down Expand Up @@ -103,6 +108,65 @@ extern bool headTrackingEnabled;
#endif
static bool servicesStarted = false;

#if defined(TARGET_TX_BACKPACK)
static void setMavlinkAccessPointSSID()
{
#if defined(PLATFORM_ESP32)
static const uint8_t unboundAddress[6] = {0, 0, 0, 0, 0, 0};
if (memcmp(firmwareOptions.uid, unboundAddress, sizeof(unboundAddress)) == 0)
{
const uint32_t suffix = (uint32_t)(ESP.getEfuseMac() & 0xFFFFFF);
snprintf(wifi_ap_ssid, sizeof(wifi_ap_ssid), "ExpressLRS TX Backpack %06lX",
(unsigned long)suffix);
return;
}
#endif
snprintf(wifi_ap_ssid, sizeof(wifi_ap_ssid), "ExpressLRS TX Backpack %02X%02X%02X",
firmwareOptions.uid[3], firmwareOptions.uid[4], firmwareOptions.uid[5]);
}
#endif

#if defined(HAS_BLE_TRAINER)
// Boot forensics kept in retained RAM: the Nomad has no USB console, and the
// MAVLink WiFi crash loop can only be diagnosed by reading, on the next boot,
// what ended the previous one. Survives software/watchdog/brownout resets.
#define TRAINER_BOOT_FX_MAGIC 0x48544246UL
struct TrainerBootForensics
{
uint32_t magic;
uint32_t boots;
};
RTC_NOINIT_ATTR static TrainerBootForensics s_bootFx;
static uint32_t s_bootCount = 0;
static int32_t s_thisReset = ESP_RST_UNKNOWN;

static const char *resetReasonName(int32_t reason)
{
switch (reason)
{
case ESP_RST_POWERON: return "power-on";
case ESP_RST_EXT: return "external-pin";
case ESP_RST_SW: return "software";
case ESP_RST_PANIC: return "panic";
case ESP_RST_INT_WDT: return "interrupt-watchdog";
case ESP_RST_TASK_WDT: return "task-watchdog";
case ESP_RST_WDT: return "other-watchdog";
case ESP_RST_DEEPSLEEP: return "deep-sleep";
case ESP_RST_BROWNOUT: return "brownout";
case ESP_RST_SDIO: return "sdio";
default: return "unknown";
}
}

static void captureBootForensics()
{
s_thisReset = (int32_t)esp_reset_reason();
s_bootCount = (s_bootFx.magic == TRAINER_BOOT_FX_MAGIC) ? s_bootFx.boots + 1 : 1;
s_bootFx.magic = TRAINER_BOOT_FX_MAGIC;
s_bootFx.boots = s_bootCount;
}
#endif

static bool target_seen = false;
static uint8_t target_pos = 0;
static String target_found;
Expand Down Expand Up @@ -314,6 +378,23 @@ static void GetConfiguration(AsyncWebServerRequest *request)
#if defined(HAS_HEADTRACKING) || defined(SUPPORT_HEADTRACKING)
json["config"]["head-tracking"] = true;
#endif
#if defined(HAS_BLE_TRAINER)
json["config"]["trainer"]["paired"] = config.IsTrainerPaired();
json["config"]["trainer"]["interval"] = config.GetTrainerIntervalMs();
const uint8_t *trainerMac = config.GetTrainerPeerMac();
char trainerMacStr[18];
snprintf(trainerMacStr, sizeof(trainerMacStr), "%02X:%02X:%02X:%02X:%02X:%02X",
trainerMac[0], trainerMac[1], trainerMac[2], trainerMac[3], trainerMac[4], trainerMac[5]);
json["config"]["trainer"]["mac"] = trainerMacStr;
const char *trainerSource = "auto";
if (config.GetTrainerSource() == TRAINER_SOURCE_ESPNOW) trainerSource = "native";
else if (config.GetTrainerSource() == TRAINER_SOURCE_BLE) trainerSource = "ble";
json["config"]["trainer"]["source"] = trainerSource;
json["config"]["trainer"]["boots"] = s_bootCount;
json["config"]["trainer"]["lastreset"] = resetReasonName(s_thisReset);
json["config"]["trainer"]["linked"] = bleTrainerConnected();
json["config"]["trainer"]["setptr"] = bleTrainerSetPtrCount();
#endif
#if defined(AAT_BACKPACK)
WebAatAppendConfig(json);
#endif
Expand Down Expand Up @@ -621,6 +702,34 @@ static void WebUpdateSetMavLink(AsyncWebServerRequest *request)
}
#endif

#if defined(HAS_BLE_TRAINER)
static void WebUpdateSetTrainer(AsyncWebServerRequest *request)
{
if (request->hasArg("interval"))
{
int interval = request->arg("interval").toInt();
config.SetTrainerIntervalMs((uint16_t)constrain(interval, 7, 1000));
}
if (request->hasArg("source"))
{
String source = request->arg("source");
if (source == "native" || source == "espnow")
config.SetTrainerSource(TRAINER_SOURCE_ESPNOW);
else if (source == "ble")
config.SetTrainerSource(TRAINER_SOURCE_BLE);
else
config.SetTrainerSource(TRAINER_SOURCE_AUTO);
}
if (request->hasArg("forget") && request->arg("forget") != "0")
{
config.ClearTrainerPeerMac();
}
config.Commit();
DBGLN("Bluetooth trainer configuration updated");
request->send(200, "text/plain", "Bluetooth trainer settings saved");
}
#endif

static void wifiOff()
{
wifiStarted = false;
Expand Down Expand Up @@ -748,6 +857,9 @@ static void startServices()
#if defined(MAVLINK_ENABLED)
server.on("/setmavlink", WebUpdateSetMavLink);
#endif
#if defined(HAS_BLE_TRAINER)
server.on("/settrainer", HTTP_POST, WebUpdateSetTrainer);
#endif
server.on("/forget", WebUpdateForget);
server.on("/connect", WebUpdateConnect);
server.on("/access", WebUpdateAccessPoint);
Expand Down Expand Up @@ -800,6 +912,15 @@ static void startServices()
static void HandleWebUpdate()
{
unsigned long now = millis();
#if defined(HAS_BLE_TRAINER)
static unsigned long lastStatusLog = 0;
if (now - lastStatusLog > 3000)
{
lastStatusLog = now;
DBGLN("WIFI status mode=%d started=%d services=%d ap=%s", (int)wifiMode, wifiStarted,
servicesStarted, WiFi.softAPIP().toString().c_str());
}
#endif
wl_status_t status = WiFi.status();
if (status != laststatus && wifiMode == WIFI_STA) {
DBGLN("WiFi status %d", status);
Expand Down Expand Up @@ -843,12 +964,7 @@ static void HandleWebUpdate()
}
else if (wifiService == WIFI_SERVICE_MAVLINK_TX)
{
// Generate a unique SSID using config.address as hex
sprintf(wifi_ap_ssid, "ExpressLRS TX Backpack %02X%02X%02X",
firmwareOptions.uid[3],
firmwareOptions.uid[4],
firmwareOptions.uid[5]
);
setMavlinkAccessPointSSID();
}
#endif
WiFi.softAP(wifi_ap_ssid, wifi_ap_password);
Expand Down Expand Up @@ -945,11 +1061,10 @@ static void HandleWebUpdate()
#endif
// When in STA mode, a small delay reduces power use from 90mA to 30mA when idle
// In AP mode, it doesn't seem to make a measurable difference, but does not hurt
#if defined(MAVLINK_ENABLED)
if (!updater.isRunning() && wifiService != WIFI_SERVICE_MAVLINK_TX)
#else
// The ESP32-C3 is single-core: always yield to the idle task here. Skipping
// the delay in MAVLINK_TX lets this tight poll loop starve the idle task
// once BLE adds its radio tasks, which resets the whole chip.
if (!updater.isRunning())
#endif
delay(1);
if (do_flash) {
do_flash = false;
Expand Down Expand Up @@ -995,6 +1110,9 @@ static void HandleWebUpdate()

static int start()
{
#if defined(HAS_BLE_TRAINER)
captureBootForensics();
#endif
return DURATION_NEVER;
}

Expand Down
Loading