diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index fe889cd2..d3309876 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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 diff --git a/examples/donglora.py b/examples/donglora.py index a80789fa..3d1259db 100644 --- a/examples/donglora.py +++ b/examples/donglora.py @@ -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, } diff --git a/examples/meshcore/meshcore_rx.py b/examples/meshcore/meshcore_rx.py index faab482c..67842f04 100755 --- a/examples/meshcore/meshcore_rx.py +++ b/examples/meshcore/meshcore_rx.py @@ -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 } diff --git a/src/board/heltec_v3.rs b/src/board/heltec_v3.rs index 6179fb25..a703bce3 100644 --- a/src/board/heltec_v3.rs +++ b/src/board/heltec_v3.rs @@ -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 { diff --git a/src/board/heltec_v4.rs b/src/board/heltec_v4.rs index b5be79b3..4ab4913b 100644 --- a/src/board/heltec_v4.rs +++ b/src/board/heltec_v4.rs @@ -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 { diff --git a/src/board/rak_wisblock_4631.rs b/src/board/rak_wisblock_4631.rs index 0a7c43e3..35c43c9b 100644 --- a/src/board/rak_wisblock_4631.rs +++ b/src/board/rak_wisblock_4631.rs @@ -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 { diff --git a/src/protocol.rs b/src/protocol.rs index 387bace1..3e8606c2 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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. diff --git a/src/radio/task.rs b/src/radio/task.rs index 4f443475..3d9bb999 100644 --- a/src/radio/task.rs +++ b/src/radio/task.rs @@ -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; @@ -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))