feat: per-board TX power range, TX_POWER_MAX sentinel (-128)

- Each board exports TX_POWER_RANGE constant (min, max dBm)
- RadioConfig::validate() uses the board's range, not hardcoded limits
- TX_POWER_MAX sentinel (-128 on the wire) means "use board's maximum"
  — resolved to the actual max before storing config
- GetConfig returns the resolved actual power, not the sentinel
- Default configs in examples use TX_POWER_MAX
- Protocol docs updated
This commit is contained in:
swaits committed 2026-03-29 20:42:58 -06:00
1 parent 0b4eb04591
commit ebe9197d04
8 files changed
+46 -14

No files matched your search

+1 -1
View File
@@ -67,7 +67,7 @@ Postcard is a compact binary format. The subset used by DongLoRa:
| `sf` | `u8` | varint | 5 – 12 | Spreading factor |
| `cr` | `u8` | varint | 5 – 8 | Coding rate denominator (5 = CR 4/5) |
| `sync_word` | `u16` | varint | any | LoRa sync word (e.g. `0x3444`) |
| `tx_power_dbm` | `i8` | zigzag varint | -9 – +22 | TX power in dBm |
| `tx_power_dbm` | `i8` | raw signed byte | board-dependent | TX power in dBm. `-128` = max power for this board. |
## Bandwidth
+5 -2
View File
@@ -197,12 +197,15 @@ def connect(port: str | None = None, timeout: float = 2.0) -> serial.Serial:
return ser
# Default radio config: 915 MHz, 125 kHz BW, SF7, CR 4/5
# Sentinel: set TX power to the board's maximum
TX_POWER_MAX = -128 # i8::MIN on the wire
# Default radio config: 915 MHz, 125 kHz BW, SF7, CR 4/5, max power
DEFAULT_CONFIG = {
"freq_hz": 915_000_000,
"bw": 7, # 125 kHz
"sf": 7,
"cr": 5, # CR 4/5
"sync_word": 0x1424,
"tx_power_dbm": 14,
"tx_power_dbm": TX_POWER_MAX,
}
+1 -1
View File
@@ -916,7 +916,7 @@ RADIO_CONFIG = {
"sf": 7,
"cr": 5, # CR 4/5 — denominator value
"sync_word": 0x3444,
"tx_power_dbm": 14,
"tx_power_dbm": -128, # TX_POWER_MAX: use board's maximum
}
+3
View File
@@ -25,6 +25,9 @@ pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
/// TX power range for this board's radio (SX1262).
pub const TX_POWER_RANGE: (i8, i8) = (-9, 22);
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
+3
View File
@@ -25,6 +25,9 @@ pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
/// TX power range for this board's radio (SX1262).
pub const TX_POWER_RANGE: (i8, i8) = (-9, 22);
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
+3
View File
@@ -33,6 +33,9 @@ pub type UsbDriver = Driver<'static, USBD, &'static embassy_nrf::usb::vbus_detec
/// I2C bus for an optional SSD1306 OLED.
pub type DisplayI2c = Twim<'static, embassy_nrf::peripherals::TWISPI0>;
/// TX power range for this board's radio (SX1262).
pub const TX_POWER_RANGE: (i8, i8) = (-9, 22);
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
+23 -5
View File
@@ -20,6 +20,9 @@ pub enum Bandwidth {
Khz500 = 9,
}
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// Complete LoRa radio configuration.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
pub struct RadioConfig {
@@ -31,13 +34,17 @@ pub struct RadioConfig {
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
pub cr: u8,
pub sync_word: u16,
/// Transmit power in dBm (-9 to +22 for SX1262).
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
/// board's maximum. Actual range is board-dependent (e.g. -9 to +22
/// for SX1262). Values above the board max are clamped by the radio
/// driver; values below the board min are clamped too.
pub tx_power_dbm: i8,
}
impl RadioConfig {
/// Validate all fields are within SX1262 hardware limits.
pub fn validate(&self) -> Result<(), &'static str> {
/// Validate fields against hardware limits. `power_range` is the
/// board's (min, max) TX power in dBm.
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
return Err("frequency out of range (150-960 MHz)");
}
@@ -47,11 +54,22 @@ impl RadioConfig {
if !(5..=8).contains(&self.cr) {
return Err("coding rate out of range (5-8)");
}
if !(-9..=22).contains(&self.tx_power_dbm) {
return Err("TX power out of range (-9 to +22 dBm)");
// TX_POWER_MAX sentinel is always valid (resolved to board max later)
if self.tx_power_dbm != TX_POWER_MAX
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
{
return Err("TX power out of range for this board");
}
Ok(())
}
/// Resolve the TX_POWER_MAX sentinel to the board's actual maximum.
pub fn resolve_power(mut self, power_range: (i8, i8)) -> Self {
if self.tx_power_dbm == TX_POWER_MAX {
self.tx_power_dbm = power_range.1;
}
self
}
}
/// Host → firmware commands.
+7 -5
View File
@@ -24,11 +24,12 @@ use embassy_time::Delay;
use lora_phy::mod_params::RadioError;
use lora_phy::{LoRa, RxMode};
use crate::board::{RadioDriver, RadioParts};
use crate::board::{RadioDriver, RadioParts, TX_POWER_RANGE};
use crate::channel::{CommandChannel, RadioState, RadioStatus, ResponseChannel, StatusWatch};
use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response};
const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD;
const POWER_RANGE: (i8, i8) = TX_POWER_RANGE;
type Radio = LoRa<RadioDriver, Delay>;
@@ -173,13 +174,14 @@ async fn handle_cmd(
}
}
Command::SetConfig(cfg) => {
if let Err(reason) = cfg.validate() {
if let Err(reason) = cfg.validate(POWER_RANGE) {
warn!("SetConfig rejected: {}", reason);
responses
.send(Response::Error(ErrorCode::InvalidConfig))
.await;
} else {
state.config = Some(cfg);
// Resolve TX_POWER_MAX sentinel to board's actual max
state.config = Some(cfg.resolve_power(POWER_RANGE));
status.sender().send(state.clone());
responses.send(Response::Ok).await;
}
@@ -215,9 +217,9 @@ async fn handle_cmd(
}
Command::DisplayOn | Command::DisplayOff => {}
Command::Transmit { config, payload } => {
let tx_config = config.or(state.config);
let tx_config = config.map(|c| c.resolve_power(POWER_RANGE)).or(state.config);
if let Some(cfg) = tx_config {
if let Err(reason) = cfg.validate() {
if let Err(reason) = cfg.validate(POWER_RANGE) {
warn!("TX config rejected: {}", reason);
responses
.send(Response::Error(ErrorCode::InvalidConfig))