feat(radio): honest TX power for GAT562 30S external PA

gat562_30s_mesh_kit has an always-on external PA rated at +30dBm, but the
firmware wrote tx_power_dbm straight to the SX1262 register (capped at 22),
so the app/CLI/UI never matched the real radiated power and couldn't ask
for more than 22 even though the hardware supports it.

Add a generic, macro-gated PA gain-curve resolution in
RadioLibWrapper::setTxPower() (the single choke point every TX-power path
already funnels through), reusable by any future board with a similar PA.
For this board, reuse the vendor-measured 869MHz curve from the open
upstream Meshtastic PR that added it for the same physical module
(meshtastic/firmware#11212): pick the lowest SX1262 setting whose measured
output reaches the requested dBm, clamped at the PA's saturation knee.
Bump LORA_TX_POWER/MAX_LORA_TX_POWER to 30 for this variant only -- the
other three GAT562 boards use the SX1262's own PA_BOOST (22dBm, no
external PA) and are unaffected.

Since a request below the PA's floor gain (14dBm) still radiates at that
floor, also make CommonCLICallbacks::setTxPower() return what was actually
applied, and have every setter (BLE CMD_SET_RADIO_TX_POWER, CLI "tx",
simple_repeater/room_server/sensor) persist and report that value instead
of the raw request -- so tx_power_dbm never silently disagrees with the
physical output in either direction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-16 12:54:48 +02:00
co-authored by Claude Sonnet 5
parent 1d5dbe2f9a
commit 95e3cf2618
12 changed files with 48 additions and 14 deletions
+4 -2
View File
@@ -2488,9 +2488,11 @@ void MyMesh::handleCmdFrame(size_t len) {
if (power < -9 || power > MAX_LORA_TX_POWER) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
_prefs.tx_power_dbm = power;
radio_driver.setTxPower(power);
// Store what the radio actually applied, not the raw request -- on a
// board with an external-PA gain curve those can differ (see setTxPower()).
_prefs.tx_power_dbm = radio_driver.getTxPower();
savePrefs();
radio_driver.setTxPower(_prefs.tx_power_dbm);
writeOKFrame();
}
} else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) {
+2 -1
View File
@@ -1095,8 +1095,9 @@ void MyMesh::dumpLogFile() {
}
}
void MyMesh::setTxPower(int8_t power_dbm) {
int8_t MyMesh::setTxPower(int8_t power_dbm) {
radio_driver.setTxPower(power_dbm);
return radio_driver.getTxPower();
}
bool MyMesh::setRxBoostedGain(bool enable) {
+1 -1
View File
@@ -212,7 +212,7 @@ public:
}
void dumpLogFile() override;
void setTxPower(int8_t power_dbm) override;
int8_t setTxPower(int8_t power_dbm) override;
void formatNeighborsReply(char *reply) override;
void removeNeighbor(const uint8_t* pubkey, int key_len) override;
void formatStatsReply(char *reply) override;
+2 -1
View File
@@ -839,8 +839,9 @@ void MyMesh::dumpLogFile() {
}
}
void MyMesh::setTxPower(int8_t power_dbm) {
int8_t MyMesh::setTxPower(int8_t power_dbm) {
radio_driver.setTxPower(power_dbm);
return radio_driver.getTxPower();
}
bool MyMesh::setRxBoostedGain(bool enable) {
+1 -1
View File
@@ -208,7 +208,7 @@ public:
}
void dumpLogFile() override;
void setTxPower(int8_t power_dbm) override;
int8_t setTxPower(int8_t power_dbm) override;
bool setRxBoostedGain(bool enable) override;
void formatNeighborsReply(char *reply) override {
+2 -1
View File
@@ -848,8 +848,9 @@ void SensorMesh::updateFloodAdvertTimer() {
}
}
void SensorMesh::setTxPower(int8_t power_dbm) {
int8_t SensorMesh::setTxPower(int8_t power_dbm) {
radio_driver.setTxPower(power_dbm);
return radio_driver.getTxPower();
}
void SensorMesh::formatStatsReply(char *reply) {
+1 -1
View File
@@ -67,7 +67,7 @@ public:
void setLoggingOn(bool enable) override { }
void eraseLogFile() override { }
void dumpLogFile() override { }
void setTxPower(int8_t power_dbm) override;
int8_t setTxPower(int8_t power_dbm) override;
void formatNeighborsReply(char *reply) override {
strcpy(reply, "not supported");
}
+3 -2
View File
@@ -706,9 +706,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "OK");
}
} else if (memcmp(config, "tx ", 3) == 0) {
_prefs->tx_power_dbm = atoi(&config[3]);
// Store what the radio actually applied, not the raw request -- on a
// board with an external-PA gain curve those can differ (see setTxPower()).
_prefs->tx_power_dbm = _callbacks->setTxPower(atoi(&config[3]));
savePrefs();
_callbacks->setTxPower(_prefs->tx_power_dbm);
strcpy(reply, "OK");
} else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) {
_prefs->freq = atof(&config[5]);
+5 -1
View File
@@ -207,7 +207,11 @@ public:
virtual void setLoggingOn(bool enable) = 0;
virtual void eraseLogFile() = 0;
virtual void dumpLogFile() = 0;
virtual void setTxPower(int8_t power_dbm) = 0;
// Returns the dBm the radio actually applied -- on a board with an
// external-PA gain curve this can differ from power_dbm (e.g. a request
// below the PA's floor gain still radiates at that floor), so callers
// should persist/report the return value, not the raw request.
virtual int8_t setTxPower(int8_t power_dbm) = 0;
virtual void formatNeighborsReply(char *reply) = 0;
virtual void removeNeighbor(const uint8_t* pubkey, int key_len) {
// no op by default
+1 -1
View File
@@ -97,7 +97,7 @@ public:
((CustomSX1262 *)_radio)->setCodingRate(_wd_cr);
updatePreamble(_wd_sf);
}
_radio->setOutputPower(getTxPower());
setTxPower(getTxPower()); // re-run through the wrapper's own setter so any PA gain curve reapplies
// Unconditional: std_init() may have just turned boosted gain back ON via
// the board's SX126X_RX_BOOSTED_GAIN compile default, so the OFF case
// needs reapplying just as much as ON.
+16 -1
View File
@@ -52,8 +52,23 @@ uint32_t RadioLibWrapper::getRngSeed() {
}
void RadioLibWrapper::setTxPower(int8_t dbm) {
_tx_dbm = dbm;
_tx_dbm = dbm; // logical/requested power -- what getTxPower(), the app and the CLI report
#if defined(NUM_PA_POINTS) && defined(TX_GAIN_LORA)
// Per-board external-PA gain curve (radio_dbm -> measured gain in dB, from
// the vendor/GAT562-30S data cited in the upstream Meshtastic PR that added
// it, meshtastic/firmware#11212). The PA is always-on (no enable pin), so
// the SX1262 register alone decides real output; pick the lowest register
// setting whose (setting + its measured gain) reaches the requested dBm,
// clamping to the last entry once the PA saturates.
static const int8_t pa_gain[NUM_PA_POINTS] = { TX_GAIN_LORA };
int8_t radio_dbm = NUM_PA_POINTS - 1;
for (int i = 0; i < NUM_PA_POINTS; i++) {
if ((i + pa_gain[i]) >= dbm) { radio_dbm = i; break; }
}
_radio->setOutputPower(radio_dbm);
#else
_radio->setOutputPower(dbm);
#endif
}
void RadioLibWrapper::idle() {
+10 -1
View File
@@ -14,7 +14,16 @@ build_flags = ${nrf52_base.build_flags}
-D UI_HAS_JOYSTICK=1
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D LORA_TX_POWER=30
-D MAX_LORA_TX_POWER=30
; External PA (not the bare SX1262's own PA_BOOST), always on, no enable
; pin -- gain curve measured by the vendor at 869MHz, from the upstream
; Meshtastic PR that added it for this exact module (meshtastic/firmware#11212).
; Picks the lowest SX1262 register setting whose (setting + gain) reaches
; the requested dBm, so "30" in the app/CLI means the real ~30dBm the PA
; saturates at, not a raw, unamplified chip register value.
-D NUM_PA_POINTS=16
-D TX_GAIN_LORA=14,15,16,17,17,18,18,18,18,18,17,17,17,16,16,15
-D SX126X_CURRENT_LIMIT=140
-D PIN_BUZZER=33
-D SX126X_RX_BOOSTED_GAIN=1