diff --git a/src/core/display.cpp b/src/core/display.cpp index 3b4f210ba..435ef1c18 100644 --- a/src/core/display.cpp +++ b/src/core/display.cpp @@ -1737,6 +1737,31 @@ uint16_t getColorVariation(uint16_t color, int delta, int direction) { return compl_color; } +uint16_t blendColors(uint16_t a, uint16_t b, uint8_t t) { + int ar = (a >> 11) & 0x1f, ag = (a >> 5) & 0x3f, ab = a & 0x1f; + int br = (b >> 11) & 0x1f, bg = (b >> 5) & 0x3f, bb = b & 0x1f; + int r = ar + (br - ar) * t / 255; + int g = ag + (bg - ag) * t / 255; + int bl = ab + (bb - ab) * t / 255; + return (uint16_t)((r << 11) | (g << 5) | bl); +} + +void buildHeatPalette(uint16_t *lut, uint8_t n) { + if (!lut || n < 2) return; + uint16_t bg = bruceConfig.bgColor; + uint16_t pri = bruceConfig.priColor; + uint16_t hot = blendColors(pri, TFT_WHITE, 150); + + lut[0] = bg; + for (uint8_t i = 1; i < n; i++) { + int t = i * 255 / (n - 1); + // ramp to the primary for the lower two thirds, then burn toward the + // highlight so strong signals stay readable against a busy plot + lut[i] = (t < 170) ? blendColors(bg, pri, 55 + t * 200 / 255) + : blendColors(pri, hot, (t - 170) * 255 / 85); + } +} + // Draw BITMAP files // These read 16- and 32-bit types from the SD card file. // BMP data is stored little-endian, Arduino is little-endian too. diff --git a/src/core/display.h b/src/core/display.h index 97c276e35..5d8c25669 100644 --- a/src/core/display.h +++ b/src/core/display.h @@ -107,6 +107,12 @@ bool showJpeg(const uint8_t *data_array, size_t data_size, int x, int y, bool ce uint16_t getComplementaryColor(uint16_t color); uint16_t getComplementaryColor2(uint16_t color); uint16_t getColorVariation(uint16_t color, int delta = 10, int direction = 0); +// Linear RGB565 mix: t = 0 returns a, t = 255 returns b. Use it to derive dim +// or highlighted shades from the theme instead of hardcoding TFT_* constants. +uint16_t blendColors(uint16_t a, uint16_t b, uint8_t t); +// Fills lut[0..n-1] with a theme ramp going from the background up to a +// brightened primary, for waterfalls and other intensity plots. +void buildHeatPalette(uint16_t *lut, uint8_t n); void resetTftDisplay( int x = 0, int y = 0, uint16_t fc = bruceConfig.priColor, int size = FM, diff --git a/src/core/spectrum_plot.cpp b/src/core/spectrum_plot.cpp new file mode 100644 index 000000000..69031bd05 --- /dev/null +++ b/src/core/spectrum_plot.cpp @@ -0,0 +1,207 @@ +#include "spectrum_plot.h" + +#include "core/display.h" +#include + +// Waterfall intensity ramp, quantised so identical columns collapse into runs. +static const int SP_HEAT_N = 16; +static uint16_t sp_heat[SP_HEAT_N]; + +static uint32_t sp_rnd_state = 0x2a3b4c5d; + +static inline uint32_t sp_rnd() { + sp_rnd_state ^= sp_rnd_state << 13; + sp_rnd_state ^= sp_rnd_state >> 17; + sp_rnd_state ^= sp_rnd_state << 5; + return sp_rnd_state; +} + +uint16_t SpectrumPlot::alertColor() { + uint16_t pri = bruceConfig.priColor; + int r = (pri >> 11) & 0x1f, g = (pri >> 5) & 0x3f, b = pri & 0x1f; + // A red alert would vanish on a red theme, so fall back to amber there. + bool reddish = (r > 18 && (g >> 1) < 12 && b < 12); + return reddish ? TFT_ORANGE : TFT_RED; +} + +void SpectrumPlot::buildGeometry() { + _plotL = 8; + _plotW = tftWidth - 16; + if (_plotW < 32) { + _plotL = 2; + _plotW = tftWidth - 4; + } + + int top = BORDER_PAD_Y + 8 * FM + 2; // just below the title + _footY = tftHeight - 8 * FP - 8; + _lblY = _footY - 8 * FP - 2; + + int avail = _lblY - top - 2; + if (avail < 20) { // no room for the ruler + _lblY = -1; + avail = _footY - top - 2; + } + if (avail < 14) { // no room for the status line either + _footY = -1; + avail = tftHeight - 6 - top; + } + if (avail < 8) avail = 8; + + _wfRows = 0; + if (avail >= 36) { + _wfRows = avail / 3; + if (_wfRows > 24) _wfRows = 24; + } + _specTop = top; + _specH = avail - _wfRows - (_wfRows ? 2 : 0); + _specBot = _specTop + _specH - 1; + _wfTop = _specBot + 3; +} + +void SpectrumPlot::buildPalette() { + uint16_t pri = bruceConfig.priColor; + _bg = bruceConfig.bgColor; + _trace = pri; + _body = blendColors(_bg, pri, 95); + _bodyHl = blendColors(_bg, pri, 165); + _peak = blendColors(pri, TFT_WHITE, 150); + _grid = blendColors(_bg, pri, 55); + _label = blendColors(_bg, pri, 170); + buildHeatPalette(sp_heat, SP_HEAT_N); +} + +bool SpectrumPlot::begin(const String &title) { + buildGeometry(); + buildPalette(); + + if (_plotW < 8) return false; + + if (_wfRows) { + _wf = (uint8_t *)calloc((size_t)_wfRows * _plotW, 1); + if (!_wf) _wfRows = 0; // degrade to a plot without history rather than fail + } + _wfHead = 0; + _ok = true; + + drawMainBorderWithTitle(title); // clears the screen itself + + drawWaterfall(); + return true; +} + +void SpectrumPlot::end() { + free(_wf); + _wf = nullptr; + _wfRows = 0; + _ok = false; +} + +void SpectrumPlot::trace(const uint8_t *env, const uint8_t *envPeak, int hlL, int hlR, bool alert) { + if (!_ok || !env) return; + + uint16_t trace = alert ? alertColor() : _trace; + uint16_t bodyHl = alert ? blendColors(_bg, trace, 165) : _bodyHl; + + int gy[3]; + gy[0] = _specBot - _specH / 4; + gy[1] = _specBot - _specH / 2; + gy[2] = _specBot - (_specH * 3) / 4; + + // Every pixel of the band is written exactly once per frame, which keeps the + // animation flicker free without needing a full-screen sprite. + for (int i = 0; i < _plotW; i++) { + int x = _plotL + i; + + int hLive = (int)env[i] * (_specH - 1) / 100; + int grass = (int)(sp_rnd() % 3); // animated noise floor + if (hLive < grass) hLive = grass; + int hPeak = envPeak ? (int)envPeak[i] * (_specH - 1) / 100 : 0; + if (hPeak < hLive) hPeak = hLive; + + int yLive = _specBot - hLive; + int yPeak = _specBot - hPeak; + + if (yPeak > _specTop) tft.drawFastVLine(x, _specTop, yPeak - _specTop, _bg); + if (hPeak > hLive) { + tft.drawPixel(x, yPeak, _peak); + if (yLive > yPeak + 1) tft.drawFastVLine(x, yPeak + 1, yLive - yPeak - 1, _bg); + } + tft.drawPixel(x, yLive, trace); + if (hLive > 0) + tft.drawFastVLine(x, yLive + 1, hLive, (i >= hlL && i <= hlR) ? bodyHl : _body); + + // dashed reference grid, visible only through the empty sky + if ((i & 3) == 0) { + for (int k = 0; k < 3; k++) + if (gy[k] > _specTop && gy[k] < yLive - 1) tft.drawPixel(x, gy[k], _grid); + } + } +} + +// Newest row sits right under the trace baseline and older ones fall away. +void SpectrumPlot::drawWaterfall() { + if (!_wfRows || !_wf) return; + + for (int r = 0; r < _wfRows; r++) { + int idx = (_wfHead - r + 2 * _wfRows) % _wfRows; + const uint8_t *row = _wf + (size_t)idx * _plotW; + int y = _wfTop + r; + + // flush equal-coloured columns as single spans, the rows are wide + int runStart = 0; + uint16_t runCol = sp_heat[row[0] * (SP_HEAT_N - 1) / 100]; + for (int i = 1; i <= _plotW; i++) { + bool last = (i == _plotW); + uint16_t c = last ? runCol : sp_heat[row[i] * (SP_HEAT_N - 1) / 100]; + if (last || c != runCol) { + tft.drawFastHLine(_plotL + runStart, y, i - runStart, runCol); + runStart = i; + runCol = c; + } + } + } +} + +void SpectrumPlot::pushRow(const uint8_t *env) { + if (!_ok || !_wfRows || !_wf || !env) return; + _wfHead = (_wfHead + 1) % _wfRows; + memcpy(_wf + (size_t)_wfHead * _plotW, env, _plotW); + drawWaterfall(); +} + +void SpectrumPlot::ruler(const int *cols, const String *labels, int count, int highlight) { + if (!_ok || _lblY < 0 || !cols || !labels) return; + + int h = 8 * FP; + tft.fillRect(_plotL, _lblY - 1, _plotW, h + 2, _bg); + tft.setTextSize(FP); + + for (int i = 0; i < count; i++) { + int w = labels[i].length() * FP * LW; + int tx = _plotL + cols[i] - w / 2; + // keep edge labels inside the plot + if (tx < _plotL) tx = _plotL; + if (tx + w > _plotL + _plotW) tx = _plotL + _plotW - w; + + if (i == highlight) { + tft.fillRect(tx - 2, _lblY - 1, w + 4, h + 2, bruceConfig.priColor); + tft.setTextColor(_bg, bruceConfig.priColor); + } else { + tft.setTextColor(_label, _bg); + } + tft.drawString(labels[i], tx, _lblY, 1); + } +} + +void SpectrumPlot::status(const String &text, bool alert) { + if (!_ok || _footY < 0) return; + + tft.fillRect(_plotL, _footY, _plotW, 8 * FP, _bg); + tft.setTextSize(FP); + tft.setTextColor(alert ? alertColor() : _label, _bg); + + String s = text; + int maxChars = _plotW / (FP * LW); + if ((int)s.length() > maxChars) s = s.substring(0, maxChars); + tft.drawString(s, _plotL, _footY, 1); +} diff --git a/src/core/spectrum_plot.h b/src/core/spectrum_plot.h new file mode 100644 index 000000000..edc6b1166 --- /dev/null +++ b/src/core/spectrum_plot.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +// Shared spectrum-analyzer plot. +// +// Owns the layout, palette and painting for every "signal strength across a +// frequency band" screen in Bruce, so Channel Analyzer, Jam Detect and the NRF +// sweeper share one look instead of each inventing its own bars and colours. +// +// The band itself is caller supplied: modules fill an envelope of width() +// values in 0-100 and the plot turns it into a filled trace with a peak-hold +// line, an animated noise floor, a dashed reference grid and a scrolling +// waterfall. Every colour is derived from the active theme. +// +// Typical use: +// SpectrumPlot plot; +// if (!plot.begin("My Scanner")) return; // frees itself on failure +// ... +// plot.trace(env, envPeak, hlLeft, hlRight); // once per animation frame +// plot.pushRow(env); // once per completed sweep +// plot.ruler(cols, labels, n, current); +// plot.status("..."); +// plot.end(); +class SpectrumPlot { +public: + bool begin(const String &title); + void end(); + bool ready() const { return _ok; } + + // Number of columns the caller must fill, and where they land on screen. + int width() const { return _plotW; } + int left() const { return _plotL; } + + // Paints one frame of the live band. `env` and `envPeak` hold width() + // values in 0-100; envPeak may be null. Columns in [hlL, hlR] are filled + // with the highlight shade to mark the slice being measured — pass + // hlL > hlR for none. `alert` recolours the trace to flag a bad reading. + void trace(const uint8_t *env, const uint8_t *envPeak, int hlL, int hlR, bool alert = false); + + // Appends `env` to the waterfall history and repaints it. No-op when the + // screen is too short for a waterfall. + void pushRow(const uint8_t *env); + + // Labelled ticks under the plot. `cols` are column indices in 0..width()-1; + // `highlight` indexes the entry to draw inverted, or -1 for none. + void ruler(const int *cols, const String *labels, int count, int highlight = -1); + + // Single line of text at the bottom, truncated to fit. + void status(const String &text, bool alert = false); + + // Colour for out-of-range readings, kept visible even on a reddish theme. + static uint16_t alertColor(); + +private: + void buildGeometry(); + void buildPalette(); + void drawWaterfall(); + + bool _ok = false; + + int _plotL = 0, _plotW = 0; + int _specTop = 0, _specBot = 0, _specH = 0; + int _wfTop = 0, _wfRows = 0; + int _lblY = -1; // -1 when the screen is too short for the ruler + int _footY = -1; // -1 when the screen is too short for the status line + + uint16_t _bg = 0, _body = 0, _bodyHl = 0, _trace = 0, _peak = 0, _grid = 0, _label = 0; + + uint8_t *_wf = nullptr; // _wfRows x _plotW ring of rendered envelopes + int _wfHead = 0; +}; diff --git a/src/modules/NRF24/nrf_spectrum.cpp b/src/modules/NRF24/nrf_spectrum.cpp index 5c9d865ec..7de8141db 100644 --- a/src/modules/NRF24/nrf_spectrum.cpp +++ b/src/modules/NRF24/nrf_spectrum.cpp @@ -1,13 +1,16 @@ #include "nrf_spectrum.h" #include "core/display.h" #include "core/mykeyboard.h" +#include "core/spectrum_plot.h" #define CHANNELS 80 -#define RGB565(r, g, b) ((((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3))) uint8_t channel[CHANNELS]; -// scanning channels -#define _BW tftWidth / CHANNELS +// The RPD accumulator settles toward 125, so that is full scale for the plot. +#define NRF_FULL_SCALE 125 + +// Sweeps the whole 2.4GHz band once and updates the smoothed per-channel +// levels. Drawing lives in nrf_draw() so the WebUI can scan without a screen. String scanChannels(bool web) { String result = "{"; @@ -21,74 +24,133 @@ String scanChannels(bool web) { NRFradio.stopListening(); int rpd = NRFradio.testRPD() ? 1 : 0; - channel[i] = (channel[i] * 3 + rpd * 125) / 4; + channel[i] = (channel[i] * 3 + rpd * NRF_FULL_SCALE) / 4; rpdValues[i] = channel[i]; } digitalWrite(bruceConfigPins.NRF24_bus.io0, HIGH); - for (int i = 0; i < CHANNELS; i++) { - int level = rpdValues[i]; - int x = i * _BW; - int c = i; - - tft.drawFastVLine( - x, tftHeight - (10 + level), level, (i % 2 == 0) ? bruceConfig.priColor : TFT_DARKGREY - ); // for level display - - tft.drawFastVLine( - x, 0, tftHeight - (9 + level), (i % 8) ? TFT_BLACK : RGB565(25, 25, 25) - ); /// for clearing - tft.drawFastVLine(x, 0, level, bruceConfig.secColor); /// for top display - // show 5 channel gap only - if (c % 5 == 0 && c != 0) { tft.drawCentreString(String(c).c_str(), x, tftHeight / 2, 1); } - - if (web) { + if (web) { + for (int i = 0; i < CHANNELS; i++) { if (i > 0) result += ","; - result += String(level); + result += String(rpdValues[i]); } + result += "}"; } + return result; // "{1,32,45,...}" with 80 values, for the WebUI +} - if (web) result += "}"; - return result; // return a string in this format "{1,32,45,32,84,32 .... 12,54,65}" with 80 values to be - // used in the WebUI (Future) +// Spreads the 80 channel levels across the plot columns, interpolating between +// carriers so the trace reads as a continuous band instead of 80 blocks. +static void nrf_envelope(const uint8_t *lvl, uint8_t *env, int plotW) { + for (int i = 0; i < plotW; i++) { + int32_t pos = (int32_t)i * (CHANNELS - 1) * 256 / (plotW - 1); + int ci = pos >> 8; + int frac = pos & 0xff; + if (ci >= CHANNELS - 1) { + ci = CHANNELS - 2; + frac = 256; + } + int v = lvl[ci] + (lvl[ci + 1] - lvl[ci]) * frac / 256; + v = v * 100 / NRF_FULL_SCALE; + env[i] = (uint8_t)(v < 0 ? 0 : (v > 100 ? 100 : v)); + } } void nrf_spectrum() { - tft.fillScreen(bruceConfig.bgColor); - tft.setTextSize(FP); - tft.drawString("2.40Ghz", 0, tftHeight - LH); - tft.drawCentreString("2.44Ghz", tftWidth / 2, tftHeight - LH, 1); - tft.drawRightString("2.48Ghz", tftWidth, tftHeight - LH, 1); - - if (nrf_start(NRF_MODE_SPI)) { // This function only works on SPI - NRFradio.setAutoAck(false); - NRFradio.disableCRC(); // accept any signal we find - NRFradio.setAddressWidth(2); // a reverse engineering tactic (not typically recommended) - const uint8_t noiseAddress[][2] = { - {0x55, 0x55}, - {0xAA, 0xAA}, - {0xA0, 0xAA}, - {0xAB, 0xAA}, - {0xAC, 0xAA}, - {0xAD, 0xAA} - }; - for (uint8_t i = 0; i < 6; ++i) { NRFradio.openReadingPipe(i, noiseAddress[i]); } - NRFradio.setDataRate(RF24_1MBPS); - - while (!check(EscPress)) { - scanChannels(); - vTaskDelay(pdMS_TO_TICKS(1)); - } - NRFradio.stopListening(); - NRFradio.powerDown(); - delay(250); + SpectrumPlot plot; + if (!plot.begin("NRF Spectrum")) { + displayError("Out of memory", true); + return; + } + + const int plotW = plot.width(); + uint8_t *env = (uint8_t *)malloc(plotW); + uint8_t *envPeak = (uint8_t *)malloc(plotW); + uint8_t peak[CHANNELS] = {0}; + if (!env || !envPeak) { + free(env); + free(envPeak); + plot.end(); + displayError("Out of memory", true); return; + } - } else { + // 2.400GHz to 2.479GHz, one tick every 20 channels + const int tickCount = 5; + int cols[tickCount]; + String labels[tickCount]; + for (int i = 0; i < tickCount; i++) { + int ch = i * (CHANNELS - 1) / (tickCount - 1); + cols[i] = ch * (plotW - 1) / (CHANNELS - 1); + labels[i] = String(2.400f + ch * 0.001f, 2); + } + plot.ruler(cols, labels, tickCount); + plot.status("starting radio..."); + + if (!nrf_start(NRF_MODE_SPI)) { // This function only works on SPI Serial.println("Fail Starting radio"); + free(env); + free(envPeak); + plot.end(); displayError("NRF24 not found"); delay(500); return; } + + NRFradio.setAutoAck(false); + NRFradio.disableCRC(); // accept any signal we find + NRFradio.setAddressWidth(2); // a reverse engineering tactic (not typically recommended) + const uint8_t noiseAddress[][2] = { + {0x55, 0x55}, + {0xAA, 0xAA}, + {0xA0, 0xAA}, + {0xAB, 0xAA}, + {0xAC, 0xAA}, + {0xAD, 0xAA} + }; + for (uint8_t i = 0; i < 6; ++i) { NRFradio.openReadingPipe(i, noiseAddress[i]); } + NRFradio.setDataRate(RF24_1MBPS); + + uint32_t lastFrame = 0, lastRow = 0; + while (!check(EscPress)) { + scanChannels(); + + int maxCh = 0; + for (int i = 0; i < CHANNELS; i++) { + if (channel[i] > peak[i]) peak[i] = channel[i]; + else if (peak[i]) peak[i]--; // slow decay keeps the hold line readable + if (channel[i] > channel[maxCh]) maxCh = i; + } + + // A full sweep is far quicker than the panel needs to be repainted, so + // cap the redraw rate and let the radio keep integrating in between. + if (millis() - lastFrame >= 40) { + lastFrame = millis(); + nrf_envelope(channel, env, plotW); + nrf_envelope(peak, envPeak, plotW); + + // highlight the busiest carrier and its immediate neighbours + int hlC = maxCh * (plotW - 1) / (CHANNELS - 1); + int hlSpan = (2 * (plotW - 1)) / (CHANNELS - 1); + plot.trace(env, envPeak, hlC - hlSpan, hlC + hlSpan); + + if (millis() - lastRow >= 120) { + lastRow = millis(); + plot.pushRow(env); + plot.status( + "peak ch" + String(maxCh) + " " + String(2.400f + maxCh * 0.001f, 3) + "GHz " + + String(env[hlC]) + "%" + ); + } + } + vTaskDelay(pdMS_TO_TICKS(1)); + } + + NRFradio.stopListening(); + NRFradio.powerDown(); + free(env); + free(envPeak); + plot.end(); + delay(250); } diff --git a/src/modules/wifi/wifi_spectrum.cpp b/src/modules/wifi/wifi_spectrum.cpp new file mode 100644 index 000000000..8f4709f56 --- /dev/null +++ b/src/modules/wifi/wifi_spectrum.cpp @@ -0,0 +1,119 @@ +#if !defined(LITE_VERSION) +#include "wifi_spectrum.h" + +#include "core/display.h" +#include +#include + +// Frequency axis in 0.1MHz units: ch1 = 2412.0MHz, 5MHz spacing, plus a small +// margin on each side so the outer lobes are not cut too abruptly. +static const int WS_FCH1 = 24120; +static const int WS_FSTEP = 50; +static const int WS_FMARGIN = 70; +static const int WS_FMIN = WS_FCH1 - WS_FMARGIN; +static const int WS_FSPAN = (WifiSpectrumView::CHANNELS - 1) * WS_FSTEP + 2 * WS_FMARGIN; +static const int WS_FLOBE = 220; // lobe reach: +/-22MHz + +// Lobe shape sampled over 0..22MHz from the carrier, built once per process. +static const int WS_SHAPE_N = 48; +static uint8_t ws_shape[WS_SHAPE_N]; +static bool ws_shape_ready = false; + +static void ws_build_shape() { + if (ws_shape_ready) return; + for (int i = 0; i < WS_SHAPE_N; i++) { + float d = 22.0f * i / (WS_SHAPE_N - 1); // MHz from the carrier + float a; + if (d <= 11.0f) a = 0.5f * (1.0f + cosf(PI * d / 11.0f)); // main lobe + else a = 0.05f * (1.0f - cosf(2.0f * PI * (d - 11.0f) / 11.0f)); // side lobe + ws_shape[i] = (uint8_t)(a * 255.0f + 0.5f); + } + ws_shape_ready = true; +} + +static inline int ws_freq(uint8_t ch) { return WS_FCH1 + (ch - 1) * WS_FSTEP; } + +// Column index for a frequency, in plot coordinates. +static inline int ws_col(int fq, int plotW) { + return (int)((int32_t)(fq - WS_FMIN) * (plotW - 1) / WS_FSPAN); +} + +bool WifiSpectrumView::begin(const String &title) { + ws_build_shape(); + if (!_plot.begin(title)) return false; + + _env = (uint8_t *)malloc(_plot.width()); + _envPeak = (uint8_t *)malloc(_plot.width()); + if (!_env || !_envPeak) { + end(); + return false; + } + memset(_disp, 0, sizeof(_disp)); + memset(_env, 0, _plot.width()); + return true; +} + +void WifiSpectrumView::end() { + free(_env); + free(_envPeak); + _env = _envPeak = nullptr; + _plot.end(); +} + +// Envelope of the per-channel values across the plot, taking the strongest +// contributor at each column so overlapping lobes read as one skyline. +void WifiSpectrumView::envelope(const uint8_t *v, uint8_t *env) const { + int plotW = _plot.width(); + for (int i = 0; i < plotW; i++) { + int fq = WS_FMIN + (int)((int32_t)i * WS_FSPAN / (plotW - 1)); + uint8_t best = 0; + for (int ch = 1; ch <= CHANNELS; ch++) { + if (!v[ch]) continue; + int d = fq - ws_freq(ch); + if (d < 0) d = -d; + if (d >= WS_FLOBE) continue; + uint8_t a = (uint8_t)((uint16_t)v[ch] * ws_shape[d * (WS_SHAPE_N - 1) / WS_FLOBE] / 255); + if (a > best) best = a; + } + env[i] = best; + } +} + +void WifiSpectrumView::animate(const uint8_t *level, const uint8_t *peak, uint8_t curCh, bool alert) { + if (!ready()) return; + + for (int ch = 1; ch <= CHANNELS; ch++) { + int d = (int)level[ch] - (int)_disp[ch]; + if (d) _disp[ch] = (uint8_t)((int)_disp[ch] + (d > 0 ? max(1, d / 3) : min(-1, d / 3))); + } + envelope(_disp, _env); + envelope(peak, _envPeak); + + // highlight the 22MHz slice the radio is parked on + int plotW = _plot.width(); + _plot.trace(_env, _envPeak, ws_col(ws_freq(curCh) - 110, plotW), ws_col(ws_freq(curCh) + 110, plotW), alert); +} + +void WifiSpectrumView::commit(const uint8_t *level, uint8_t curCh) { + if (!ready()) return; + + envelope(level, _env); + _plot.pushRow(_env); + + int plotW = _plot.width(); + int cols[CHANNELS]; + String labels[CHANNELS]; + int n = 0, highlight = -1; + // drop even channels when the ruler would collide, but never the current one + bool sparse = (plotW * WS_FSTEP) / WS_FSPAN < (2 * FP * LW + 4); + for (int ch = 1; ch <= CHANNELS; ch++) { + if (sparse && !(ch & 1) && ch != curCh) continue; + if (ch == curCh) highlight = n; + cols[n] = ws_col(ws_freq(ch), plotW); + labels[n] = String(ch); + n++; + } + _plot.ruler(cols, labels, n, highlight); +} + +#endif diff --git a/src/modules/wifi/wifi_spectrum.h b/src/modules/wifi/wifi_spectrum.h new file mode 100644 index 000000000..371664dee --- /dev/null +++ b/src/modules/wifi/wifi_spectrum.h @@ -0,0 +1,47 @@ +#pragma once + +#if !defined(LITE_VERSION) + +#include "core/spectrum_plot.h" +#include + +// 2.4GHz WiFi channel view, built on SpectrumPlot. +// +// Turns per-channel levels into overlapping spectral lobes on a real frequency +// axis, so the 22MHz overlap between neighbours is visible and a sweep reads as +// one continuous trace instead of eleven isolated bars. +// +// Callers own the radio and feed normalised 0-100 levels indexed by channel +// number; everything on screen belongs to the view. +class WifiSpectrumView { +public: + static const int CHANNELS = 11; + static const int CH_MAX = 12; // arrays are indexed by channel number + + // Allocates the column buffers and paints the frame. Returns false when + // there is not enough memory, in which case nothing was drawn. + bool begin(const String &title); + void end(); + bool ready() const { return _env != nullptr; } + + // One animation frame. The drawn levels ease toward `level` so the sweep + // glides instead of snapping when a measurement lands. `alert` recolours + // the trace to flag an abnormal reading. + void animate(const uint8_t *level, const uint8_t *peak, uint8_t curCh, bool alert = false); + + // Call once per completed measurement: pushes a waterfall row and repaints + // the channel ruler with `curCh` highlighted. + void commit(const uint8_t *level, uint8_t curCh); + + void status(const String &text, bool alert = false) { _plot.status(text, alert); } + +private: + void envelope(const uint8_t *v, uint8_t *env) const; + + SpectrumPlot _plot; + uint8_t *_env = nullptr; + uint8_t *_envPeak = nullptr; + uint8_t _disp[CH_MAX] = {0}; +}; + +#endif