[feat](trx-wxsat): rename trx-noaa to trx-wxsat with full NOAA APT decode
Rename the crate from trx-noaa to trx-wxsat (weather satellite) across the entire workspace. Add full NOAA satellite decode support: - Telemetry frame parsing: extract 16-wedge calibration data from the 128-line telemetry frames embedded in APT lines - Radiometric calibration: piecewise-linear LUT built from wedges 1-8 to correct pixel values against known reference levels - Channel identification: detect AVHRR sensor channels (VIS, NIR, MIR, TIR) from wedge 9 values per APT sub-channel - Satellite identification: heuristic NOAA-15/18/19 detection from channel A/B sensor pairings - Histogram equalisation: per-channel contrast enhancement for improved image output - WxsatImage now carries satellite name and channel labels in decoded message broadcasts https://claude.ai/code/session_01JA13DHuzuHUL4nSBBRU83f Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+11
-11
@@ -2784,16 +2784,6 @@ dependencies = [
|
|||||||
"rustfft",
|
"rustfft",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "trx-noaa"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"image",
|
|
||||||
"num-complex",
|
|
||||||
"rustfft",
|
|
||||||
"trx-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-protocol"
|
name = "trx-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2848,11 +2838,11 @@ dependencies = [
|
|||||||
"trx-cw",
|
"trx-cw",
|
||||||
"trx-decode-log",
|
"trx-decode-log",
|
||||||
"trx-ftx",
|
"trx-ftx",
|
||||||
"trx-noaa",
|
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"trx-reporting",
|
"trx-reporting",
|
||||||
"trx-vdes",
|
"trx-vdes",
|
||||||
"trx-wspr",
|
"trx-wspr",
|
||||||
|
"trx-wxsat",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2868,6 +2858,16 @@ dependencies = [
|
|||||||
name = "trx-wspr"
|
name = "trx-wspr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-wxsat"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"image",
|
||||||
|
"num-complex",
|
||||||
|
"rustfft",
|
||||||
|
"trx-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"src/decoders/trx-ais",
|
"src/decoders/trx-ais",
|
||||||
"src/decoders/trx-noaa",
|
"src/decoders/trx-wxsat",
|
||||||
"src/decoders/trx-aprs",
|
"src/decoders/trx-aprs",
|
||||||
"src/decoders/trx-cw",
|
"src/decoders/trx-cw",
|
||||||
"src/decoders/trx-decode-log",
|
"src/decoders/trx-decode-log",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# SPDX-License-Identifier: BSD-2-Clause
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "trx-noaa"
|
name = "trx-wxsat"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
//! APT (Automatic Picture Transmission) demodulator and line decoder.
|
//! APT (Automatic Picture Transmission) demodulator and line decoder.
|
||||||
//!
|
//!
|
||||||
//! NOAA APT signal chain:
|
//! Weather satellite APT signal chain:
|
||||||
//! FM-demodulated audio → 2400 Hz AM subcarrier → envelope → 4160 Hz image
|
//! FM-demodulated audio → 2400 Hz AM subcarrier → envelope → 4160 Hz image
|
||||||
//!
|
//!
|
||||||
//! Frame layout at 4160 Hz (2080 samples = 0.5 s per line, 2 lines/sec):
|
//! Frame layout at 4160 Hz (2080 samples = 0.5 s per line, 2 lines/sec):
|
||||||
@@ -38,11 +38,18 @@ const SYNC_THRESHOLD: f32 = 0.15;
|
|||||||
const SYNC_SEARCH_LOCKED: usize = 12; // ±samples around expected sync position when locked
|
const SYNC_SEARCH_LOCKED: usize = 12; // ±samples around expected sync position when locked
|
||||||
const MAX_BAD_SYNC_LINES: u32 = 8; // unlock after this many low-confidence lines
|
const MAX_BAD_SYNC_LINES: u32 = 8; // unlock after this many low-confidence lines
|
||||||
|
|
||||||
/// A decoded APT line: raw pixel arrays for both image channels.
|
/// Telemetry block length (samples per channel).
|
||||||
|
pub const TEL_LEN: usize = 45;
|
||||||
|
|
||||||
|
/// A decoded APT line: raw pixel arrays for both image channels plus telemetry.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RawLine {
|
pub struct RawLine {
|
||||||
pub pixels_a: Box<[u8; IMAGE_A_LEN]>,
|
pub pixels_a: Box<[u8; IMAGE_A_LEN]>,
|
||||||
pub pixels_b: Box<[u8; IMAGE_B_LEN]>,
|
pub pixels_b: Box<[u8; IMAGE_B_LEN]>,
|
||||||
|
/// Telemetry block A (45 samples, normalised to 0-255).
|
||||||
|
pub tel_a: Box<[u8; TEL_LEN]>,
|
||||||
|
/// Telemetry block B (45 samples, normalised to 0-255).
|
||||||
|
pub tel_b: Box<[u8; TEL_LEN]>,
|
||||||
pub line_no: u32,
|
pub line_no: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,9 +317,27 @@ impl SyncTracker {
|
|||||||
*p = norm(samples[IMAGE_B_OFFSET + i]);
|
*p = norm(samples[IMAGE_B_OFFSET + i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract telemetry blocks (adjacent to image data)
|
||||||
|
let tel_a_offset = IMAGE_A_OFFSET + IMAGE_A_LEN; // right after image A
|
||||||
|
let tel_b_offset = IMAGE_B_OFFSET + IMAGE_B_LEN; // right after image B
|
||||||
|
let mut tel_a = Box::new([0u8; TEL_LEN]);
|
||||||
|
for (i, p) in tel_a.iter_mut().enumerate() {
|
||||||
|
if tel_a_offset + i < LINE_SAMPLES {
|
||||||
|
*p = norm(samples[tel_a_offset + i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut tel_b = Box::new([0u8; TEL_LEN]);
|
||||||
|
for (i, p) in tel_b.iter_mut().enumerate() {
|
||||||
|
if tel_b_offset + i < LINE_SAMPLES {
|
||||||
|
*p = norm(samples[tel_b_offset + i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.lines.push(RawLine {
|
self.lines.push(RawLine {
|
||||||
pixels_a,
|
pixels_a,
|
||||||
pixels_b,
|
pixels_b,
|
||||||
|
tel_a,
|
||||||
|
tel_b,
|
||||||
line_no: self.line_no,
|
line_no: self.line_no,
|
||||||
});
|
});
|
||||||
self.line_no += 1;
|
self.line_no += 1;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
//! NOAA APT satellite image decoder.
|
//! Weather satellite APT image decoder.
|
||||||
//!
|
//!
|
||||||
//! Decodes the Automatic Picture Transmission (APT) format broadcast by
|
//! Decodes the Automatic Picture Transmission (APT) format broadcast by
|
||||||
//! NOAA-15 (137.620 MHz), NOAA-18 (137.9125 MHz) and NOAA-19 (137.100 MHz).
|
//! NOAA-15 (137.620 MHz), NOAA-18 (137.9125 MHz) and NOAA-19 (137.100 MHz).
|
||||||
@@ -21,8 +21,10 @@
|
|||||||
|
|
||||||
pub mod apt;
|
pub mod apt;
|
||||||
mod image_enc;
|
mod image_enc;
|
||||||
|
pub mod telemetry;
|
||||||
|
|
||||||
use apt::{AptDemod, SyncTracker};
|
use apt::{AptDemod, SyncTracker};
|
||||||
|
use telemetry::{Satellite, SensorChannel};
|
||||||
|
|
||||||
/// JPEG encoding quality (0–100).
|
/// JPEG encoding quality (0–100).
|
||||||
const JPEG_QUALITY: u8 = 85;
|
const JPEG_QUALITY: u8 = 85;
|
||||||
@@ -35,9 +37,15 @@ pub struct AptImage {
|
|||||||
pub line_count: u32,
|
pub line_count: u32,
|
||||||
/// Millisecond timestamp when the first line was decoded.
|
/// Millisecond timestamp when the first line was decoded.
|
||||||
pub first_line_ms: i64,
|
pub first_line_ms: i64,
|
||||||
|
/// Identified satellite, if telemetry was decodable.
|
||||||
|
pub satellite: Satellite,
|
||||||
|
/// Detected sensor channel for sub-channel A.
|
||||||
|
pub sensor_a: SensorChannel,
|
||||||
|
/// Detected sensor channel for sub-channel B.
|
||||||
|
pub sensor_b: SensorChannel,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level NOAA APT decoder.
|
/// Top-level weather satellite APT decoder.
|
||||||
///
|
///
|
||||||
/// Feed audio samples with [`process_samples`] and call [`finalize`] at
|
/// Feed audio samples with [`process_samples`] and call [`finalize`] at
|
||||||
/// pass end to retrieve the assembled JPEG.
|
/// pass end to retrieve the assembled JPEG.
|
||||||
@@ -87,14 +95,56 @@ impl AptDecoder {
|
|||||||
|
|
||||||
/// Encode all accumulated lines as a JPEG image and return the result.
|
/// Encode all accumulated lines as a JPEG image and return the result.
|
||||||
///
|
///
|
||||||
|
/// Performs telemetry extraction, radiometric calibration (when enough
|
||||||
|
/// lines are available for a full 128-line telemetry frame), and
|
||||||
|
/// histogram equalisation before JPEG encoding.
|
||||||
|
///
|
||||||
/// Returns `None` if no lines have been decoded yet.
|
/// Returns `None` if no lines have been decoded yet.
|
||||||
/// Does **not** reset the decoder; call [`reset`] afterwards if needed.
|
/// Does **not** reset the decoder; call [`reset`] afterwards if needed.
|
||||||
pub fn finalize(&self) -> Option<AptImage> {
|
pub fn finalize(&self) -> Option<AptImage> {
|
||||||
let jpeg = image_enc::encode_jpeg(&self.sync.lines, JPEG_QUALITY)?;
|
if self.sync.lines.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract telemetry for calibration and satellite identification
|
||||||
|
let tel = telemetry::extract_telemetry(&self.sync.lines);
|
||||||
|
|
||||||
|
// Clone lines so we can apply calibration without mutating decoder state
|
||||||
|
let mut lines = self.sync.lines.clone();
|
||||||
|
|
||||||
|
let (satellite, sensor_a, sensor_b) = if let Some(ref tf) = tel {
|
||||||
|
// Apply radiometric calibration using telemetry wedge LUTs
|
||||||
|
for line in &mut lines {
|
||||||
|
telemetry::calibrate_line_a(&mut line.pixels_a, &tf.cal_lut_a);
|
||||||
|
telemetry::calibrate_line_b(&mut line.pixels_b, &tf.cal_lut_b);
|
||||||
|
}
|
||||||
|
(tf.satellite, tf.sensor_a, tf.sensor_b)
|
||||||
|
} else {
|
||||||
|
(Satellite::Unknown, SensorChannel::Unknown, SensorChannel::Unknown)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply histogram equalisation per-channel for contrast enhancement
|
||||||
|
let mut all_a: Vec<u8> = lines.iter().flat_map(|l| l.pixels_a.iter().copied()).collect();
|
||||||
|
let mut all_b: Vec<u8> = lines.iter().flat_map(|l| l.pixels_b.iter().copied()).collect();
|
||||||
|
telemetry::histogram_equalize(&mut all_a);
|
||||||
|
telemetry::histogram_equalize(&mut all_b);
|
||||||
|
|
||||||
|
// Write equalised pixels back
|
||||||
|
let width_a = apt::IMAGE_A_LEN;
|
||||||
|
let width_b = apt::IMAGE_B_LEN;
|
||||||
|
for (i, line) in lines.iter_mut().enumerate() {
|
||||||
|
line.pixels_a.copy_from_slice(&all_a[i * width_a..(i + 1) * width_a]);
|
||||||
|
line.pixels_b.copy_from_slice(&all_b[i * width_b..(i + 1) * width_b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let jpeg = image_enc::encode_jpeg(&lines, JPEG_QUALITY)?;
|
||||||
Some(AptImage {
|
Some(AptImage {
|
||||||
jpeg,
|
jpeg,
|
||||||
line_count: self.sync.lines.len() as u32,
|
line_count: lines.len() as u32,
|
||||||
first_line_ms: self.first_line_ms.unwrap_or_else(now_ms),
|
first_line_ms: self.first_line_ms.unwrap_or_else(now_ms),
|
||||||
|
satellite,
|
||||||
|
sensor_a,
|
||||||
|
sensor_b,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! APT telemetry frame parsing, satellite identification, and channel detection.
|
||||||
|
//!
|
||||||
|
//! Each APT line contains two 45-sample telemetry blocks (one per channel).
|
||||||
|
//! The telemetry frame repeats every 128 lines and contains 16 wedges of
|
||||||
|
//! 8 lines each. Wedges 1-8 carry calibration reference levels, wedge 9
|
||||||
|
//! carries the channel ID, and wedges 10-15 carry thermal calibration data.
|
||||||
|
//! Wedge 16 is the "zero modulation" reference (black body equivalent).
|
||||||
|
|
||||||
|
use crate::apt::{IMAGE_A_LEN, IMAGE_B_LEN, RawLine};
|
||||||
|
|
||||||
|
/// Lines per telemetry frame (128 lines = 16 wedges x 8 lines each).
|
||||||
|
pub const FRAME_LINES: usize = 128;
|
||||||
|
|
||||||
|
/// Lines per wedge.
|
||||||
|
pub const WEDGE_LINES: usize = 8;
|
||||||
|
|
||||||
|
/// Number of wedges in a telemetry frame.
|
||||||
|
pub const NUM_WEDGES: usize = 16;
|
||||||
|
|
||||||
|
/// The 8 calibration step values defined by the APT spec (wedges 1-8).
|
||||||
|
/// These represent known modulation levels from 1/8 to 8/8 of full scale.
|
||||||
|
pub const WEDGE_STEPS: [f32; 8] = [
|
||||||
|
0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// NOAA AVHRR sensor channel assignments.
|
||||||
|
///
|
||||||
|
/// The NOAA APT format transmits two channels simultaneously. Which sensors
|
||||||
|
/// are mapped to channel A and B depends on the satellite and illumination.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SensorChannel {
|
||||||
|
/// Channel 1: Visible (0.58 - 0.68 um)
|
||||||
|
Visible1,
|
||||||
|
/// Channel 2: Near-IR (0.725 - 1.0 um)
|
||||||
|
NearIr2,
|
||||||
|
/// Channel 3A: Near-IR (1.58 - 1.64 um) — daytime only on NOAA-15/18/19
|
||||||
|
NearIr3A,
|
||||||
|
/// Channel 3B: Mid-IR thermal (3.55 - 3.93 um)
|
||||||
|
MidIr3B,
|
||||||
|
/// Channel 4: Thermal IR (10.30 - 11.30 um)
|
||||||
|
ThermalIr4,
|
||||||
|
/// Channel 5: Thermal IR (11.50 - 12.50 um) — not on NOAA-15 APT
|
||||||
|
ThermalIr5,
|
||||||
|
/// Unknown / could not be determined.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SensorChannel {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SensorChannel::Visible1 => write!(f, "1-VIS"),
|
||||||
|
SensorChannel::NearIr2 => write!(f, "2-NIR"),
|
||||||
|
SensorChannel::NearIr3A => write!(f, "3A-NIR"),
|
||||||
|
SensorChannel::MidIr3B => write!(f, "3B-MIR"),
|
||||||
|
SensorChannel::ThermalIr4 => write!(f, "4-TIR"),
|
||||||
|
SensorChannel::ThermalIr5 => write!(f, "5-TIR"),
|
||||||
|
SensorChannel::Unknown => write!(f, "unknown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identified NOAA satellite.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Satellite {
|
||||||
|
Noaa15,
|
||||||
|
Noaa18,
|
||||||
|
Noaa19,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Satellite {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Satellite::Noaa15 => write!(f, "NOAA-15"),
|
||||||
|
Satellite::Noaa18 => write!(f, "NOAA-18"),
|
||||||
|
Satellite::Noaa19 => write!(f, "NOAA-19"),
|
||||||
|
Satellite::Unknown => write!(f, "Unknown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wedge 9 channel-ID values for each satellite.
|
||||||
|
///
|
||||||
|
/// The channel ID wedge has a distinctive grey level that encodes which
|
||||||
|
/// AVHRR sensor channel is being transmitted on that APT sub-channel.
|
||||||
|
/// Values are approximate normalised levels (0.0 - 1.0).
|
||||||
|
///
|
||||||
|
/// Reference: NOAA KLM User's Guide, Section 4.2 (APT format).
|
||||||
|
///
|
||||||
|
/// Channel A mapping:
|
||||||
|
/// Wedge 9 ≈ step 1 (1/8) → channel 1 (VIS)
|
||||||
|
/// Wedge 9 ≈ step 2 (2/8) → channel 2 (NIR)
|
||||||
|
/// Wedge 9 ≈ step 3 (3/8) → channel 3A (NIR, daytime)
|
||||||
|
///
|
||||||
|
/// Channel B mapping:
|
||||||
|
/// Wedge 9 ≈ step 4 (4/8) → channel 3B (MIR)
|
||||||
|
/// Wedge 9 ≈ step 5 (5/8) → channel 4 (TIR)
|
||||||
|
/// Wedge 9 ≈ step 6 (6/8) → channel 5 (TIR)
|
||||||
|
fn wedge9_to_sensor(normalised: f32) -> SensorChannel {
|
||||||
|
// Map to nearest step (1/8 increments)
|
||||||
|
let step = (normalised * 8.0).round() as u8;
|
||||||
|
match step {
|
||||||
|
1 => SensorChannel::Visible1,
|
||||||
|
2 => SensorChannel::NearIr2,
|
||||||
|
3 => SensorChannel::NearIr3A,
|
||||||
|
4 => SensorChannel::MidIr3B,
|
||||||
|
5 => SensorChannel::ThermalIr4,
|
||||||
|
6 => SensorChannel::ThermalIr5,
|
||||||
|
_ => SensorChannel::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracted telemetry data from one complete 128-line frame.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TelemetryFrame {
|
||||||
|
/// Mean pixel value for each of the 16 wedges (normalised 0.0 - 1.0).
|
||||||
|
pub wedge_means_a: [f32; NUM_WEDGES],
|
||||||
|
pub wedge_means_b: [f32; NUM_WEDGES],
|
||||||
|
/// Detected sensor channel for sub-channel A.
|
||||||
|
pub sensor_a: SensorChannel,
|
||||||
|
/// Detected sensor channel for sub-channel B.
|
||||||
|
pub sensor_b: SensorChannel,
|
||||||
|
/// Calibration mapping: maps raw pixel [0,255] → calibrated [0.0, 1.0]
|
||||||
|
/// using wedges 1-8 as known reference levels.
|
||||||
|
pub cal_lut_a: [u8; 256],
|
||||||
|
pub cal_lut_b: [u8; 256],
|
||||||
|
/// Identified satellite (from channel pairing heuristics).
|
||||||
|
pub satellite: Satellite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract telemetry from raw lines, requiring at least one full 128-line frame.
|
||||||
|
///
|
||||||
|
/// Picks the best complete frame (highest overall signal quality) and parses
|
||||||
|
/// wedge values from the telemetry blocks.
|
||||||
|
pub fn extract_telemetry(lines: &[RawLine]) -> Option<TelemetryFrame> {
|
||||||
|
if lines.len() < FRAME_LINES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the middle complete frame for best quality (avoids pass start/end noise)
|
||||||
|
let num_frames = lines.len() / FRAME_LINES;
|
||||||
|
let frame_idx = num_frames / 2;
|
||||||
|
let frame_start = frame_idx * FRAME_LINES;
|
||||||
|
let frame = &lines[frame_start..frame_start + FRAME_LINES];
|
||||||
|
|
||||||
|
// Extract wedge means from telemetry blocks.
|
||||||
|
// Each wedge spans 8 lines; we average the telemetry samples across those lines.
|
||||||
|
let mut wedge_means_a = [0.0f32; NUM_WEDGES];
|
||||||
|
let mut wedge_means_b = [0.0f32; NUM_WEDGES];
|
||||||
|
|
||||||
|
for wedge_idx in 0..NUM_WEDGES {
|
||||||
|
let line_start = wedge_idx * WEDGE_LINES;
|
||||||
|
let mut sum_a = 0.0f32;
|
||||||
|
let mut sum_b = 0.0f32;
|
||||||
|
let mut count = 0u32;
|
||||||
|
|
||||||
|
for line_offset in 0..WEDGE_LINES {
|
||||||
|
let line = &frame[line_start + line_offset];
|
||||||
|
for &v in line.tel_a.as_ref() {
|
||||||
|
sum_a += v as f32;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
for &v in line.tel_b.as_ref() {
|
||||||
|
sum_b += v as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
wedge_means_a[wedge_idx] = sum_a / count as f32 / 255.0;
|
||||||
|
wedge_means_b[wedge_idx] = sum_b / count as f32 / 255.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect sensor channels from wedge 9 (index 8)
|
||||||
|
let sensor_a = wedge9_to_sensor(wedge_means_a[8]);
|
||||||
|
let sensor_b = wedge9_to_sensor(wedge_means_b[8]);
|
||||||
|
|
||||||
|
// Build calibration LUTs from wedges 1-8
|
||||||
|
let cal_lut_a = build_calibration_lut(&wedge_means_a);
|
||||||
|
let cal_lut_b = build_calibration_lut(&wedge_means_b);
|
||||||
|
|
||||||
|
// Identify satellite from channel pairing
|
||||||
|
let satellite = identify_satellite(sensor_a, sensor_b);
|
||||||
|
|
||||||
|
Some(TelemetryFrame {
|
||||||
|
wedge_means_a,
|
||||||
|
wedge_means_b,
|
||||||
|
sensor_a,
|
||||||
|
sensor_b,
|
||||||
|
cal_lut_a,
|
||||||
|
cal_lut_b,
|
||||||
|
satellite,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a 256-entry calibration look-up table from wedge means.
|
||||||
|
///
|
||||||
|
/// Wedges 1-8 (indices 0-7) represent known reference levels at 1/8 to 8/8.
|
||||||
|
/// We fit a piecewise linear mapping from observed pixel values to calibrated
|
||||||
|
/// output levels, producing a corrected 0-255 output.
|
||||||
|
fn build_calibration_lut(wedge_means: &[f32; NUM_WEDGES]) -> [u8; 256] {
|
||||||
|
let mut lut = [0u8; 256];
|
||||||
|
|
||||||
|
// Collect (observed_pixel_value, target_normalised) pairs from wedges 1-8
|
||||||
|
let mut pairs: Vec<(f32, f32)> = Vec::with_capacity(8);
|
||||||
|
for i in 0..8 {
|
||||||
|
let observed = wedge_means[i] * 255.0;
|
||||||
|
let target = WEDGE_STEPS[i];
|
||||||
|
pairs.push((observed, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by observed value
|
||||||
|
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
// Deduplicate (if two wedges map to nearly the same observed value)
|
||||||
|
pairs.dedup_by(|a, b| (a.0 - b.0).abs() < 0.5);
|
||||||
|
|
||||||
|
if pairs.len() < 2 {
|
||||||
|
// Not enough calibration data — return identity
|
||||||
|
for (i, v) in lut.iter_mut().enumerate() {
|
||||||
|
*v = i as u8;
|
||||||
|
}
|
||||||
|
return lut;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Piecewise linear interpolation
|
||||||
|
for (i, entry) in lut.iter_mut().enumerate() {
|
||||||
|
let x = i as f32;
|
||||||
|
let calibrated = if x <= pairs[0].0 {
|
||||||
|
pairs[0].1
|
||||||
|
} else if x >= pairs[pairs.len() - 1].0 {
|
||||||
|
pairs[pairs.len() - 1].1
|
||||||
|
} else {
|
||||||
|
let mut cal = pairs[0].1;
|
||||||
|
for w in pairs.windows(2) {
|
||||||
|
if x >= w[0].0 && x <= w[1].0 {
|
||||||
|
let t = (x - w[0].0) / (w[1].0 - w[0].0).max(1e-6);
|
||||||
|
cal = w[0].1 + t * (w[1].1 - w[0].1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cal
|
||||||
|
};
|
||||||
|
*entry = (calibrated * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
lut
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identify the satellite based on channel pairing heuristics.
|
||||||
|
///
|
||||||
|
/// Typical APT channel pairings:
|
||||||
|
/// - NOAA-15: Ch A = 2 (NIR), Ch B = 4 (TIR) daytime;
|
||||||
|
/// Ch A = 3A (NIR), Ch B = 4 (TIR) alternate daytime
|
||||||
|
/// - NOAA-18: Ch A = 1 (VIS), Ch B = 4 (TIR) daytime;
|
||||||
|
/// Ch A = 3A (NIR), Ch B = 4 (TIR) alternate
|
||||||
|
/// - NOAA-19: Ch A = 2 (NIR), Ch B = 4 (TIR) daytime
|
||||||
|
///
|
||||||
|
/// Night passes typically transmit Ch 3B or Ch 4 on channel A.
|
||||||
|
fn identify_satellite(sensor_a: SensorChannel, sensor_b: SensorChannel) -> Satellite {
|
||||||
|
match (sensor_a, sensor_b) {
|
||||||
|
// NOAA-18 typically sends VIS ch1 on A
|
||||||
|
(SensorChannel::Visible1, SensorChannel::ThermalIr4) => Satellite::Noaa18,
|
||||||
|
// NOAA-15 and NOAA-19 both send NIR ch2 on A; distinguish by B channel
|
||||||
|
(SensorChannel::NearIr2, SensorChannel::ThermalIr4) => {
|
||||||
|
// Both NOAA-15 and NOAA-19 use this pairing; cannot easily distinguish
|
||||||
|
// without orbital data. Default to NOAA-19 (most common active).
|
||||||
|
Satellite::Noaa19
|
||||||
|
}
|
||||||
|
(SensorChannel::NearIr3A, SensorChannel::ThermalIr4) => Satellite::Noaa15,
|
||||||
|
(SensorChannel::NearIr2, SensorChannel::ThermalIr5) => Satellite::Noaa19,
|
||||||
|
_ => Satellite::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply calibration LUT to a line's pixel data (in-place).
|
||||||
|
pub fn calibrate_line_a(pixels: &mut [u8; IMAGE_A_LEN], lut: &[u8; 256]) {
|
||||||
|
for p in pixels.iter_mut() {
|
||||||
|
*p = lut[*p as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply calibration LUT to a line's pixel data (in-place).
|
||||||
|
pub fn calibrate_line_b(pixels: &mut [u8; IMAGE_B_LEN], lut: &[u8; 256]) {
|
||||||
|
for p in pixels.iter_mut() {
|
||||||
|
*p = lut[*p as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply histogram equalisation to an image channel for contrast enhancement.
|
||||||
|
pub fn histogram_equalize(pixels: &mut [u8]) {
|
||||||
|
if pixels.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build histogram
|
||||||
|
let mut hist = [0u32; 256];
|
||||||
|
for &p in pixels.iter() {
|
||||||
|
hist[p as usize] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute CDF
|
||||||
|
let mut cdf = [0u32; 256];
|
||||||
|
cdf[0] = hist[0];
|
||||||
|
for i in 1..256 {
|
||||||
|
cdf[i] = cdf[i - 1] + hist[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find minimum non-zero CDF value
|
||||||
|
let cdf_min = cdf.iter().copied().find(|&v| v > 0).unwrap_or(0);
|
||||||
|
let total = pixels.len() as u32;
|
||||||
|
let denom = (total - cdf_min).max(1);
|
||||||
|
|
||||||
|
// Build equalisation LUT
|
||||||
|
let mut lut = [0u8; 256];
|
||||||
|
for i in 0..256 {
|
||||||
|
lut[i] = ((cdf[i].saturating_sub(cdf_min) as f64 / denom as f64) * 255.0).round() as u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
for p in pixels.iter_mut() {
|
||||||
|
*p = lut[*p as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wedge9_to_sensor() {
|
||||||
|
assert_eq!(wedge9_to_sensor(0.125), SensorChannel::Visible1);
|
||||||
|
assert_eq!(wedge9_to_sensor(0.25), SensorChannel::NearIr2);
|
||||||
|
assert_eq!(wedge9_to_sensor(0.375), SensorChannel::NearIr3A);
|
||||||
|
assert_eq!(wedge9_to_sensor(0.5), SensorChannel::MidIr3B);
|
||||||
|
assert_eq!(wedge9_to_sensor(0.625), SensorChannel::ThermalIr4);
|
||||||
|
assert_eq!(wedge9_to_sensor(0.75), SensorChannel::ThermalIr5);
|
||||||
|
assert_eq!(wedge9_to_sensor(0.0), SensorChannel::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_identify_satellite() {
|
||||||
|
assert_eq!(
|
||||||
|
identify_satellite(SensorChannel::Visible1, SensorChannel::ThermalIr4),
|
||||||
|
Satellite::Noaa18
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
identify_satellite(SensorChannel::NearIr2, SensorChannel::ThermalIr4),
|
||||||
|
Satellite::Noaa19
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
identify_satellite(SensorChannel::NearIr3A, SensorChannel::ThermalIr4),
|
||||||
|
Satellite::Noaa15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calibration_lut_identity_on_insufficient_data() {
|
||||||
|
let mut means = [0.0f32; NUM_WEDGES];
|
||||||
|
// All zeros → insufficient data → identity LUT
|
||||||
|
let lut = build_calibration_lut(&means);
|
||||||
|
for i in 0..256 {
|
||||||
|
assert_eq!(lut[i], i as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One non-zero wedge still insufficient (need ≥ 2 distinct)
|
||||||
|
means[0] = 0.5;
|
||||||
|
let lut = build_calibration_lut(&means);
|
||||||
|
// Still degenerate
|
||||||
|
assert!(lut[0] == lut[0]); // trivially true, but confirms no panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_histogram_equalize_uniform() {
|
||||||
|
// Uniform distribution should remain roughly unchanged
|
||||||
|
let mut pixels: Vec<u8> = (0..=255).collect();
|
||||||
|
histogram_equalize(&mut pixels);
|
||||||
|
// After equalization, values should span full range
|
||||||
|
assert_eq!(*pixels.first().unwrap(), 0);
|
||||||
|
assert_eq!(*pixels.last().unwrap(), 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sensor_channel_display() {
|
||||||
|
assert_eq!(format!("{}", SensorChannel::Visible1), "1-VIS");
|
||||||
|
assert_eq!(format!("{}", SensorChannel::ThermalIr4), "4-TIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_satellite_display() {
|
||||||
|
assert_eq!(format!("{}", Satellite::Noaa15), "NOAA-15");
|
||||||
|
assert_eq!(format!("{}", Satellite::Noaa19), "NOAA-19");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -519,7 +519,7 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
history.push_back((now, None, message));
|
history.push_back((now, None, message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DecodedMessage::NoaaImage(_) => {}
|
DecodedMessage::WxsatImage(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2485,7 +2485,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
|
|||||||
ft4_decode_enabled: state.ft4_decode_enabled,
|
ft4_decode_enabled: state.ft4_decode_enabled,
|
||||||
ft2_decode_enabled: state.ft2_decode_enabled,
|
ft2_decode_enabled: state.ft2_decode_enabled,
|
||||||
wspr_decode_enabled: state.wspr_decode_enabled,
|
wspr_decode_enabled: state.wspr_decode_enabled,
|
||||||
noaa_decode_enabled: state.noaa_decode_enabled,
|
wxsat_decode_enabled: state.wxsat_decode_enabled,
|
||||||
filter: state.filter.clone(),
|
filter: state.filter.clone(),
|
||||||
spectrum: None,
|
spectrum: None,
|
||||||
vchan_rds: None,
|
vchan_rds: None,
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
|||||||
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
|
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
|
||||||
DecodedMessage::Ft2(msg) => record_ft2(&context, msg),
|
DecodedMessage::Ft2(msg) => record_ft2(&context, msg),
|
||||||
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
||||||
DecodedMessage::NoaaImage(_) => {}
|
DecodedMessage::WxsatImage(_) => {}
|
||||||
},
|
},
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ pub const AUDIO_MSG_VCHAN_BW: u8 = 0x13;
|
|||||||
pub const AUDIO_MSG_FT4_DECODE: u8 = 0x14;
|
pub const AUDIO_MSG_FT4_DECODE: u8 = 0x14;
|
||||||
/// Server → client: FT2 decoded message (JSON `DecodedMessage::Ft2`).
|
/// Server → client: FT2 decoded message (JSON `DecodedMessage::Ft2`).
|
||||||
pub const AUDIO_MSG_FT2_DECODE: u8 = 0x15;
|
pub const AUDIO_MSG_FT2_DECODE: u8 = 0x15;
|
||||||
/// Server → client: NOAA APT image complete (JSON `DecodedMessage::NoaaImage`).
|
/// Server → client: weather satellite APT image complete (JSON `DecodedMessage::WxsatImage`).
|
||||||
pub const AUDIO_MSG_NOAA_IMAGE: u8 = 0x16;
|
pub const AUDIO_MSG_WXSAT_IMAGE: u8 = 0x16;
|
||||||
|
|
||||||
/// Maximum payload size for normal messages (1 MB).
|
/// Maximum payload size for normal messages (1 MB).
|
||||||
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ pub enum DecodedMessage {
|
|||||||
Ft2(Ft8Message),
|
Ft2(Ft8Message),
|
||||||
#[serde(rename = "wspr")]
|
#[serde(rename = "wspr")]
|
||||||
Wspr(WsprMessage),
|
Wspr(WsprMessage),
|
||||||
#[serde(rename = "noaa_image")]
|
#[serde(rename = "wxsat_image")]
|
||||||
NoaaImage(NoaaImage),
|
WxsatImage(WxsatImage),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DecodedMessage {
|
impl DecodedMessage {
|
||||||
@@ -42,7 +42,7 @@ impl DecodedMessage {
|
|||||||
Self::Cw(m) => m.rig_id = Some(id),
|
Self::Cw(m) => m.rig_id = Some(id),
|
||||||
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id = Some(id),
|
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id = Some(id),
|
||||||
Self::Wspr(m) => m.rig_id = Some(id),
|
Self::Wspr(m) => m.rig_id = Some(id),
|
||||||
Self::NoaaImage(m) => m.rig_id = Some(id),
|
Self::WxsatImage(m) => m.rig_id = Some(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ impl DecodedMessage {
|
|||||||
Self::Cw(m) => m.rig_id.as_deref(),
|
Self::Cw(m) => m.rig_id.as_deref(),
|
||||||
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id.as_deref(),
|
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id.as_deref(),
|
||||||
Self::Wspr(m) => m.rig_id.as_deref(),
|
Self::Wspr(m) => m.rig_id.as_deref(),
|
||||||
Self::NoaaImage(m) => m.rig_id.as_deref(),
|
Self::WxsatImage(m) => m.rig_id.as_deref(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,9 +207,9 @@ pub struct Ft8Message {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A completed NOAA APT satellite image, saved to disk as a JPEG.
|
/// A completed weather satellite APT image, saved to disk as a JPEG.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NoaaImage {
|
pub struct WxsatImage {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub rig_id: Option<String>,
|
pub rig_id: Option<String>,
|
||||||
/// UTC timestamp (milliseconds since epoch) of pass start (first decoded line).
|
/// UTC timestamp (milliseconds since epoch) of pass start (first decoded line).
|
||||||
@@ -222,6 +222,15 @@ pub struct NoaaImage {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ts_ms: Option<i64>,
|
pub ts_ms: Option<i64>,
|
||||||
|
/// Identified satellite (e.g. "NOAA-15", "NOAA-18", "NOAA-19").
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub satellite: Option<String>,
|
||||||
|
/// Sensor channel name for sub-channel A (e.g. "1-VIS", "2-NIR", "4-TIR").
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub channel_a: Option<String>,
|
||||||
|
/// Sensor channel name for sub-channel B.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub channel_b: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub struct RigState {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wspr_decode_enabled: bool,
|
pub wspr_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub noaa_decode_enabled: bool,
|
pub wxsat_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cw_auto: bool,
|
pub cw_auto: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -80,7 +80,7 @@ pub struct RigState {
|
|||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub wspr_decode_reset_seq: u64,
|
pub wspr_decode_reset_seq: u64,
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub noaa_decode_reset_seq: u64,
|
pub wxsat_decode_reset_seq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mode supported by the rig.
|
/// Mode supported by the rig.
|
||||||
@@ -163,7 +163,7 @@ impl RigState {
|
|||||||
ft4_decode_enabled: false,
|
ft4_decode_enabled: false,
|
||||||
ft2_decode_enabled: false,
|
ft2_decode_enabled: false,
|
||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
noaa_decode_enabled: false,
|
wxsat_decode_enabled: false,
|
||||||
cw_auto: true,
|
cw_auto: true,
|
||||||
cw_wpm: 15,
|
cw_wpm: 15,
|
||||||
cw_tone_hz: 700,
|
cw_tone_hz: 700,
|
||||||
@@ -177,7 +177,7 @@ impl RigState {
|
|||||||
ft4_decode_reset_seq: 0,
|
ft4_decode_reset_seq: 0,
|
||||||
ft2_decode_reset_seq: 0,
|
ft2_decode_reset_seq: 0,
|
||||||
wspr_decode_reset_seq: 0,
|
wspr_decode_reset_seq: 0,
|
||||||
noaa_decode_reset_seq: 0,
|
wxsat_decode_reset_seq: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ impl RigState {
|
|||||||
ft4_decode_enabled: snapshot.ft4_decode_enabled,
|
ft4_decode_enabled: snapshot.ft4_decode_enabled,
|
||||||
ft2_decode_enabled: snapshot.ft2_decode_enabled,
|
ft2_decode_enabled: snapshot.ft2_decode_enabled,
|
||||||
wspr_decode_enabled: snapshot.wspr_decode_enabled,
|
wspr_decode_enabled: snapshot.wspr_decode_enabled,
|
||||||
noaa_decode_enabled: snapshot.noaa_decode_enabled,
|
wxsat_decode_enabled: snapshot.wxsat_decode_enabled,
|
||||||
filter: snapshot.filter,
|
filter: snapshot.filter,
|
||||||
spectrum: None, // spectrum flows through /api/spectrum, not persistent state
|
spectrum: None, // spectrum flows through /api/spectrum, not persistent state
|
||||||
vchan_rds: None, // vchan RDS flows through /api/spectrum, not persistent state
|
vchan_rds: None, // vchan RDS flows through /api/spectrum, not persistent state
|
||||||
@@ -248,7 +248,7 @@ impl RigState {
|
|||||||
ft4_decode_reset_seq: 0,
|
ft4_decode_reset_seq: 0,
|
||||||
ft2_decode_reset_seq: 0,
|
ft2_decode_reset_seq: 0,
|
||||||
wspr_decode_reset_seq: 0,
|
wspr_decode_reset_seq: 0,
|
||||||
noaa_decode_reset_seq: 0,
|
wxsat_decode_reset_seq: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ impl RigState {
|
|||||||
ft4_decode_enabled: self.ft4_decode_enabled,
|
ft4_decode_enabled: self.ft4_decode_enabled,
|
||||||
ft2_decode_enabled: self.ft2_decode_enabled,
|
ft2_decode_enabled: self.ft2_decode_enabled,
|
||||||
wspr_decode_enabled: self.wspr_decode_enabled,
|
wspr_decode_enabled: self.wspr_decode_enabled,
|
||||||
noaa_decode_enabled: self.noaa_decode_enabled,
|
wxsat_decode_enabled: self.wxsat_decode_enabled,
|
||||||
filter: self.filter.clone(),
|
filter: self.filter.clone(),
|
||||||
spectrum: self.spectrum.clone(),
|
spectrum: self.spectrum.clone(),
|
||||||
vchan_rds: self.vchan_rds.clone(),
|
vchan_rds: self.vchan_rds.clone(),
|
||||||
@@ -498,7 +498,7 @@ pub struct RigSnapshot {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wspr_decode_enabled: bool,
|
pub wspr_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub noaa_decode_enabled: bool,
|
pub wxsat_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cw_auto: bool,
|
pub cw_auto: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ mod tests {
|
|||||||
ft4_decode_enabled: false,
|
ft4_decode_enabled: false,
|
||||||
ft2_decode_enabled: false,
|
ft2_decode_enabled: false,
|
||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
|
wxsat_decode_enabled: false,
|
||||||
cw_auto: false,
|
cw_auto: false,
|
||||||
cw_wpm: 0,
|
cw_wpm: 0,
|
||||||
cw_tone_hz: 0,
|
cw_tone_hz: 0,
|
||||||
|
|||||||
@@ -39,6 +39,6 @@ trx-cw = { path = "../decoders/trx-cw" }
|
|||||||
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
||||||
trx-ftx = { path = "../decoders/trx-ftx" }
|
trx-ftx = { path = "../decoders/trx-ftx" }
|
||||||
trx-wspr = { path = "../decoders/trx-wspr" }
|
trx-wspr = { path = "../decoders/trx-wspr" }
|
||||||
trx-noaa = { path = "../decoders/trx-noaa" }
|
trx-wxsat = { path = "../decoders/trx-wxsat" }
|
||||||
trx-protocol = { path = "../trx-protocol" }
|
trx-protocol = { path = "../trx-protocol" }
|
||||||
trx-reporting = { path = "../trx-reporting" }
|
trx-reporting = { path = "../trx-reporting" }
|
||||||
+57
-48
@@ -25,21 +25,21 @@ use trx_core::audio::{
|
|||||||
parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_audio_frame,
|
parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_audio_frame,
|
||||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_NOAA_IMAGE,
|
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_WXSAT_IMAGE,
|
||||||
AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||||
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||||
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||||
AUDIO_MSG_WSPR_DECODE,
|
AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, NoaaImage, VdesMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WsprMessage,
|
||||||
WsprMessage,
|
WxsatImage,
|
||||||
};
|
};
|
||||||
use trx_core::rig::state::{RigMode, RigState};
|
use trx_core::rig::state::{RigMode, RigState};
|
||||||
use trx_core::vchan::SharedVChanManager;
|
use trx_core::vchan::SharedVChanManager;
|
||||||
use trx_cw::CwDecoder;
|
use trx_cw::CwDecoder;
|
||||||
use trx_ftx::Ft8Decoder;
|
use trx_ftx::Ft8Decoder;
|
||||||
use trx_noaa::AptDecoder;
|
use trx_wxsat::AptDecoder;
|
||||||
use trx_vdes::VdesDecoder;
|
use trx_vdes::VdesDecoder;
|
||||||
use trx_wspr::WsprDecoder;
|
use trx_wspr::WsprDecoder;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -54,9 +54,9 @@ const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
|||||||
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const NOAA_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const WXSAT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
/// Silence timeout before auto-finalising a NOAA pass (30 s without new lines).
|
/// Silence timeout before auto-finalising a wxsat pass (30 s without new lines).
|
||||||
const NOAA_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
const WXSAT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const FT8_SAMPLE_RATE: u32 = 12_000;
|
const FT8_SAMPLE_RATE: u32 = 12_000;
|
||||||
const FT2_ASYNC_BUFFER_SAMPLES: usize = 45_000;
|
const FT2_ASYNC_BUFFER_SAMPLES: usize = 45_000;
|
||||||
const FT2_ASYNC_TRIGGER_SAMPLES: usize = 9_000;
|
const FT2_ASYNC_TRIGGER_SAMPLES: usize = 9_000;
|
||||||
@@ -213,7 +213,7 @@ pub struct DecoderHistories {
|
|||||||
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||||
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||||
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||||
pub noaa: Mutex<VecDeque<(Instant, NoaaImage)>>,
|
pub wxsat: Mutex<VecDeque<(Instant, WxsatImage)>>,
|
||||||
/// Approximate total entry count across all decoders, maintained
|
/// Approximate total entry count across all decoders, maintained
|
||||||
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
|
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
|
||||||
total_count: AtomicUsize,
|
total_count: AtomicUsize,
|
||||||
@@ -231,7 +231,7 @@ impl DecoderHistories {
|
|||||||
ft4: Mutex::new(VecDeque::new()),
|
ft4: Mutex::new(VecDeque::new()),
|
||||||
ft2: Mutex::new(VecDeque::new()),
|
ft2: Mutex::new(VecDeque::new()),
|
||||||
wspr: Mutex::new(VecDeque::new()),
|
wspr: Mutex::new(VecDeque::new()),
|
||||||
noaa: Mutex::new(VecDeque::new()),
|
wxsat: Mutex::new(VecDeque::new()),
|
||||||
total_count: AtomicUsize::new(0),
|
total_count: AtomicUsize::new(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -591,10 +591,10 @@ impl DecoderHistories {
|
|||||||
self.adjust_total_count(before, 0);
|
self.adjust_total_count(before, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NOAA ---
|
// --- WXSAT ---
|
||||||
|
|
||||||
fn prune_noaa(history: &mut VecDeque<(Instant, NoaaImage)>) {
|
fn prune_wxsat(history: &mut VecDeque<(Instant, WxsatImage)>) {
|
||||||
let cutoff = Instant::now() - NOAA_HISTORY_RETENTION;
|
let cutoff = Instant::now() - WXSAT_HISTORY_RETENTION;
|
||||||
while let Some((ts, _)) = history.front() {
|
while let Some((ts, _)) = history.front() {
|
||||||
if *ts < cutoff {
|
if *ts < cutoff {
|
||||||
history.pop_front();
|
history.pop_front();
|
||||||
@@ -604,21 +604,21 @@ impl DecoderHistories {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_noaa_image(&self, mut img: NoaaImage) {
|
pub fn record_wxsat_image(&self, mut img: WxsatImage) {
|
||||||
if img.ts_ms.is_none() {
|
if img.ts_ms.is_none() {
|
||||||
img.ts_ms = Some(current_timestamp_ms());
|
img.ts_ms = Some(current_timestamp_ms());
|
||||||
}
|
}
|
||||||
let mut h = self.noaa.lock().unwrap_or_else(|e| e.into_inner());
|
let mut h = self.wxsat.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let before = h.len();
|
let before = h.len();
|
||||||
h.push_back((Instant::now(), img));
|
h.push_back((Instant::now(), img));
|
||||||
Self::prune_noaa(&mut h);
|
Self::prune_wxsat(&mut h);
|
||||||
self.adjust_total_count(before, h.len());
|
self.adjust_total_count(before, h.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot_noaa_history(&self) -> Vec<NoaaImage> {
|
pub fn snapshot_wxsat_history(&self) -> Vec<WxsatImage> {
|
||||||
let mut h = self.noaa.lock().unwrap_or_else(|e| e.into_inner());
|
let mut h = self.wxsat.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let before = h.len();
|
let before = h.len();
|
||||||
Self::prune_noaa(&mut h);
|
Self::prune_wxsat(&mut h);
|
||||||
self.adjust_total_count(before, h.len());
|
self.adjust_total_count(before, h.len());
|
||||||
h.iter().map(|(_, img)| img.clone()).collect()
|
h.iter().map(|(_, img)| img.clone()).collect()
|
||||||
}
|
}
|
||||||
@@ -2394,16 +2394,16 @@ pub async fn run_wspr_decoder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// NOAA APT decoder task
|
// Weather satellite APT decoder task
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Decode NOAA APT satellite images from FM-demodulated audio.
|
/// Decode weather satellite APT images from FM-demodulated audio.
|
||||||
///
|
///
|
||||||
/// The task is idle until `state.noaa_decode_enabled` becomes `true`.
|
/// The task is idle until `state.wxsat_decode_enabled` becomes `true`.
|
||||||
/// When the user disables the decoder (or 30 s of silence elapses with no
|
/// When the user disables the decoder (or 30 s of silence elapses with no
|
||||||
/// new decoded lines), the accumulated image is encoded as JPEG and saved to
|
/// new decoded lines), the accumulated image is encoded as JPEG and saved to
|
||||||
/// `output_dir/<YYYY-MM-DD_HH-MM-SS>.jpg`.
|
/// `output_dir/<YYYY-MM-DD_HH-MM-SS>.jpg`.
|
||||||
pub async fn run_noaa_decoder(
|
pub async fn run_wxsat_decoder(
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u16,
|
channels: u16,
|
||||||
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||||
@@ -2412,10 +2412,10 @@ pub async fn run_noaa_decoder(
|
|||||||
histories: Arc<DecoderHistories>,
|
histories: Arc<DecoderHistories>,
|
||||||
output_dir: std::path::PathBuf,
|
output_dir: std::path::PathBuf,
|
||||||
) {
|
) {
|
||||||
info!("NOAA decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("wxsat decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let mut decoder = AptDecoder::new(sample_rate);
|
let mut decoder = AptDecoder::new(sample_rate);
|
||||||
let mut last_reset_seq: u64 = 0;
|
let mut last_reset_seq: u64 = 0;
|
||||||
let mut active = state_rx.borrow().noaa_decode_enabled;
|
let mut active = state_rx.borrow().wxsat_decode_enabled;
|
||||||
let mut pass_start_ms: i64 = 0;
|
let mut pass_start_ms: i64 = 0;
|
||||||
// Instant of the last time new lines were decoded (for auto-finalise)
|
// Instant of the last time new lines were decoded (for auto-finalise)
|
||||||
let mut last_line_at = tokio::time::Instant::now();
|
let mut last_line_at = tokio::time::Instant::now();
|
||||||
@@ -2430,15 +2430,15 @@ pub async fn run_noaa_decoder(
|
|||||||
match state_rx.changed().await {
|
match state_rx.changed().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let state = state_rx.borrow();
|
let state = state_rx.borrow();
|
||||||
active = state.noaa_decode_enabled;
|
active = state.wxsat_decode_enabled;
|
||||||
if active {
|
if active {
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
pass_start_ms = current_timestamp_ms();
|
pass_start_ms = current_timestamp_ms();
|
||||||
last_line_at = tokio::time::Instant::now();
|
last_line_at = tokio::time::Instant::now();
|
||||||
pcm_rx = pcm_rx.resubscribe();
|
pcm_rx = pcm_rx.resubscribe();
|
||||||
}
|
}
|
||||||
if state.noaa_decode_reset_seq != last_reset_seq {
|
if state.wxsat_decode_reset_seq != last_reset_seq {
|
||||||
last_reset_seq = state.noaa_decode_reset_seq;
|
last_reset_seq = state.wxsat_decode_reset_seq;
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2447,13 +2447,13 @@ pub async fn run_noaa_decoder(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let silence_deadline = last_line_at + NOAA_PASS_SILENCE_TIMEOUT;
|
let silence_deadline = last_line_at + WXSAT_PASS_SILENCE_TIMEOUT;
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
recv = pcm_rx.recv() => {
|
recv = pcm_rx.recv() => {
|
||||||
match recv {
|
match recv {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
let reset_seq = state_rx.borrow().noaa_decode_reset_seq;
|
let reset_seq = state_rx.borrow().wxsat_decode_reset_seq;
|
||||||
if reset_seq != last_reset_seq {
|
if reset_seq != last_reset_seq {
|
||||||
last_reset_seq = reset_seq;
|
last_reset_seq = reset_seq;
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
@@ -2468,7 +2468,7 @@ pub async fn run_noaa_decoder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
warn!("NOAA decoder: dropped {} PCM frames", n);
|
warn!("wxsat decoder: dropped {} PCM frames", n);
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
}
|
}
|
||||||
@@ -2479,7 +2479,7 @@ pub async fn run_noaa_decoder(
|
|||||||
// Extract fields before any await so the Ref is dropped.
|
// Extract fields before any await so the Ref is dropped.
|
||||||
let (new_active, new_reset_seq) = {
|
let (new_active, new_reset_seq) = {
|
||||||
let state = state_rx.borrow();
|
let state = state_rx.borrow();
|
||||||
(state.noaa_decode_enabled, state.noaa_decode_reset_seq)
|
(state.wxsat_decode_enabled, state.wxsat_decode_reset_seq)
|
||||||
};
|
};
|
||||||
let was_active = active;
|
let was_active = active;
|
||||||
active = new_active;
|
active = new_active;
|
||||||
@@ -2490,7 +2490,7 @@ pub async fn run_noaa_decoder(
|
|||||||
}
|
}
|
||||||
if was_active && !active {
|
if was_active && !active {
|
||||||
// User disabled — finalise whatever we have
|
// User disabled — finalise whatever we have
|
||||||
finalize_noaa_pass(
|
finalize_wxsat_pass(
|
||||||
&mut decoder,
|
&mut decoder,
|
||||||
&output_dir,
|
&output_dir,
|
||||||
&decode_tx,
|
&decode_tx,
|
||||||
@@ -2510,11 +2510,11 @@ pub async fn run_noaa_decoder(
|
|||||||
// Auto-finalise after sustained silence (satellite pass ended)
|
// Auto-finalise after sustained silence (satellite pass ended)
|
||||||
_ = tokio::time::sleep_until(silence_deadline), if decoder.line_count() > 0 => {
|
_ = tokio::time::sleep_until(silence_deadline), if decoder.line_count() > 0 => {
|
||||||
info!(
|
info!(
|
||||||
"NOAA: no new lines for {}s — finalising pass ({} lines)",
|
"wxsat: no new lines for {}s — finalising pass ({} lines)",
|
||||||
NOAA_PASS_SILENCE_TIMEOUT.as_secs(),
|
WXSAT_PASS_SILENCE_TIMEOUT.as_secs(),
|
||||||
decoder.line_count()
|
decoder.line_count()
|
||||||
);
|
);
|
||||||
finalize_noaa_pass(
|
finalize_wxsat_pass(
|
||||||
&mut decoder,
|
&mut decoder,
|
||||||
&output_dir,
|
&output_dir,
|
||||||
&decode_tx,
|
&decode_tx,
|
||||||
@@ -2530,8 +2530,8 @@ pub async fn run_noaa_decoder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encode all accumulated lines as JPEG, write to disk, and broadcast the
|
/// Encode all accumulated lines as JPEG, write to disk, and broadcast the
|
||||||
/// `DecodedMessage::NoaaImage` event. No-ops if fewer than 2 lines decoded.
|
/// `DecodedMessage::WxsatImage` event. No-ops if fewer than 2 lines decoded.
|
||||||
async fn finalize_noaa_pass(
|
async fn finalize_wxsat_pass(
|
||||||
decoder: &mut AptDecoder,
|
decoder: &mut AptDecoder,
|
||||||
output_dir: &std::path::Path,
|
output_dir: &std::path::Path,
|
||||||
decode_tx: &broadcast::Sender<DecodedMessage>,
|
decode_tx: &broadcast::Sender<DecodedMessage>,
|
||||||
@@ -2556,7 +2556,7 @@ async fn finalize_noaa_pass(
|
|||||||
|
|
||||||
if let Err(e) = std::fs::create_dir_all(output_dir) {
|
if let Err(e) = std::fs::create_dir_all(output_dir) {
|
||||||
warn!(
|
warn!(
|
||||||
"NOAA: failed to create output directory {:?}: {}",
|
"wxsat: failed to create output directory {:?}: {}",
|
||||||
output_dir, e
|
output_dir, e
|
||||||
);
|
);
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
@@ -2565,26 +2565,35 @@ async fn finalize_noaa_pass(
|
|||||||
|
|
||||||
match std::fs::write(&path, &apt_image.jpeg) {
|
match std::fs::write(&path, &apt_image.jpeg) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
let sat_str = format!("{}", apt_image.satellite);
|
||||||
|
let ch_a_str = format!("{}", apt_image.sensor_a);
|
||||||
|
let ch_b_str = format!("{}", apt_image.sensor_b);
|
||||||
info!(
|
info!(
|
||||||
"NOAA: saved {} ({} lines, {} bytes) to {:?}",
|
"wxsat: saved {} ({} lines, {} bytes, {}, A={}, B={}) to {:?}",
|
||||||
filename,
|
filename,
|
||||||
apt_image.line_count,
|
apt_image.line_count,
|
||||||
apt_image.jpeg.len(),
|
apt_image.jpeg.len(),
|
||||||
|
sat_str,
|
||||||
|
ch_a_str,
|
||||||
|
ch_b_str,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
let img = NoaaImage {
|
let img = WxsatImage {
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
pass_start_ms: apt_image.first_line_ms,
|
pass_start_ms: apt_image.first_line_ms,
|
||||||
pass_end_ms,
|
pass_end_ms,
|
||||||
line_count: apt_image.line_count,
|
line_count: apt_image.line_count,
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: path.to_string_lossy().into_owned(),
|
||||||
ts_ms: Some(pass_end_ms),
|
ts_ms: Some(pass_end_ms),
|
||||||
|
satellite: Some(sat_str),
|
||||||
|
channel_a: Some(ch_a_str),
|
||||||
|
channel_b: Some(ch_b_str),
|
||||||
};
|
};
|
||||||
histories.record_noaa_image(img.clone());
|
histories.record_wxsat_image(img.clone());
|
||||||
let _ = decode_tx.send(DecodedMessage::NoaaImage(img));
|
let _ = decode_tx.send(DecodedMessage::WxsatImage(img));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("NOAA: failed to write {:?}: {}", path, e);
|
warn!("wxsat: failed to write {:?}: {}", path, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3191,9 +3200,9 @@ async fn handle_audio_client(
|
|||||||
AUDIO_MSG_CW_DECODE
|
AUDIO_MSG_CW_DECODE
|
||||||
);
|
);
|
||||||
push_history!(
|
push_history!(
|
||||||
histories.snapshot_noaa_history(),
|
histories.snapshot_wxsat_history(),
|
||||||
DecodedMessage::NoaaImage,
|
DecodedMessage::WxsatImage,
|
||||||
AUDIO_MSG_NOAA_IMAGE
|
AUDIO_MSG_WXSAT_IMAGE
|
||||||
);
|
);
|
||||||
|
|
||||||
(blob, count)
|
(blob, count)
|
||||||
@@ -3278,7 +3287,7 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
||||||
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
||||||
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
||||||
DecodedMessage::NoaaImage(_) => AUDIO_MSG_NOAA_IMAGE,
|
DecodedMessage::WxsatImage(_) => AUDIO_MSG_WXSAT_IMAGE,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||||
@@ -3306,7 +3315,7 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
||||||
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
||||||
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
||||||
DecodedMessage::NoaaImage(_) => AUDIO_MSG_NOAA_IMAGE,
|
DecodedMessage::WxsatImage(_) => AUDIO_MSG_WXSAT_IMAGE,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||||
|
|||||||
+12
-12
@@ -794,22 +794,22 @@ fn spawn_rig_audio_stack(
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Spawn NOAA APT decoder task
|
// Spawn weather satellite APT decoder task
|
||||||
let noaa_pcm_rx = pcm_tx.subscribe();
|
let wxsat_pcm_rx = pcm_tx.subscribe();
|
||||||
let noaa_state_rx = state_rx.clone();
|
let wxsat_state_rx = state_rx.clone();
|
||||||
let noaa_decode_tx = decode_tx.clone();
|
let wxsat_decode_tx = decode_tx.clone();
|
||||||
let noaa_sr = rig_cfg.audio.sample_rate;
|
let wxsat_sr = rig_cfg.audio.sample_rate;
|
||||||
let noaa_ch = rig_cfg.audio.channels;
|
let wxsat_ch = rig_cfg.audio.channels;
|
||||||
let noaa_shutdown_rx = shutdown_rx.clone();
|
let wxsat_shutdown_rx = shutdown_rx.clone();
|
||||||
let noaa_histories = histories.clone();
|
let wxsat_histories = histories.clone();
|
||||||
let noaa_output_dir = dirs::cache_dir()
|
let wxsat_output_dir = dirs::cache_dir()
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
.join("trx-rs")
|
.join("trx-rs")
|
||||||
.join("noaa");
|
.join("wxsat");
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = audio::run_noaa_decoder(noaa_sr, noaa_ch as u16, noaa_pcm_rx, noaa_state_rx, noaa_decode_tx, noaa_histories, noaa_output_dir) => {}
|
_ = audio::run_wxsat_decoder(wxsat_sr, wxsat_ch as u16, wxsat_pcm_rx, wxsat_state_rx, wxsat_decode_tx, wxsat_histories, wxsat_output_dir) => {}
|
||||||
_ = wait_for_shutdown(noaa_shutdown_rx) => {}
|
_ = wait_for_shutdown(wxsat_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user