// ============================================================================
// Tier-1 RID Sensor Firmware
// Hardware: Heltec WiFi LoRa V4 (ESP32-S3 + SX1262)
//
// Forward-deployed drone detection sensor. Runs on-device TFLite RF
// classification on 2.4 GHz WiFi spectrum, publishes detections to
// Tier-2 gateways via LoRa mesh with FFPS corroboration.
// ============================================================================

#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
#include <cstring>
#include <cmath>

// Arduino-ESP32's loopTask defaults to an 8 KB stack. Confirmed via a real
// crash (Guru Meditation Error: stack canary watchpoint triggered, loopTask)
// the first time c2_wifi_bridge.cpp's WiFiClientSecure+HTTPClient POST
// actually ran from loop() -- TLS handshake stack usage plus everything
// else this loop() already does overflowed the default. Must be a
// top-level statement, evaluated before setup(). 320KB total RAM on this
// board with only ~35% used, so doubling this is a non-issue.
SET_LOOP_TASK_STACK_SIZE(16 * 1024);

#include "config.h"
#include "rf_classifier.h"
#include "mesh_publish.h"
#include "ffps.h"
#include "health_report.h"
#include "ota_adapter.h"
#include "blue_force.h"
#include "model_manager.h"
#include "threat_correlator.h"
#include "command_handler.h"
#include "uav_relay.h"
#include "uav_pairing.h"
#include "mesh_routing.h"
#include "band_bridge.h"
#include "markov_scorer.h"
#include "wfx_features.h"
#include "spoof_gate_client.h"
#include "pec_feedback.h"
#include "fullmode_t1.h"
// Batch A
#include "sensor_location.h"
#include "gps_module.h"
#include "wifi_status_server.h"
#include "mythos_bridge.h"
#include "c2_wifi_bridge.h"
#include "wifi_command_poll.h"
#include "handset_fingerprint.h"
#include "wifi_security_scan.h"
#include "wifi_extended_scan.h"
#include "csi_carrier_detector.h"
#include "subghz_scanner.h"
#include "rf_cataloger.h"
#include "hidden_tx_scanner.h"
#include "watchdog.h"
#include "jamming_detector.h"
// Batch B
#include "mesh_timesync.h"
#include "ntp_timesync.h"
#include "ota_firmware.h"
#include "peer_relay.h"
#include "enrollment.h"
#include "ota_signed.h"
// Batch C
#include "fhss_hop.h"
#include "threat_vqc.h"
#include "attack_graph_local.h"
// Batch D
#include "talon_federated.h"
#include "cognitive_rf.h"
// Active subsystem
#include "network_id.h"
#include "payload_buffer.h"
#include "wifi_inject.h"
#include "auth_gate.h"
#include "ble_odid_scanner.h"   // shared OdidLocation/decode fns used by wifi_odid_scanner too
#include "wifi_odid_scanner.h"
#include "esp_system.h"
#include "nvs_flash.h"
#ifdef PDL_INTEROP_ENABLED
#include "radio_scheduler.h"
#include "meshtastic_emit.h"
#include "rnode_bridge.h"
#include "mqtt_client.h"
#include "interop_router.h"
#endif
// ble_companion.h is deliberately unconditional (unlike its 3 siblings
// below): its own BLE_STUB_MODE gate makes every declared function a real,
// linkable no-op when PDL_BLE_COMPANION_ENABLED isn't defined, specifically
// so callers don't need to guard every call site. Gating the #include
// itself defeated that design for any call site that (correctly, per that
// contract) didn't add its own #ifdef -- found via a real build failure:
// heltec_wifi_lora_v4_uav_bridge (which never defines
// PDL_BLE_COMPANION_ENABLED) failed with "ble_companion_publish_vqc_sample
// was not declared in this scope" at its one unguarded call site in loop().
#include "ble_companion.h"
#ifdef PDL_BLE_COMPANION_ENABLED
#include "c2_proxy_tunnel.h"
#include "ble_proxy_chars.h"
#include "ble_odid_scanner.h"
#endif
#include <U8g2lib.h>
#include <Wire.h>

#ifdef PDL_BOARD_TDECK
// LilyGo T-Deck Plus: ST7789 320x240 over the SHARED SPI bus (same SCK/MOSI/
// MISO as the SX1262 — U8g2 and RadioLib each assert their own CS, so the bus
// is arbitrated by chip select rather than being duplicated).
//
// Using U8g2 for both boards is deliberate: it supports ST7789 natively, so
// every drawStr/sendBuffer call site below works unmodified on either display
// and there is only ONE screen layout to maintain. The alternative (TFT_eSPI
// or Adafruit_GFX) would have meant a parallel implementation of all 24 call
// sites for no functional gain.
//
// The pinned U8g2 (2.35.30) has NO ST7789 driver at all, so this panel is
// driven by Adafruit_ST7789 behind a U8g2-shaped adapter (tdeck_display.h) —
// which is what keeps every drawStr/sendBuffer call site below identical
// across the two boards instead of forking the layout.
#include "tdeck_display.h"
static TDeckDisplay s_display;
#else
// Heltec V3/V4 OLED (SSD1306, I2C @ 0x3C, SDA=17, SCL=18, RST=21).
// NOTE: these pins are Heltec-only. On a T-Deck, 17 is the LoRa reset and 18
// is the keyboard I2C data line — driving them there would break the radio.
#define OLED_SDA 17
#define OLED_SCL 18
#define OLED_RST 21
static U8G2_SSD1306_128X64_NONAME_F_HW_I2C s_display(U8G2_R0, OLED_RST, OLED_SCL, OLED_SDA);
#endif
static uint32_t s_last_display_ms = 0;
static uint32_t s_last_detection_ms = 0;
static char     s_last_class_str[24] = "—";

static const char* TAG = "T1Sensor";

// ---- State ----
// Sensor ID is set via enrollment (NVS-backed). Falls back to DEFAULT_SENSOR_ID
// after enrollment timeout if T2 doesn't respond — sensor still operates with
// non-unique ID; T2 will rebroadcast enrollment response as soon as it sees us.
static uint32_t s_sensor_id = DEFAULT_SENSOR_ID;
static uint32_t s_last_attackgraph_decay_ms = 0;
// Non-static — referenced via extern in command_handler.cpp's DELIVER_PAYLOAD
// dispatch path. Stored here when MSG_TYPE_AUTH_TOKEN arrives over LoRa.
uint8_t  s_pending_auth_token[84];
uint32_t s_pending_auth_token_payload_id = 0;
bool     s_pending_auth_token_valid = false;
// s_spectrum is non-static so command_handler can extern-reference it
// for the WFX_DUMP_LATEST command (publish current spectrum on demand).
float           s_spectrum[SPECTRUM_BINS];
static uint32_t s_loop_count = 0;
static uint32_t s_last_cycle_ms = 0;

// Rolling inputs to threat_vqc_score() — updated wherever each signal is
// already computed, read once per cycle at the composite-correlation point.
static float   s_last_wfx_occupancy = 0.0f;
static int16_t s_last_rssi_dbm = -100;

// Multi-model inference results buffer
static ModelInferenceResult s_model_results[MODEL_MAX_SLOTS];
static bool s_multimodel_active = false;  // true if model_manager initialized OK

// SPI bus for LoRa
static SPIClass s_lora_spi(FSPI);

// ---- WiFi promiscuous mode for RF spectrum capture ----
// We use the ESP32's promiscuous mode to sample 2.4 GHz RF energy.
// This captures raw RSSI across channels as a proxy for spectrum analysis.

static int16_t  s_channel_rssi[14];  // channels 1-13 + padding
static uint32_t s_channel_pkt_count[14];
static volatile uint8_t s_scan_channel = 1;

// Promiscuous mode callback — accumulates RSSI per channel.
// Retained as fallback when network_id_sniffer_cb isn't registered.
static void IRAM_ATTR wifi_sniffer_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
    const wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf;
    uint8_t ch = s_scan_channel;
    if (ch >= 1 && ch <= 13) {
        int16_t rssi = pkt->rx_ctrl.rssi;
        if (s_channel_pkt_count[ch] == 0) {
            s_channel_rssi[ch] = rssi;
        } else {
            s_channel_rssi[ch] = (int16_t)(s_channel_rssi[ch] * 0.8f + rssi * 0.2f);
        }
        s_channel_pkt_count[ch]++;
    }
}

// ESP-IDF only allows ONE registered esp_wifi_set_promiscuous_rx_cb — this
// thin dispatcher lets both network_id_sniffer_cb (AP/beacon cataloging)
// AND the new WiFi ODID scanner (drone vendor-IE decode) run off the same
// promiscuous capture, instead of one replacing the other.
static void IRAM_ATTR combined_promiscuous_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
    network_id_sniffer_cb(buf, type);
    if (type == WIFI_PKT_MGMT) {
        wifi_odid_scanner_process_packet(buf);

        // Probe-request (type=0 mgmt, subtype=4 -> frame ctrl byte 0x40):
        // passive handset/phone metadata (OUI vendor, drone-controller-app
        // signature) — no decryption, no association, same capture already
        // running for the ODID beacon parser above.
        const wifi_promiscuous_pkt_t* pkt = (const wifi_promiscuous_pkt_t*)buf;
        if (pkt && pkt->rx_ctrl.sig_len >= 1 && pkt->payload[0] == 0x40) {
            handset_fingerprint_process_wifi_probe(
                pkt->payload, pkt->rx_ctrl.sig_len, (int8_t)pkt->rx_ctrl.rssi);
        }
        if (pkt) {
            wifi_security_scan_process_frame(pkt->payload, pkt->rx_ctrl.sig_len);
            if (pkt->rx_ctrl.sig_len >= 1) {
                uint8_t fc0 = pkt->payload[0];
                uint8_t frame_subtype = (fc0 >> 4) & 0x0F;
                // frame_type=0 (management) -- covers Action (RTT/FTM) and
                // Beacon/Probe-Response (WPA3 downgrade) subtypes; see
                // wifi_extended_scan.h for what each subtype is used for.
                wifi_extended_scan_process_frame(pkt->payload, pkt->rx_ctrl.sig_len,
                                                 0, frame_subtype, millis());
            }
        }
    } else if (type == WIFI_PKT_DATA) {
        const wifi_promiscuous_pkt_t* pkt = (const wifi_promiscuous_pkt_t*)buf;
        if (pkt && pkt->rx_ctrl.sig_len >= 1) {
            uint8_t fc0 = pkt->payload[0];
            uint8_t frame_subtype = (fc0 >> 4) & 0x0F;
            // frame_type=2 (data) -- covers EAPOL (KRACK/PMKID) and, on
            // open networks only, DHCP/DNS (rogue-server/hijack detection).
            wifi_extended_scan_process_frame(pkt->payload, pkt->rx_ctrl.sig_len,
                                             2, frame_subtype, millis());
        }
    }
}

// CSI RX callback — see csi_carrier_detector.h for what this actually adds
// over the disabled hidden_tx_scanner (per-received-frame subcarrier detail
// instead of one RSSI scalar). Registered via esp_wifi_set_csi_rx_cb() in
// setup(), AFTER promiscuous mode is enabled (esp_wifi_set_csi_config()'s
// own documented error conditions require it). Fires on every frame CSI
// data is available for -- same "must actually receive a frame" gate the
// rest of this sensor's WiFi capture already has.
static void IRAM_ATTR csi_rx_cb(void* /*ctx*/, wifi_csi_info_t* data) {
    if (!data || !data->buf || data->len == 0) return;
    csi_carrier_detector_process_csi(data->buf, data->len,
                                     (uint8_t)data->rx_ctrl.channel,
                                     (int8_t)data->rx_ctrl.rssi, millis());
}

// Legacy RSSI observer — called by network_id_sniffer_cb for non-mgmt frames.
// Provides backwards compatibility with the per-channel RSSI scan accounting
// while the 802.11 frame parser handles beacons/probe-responses for the
// network identity table. Also feeds jamming_detector and cognitive_rf.
extern "C" void IRAM_ATTR legacy_rssi_observe(uint8_t ch, int16_t rssi) {
    if (ch >= 1 && ch <= 13) {
        if (s_channel_pkt_count[ch] == 0) {
            s_channel_rssi[ch] = rssi;
        } else {
            s_channel_rssi[ch] = (int16_t)(s_channel_rssi[ch] * 0.8f + rssi * 0.2f);
        }
        s_channel_pkt_count[ch]++;
        jamming_observe(ch, rssi, true);
        cogrf_observe(ch, rssi);
    }
}

// Scan all 13 WiFi channels and build a 128-bin spectrum
static void capture_rf_spectrum(float* spectrum_out) {
    memset(s_channel_pkt_count, 0, sizeof(s_channel_pkt_count));

    // Sweep channels 1-13
    for (uint8_t ch = 1; ch <= 13; ch++) {
        s_scan_channel = ch;
        esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);
        delay(WIFI_SCAN_DWELL_MS / 13);  // divide dwell across channels
    }

    // Restore the radio to the AP's channel afterward so an active WiFi STA
    // connection (e.g. wifi_status_server) keeps receiving — otherwise the
    // radio is left parked on channel 13 until the next sweep ever happens
    // to pass back through the AP's real channel.
    if (WiFi.status() == WL_CONNECTED) {
        esp_wifi_set_channel(WiFi.channel(), WIFI_SECOND_CHAN_NONE);
    }

    // Map 13 channels into 128 frequency bins via interpolation.
    // Channel 1 = 2412 MHz, Channel 13 = 2472 MHz.
    // Each channel occupies ~22 MHz bandwidth, center spacing = 5 MHz.
    // We interpolate to fill 128 bins across 2400-2500 MHz.
    for (int bin = 0; bin < SPECTRUM_BINS; bin++) {
        // Map bin to frequency
        float freq = 2400.0f + (bin / (float)SPECTRUM_BINS) * 100.0f;  // 2400-2500 MHz

        // Find nearest channel
        float ch_f = (freq - 2412.0f) / 5.0f + 1.0f;
        int ch_lo = (int)floorf(ch_f);
        int ch_hi = ch_lo + 1;

        if (ch_lo < 1) ch_lo = 1;
        if (ch_hi > 13) ch_hi = 13;
        if (ch_lo > 13) ch_lo = 13;

        float frac = ch_f - floorf(ch_f);

        // Interpolate RSSI and normalize to [0, 1] range
        // RSSI typically ranges from -100 to 0 dBm
        float rssi_lo = (float)s_channel_rssi[ch_lo];
        float rssi_hi = (float)s_channel_rssi[ch_hi];
        float rssi = rssi_lo * (1.0f - frac) + rssi_hi * frac;

        // Normalize: -100 dBm -> 0.0, 0 dBm -> 1.0
        spectrum_out[bin] = (rssi + 100.0f) / 100.0f;
        if (spectrum_out[bin] < 0.0f) spectrum_out[bin] = 0.0f;
        if (spectrum_out[bin] > 1.0f) spectrum_out[bin] = 1.0f;
    }
}

// Compute signature hash from spectrum (FNV-1a over the raw spectrum bins)
static void compute_signature_hash(const float* spectrum, uint8_t* hash_out) {
    uint64_t hash = 0xcbf29ce484222325ULL;
    const uint8_t* raw = (const uint8_t*)spectrum;
    size_t len = SPECTRUM_BINS * sizeof(float);
    for (size_t i = 0; i < len; i++) {
        hash ^= raw[i];
        hash *= 0x100000001b3ULL;
    }
    hash_out[0] = (hash >> 56) & 0xFF;
    hash_out[1] = (hash >> 48) & 0xFF;
    hash_out[2] = (hash >> 40) & 0xFF;
    hash_out[3] = (hash >> 32) & 0xFF;
    hash_out[4] = (hash >> 24) & 0xFF;
    hash_out[5] = (hash >> 16) & 0xFF;
    hash_out[6] = (hash >> 8)  & 0xFF;
    hash_out[7] = (hash)       & 0xFF;
}

// ============================================================================
// SETUP
// ============================================================================
void setup() {
    Serial.begin(115200);
    delay(1000);
    Serial.println();
    Serial.println("======================================");
    Serial.printf("  T1 RID Sensor Firmware v%s\n", SENSOR_FW_VERSION);
    Serial.printf("  Sensor name: %s\n", SENSOR_NAME);
    Serial.printf("  Default ID:  0x%08X\n", (unsigned int)DEFAULT_SENSOR_ID);
    Serial.println("======================================");

    // ---- Enrollment FIRST: tries NVS-cached id, otherwise self-register ----
    // We block briefly for the T2 response. After 30s of no response we proceed
    // with the default sensor id; subsequent enrollment responses still apply.
    if (enrollment_init()) {
        s_sensor_id = enrollment_get_sensor_id();
        Serial.printf("[T1] Enrolled. Sensor ID: 0x%08X\n", s_sensor_id);
    } else {
        Serial.printf("[T1] Not enrolled. Bootstrapping...\n");
        // (mesh_receive isn't ready yet; the bootstrap loop runs in loop()
        // via enrollment_tick once mesh is up.)
    }

#ifdef PDL_HAS_VEXT
    // Enable Vext power
    pinMode(VEXT_PIN, OUTPUT);
    digitalWrite(VEXT_PIN, LOW);  // LOW = ON for Heltec V4
    delay(50);  // Vext rail settle before peripheral init
#endif

#ifdef PDL_BOARD_TDECK
    // ---- T-Deck board power rail ----
    // MUST come before any peripheral init. On the T-Deck nothing downstream
    // of GPIO10 is powered until it is driven HIGH — not the SX1262, not the
    // ST7789, not the keyboard. Skipping this presents as completely dead
    // hardware with a successful boot log, which is a miserable thing to
    // debug, so it is the first thing that happens here.
    pinMode(TDECK_POWERON_PIN, OUTPUT);
    digitalWrite(TDECK_POWERON_PIN, HIGH);
    delay(100);   // rail settle before SPI peripherals are addressed

    // Display backlight (active HIGH). The panel is driven regardless, but
    // without this the screen is on and simply invisible.
    pinMode(TDECK_TFT_BL, OUTPUT);
    digitalWrite(TDECK_TFT_BL, HIGH);

    // ---- ST7789 init ----
    // The panel shares the SX1262's SPI bus, so that bus must be up BEFORE the
    // display is addressed — and display init happens earlier in setup() than
    // mesh_init(), so begin it here rather than relying on the radio path.
    // Re-begin from mesh_init() later is harmless; the two peripherals are
    // arbitrated by their separate CS lines (LoRa GPIO9, panel GPIO12).
    s_lora_spi.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
    s_display.attach(&s_lora_spi);
    // No reset pulse: the panel's reset line is not broken out on this board,
    // it resets with the GPIO10 rail asserted above.
    s_display.begin();
    s_display.clearBuffer();
#else
    // ---- OLED init (Heltec V3/V4 SSD1306) ----
    // Reset pulse + display.begin() initializes the SSD1306 controller.
    pinMode(OLED_RST, OUTPUT);
    digitalWrite(OLED_RST, LOW);  delay(20);
    digitalWrite(OLED_RST, HIGH); delay(20);
    s_display.begin();
    s_display.setContrast(255);
    s_display.clearBuffer();
#endif
    s_display.setFont(u8g2_font_7x13B_tr);
    s_display.drawStr(0, 14, "PDL T1 RID");
    s_display.setFont(u8g2_font_6x10_tr);
    s_display.drawStr(0, 30, "Sensor:");
    s_display.drawStr(50, 30, SENSOR_NAME);
    s_display.drawStr(0, 44, "Booting...");
    s_display.sendBuffer();

#ifdef PDL_HAS_LED
    // LED
    pinMode(LED_PIN, OUTPUT);
    PDL_LED_WRITE(LOW);
#endif

    // Battery ADC
    analogReadResolution(12);
    analogSetAttenuation(ADC_11db);

    // Report PSRAM
    size_t psram_size = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
    size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
    Serial.printf("[%s] PSRAM: %u KB total, %u KB free\n",
                  TAG, psram_size / 1024, psram_free / 1024);

    // ---- Initialize WiFi in promiscuous mode for RF scanning ----
    Serial.printf("[%s] Starting WiFi promiscuous mode...\n", TAG);
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    esp_wifi_set_promiscuous(true);
    network_id_init();
    esp_wifi_set_promiscuous_rx_cb(combined_promiscuous_cb);
    // Set filter to capture all packet types
    wifi_promiscuous_filter_t filter;
    filter.filter_mask = WIFI_PROMIS_FILTER_MASK_ALL;
    esp_wifi_set_promiscuous_filter(&filter);
    Serial.printf("[%s] WiFi promiscuous mode active (network_id + ODID beacon parser online)\n", TAG);

    // ---- CSI (Channel State Information) — unblocks hidden_tx_scanner's
    // continuous-carrier detection via per-frame subcarrier analysis (see
    // csi_carrier_detector.h). MUST come after promiscuous mode is enabled
    // above -- esp_wifi_set_csi_config()'s own documented error conditions
    // require it (ESP_ERR_WIFI_NOT_STARTED if promiscuous mode isn't on).
    csi_carrier_detector_init();
    {
        wifi_csi_config_t csi_cfg = {};
        csi_cfg.lltf_en = true;
        csi_cfg.htltf_en = true;
        csi_cfg.stbc_htltf2_en = true;
        csi_cfg.ltf_merge_en = true;
        csi_cfg.channel_filter_en = true;
        csi_cfg.manu_scale = false;
        csi_cfg.shift = 0;
        esp_err_t csi_err = esp_wifi_set_csi_config(&csi_cfg);
        if (csi_err == ESP_OK) {
            esp_wifi_set_csi_rx_cb(csi_rx_cb, nullptr);
            esp_wifi_set_csi(true);
            Serial.printf("[%s] CSI carrier detector active\n", TAG);
        } else {
            // Non-fatal: the sensor's core detection capabilities don't
            // depend on this. hidden_tx_scanner's disabled bin-based path
            // stays disabled either way; this just means the CSI-based
            // replacement doesn't get live data on this boot.
            Serial.printf("[%s] WARNING: CSI config failed (err=%d), carrier detector inactive\n",
                          TAG, (int)csi_err);
        }
    }

    // ---- Initialize LoRa SPI + radio ----
    Serial.printf("[%s] Initializing LoRa radio...\n", TAG);
    s_lora_spi.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
    if (!mesh_init(s_lora_spi)) {
        Serial.printf("[%s] FATAL: LoRa init failed! Halting.\n", TAG);
        while (true) {
            PDL_LED_TOGGLE();
            delay(200);
        }
    }
#ifdef PDL_ENABLE_MESH_ROUTING
    // See loop()'s mesh_routing_tick() call for why this was missing.
    // own_node_id just needs to be a stable, printable identity -- MRSH
    // hashes it internally (mesh_node_hash()) for the compact 6-byte wire
    // form, so the human-readable sensor name is fine here, matching the
    // pattern already used for the BLE local name (s_local_name).
    {
        char mesh_node_id[17];
        snprintf(mesh_node_id, sizeof(mesh_node_id), "%s", SENSOR_NAME);
        // This build variant (env:heltec_wifi_lora_v4_uav_bridge) advertises
        // both the 915 MHz LoRa radio and the 433 MHz CC1101 daughterboard
        // per platformio.ini's PDL_RADIO_LORA_915 + PDL_RADIO_CC1101_433.
        mesh_routing_init(mesh_node_id, MRSH_RADIO_LORA915 | MRSH_RADIO_SUB_GHZ_433);
        Serial.printf("[%s] MRSH mesh routing up: node_id=%s\n", TAG, mesh_node_id);
    }
#endif

    // ---- Initialize TFLite classifier (legacy single-model) ----
    Serial.printf("[%s] Loading TFLite model (legacy)...\n", TAG);
    if (!classifier_init()) {
        Serial.printf("[%s] WARNING: Legacy classifier init failed. Running in passthrough mode.\n", TAG);
        // Non-fatal: sensor can still relay mesh traffic and report health
    }

    // ---- Initialize multi-model manager ----
    Serial.printf("[%s] Initializing multi-model manager...\n", TAG);
    model_manager_init();

    // Register all available models
    // Triage models (always resident) — these stay loaded in PSRAM permanently
    int slot_drone = model_manager_register(
        "drone_classifier", MODEL_PATH_DRONE_CLASSIFIER,
        MODEL_DRONE_INPUT_SIZE, MODEL_DRONE_OUTPUT_CLASSES,
        MODEL_PRIORITY_TRIAGE);

    int slot_rf_attack = model_manager_register(
        "rf_attack", MODEL_PATH_RF_ATTACK,
        MODEL_RF_ATTACK_INPUT_SIZE, MODEL_RF_ATTACK_OUTPUT_CLASSES,
        MODEL_PRIORITY_TRIAGE);

    // Specialist models (paged in on-demand when triage flags anomaly)
    int slot_mavlink = model_manager_register(
        "mavlink_anomaly", MODEL_PATH_MAVLINK_ANOMALY,
        MODEL_MAVLINK_INPUT_SIZE, MODEL_MAVLINK_OUTPUT_CLASSES,
        MODEL_PRIORITY_SPECIALIST);

    int slot_lora = model_manager_register(
        "lora_mesh_attack", MODEL_PATH_LORA_MESH_ATTACK,
        MODEL_LORA_MESH_INPUT_SIZE, MODEL_LORA_MESH_OUTPUT_CLASSES,
        MODEL_PRIORITY_SPECIALIST);

    int slot_protocol = model_manager_register(
        "protocol_anomaly", MODEL_PATH_PROTOCOL_ANOMALY,
        MODEL_PROTOCOL_INPUT_SIZE, MODEL_PROTOCOL_OUTPUT_CLASSES,
        MODEL_PRIORITY_SPECIALIST);

    s_multimodel_active = (model_manager_count() > 0);
    Serial.printf("[%s] Multi-model: %d models registered (%s)\n",
                  TAG, model_manager_count(),
                  s_multimodel_active ? "ACTIVE" : "INACTIVE");

    // Suppress unused variable warnings for slot IDs
    (void)slot_drone;
    (void)slot_rf_attack;
    (void)slot_mavlink;
    (void)slot_lora;
    (void)slot_protocol;

    // ---- Initialize threat correlator ----
    correlator_init();

    // ---- Initialize subsystems ----
    ffps_init(s_sensor_id);
    health_init(s_sensor_id);
    blue_force_init();
    commandHandlerInit((uint16_t)(s_sensor_id & 0xFFFFu));

#ifdef PDL_ENABLE_UAV_RELAY
    // FIXED 2026-07-30: uav_relay_init() was never called anywhere in this
    // file -- INTEGRATION_UAV_T1.md documented it as a step for the
    // integrator to add, but nothing ever did, so s_initialized stayed
    // false and every UAV command/pairing frame was silently dead code in
    // the env:heltec_wifi_lora_v4_uav_bridge build. No explicit key here;
    // it now self-provisions via uav_pairing (NVS-persisted root, or a
    // fresh pairing window on cold boot -- see uav_pairing.h).
    uav_relay_init(nullptr, 0);
    uav_relay_set_own_sensor_id(s_sensor_id);
#endif

    // ---- Behavioral + feedback channels ----
    wfx_features_init();
    spoof_gate_init();
    pec_feedback_init();
    if (markov_init(MARKOV_BASELINE_PATH)) {
        Serial.printf("[%s] Markov RID scorer loaded\n", TAG);
    } else {
        Serial.printf("[%s] WARNING: Markov baseline missing — scorer disabled\n", TAG);
    }

    // ---- Cactus full_mode runtime (parallel to TFLite model_manager) ----
    fullmode_t1_init();

    // ---- Batch A: location, watchdog, jamming detector ----
    location_init();
    gps_init();   // L76K live fix; location_get() prefers this over the static value
    wifi_status_server_init();  // inert until WIFI_CONNECT issued via serial console
    wifi_autojoin_init();       // field deployments: keep trying to get online on its own
    mythos_bridge_init();       // inert until MYTHOS_CONNECT issued
    c2_wifi_bridge_init();      // inert until C2_WIFI_CONNECT issued
    c2_wifi_bridge_restore_from_nvs();  // reconnects automatically if a prior
                                         // C2_WIFI_CONNECT was persisted
    wifi_command_poll_init((uint16_t)(s_sensor_id & 0xFFFFu));  // inert until C2_WIFI_CONNECT issued
    handset_fingerprint_init(); // passive WiFi/BLE handset metadata detection
    wifi_security_scan_init();  // whitehat WiFi security-posture scanner
    wifi_extended_scan_init();  // RTT/FTM, KRACK/PMKID, WPA3 downgrade, rogue DHCP/DNS, channel-hop
    subghz_scanner_init();      // sub-GHz ELRS/Crossfire-band burst detector
    rf_cataloger_init();        // situational sub-GHz ISM activity cataloger
    hidden_tx_scanner_init();   // whitehat continuous-carrier/hidden-camera scan
    jamming_init();

    // ---- Batch B: time sync + OTA pubkey ----
    timesync_init();
    {
        uint8_t pk[32];
        if (ota_signed_get_pubkey(pk)) {
            ota_signed_set_pubkey(pk);
            Serial.printf("[%s] OTA Ed25519 pubkey loaded from NVS\n", TAG);
        } else {
            Serial.printf("[%s] WARNING: no OTA Ed25519 pubkey — OTA chunks will be rejected\n", TAG);
        }
    }

    // ---- Batch C: FHSS predictor, threat VQC, attack graph ----
    fhss_init();
    threat_vqc_init();
    attackgraph_init();
    s_last_attackgraph_decay_ms = millis();

    // ---- Batch D: TALON federated receiver, cognitive RF baseline ----
    talon_init();
    cogrf_init();

    // ---- Active subsystem: payload buffer + WiFi inject + auth gate ----
    payload_buffer_init();
    wifi_inject_init();
    authgate_reload_pubkey();   // lazy-load already; this caches it explicitly

    // ---- Multi-radio interop (PDL_INTEROP_ENABLED variant only) ----
#ifdef PDL_INTEROP_ENABLED
    radio_sched_init();   // scheduler before protocol inits — owns SX1262
    meshtastic_init();
    rnode_init();
    mqtt_init();
    peer_relay_init();   // restore any configured WiFi peer-relay target
    interop_router_init();
    Serial.printf("[%s] Interop active: PDL/Meshtastic/RNode time-slice + MQTT-over-WiFi\n", TAG);
#endif

    // ---- BLE companion + C2 proxy tunnel (operator phone direct-link) ----
#ifdef PDL_BLE_COMPANION_ENABLED
    // FIXED 2026-07-26: ble_proxy_register() was called with a null service
    // pointer (no ble_companion_get_service() accessor existed) AFTER the
    // service had already been started inside ble_companion_init() — NimBLE
    // rejects new characteristics on an already-started service, so ProxyTx/
    // ProxyRx were silently never added (confirmed via live BLE scan: only
    // 7 of 9 custom characteristics present). ble_companion_init() no longer
    // starts the service itself; ble_companion_start_advertising() does that
    // explicitly, after proxy registration below.
    ble_companion_init();          // creates NimBLE server/service/characteristics
    ble_proxy_register(ble_companion_get_service());  // adds ProxyTx/ProxyRx BEFORE start()
    ble_companion_start_advertising();  // NOW start the service, advertise PDL-<NAME>
    c2_proxy_tunnel_init();        // chunk reassembly + outbound LoRa queue
    Serial.printf("[%s] BLE companion + C2 proxy tunnel online\n", TAG);

    // ODID scanner attaches to the NimBLE host ble_companion_init() just
    // brought up (must come after it — this module never calls
    // NimBLEDevice::init() itself).
    ble_odid_scanner_init();
    Serial.printf("[%s] BLE OpenDroneID scanner online\n", TAG);
#endif

    // Watchdog: register panic handler then start the WDT.
    // Coredumps land at /coredump_N.bin on LittleFS.
    watchdog_register_panic_dump();
    watchdog_init(30);

    if (!ota_init()) {
        Serial.printf("[%s] WARNING: OTA adapter init failed.\n", TAG);
    }

    // ---- Start receiving ----
    mesh_start_receive();

    // ---- Initialize spectrum buffer ----
    memset(s_spectrum, 0, sizeof(s_spectrum));
    memset(s_channel_rssi, 0, sizeof(s_channel_rssi));

    s_last_cycle_ms = millis();
    Serial.printf("[%s] Setup complete. Entering main loop at 1 Hz.\n", TAG);
    Serial.println("--------------------------------------");
}

// ============================================================================
// USB-serial debug console (ADDITIVE — does not replace the LoRa/BLE binary
// command path in command_handler.cpp; this is a separate, host-facing text
// interface so tools like PDL Deploy can verify a flash over the same USB
// link used to program the board, without needing a paired T2 LoRa gateway).
// ============================================================================
static void printReportStatusJson() {
    uint8_t loaded = 0, triage_missing = 0;
    uint8_t count = model_manager_count();
    for (uint8_t i = 0; i < count; i++) {
        ModelStats st{};
        if (model_manager_get_stats(i, st)) {
            if (st.loaded) loaded++;
            if (st.priority == MODEL_PRIORITY_TRIAGE && !st.loaded) triage_missing++;
        }
    }
    bool models_loaded = s_multimodel_active && count > 0 && triage_missing == 0;

    Serial.printf(
        "{\"status\":\"ok\",\"sensor_id\":\"0x%08X\",\"fw_version\":\"%s\","
        "\"sensor_name\":\"%s\",\"models_loaded\":%s,\"model_count\":%u,"
        "\"models_resident\":%u,\"enrolled\":%s,\"uptime_s\":%lu",
        (unsigned int)s_sensor_id, SENSOR_FW_VERSION, SENSOR_NAME,
        models_loaded ? "true" : "false", (unsigned)count, (unsigned)loaded,
        enrollment_is_complete() ? "true" : "false",
        (unsigned long)(millis() / 1000));
#ifdef PDL_BLE_COMPANION_ENABLED
    OdidScannerStats ostats{};
    ble_odid_scanner_get_stats(&ostats);
    Serial.printf(
        ",\"odid_adverts_seen\":%lu,\"odid_rid_hits\":%lu,"
        "\"odid_location_hits\":%lu,\"odid_blue_force_matches\":%lu",
        (unsigned long)ostats.adverts_seen, (unsigned long)ostats.rid_hits,
        (unsigned long)ostats.location_hits, (unsigned long)ostats.blue_force_matches);
#endif
    {
        WifiOdidScannerStats wstats{};
        wifi_odid_scanner_get_stats(&wstats);
        Serial.printf(
            ",\"wifi_odid_frames_seen\":%lu,\"wifi_odid_rid_hits\":%lu,"
            "\"wifi_odid_location_hits\":%lu,\"wifi_odid_blue_force_matches\":%lu",
            (unsigned long)wstats.frames_seen, (unsigned long)wstats.rid_hits,
            (unsigned long)wstats.location_hits, (unsigned long)wstats.blue_force_matches);
    }
    {
        GpsStats gstats{};
        gps_get_stats(&gstats);
        int32_t lat_fixed = 0, lon_fixed = 0;
        location_get(&lat_fixed, &lon_fixed);
        Serial.printf(
            ",\"gps_has_fix\":%s,\"gps_satellites\":%u,\"gps_hdop\":%.1f,"
            "\"gps_chars_processed\":%lu,\"gps_checksum_failures\":%lu,"
            "\"lat\":%.7f,\"lon\":%.7f",
            gstats.has_fix ? "true" : "false", (unsigned)gstats.satellites,
            gstats.hdop, (unsigned long)gstats.chars_processed,
            (unsigned long)gstats.checksum_failures,
            lat_fixed / 1e7, lon_fixed / 1e7);
    }
    {
        MythosBridgeStats mstats{};
        mythos_bridge_get_stats(&mstats);
        C2WifiBridgeStats cstats{};
        c2_wifi_bridge_get_stats(&cstats);
        Serial.printf(
            ",\"mythos_configured\":%s,\"mythos_polls\":%lu,"
            "\"mythos_poll_failures\":%lu,\"mythos_corrections_seen\":%lu,"
            "\"c2_wifi_configured\":%s,\"c2_wifi_posts_ok\":%lu,"
            "\"c2_wifi_posts_failed\":%lu",
            mstats.configured ? "true" : "false",
            (unsigned long)mstats.poll_count, (unsigned long)mstats.poll_failures,
            (unsigned long)mstats.corrections_seen_total,
            cstats.configured ? "true" : "false",
            (unsigned long)cstats.posts_ok, (unsigned long)cstats.posts_failed);
    }
    {
        HandsetStats hstats{};
        handset_fingerprint_get_stats(&hstats);
        Serial.printf(
            ",\"handset_wifi_probes_seen\":%lu,\"handset_ble_adverts_seen\":%lu,"
            "\"handset_devices_nearby\":%u,\"handset_vendor_matches\":%u,"
            "\"handset_controller_app_matches\":%u,\"handset_camera_vendor_matches\":%u",
            (unsigned long)hstats.wifi_probes_seen, (unsigned long)hstats.ble_adverts_seen,
            (unsigned)hstats.distinct_devices_last_60s,
            (unsigned)hstats.vendor_matches_total,
            (unsigned)hstats.controller_app_matches_total,
            (unsigned)hstats.camera_vendor_matches_total);
    }
    {
        WifiSecurityStats wsstats{};
        wifi_security_scan_get_stats(&wsstats);
        Serial.printf(
            ",\"wsec_open_networks\":%u,\"wsec_wep_networks\":%u,"
            "\"wsec_weak_tkip_only\":%u,\"wsec_evil_twin_suspects\":%u,"
            "\"wsec_deauth_frames_60s\":%lu,\"wsec_disassoc_frames_60s\":%lu,"
            "\"wsec_deauth_flood_alert\":%s",
            (unsigned)wsstats.open_networks, (unsigned)wsstats.wep_networks,
            (unsigned)wsstats.weak_tkip_only_networks, (unsigned)wsstats.evil_twin_suspects,
            (unsigned long)wsstats.deauth_frames_last_60s,
            (unsigned long)wsstats.disassoc_frames_last_60s,
            wsstats.deauth_flood_alert ? "true" : "false");
    }
    {
        SubGhzScannerStats sgstats{};
        subghz_scanner_get_stats(&sgstats);
        Serial.printf(
            ",\"subghz_scan_cycles\":%lu,\"subghz_findings_last_cycle\":%u",
            (unsigned long)sgstats.scan_cycles_completed,
            (unsigned)sgstats.findings_last_cycle);
    }
    {
        RfCatalogerStats rfstats{};
        rf_cataloger_get_stats(&rfstats);
        Serial.printf(
            ",\"rf_catalog_scan_cycles\":%lu,\"rf_catalog_active_last_cycle\":%u",
            (unsigned long)rfstats.scan_cycles_completed,
            (unsigned)rfstats.entries_last_cycle);
    }
    {
        // hidden_tx_scanner is DISABLED (see the loop()-side comment for
        // why) -- report that honestly rather than a live-looking "0
        // suspects" that could be misread as a clean scan result.
        Serial.print(",\"hidden_tx_scan\":\"disabled_architectural_limitation\"");
    }
    Serial.println("}");
}

// Decode a hex string ("AABBCC...") into raw bytes. Returns bytes written,
// or 0 on malformed input (odd length, non-hex char, or overflow).
static size_t hexDecode(const char* hex, uint8_t* out, size_t out_cap) {
    size_t hex_len = strlen(hex);
    if (hex_len % 2 != 0) return 0;
    size_t n = hex_len / 2;
    if (n > out_cap) return 0;
    for (size_t i = 0; i < n; i++) {
        char hi = hex[i * 2], lo = hex[i * 2 + 1];
        int8_t hv = (hi >= '0' && hi <= '9') ? hi - '0' :
                    (hi >= 'A' && hi <= 'F') ? hi - 'A' + 10 :
                    (hi >= 'a' && hi <= 'f') ? hi - 'a' + 10 : -1;
        int8_t lv = (lo >= '0' && lo <= '9') ? lo - '0' :
                    (lo >= 'A' && lo <= 'F') ? lo - 'A' + 10 :
                    (lo >= 'a' && lo <= 'f') ? lo - 'a' + 10 : -1;
        if (hv < 0 || lv < 0) return 0;
        out[i] = (uint8_t)((hv << 4) | lv);
    }
    return n;
}

// Test-only console commands (ADDITIVE — a debugging/verification aid, not
// part of the production detection path). These let an on-device test
// inject a byte-for-byte realistic ODID payload/frame through the EXACT
// same production parse -> classify -> blue-force -> FFPS pipeline the real
// BLE scan / WiFi promiscuous callback use (see process_hit() in
// ble_odid_scanner.cpp / wifi_odid_scanner.cpp), without needing a second
// radio to broadcast a real over-the-air signal.
static void handleTestCommand(char* line) {
    uint8_t buf[128];

    if (strncmp(line, "TEST_BLE_ODID ", 14) == 0) {
#ifdef PDL_BLE_COMPANION_ENABLED
        size_t n = hexDecode(line + 14, buf, sizeof(buf));
        if (n == 0) { Serial.println("{\"error\":\"bad hex\"}"); return; }
        bool hit = ble_odid_scanner_test_inject(buf, n, -50);
        Serial.printf("{\"test\":\"ble_odid\",\"bytes\":%u,\"hit\":%s}\n",
                      (unsigned)n, hit ? "true" : "false");
#else
        Serial.println("{\"error\":\"PDL_BLE_COMPANION_ENABLED not built\"}");
#endif
        return;
    }

    if (strncmp(line, "TEST_WIFI_ODID ", 15) == 0) {
        size_t n = hexDecode(line + 15, buf, sizeof(buf));
        if (n == 0) { Serial.println("{\"error\":\"bad hex\"}"); return; }
        bool hit = wifi_odid_scanner_test_inject(buf, n, -50);
        Serial.printf("{\"test\":\"wifi_odid\",\"bytes\":%u,\"hit\":%s}\n",
                      (unsigned)n, hit ? "true" : "false");
        return;
    }

    if (strcmp(line, "TSCM_SWEEP") == 0) {
        // Empirical test (not a datasheet assertion): sweep the SX1262's
        // synthesizer across its documented range and log real RSSI at
        // each step, to see what this SPECIFIC board's antenna/matching
        // network actually passes vs. attenuates outside the 915 MHz ISM
        // band it's designed for. Restores normal operation afterward.
        SX1262& radio = mesh_get_radio();
        static const float kSweepMhz[] = {
            150, 250, 350, 433, 470, 550, 650, 750, 850,
            902, 915, 928, 960
        };
        Serial.println("{\"tscm_sweep\":[");
        for (size_t i = 0; i < sizeof(kSweepMhz) / sizeof(kSweepMhz[0]); i++) {
            int16_t state = radio.setFrequency(kSweepMhz[i]);
            radio.startReceive();   // put the front end back in live RX so
                                     // getRSSI(false) reads a real instant
                                     // value, not a stale last-packet one
            delay(100);
            float rssi = radio.getRSSI(false);
            Serial.printf("%s{\"mhz\":%.1f,\"set_ok\":%s,\"rssi_dbm\":%.1f}",
                          i == 0 ? "" : ",", kSweepMhz[i],
                          state == RADIOLIB_ERR_NONE ? "true" : "false", rssi);
        }
        Serial.println("]}");
        radio.setFrequency(915.0);   // restore normal operation
        mesh_start_receive();
        return;
    }

    if (strncmp(line, "TEST_HANDSET_WIFI ", 19) == 0) {
        size_t n = hexDecode(line + 19, buf, sizeof(buf));
        if (n == 0) { Serial.println("{\"error\":\"bad hex\"}"); return; }
        bool hit = handset_fingerprint_test_inject_wifi(buf, n, -50);
        Serial.printf("{\"test\":\"handset_wifi\",\"bytes\":%u,\"hit\":%s}\n",
                      (unsigned)n, hit ? "true" : "false");
        return;
    }

    if (strncmp(line, "TEST_HANDSET_BLE ", 18) == 0) {
        // "TEST_HANDSET_BLE <12-hex-char-mac><hex-adv-data>"
        if (strlen(line + 18) < 12) { Serial.println("{\"error\":\"bad mac\"}"); return; }
        uint8_t mac[6];
        if (hexDecode(line + 18, mac, 6) != 6) { Serial.println("{\"error\":\"bad mac\"}"); return; }
        size_t n = hexDecode(line + 18 + 12, buf, sizeof(buf));
        bool hit = handset_fingerprint_test_inject_ble(mac, buf, n, -50);
        Serial.printf("{\"test\":\"handset_ble\",\"bytes\":%u,\"hit\":%s}\n",
                      (unsigned)n, hit ? "true" : "false");
        return;
    }

    if (strncmp(line, "GPS_PINS ", 9) == 0) {
        // "GPS_PINS <rx> <tx> [baud]" — bench aid for finding an unknown GPS
        // wiring fast. Opens the pair immediately and halts the 28-candidate
        // autoscan (which takes ~112 s per sweep and can't be steered).
        //
        // gps_chars_processed counts RAW bytes before parsing, so even a wrong
        // baud produces a climbing count. That "garbage reading" is the goal
        // of the first pass: it proves the electrical path independently of
        // whether the data is intelligible.
        int rx = -1, tx = -1;
        unsigned long baud = 0;
        int n = sscanf(line + 9, "%d %d %lu", &rx, &tx, &baud);
        if (n < 2) {
            Serial.println("{\"error\":\"usage: GPS_PINS <rx> <tx> [baud]\"}");
            return;
        }
        bool ok = gps_force_pins(rx, tx, (uint32_t)baud);
        Serial.printf("{\"test\":\"gps_pins\",\"rx\":%d,\"tx\":%d,\"baud\":%lu,\"ok\":%s}\n",
                      rx, tx, baud ? baud : 9600UL, ok ? "true" : "false");
        return;
    }

    if (strcmp(line, "GPS_STATS") == 0) {
        GpsStats g{};
        gps_get_stats(&g);
        Serial.printf("{\"chars\":%lu,\"cksum_fail\":%lu,\"fix\":%s,\"sats\":%u,\"hdop\":%.1f}\n",
                      (unsigned long)g.chars_processed,
                      (unsigned long)g.checksum_failures,
                      g.has_fix ? "true" : "false",
                      (unsigned)g.satellites, g.hdop);
        return;
    }

    if (strcmp(line, "GPS_LOOPBACK") == 0) {
        // Jumper the configured TX pin to the RX pin, then run this.
        // Bytes returned  -> ESP32 UART + pin routing + read loop all work,
        //                    so the fault is the module or its wiring.
        // Nothing returned-> the fault is in this firmware's UART setup.
        unsigned long sent = 0, recv = 0;
        bool ok = gps_loopback_test(&sent, &recv);
        Serial.printf("{\"test\":\"gps_loopback\",\"sent\":%lu,\"received\":%lu,\"uart_ok\":%s}\n",
                      sent, recv, ok ? "true" : "false");
        return;
    }

    if (strcmp(line, "GPS_WHERE") == 0) {
        int rx = -1, tx = -1, idx = 0, cnt = 0; bool locked = false;
        gps_get_active_pins(&rx, &tx, &locked, &idx, &cnt);
        Serial.printf("{\"rx\":%d,\"tx\":%d,\"locked\":%s,\"candidate\":%d,\"of\":%d}\n",
                      rx, tx, locked ? "true" : "false", idx + 1, cnt);
        return;
    }

    if (strcmp(line, "GPS_RESET") == 0) {
        gps_reset_stats();
        Serial.println("{\"test\":\"gps_reset\",\"ok\":true}");
        return;
    }

    if (strncmp(line, "SET_LOCATION ", 13) == 0) {
        // "SET_LOCATION <lat> <lon>" in decimal degrees, e.g.
        //     SET_LOCATION 38.5822030 -78.1450658
        //
        // Persists to NVS ("pdl_loc"). location_get() prefers a live GPS fix
        // and falls back to this, so a unit with no GNSS fitted (Heltec V3,
        // or a T-Deck without the Plus module) reports a REAL position to C2
        // instead of 0,0 — which is what put those sensors at null island on
        // the operator map.
        //
        // This existed only as command 19 over BLE/T2 before, which meant a
        // USB-tethered or WiFi-only unit had no way to be told where it is.
        double lat = 0.0, lon = 0.0;
        if (sscanf(line + 13, "%lf %lf", &lat, &lon) != 2) {
            Serial.println("{\"error\":\"usage: SET_LOCATION <lat> <lon>\"}");
            return;
        }
        if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) {
            Serial.println("{\"error\":\"out of WGS84 range\"}");
            return;
        }
        int32_t lat_fixed = (int32_t)llround(lat * 1e7);
        int32_t lon_fixed = (int32_t)llround(lon * 1e7);
        bool ok = location_set(lat_fixed, lon_fixed);
        Serial.printf("{\"test\":\"set_location\",\"lat\":%.7f,\"lon\":%.7f,\"persisted\":%s}\n",
                      lat, lon, ok ? "true" : "false");
        return;
    }

    if (strncmp(line, "PEER_RELAY_CONNECT ", 19) == 0) {
        // "PEER_RELAY_CONNECT <host> <port>" — forward detections to a peer
        // sensor over WiFi when this unit cannot reach C2 itself. Pass a bare
        // "off" to disable.
        char* rest = line + 19;
        if (strcmp(rest, "off") == 0) {
            peer_relay_configure("", 0);
            Serial.println("{\"test\":\"peer_relay\",\"enabled\":false}");
            return;
        }
        char* space = strchr(rest, ' ');
        if (!space) {
            Serial.println("{\"error\":\"usage: PEER_RELAY_CONNECT <host> <port> | off\"}");
            return;
        }
        *space = '\0';
        uint16_t port = (uint16_t)atoi(space + 1);
        peer_relay_configure(rest, port ? port : 80);
        Serial.printf("{\"test\":\"peer_relay\",\"host\":\"%s\",\"port\":%u}\n",
                      rest, (unsigned)(port ? port : 80));
        return;
    }

    if (strncmp(line, "WHITELIST_ADD ", 14) == 0) {
        bool ok = blue_force_add(line + 14);
        Serial.printf("{\"test\":\"whitelist_add\",\"serial\":\"%s\",\"ok\":%s}\n",
                      line + 14, ok ? "true" : "false");
        return;
    }

    if (strncmp(line, "WIFI_CONNECT ", 13) == 0) {
        // "WIFI_CONNECT <ssid> <password>" — space-separated, no spaces
        // supported in either field. Credentials are never persisted or
        // committed anywhere; they only live in RAM for this boot session.
        char* rest = line + 13;
        char* space = strchr(rest, ' ');
        if (!space) {
            Serial.println("{\"error\":\"usage: WIFI_CONNECT <ssid> <password>\"}");
            return;
        }
        *space = '\0';
        const char* ssid = rest;
        const char* pass = space + 1;
        wifi_status_server_connect(ssid, pass);
        Serial.printf("{\"test\":\"wifi_connect\",\"ssid\":\"%s\"}\n", ssid);
        return;
    }

    if (strncmp(line, "WIFI_REMEMBER ", 14) == 0) {
        // "WIFI_REMEMBER <ssid> <password>" — saves a credential pair to
        // NVS for future autojoin attempts (survives reboot/reflash).
        // Password may be omitted/empty for a network to be treated as open.
        char* rest = line + 14;
        char* space = strchr(rest, ' ');
        const char* ssid;
        const char* pass;
        if (space) {
            *space = '\0';
            ssid = rest;
            pass = space + 1;
        } else {
            ssid = rest;
            pass = "";
        }
        wifi_autojoin_remember(ssid, pass);
        Serial.printf("{\"test\":\"wifi_remember\",\"ssid\":\"%s\"}\n", ssid);
        return;
    }

    if (strcmp(line, "WIFI_AUTOJOIN") == 0) {
        wifi_autojoin_trigger_now();
        Serial.println("{\"test\":\"wifi_autojoin_triggered\"}");
        return;
    }

    if (strcmp(line, "WIFI_PROVISION") == 0) {
        // Force field WiFi provisioning mode on demand, rather than waiting
        // for WIFI_PROVISION_TRIGGER_FAILURES consecutive failed autojoin
        // cycles -- lets this be tested/used deliberately over serial too,
        // not just as an automatic last-resort fallback.
        wifi_provisioning_start();
        Serial.println("{\"test\":\"wifi_provision_started\"}");
        return;
    }

    if (strcmp(line, "RNODE_ANNOUNCE") == 0) {
        // Sends a real, signed RNS self-announce for this sensor's own
        // Reticulum identity/destination (pdl.<SENSOR_NAME>) -- added
        // 2026-07-30 alongside reticulum_identity.h. No connectivity
        // required beyond the RNode-configured LoRa radio; a real
        // Reticulum node in range can discover and path-route to this
        // sensor from this alone.
        const char* aspects[] = { SENSOR_NAME };
        bool ok = rnode_announce("pdl", aspects, 1, nullptr, 0);
        Serial.printf("{\"test\":\"rnode_announce\",\"ok\":%s}\n", ok ? "true" : "false");
        return;
    }

    if (strncmp(line, "MYTHOS_CONNECT ", 15) == 0) {
        // "MYTHOS_CONNECT <host> <port>"
        char* rest = line + 15;
        char* space = strchr(rest, ' ');
        if (!space) {
            Serial.println("{\"error\":\"usage: MYTHOS_CONNECT <host> <port>\"}");
            return;
        }
        *space = '\0';
        const char* host = rest;
        uint16_t port = (uint16_t)atoi(space + 1);
        mythos_bridge_connect(host, port);
        Serial.printf("{\"test\":\"mythos_connect\",\"host\":\"%s\",\"port\":%u}\n",
                      host, (unsigned)port);
        return;
    }

    if (strncmp(line, "C2_WIFI_CONNECT ", 16) == 0) {
        // "C2_WIFI_CONNECT <host> <port> <path>"
        char* rest = line + 16;
        char* sp1 = strchr(rest, ' ');
        if (!sp1) {
            Serial.println("{\"error\":\"usage: C2_WIFI_CONNECT <host> <port> <path>\"}");
            return;
        }
        *sp1 = '\0';
        char* sp2 = strchr(sp1 + 1, ' ');
        if (!sp2) {
            Serial.println("{\"error\":\"usage: C2_WIFI_CONNECT <host> <port> <path>\"}");
            return;
        }
        *sp2 = '\0';
        const char* host = rest;
        uint16_t port = (uint16_t)atoi(sp1 + 1);
        const char* path = sp2 + 1;
        c2_wifi_bridge_connect(host, port, path);
        Serial.printf("{\"test\":\"c2_wifi_connect\",\"host\":\"%s\",\"port\":%u,\"path\":\"%s\"}\n",
                      host, (unsigned)port, path);
        return;
    }
}

static void serialConsoleTick() {
    static char line[160];
    static uint8_t line_len = 0;
    while (Serial.available()) {
        char c = (char)Serial.read();
        if (c == '\n' || c == '\r') {
            if (line_len > 0) {
                line[line_len] = '\0';
                if (strcmp(line, "REPORT_STATUS") == 0) {
                    printReportStatusJson();
                } else if (strcmp(line, "HIDDEN_TX_SCAN") == 0) {
                    // Disabled -- see the loop()-side comment near
                    // capture_rf_spectrum() for the confirmed root cause
                    // (no continuous-energy-detection primitive available
                    // without CSI/ESP-IDF, not a tunable threshold issue).
                    Serial.println("{\"error\":\"disabled_architectural_limitation\",\"count\":0}");
                } else if (strcmp(line, "SUBGHZ_FINDINGS") == 0) {
                    SubGhzFinding findings[8];
                    size_t n = subghz_scanner_list_findings(findings, 8);
                    Serial.print("{\"findings\":[");
                    for (size_t i = 0; i < n; i++) {
                        if (i > 0) Serial.print(",");
                        Serial.printf(
                            "{\"freq_mhz\":%.2f,\"est_rate_hz\":%.1f,\"peak_rssi_dbm\":%d}",
                            findings[i].freq_mhz, findings[i].estimated_rate_hz,
                            (int)findings[i].peak_rssi_dbm);
                    }
                    Serial.printf("],\"count\":%u}\n", (unsigned)n);
                } else if (strcmp(line, "RF_CATALOG") == 0) {
                    RfCatalogEntry entries[8];
                    size_t n = rf_cataloger_list(entries, 8);
                    static const char* kClassNames[] = {
                        "none", "continuous", "low_rate", "periodic",
                        "control_link_rate", "erratic"
                    };
                    Serial.print("{\"entries\":[");
                    for (size_t i = 0; i < n; i++) {
                        if (i > 0) Serial.print(",");
                        Serial.printf(
                            "{\"freq_mhz\":%.2f,\"class\":\"%s\","
                            "\"crossing_rate_hz\":%.1f,\"peak_rssi_dbm\":%d}",
                            entries[i].freq_mhz, kClassNames[entries[i].activity_class],
                            entries[i].crossing_rate_hz, (int)entries[i].peak_rssi_dbm);
                    }
                    Serial.printf("],\"count\":%u}\n", (unsigned)n);
                } else if (strcmp(line, "SECURITY_SCAN") == 0) {
                    WifiSecurityFinding findings[32];
                    size_t n = wifi_security_scan_list_findings(findings, 32);
                    static const char* kIssueNames[] = {
                        "none", "open", "wep", "weak_tkip_only", "evil_twin_suspect"
                    };
                    Serial.print("{\"findings\":[");
                    for (size_t i = 0; i < n; i++) {
                        if (i > 0) Serial.print(",");
                        Serial.printf(
                            "{\"bssid\":\"%02X:%02X:%02X:%02X:%02X:%02X\","
                            "\"ssid\":\"%s\",\"issue\":\"%s\",\"rssi\":%d}",
                            findings[i].bssid[0], findings[i].bssid[1], findings[i].bssid[2],
                            findings[i].bssid[3], findings[i].bssid[4], findings[i].bssid[5],
                            findings[i].ssid, kIssueNames[findings[i].issue],
                            (int)findings[i].last_rssi);
                    }
                    Serial.printf("],\"count\":%u}\n", (unsigned)n);
                } else if (strcmp(line, "HANDSET_LIST") == 0) {
                    HandsetDeviceInfo devs[48];
                    size_t n = handset_fingerprint_list_devices(devs, 48);
                    Serial.print("{\"devices\":[");
                    for (size_t i = 0; i < n; i++) {
                        if (i > 0) Serial.print(",");
                        Serial.printf(
                            "{\"mac\":\"%02X:%02X:%02X:%02X:%02X:%02X\","
                            "\"vendor\":\"%s\",\"app\":\"%s\",\"rssi\":%d,"
                            "\"via\":\"%s\",\"addr_type\":\"%s\",\"age_ms\":%lu,"
                            "\"camera_vendor\":%s}",
                            devs[i].mac[0], devs[i].mac[1], devs[i].mac[2],
                            devs[i].mac[3], devs[i].mac[4], devs[i].mac[5],
                            devs[i].vendor, devs[i].app_label, (int)devs[i].last_rssi,
                            devs[i].via_ble ? "ble" : "wifi",
                            devs[i].is_public_addr ? "public" : "random",
                            (unsigned long)(millis() - devs[i].last_seen_ms),
                            devs[i].is_known_camera_vendor ? "true" : "false");
                    }
                    Serial.printf("],\"count\":%u}\n", (unsigned)n);
                } else {
                    handleTestCommand(line);
                }
                line_len = 0;
            }
        } else if (line_len < sizeof(line) - 1) {
            line[line_len++] = c;
        }
    }
}

// ============================================================================
// MAIN LOOP — 1 Hz target
// ============================================================================
void loop() {
    uint32_t cycle_start = millis();

    // ---- Watchdog feed (top of cycle, before any blocking work) ----
    watchdog_feed();

    // ---- USB-serial debug console (non-blocking, additive) ----
    serialConsoleTick();

    // ---- GPS (L76K): drain UART, feed NMEA parser. Non-blocking. ----
    gps_tick();

    // ---- Persist the live GPS fix to NVS every few minutes so location_get()
    //      has a real last-known position to fall back on during the NEXT
    //      boot's GPS re-acquisition window. Without this, detections in the
    //      first ~15 s after a reboot resolve to 0,0 and plot a false dot in
    //      the ocean. Rate-limited (5 min) to protect NVS write endurance. ----
    {
        static uint32_t s_last_loc_persist_ms = 0;
        if (gps_has_fix() &&
            (s_last_loc_persist_ms == 0 || millis() - s_last_loc_persist_ms > 300000UL)) {
            int32_t plat = 0, plon = 0;
            gps_get_location(&plat, &plon);
            if (plat != 0 || plon != 0) {
                location_set(plat, plon);
                s_last_loc_persist_ms = millis();
            }
        }
    }

    // ---- WiFi status server: only active after WIFI_CONNECT issued. ----
    wifi_status_server_tick();

    // ---- WiFi autojoin: always active, no-op while already connected. ----
    wifi_autojoin_tick();

    // ---- NTP wall-clock: gives a GPS-less unit (Heltec V3 — no GNSS ----
    // ---- header) a real UTC epoch once it's on WiFi. Without this, a  ----
    // ---- V3 posting DIRECTLY to C2 stamps detections with boot-seconds,----
    // ---- which dayKey() files under 1970 and the map never shows. The ----
    // ---- LoRa relay path never needed it (the relaying unit restamps).----
    // ---- No-op on a unit already synced by mesh or lacking WiFi.      ----
    ntp_timesync_tick();

    // ---- Firmware OTA responder: starts once WiFi is up, then accepts  ----
    // ---- `pio run -t upload --upload-port <ip>` over espota. Requires  ----
    // ---- a dual-app partition table (partitions_*_ota.csv) — with the  ----
    // ---- old single-slot table ArduinoOTA.begin() has nowhere to stage ----
    // ---- the image and every update fails at OTA_BEGIN_ERROR.          ----
    ota_firmware_tick();

    // ---- MQTT status heartbeat: fixed cadence, independent of activity, ----
    // ---- so a silent unit on the broker genuinely means a dead unit.    ----
    mqtt_tick();

    // ---- Mythos corrections bridge + direct C2 detection POST: only ----
    // ---- active after their respective *_CONNECT commands are issued. ----
    mythos_bridge_tick();

    // ---- Direct C2 health heartbeat: only active after C2_WIFI_CONNECT. --
    // Without this, a sensor using the direct-WiFi bridge (no T2 gateway)
    // has no path at all to report its own position to C2 -- it would only
    // ever appear on the map if/when it publishes an actual detection.
    c2_wifi_bridge_tick();

    // ---- Inbound tasking over the same direct-WiFi bridge: only active
    // after C2_WIFI_CONNECT. Lets an operator (OpenForge C2 / PDL Tactical)
    // task this sensor directly -- FOCUS_FREQ, SET_THRESHOLD, stealth, TX
    // power, on-demand status/scan -- with no LoRa-connected T2 gateway
    // required. See wifi_command_poll.h for the supported command shapes.
    wifi_command_poll_tick();

#ifdef PDL_INTEROP_ENABLED
    // Time-slice the SX1262 between PDL / Meshtastic / RNode protocols.
    // Must run BEFORE any LoRa TX/RX in this cycle.
    radio_sched_tick();
    interop_router_tick();
#endif
#ifdef PDL_BLE_COMPANION_ENABLED
    ble_companion_tick();         // drain Cmd writes + emit status notify
    c2_proxy_tunnel_tick();       // forward queued proxy fragments out over LoRa
#endif
#ifdef PDL_ENABLE_MESH_ROUTING
    // Real gap found and fixed here: RX dispatch already called
    // mesh_handle_route_packet()/mesh_handle_ack()/mesh_handle_discover()
    // for incoming 0x0D/0x0E/0x0F packets, but mesh_routing_init() and
    // mesh_routing_tick() were never called anywhere -- so this sensor's
    // own node identity was never set (mesh_send()/ACKs would use a
    // zeroed/default identity) and none of the periodic work (15s DISCOVER
    // broadcasts, store-and-forward retry, 60s self-assessment stats) ever
    // ran. Only affects env:heltec_wifi_lora_v4_uav_bridge -- the only
    // build with PDL_ENABLE_MESH_ROUTING defined; every other environment
    // (including the deployed Spencer01 unit) is untouched by this #ifdef.
    mesh_routing_tick();
#endif
#ifdef PDL_ENABLE_UAV_RELAY
    // Same class of gap as mesh_routing_tick() above: uav_relay_init()
    // calls uav_pairing_init() to open the initial pairing window, but
    // nothing ever called uav_pairing_tick() -- so the 60s window opened
    // at boot never closed (a PAIR_REQ would still be accepted at any
    // point after boot, not just within the intended window) and the
    // documented BOOT-button long-press re-pair path was dead code. No
    // physical pairing button is wired on T1 yet, so button_held_ms is
    // always 0 here -- only the window-expiry half of tick() is live;
    // re-pairing after the window closes still requires a reboot.
    uav_pairing_tick(millis(), 0);
#endif

    // ---- OLED refresh (every cycle, ~1Hz) ----
    {
        s_display.clearBuffer();
        s_display.setFont(u8g2_font_7x13B_tr);
        s_display.drawStr(0, 12, SENSOR_NAME);
        s_display.setFont(u8g2_font_6x10_tr);
        char ln[32];
        unsigned long s = millis() / 1000;
        snprintf(ln, sizeof(ln), "%02lu:%02lu:%02lu",
                 s / 3600, (s % 3600) / 60, s % 60);
        s_display.drawStr(72, 12, ln);
        s_display.drawHLine(0, 14, 128);
        snprintf(ln, sizeof(ln), "ID  0x%08X", (unsigned int)s_sensor_id);
        s_display.drawStr(0, 26, ln);
        snprintf(ln, sizeof(ln), "Mesh %s",
                 timesync_is_synced() ? "SYNC" : "boot");
        s_display.drawStr(0, 38, ln);
        snprintf(ln, sizeof(ln), "Nets %d  NLL %.1f",
                 network_id_count(), markov_current_nll());
        s_display.drawStr(0, 50, ln);
        if (millis() - s_last_detection_ms < 5000) {
            s_display.drawStr(0, 62, "DET");
            s_display.drawStr(28, 62, s_last_class_str);
        } else {
            s_display.drawStr(0, 62, enrollment_is_complete() ? "READY" : "scanning");
        }
        s_display.sendBuffer();
    }

    // ---- Enrollment bootstrap (no-op once enrolled) ----
    if (!enrollment_is_complete()) {
        enrollment_tick();
    }

    // ---- Attack-graph decay (1% per minute baseline) ----
    {
        uint32_t now_ms = millis();
        attackgraph_decay(now_ms - s_last_attackgraph_decay_ms);
        s_last_attackgraph_decay_ms = now_ms;
    }

    // ---- Failsafe trip on attack-graph extreme ----
    if (attackgraph_should_failsafe()) {
        markov_publish_nll(s_sensor_id, 0, 0.0f);
        if (attackgraph_node_risk(NODE_AUTH_PUBKEY) > 0.95f) {
            Serial.printf("[%s] FAILSAFE: AUTH_PUBKEY compromised — wiping NVS + reboot\n", TAG);
            #ifdef ARDUINO
              nvs_flash_erase();
              esp_restart();
            #endif
        }
    }

    // ----------------------------------------------------------------
    // 1. Check for incoming mesh packets (FFPS corroboration, OTA, etc.)
    // ----------------------------------------------------------------
    uint8_t rx_buf[256];
    float rx_rssi, rx_snr;
    int rx_len = mesh_receive(rx_buf, sizeof(rx_buf), rx_rssi, rx_snr);

    if (rx_len > 0) {
        health_update_rssi((int16_t)rx_rssi);
        s_last_rssi_dbm = (int16_t)rx_rssi;   // feeds threat_vqc_score()

        // WaveForge L0/L1: feed every received LoRa packet into the rolling
        // RSSI/SNR/jitter window for the feature emitter.
        wfx_features_observe_rssi_packet(
            (int16_t)rx_rssi,
            (int16_t)rx_snr,
            millis(),
            /*band_id=*/1);   // 1 = 915 MHz LoRa

        // Markov scorer: every successful LoRa packet is a HEARTBEAT-class event.
        markov_observe_event((uint8_t)MK_HEARTBEAT, millis());

        uint8_t msg_type = rx_buf[0];
        switch (msg_type) {
            case MSG_TYPE_CANDIDATE:
            case MSG_TYPE_CORROBORATE:
                ffps_handle_packet(rx_buf, rx_len, rx_rssi);
                break;

            case MSG_TYPE_OTA_CHUNK: {
                // Reject if chunk is too short to carry the trailing 64-byte sig.
                if ((size_t)rx_len >= 64) {
                    const uint8_t* sig = rx_buf + rx_len - 64;
                    if (!ota_signed_verify_chunk(rx_buf, rx_len - 64, sig)) {
                        Serial.printf("[%s] OTA chunk rejected (bad Ed25519 sig)\n", TAG);
                        attackgraph_observe(NODE_BOOTLOADER, EVT_OTA_REJECT, 1.0f);
                        attackgraph_observe(NODE_FIRMWARE,   EVT_OTA_REJECT, 0.6f);
                        break;
                    }
                    ota_handle_chunk(rx_buf, rx_len - 64);
                } else {
                    Serial.printf("[%s] OTA chunk too short for sig\n", TAG);
                }
                break;
            }

            case MSG_TYPE_COMMAND:
                // T2 -> T1 command. handleIncomingCommand handles parse,
                // target-check, dispatch, and ACK TX internally.
                handleIncomingCommand(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_CMD_ACK:
                // Our own ACKs echo back in promiscuous receive — ignore.
                break;

            case MSG_TYPE_UAV_CMD:                          // 0x0A
                uav_relay_handle(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_UAV_ACK:                          // 0x0B
                // UAV ACKs are routed back up by uav_relay on a separate path.
                break;

            case MSG_TYPE_UAV_PAIR_REQ:                     // 0x1E
                // Ground-side pairing request (drone-companion's PAIR_REQ) --
                // provisions this relay's HMAC allowlist key over the air
                // instead of it staying permanently empty. See uav_pairing.h.
                uav_relay_handle_pair_request(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_UAV_TELEM:                        // 0x0C
                // Drone telemetry beacon (position/battery/mode/armed) --
                // CRC-validated and relayed onward to T2/C2 by uav_relay.
                // The software-only "SiK telemetry" equivalent: no new
                // hardware, built entirely on the existing LoRa mesh.
                uav_relay_handle_telemetry(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_MESH_ROUTE:                       // 0x0D
                mesh_handle_route_packet(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_MESH_ACK:                         // 0x0E
                mesh_handle_ack(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_MESH_DISCOVER:                    // 0x0F
                // When PDL_ENABLE_BAND_BRIDGE is defined, 0x0F is dispatched
                // to band_bridge_handle_route_delivery(). Otherwise, this
                // byte is MRSH MESH_DISCOVER owned by mesh_routing.
#ifdef PDL_ENABLE_BAND_BRIDGE
                band_bridge_handle_route_delivery(
                    rx_buf, rx_len,
                    /*rx_band_from_header=*/BandId::CC1101_433);
#else
                mesh_handle_discover(rx_buf, (size_t)rx_len);
#endif
                break;

            case MSG_TYPE_SPOOF_SCORE:                      // 0x11
                spoof_gate_handle_packet(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_PEC_FEEDBACK:                     // 0x12
                pec_feedback_handle_packet(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_PAYLOAD_CHUNK: {                  // 0x14
                PayloadBufStatus st = payload_buffer_handle_chunk(rx_buf, (size_t)rx_len);
                // Emit a per-chunk ACK (T2 expects MSG_TYPE_PAYLOAD_CHUNK_ACK = 0x94)
                if (rx_len >= 9) {
                    uint32_t pid = ((uint32_t)rx_buf[1]) |
                                   ((uint32_t)rx_buf[2] << 8) |
                                   ((uint32_t)rx_buf[3] << 16) |
                                   ((uint32_t)rx_buf[4] << 24);
                    uint16_t cidx = ((uint16_t)rx_buf[7]) |
                                    ((uint16_t)rx_buf[8] << 8);
                    uint8_t ack[8] = { (uint8_t)MSG_TYPE_PAYLOAD_CHUNK_ACK,
                                       (uint8_t)(pid & 0xFF),
                                       (uint8_t)((pid >> 8) & 0xFF),
                                       (uint8_t)((pid >> 16) & 0xFF),
                                       (uint8_t)((pid >> 24) & 0xFF),
                                       (uint8_t)(cidx & 0xFF),
                                       (uint8_t)((cidx >> 8) & 0xFF),
                                       (uint8_t)st };
                    mesh_publish_raw(ack, sizeof(ack));
                    mesh_start_receive();
                }
                break;
            }

            case MSG_TYPE_AUTH_TOKEN: {                     // 0x1D
                if (rx_len >= 84 + 1) {
                    // Token payload = 84 bytes; some encodings carry msg_type byte before
                    const uint8_t* tok = (rx_len == 85) ? (rx_buf + 1) : rx_buf;
                    memcpy(s_pending_auth_token, tok, 84);
                    memcpy(&s_pending_auth_token_payload_id, tok + 10, 4);
                    s_pending_auth_token_valid = true;
                }
                break;
            }

            case MSG_TYPE_ENROLLMENT_RESPONSE:              // 0x18
                enrollment_handle_response(rx_buf, (size_t)rx_len);
                if (enrollment_is_complete()) {
                    s_sensor_id = enrollment_get_sensor_id();
                    ffps_init(s_sensor_id);
                    health_init(s_sensor_id);
#ifdef PDL_ENABLE_UAV_RELAY
                    uav_relay_set_own_sensor_id(s_sensor_id);
#endif
                    Serial.printf("[%s] Enrollment complete. Sensor ID: 0x%08X\n",
                                  TAG, s_sensor_id);
                }
                break;

            case MSG_TYPE_TIME_SYNC:                        // 0x19
                timesync_handle_packet(rx_buf, (size_t)rx_len);
                break;

            case MSG_TYPE_FED_UPDATE:                       // 0x1B
                talon_handle_chunk(rx_buf, (size_t)rx_len);
                break;

#ifdef PDL_BLE_COMPANION_ENABLED
            case MSG_TYPE_C2_PROXY_RSP:                     // 0x25
                c2_proxy_handle_lora_rsp(rx_buf, (size_t)rx_len);
                break;
            case MSG_TYPE_C2_PROXY_REQ:                     // 0x24 (T2-side)
                c2_proxy_handle_lora_req(rx_buf, (size_t)rx_len);
                break;
#endif

            case MSG_TYPE_DETECTION: {
                // Peer relay: any WiFi-connected unit forwards a peer's
                // detection to C2 on their behalf, so a sensor with no
                // WiFi/T2 gateway of its own still gets reported as long as
                // at least one peer in LoRa range has WiFi. own-echo guard
                // is defensive (half-duplex LoRa shouldn't RX its own TX)
                // so this never double-posts a detection already sent by
                // the direct c2_wifi_bridge_post_detection() call above.
                DetectionEvent peer_evt;
                if (mesh_deserialize_event(rx_buf, (size_t)rx_len, peer_evt) &&
                    peer_evt.sensor_id != s_sensor_id) {
                    c2_wifi_bridge_post_relayed_detection(peer_evt);
                }
                break;
            }

            case MSG_TYPE_HEALTH: {
                // Peer relay, health side: same own-echo-guarded pattern as
                // MSG_TYPE_DETECTION above, routed through the health JSON
                // mapping (c2_wifi_bridge_post_relayed_health) since health
                // packets repurpose these wire fields differently (see
                // health_report.cpp).
                DetectionEvent peer_health;
                if (mesh_deserialize_event(rx_buf, (size_t)rx_len, peer_health) &&
                    peer_health.sensor_id != s_sensor_id) {
                    c2_wifi_bridge_post_relayed_health(peer_health);
                }
                break;
            }

            default:
                // Other packet types (BLUE_FORCE) — log/ignore.
                break;
        }

        // Return to receive mode
        mesh_start_receive();
    }

    // ----------------------------------------------------------------
    // 2. FFPS state machine tick
    // ----------------------------------------------------------------
    FFPSState ffps_state = ffps_tick();

    if (ffps_state == FFPS_PROMOTED) {
        // Detection was corroborated — publish to mesh
        const DetectionEvent& promoted = ffps_get_promoted_event();

        // Markov: a corroborated drone signature is a BEACON event.
        markov_observe_event((uint8_t)MK_BEACON, millis());

        // Check blue force before publishing
        if (blue_force_check_hash(promoted.signature_hash)) {
            Serial.printf("[%s] Blue force match — suppressing alert\n", TAG);
            // Don't publish, but log
        } else {
            mesh_publish_detection(promoted);
            health_increment_detections();
            mesh_start_receive();

#ifdef PDL_BLE_COMPANION_ENABLED
            ble_companion_publish_detection(promoted);
#endif
            // Direct WiFi POST — no-op unless C2_WIFI_CONNECT has been
            // issued and WiFi is actually connected; LoRa/BLE paths above
            // are unaffected either way.
            bool c2_ok = c2_wifi_bridge_post_detection(promoted);

            // ---- MQTT ----
            // mqtt_publish_detection() previously had NO CALL SITE ANYWHERE in
            // the tree — mqtt_init() ran at boot, the module was configurable,
            // and nothing ever published. MQTT support existed on paper only.
            // Uses the same `promoted` event as every other transport, and
            // mqtt_format_detection_json() resolves position through the same
            // location_resolve_report() helper C2 uses, so all transports carry
            // identical coordinates by construction.
            mqtt_publish_detection(promoted);

            // ---- WiFi peer relay (Option B) ----
            // FALLBACK ONLY: if we reached C2 ourselves, forwarding to a peer
            // would have it post the same detection a second time and
            // double-count the contact on the operator map.
            if (!c2_ok) {
                peer_relay_post_detection(promoted);
            }

            // Flash LED on detection
            PDL_LED_WRITE(HIGH);
        }
        ffps_reset();

    } else if (ffps_state == FFPS_SUPPRESSED) {
        Serial.printf("[%s] FFPS suppressed — no corroboration\n", TAG);
        ffps_reset();

    } else if (ffps_state == FFPS_WAITING_CORROBORATION) {
        // Still waiting — skip new classification this cycle
        goto end_cycle;
    }

    // ----------------------------------------------------------------
    // 3. Capture RF spectrum (WiFi 2.4 GHz promiscuous scan)
    // ----------------------------------------------------------------
    capture_rf_spectrum(s_spectrum);
    // hidden_tx_scanner_observe() stays DISABLED 2026-07-27 -- confirmed via
    // real hardware testing to flag all 128 bins as "active suspects," a
    // false positive on everything. Root cause confirmed architectural, not
    // a tunable threshold: capture_rf_spectrum()'s underlying per-channel
    // RSSI only updates when a real WiFi frame arrives (legacy_rssi_
    // observe(), called from network_id.cpp), so channels with less real
    // AP traffic go stale, and that staleness propagates across all 128
    // interpolated bins. See hidden_tx_scanner.cpp for the full history of
    // what was tried.
    //
    // REPLACEMENT LIVE 2026-07-30: csi_carrier_detector (wired in setup(),
    // fed from the CSI RX callback) covers the same continuous-carrier /
    // hidden-camera threat category via a working mechanism -- per-received
    // -frame subcarrier amplitude analysis instead of hidden_tx_scanner's
    // broken cross-frame RSSI-level tracking. It is NOT simply "CSI fixes
    // the staleness problem": CSI still only arrives when a frame is
    // received, same fundamental gate. What it adds is per-frame
    // resolution fine enough to see a localized narrowband spike (real
    // carrier bleed) within a single reception, which a single scalar RSSI
    // value can't represent at all. See csi_carrier_detector.h for the
    // full mechanism and its own honest validation caveat (pure classifier
    // logic tested; no real continuous-carrier transmitter available to
    // validate live detection against, same gap subghz_scanner.h
    // documents for ELRS/Crossfire).

    // 3a. WaveForge L0/L1 feature vector — every 10 cycles (LoRa duty budget)
    if (s_loop_count % 10 == 0) {
        WfxFeatureVec wfx_v;
        if (wfx_features_compute(s_spectrum, SPECTRUM_BINS, &wfx_v)) {
            wfx_v.timestamp_ms = millis();
            wfx_v.band_id      = 0;   // 2.4 GHz WiFi spectrum proxy
            wfx_features_publish(wfx_v);
            mesh_start_receive();
            s_last_wfx_occupancy = wfx_v.occupancy;   // feeds threat_vqc_score()
        }
    }

    // 3b. Markov behavioral NLL telemetry — every 30 cycles (~30 s)
    if (s_loop_count % 30 == 0) {
        markov_publish_nll(s_sensor_id, /*track_slot=*/0, /*threshold=*/0.0f);
        mesh_start_receive();
#ifdef PDL_BLE_COMPANION_ENABLED
        ble_companion_publish_nll(markov_current_nll());
#endif
    }

    // 3c. Network identity scan publish — every 30 cycles (~30 s)
    if (s_loop_count % 30 == 5) {
        network_id_publish(s_sensor_id);
        mesh_start_receive();
        wifi_security_scan_tick();   // whitehat scan runs off the same table
    }

    // 3d. Cognitive RF baseline publish — every 300 cycles (~5 min)
    if (s_loop_count % 300 == 7) {
        cogrf_publish(s_sensor_id);
        mesh_start_receive();
    }

    // 3d2. Sub-GHz ELRS/Crossfire-band burst scan — self-gates on its own
    // 60s interval internally. Shares the SX1262 with the LoRa mesh; always
    // restores 915 MHz + mesh_start_receive() before returning (see
    // subghz_scanner.cpp), same discipline as the TSCM_SWEEP command.
    subghz_scanner_tick();
    rf_cataloger_tick();

    // 3d3. WiFi-space channel-hop sweep -- self-gates on its own 3s dwell
    // interval internally. Uses the WiFi radio only (separate from the
    // shared LoRa SX1262 above), so no radio-sharing discipline needed.
    wifi_extended_scan_tick();

    // 3e. FHSS observation: feed strongest WiFi channel as a hop sample.
    {
        int strongest = 6; int16_t best = -127;
        for (int c = 1; c <= 13; ++c) {
            if (s_channel_rssi[c] > best) { best = s_channel_rssi[c]; strongest = c; }
        }
        uint16_t freq_mhz = (uint16_t)(2412 + (strongest - 1) * 5);
        fhss_observe(freq_mhz, micros());
    }

    // 3f. TALON federated round apply
    if (talon_round_complete()) {
        if (talon_apply_round()) {
            Serial.printf("[%s] TALON round %u applied\n", TAG, (unsigned)talon_current_round());
        } else {
            attackgraph_observe(NODE_MODELS, EVT_PEC_DEVIATION, 0.3f);
        }
    }

    // ----------------------------------------------------------------
    // 4. Run TFLite classifier on spectrum
    // ----------------------------------------------------------------
    {
        ClassificationResult result;
        bool inferred = classifier_infer(s_spectrum, result);
        if (inferred) {
            // FIXED 2026-07-30: apply any operator label correction pulled
            // via Mythos (see mythos_bridge.h) before this label is used
            // for anything -- the noise-suppress/threshold checks right
            // below must see the CORRECTED label, not the raw one, or a
            // correction that remaps a systematically-mislabeled class
            // away from CLASS_NOISE would still get silently suppressed
            // here. Identity/no-op when no correction has ever applied.
            result.class_label = mythos_bridge_apply_correction(result.class_label);
        }

        if (inferred) {
            // Skip if noise with high confidence
            if (result.class_label == CLASS_NOISE &&
                result.confidence > NOISE_SUPPRESS_THRESHOLD) {
                // Nothing interesting
                goto post_classification;
            }

            // Check if detection exceeds confidence threshold
            if (result.class_label != CLASS_NOISE &&
                result.confidence >= CONFIDENCE_THRESHOLD) {

                Serial.printf("[%s] Detection: class=%d (%s) conf=%.2f\n",
                              TAG, result.class_label,
                              (result.class_label == CLASS_OCUSYNC)       ? "OcuSync" :
                              (result.class_label == CLASS_FPV_FHSS)      ? "FPV/FHSS" :
                              (result.class_label == CLASS_SKYDIO)        ? "Skydio" :
                              (result.class_label == CLASS_UNKNOWN_DRONE) ? "Unknown Drone" :
                              (result.class_label == CLASS_WIFI)          ? "WiFi" :
                              (result.class_label == CLASS_BLUETOOTH)     ? "Bluetooth" :
                              "???",
                              result.confidence);

                // Build detection event
                DetectionEvent evt;
                memset(&evt, 0, sizeof(evt));
                evt.msg_type      = MSG_TYPE_CANDIDATE;
                evt.sensor_id     = s_sensor_id;
                evt.timestamp_utc = timesync_is_synced()
                                  ? (uint32_t)(timesync_now_utc_ms() / 1000)
                                  : (uint32_t)(millis() / 1000);
                evt.class_label   = result.class_label;
                evt.confidence    = (uint8_t)(result.confidence * 255.0f);
                evt.rssi          = s_channel_rssi[6];  // use channel 6 as representative
                evt.noise_floor   = s_channel_rssi[1];  // channel 1 as noise reference
                compute_signature_hash(s_spectrum, evt.signature_hash);
                location_get(&evt.lat_fixed, &evt.lon_fixed);
                evt.battery_pct   = health_read_battery_pct();
                evt.flags         = 0;

                // Check blue force before entering FFPS
                if (blue_force_check_hash(evt.signature_hash)) {
                    evt.flags |= FLAG_BLUE_FORCE;
                    Serial.printf("[%s] Blue force match — skipping FFPS\n", TAG);
                } else {
                    // Submit to FFPS for corroboration
                    ffps_submit_candidate(evt);
                }
            }
        }
    }

post_classification:

    // ----------------------------------------------------------------
    // 4b. Multi-model inference + threat correlation
    // ----------------------------------------------------------------
    if (s_multimodel_active) {
        // Run all currently-resident models against the spectrum
        int num_results = model_manager_infer_all_resident(
            s_spectrum, SPECTRUM_BINS, s_model_results, MODEL_MAX_SLOTS);

        // Check if any triage model flagged something worth paging in specialists
        bool triage_triggered = false;
        for (int i = 0; i < num_results; i++) {
            if (!s_model_results[i].valid) continue;

            // Feed every valid result into the correlator
            correlator_feed(s_model_results[i]);

            // Check if triage model confidence warrants paging in specialists
            ModelStats stats;
            if (model_manager_get_stats(s_model_results[i].slot_id, stats)) {
                if (stats.priority == MODEL_PRIORITY_TRIAGE &&
                    s_model_results[i].class_label != CLASS_NOISE &&
                    s_model_results[i].confidence >= PAGING_CONFIDENCE_THRESHOLD) {
                    triage_triggered = true;
                }
            }
        }

        // If triage flagged an anomaly, page in specialist models
        if (triage_triggered) {
            for (uint8_t s = 0; s < MODEL_MAX_SLOTS; s++) {
                ModelStats stats;
                if (model_manager_get_stats(s, stats) &&
                    stats.priority == MODEL_PRIORITY_SPECIALIST &&
                    !stats.loaded) {
                    if (model_manager_page_in(s)) {
                        // Run the newly-paged specialist immediately
                        ModelInferenceResult specialist_result =
                            model_manager_infer(s, s_spectrum, SPECTRUM_BINS);
                        if (specialist_result.valid) {
                            correlator_feed(specialist_result);
                        }
                    }
                }
            }
        }

        // ---- Cactus full_mode (parallel backend) ----
        {
            ModelInferenceResult fm_results[FULLMODE_T1_MAX_SLOTS];
            int fm_count = fullmode_t1_infer_all_resident(
                s_spectrum, SPECTRUM_BINS, fm_results, FULLMODE_T1_MAX_SLOTS);
            for (int i = 0; i < fm_count; i++) {
                if (fm_results[i].valid) correlator_feed(fm_results[i]);
            }
        }

        // Run the correlation engine
        CompositeThreat composite_threats[CORRELATOR_MAX_THREATS_PER_CYCLE];
        int num_composites = correlator_run(
            composite_threats, CORRELATOR_MAX_THREATS_PER_CYCLE);

        // Quantum Bayesian threat scorer — was previously dead code (init'd
        // but never scored). Feeds real live signals; logged for visibility.
        // Trained on a synthetic-but-reasoned proxy (see threat_vqc.cpp
        // header) -- an observational signal, not yet gating any action.
        //
        // 2026-07-27: also published (best-effort, no-op if MQTT isn't
        // connected — see mqtt_publish_text) so real (features, later-
        // triaged label) pairs can start accumulating server-side for an
        // honest eval — see tools/eval_threat_vqc.cpp and
        // GET /api/sensor/vqc-training-data on OpenForge C2. This does NOT
        // change what the scorer does with its own output here: still
        // Serial-only above 0.7, still never gates anything. Wiring it into
        // gating is a separate decision gated on that eval clearing a real
        // quality bar on real data, not something this change does.
        {
            float threshold = markov_get_anomaly_threshold();
            float nll_norm = threshold > 0.0f
                ? (markov_current_nll() / (2.0f * threshold)) : 0.0f;
            if (nll_norm > 1.0f) nll_norm = 1.0f;
            if (nll_norm < 0.0f) nll_norm = 0.0f;
            float rssi_norm = ((float)s_last_rssi_dbm + 100.0f) / 70.0f;
            if (rssi_norm > 1.0f) rssi_norm = 1.0f;
            if (rssi_norm < 0.0f) rssi_norm = 0.0f;
            float corrob_norm = (float)ffps_get_corroboration_count() / 4.0f;
            if (corrob_norm > 1.0f) corrob_norm = 1.0f;

            float vqc_feats[5] = {
                nll_norm, s_last_wfx_occupancy, corrob_norm, rssi_norm,
                num_composites > 0 ? 1.0f : 0.0f
            };
            float vqc_threat_prob = threat_vqc_score(vqc_feats);
            if (vqc_threat_prob > 0.7f) {
                Serial.printf("[%s] VQC threat score elevated: %.2f\n", TAG, vqc_threat_prob);
            }

            // Phone-as-T2 path (2026-07-29): publish over BLE companion so
            // the PDL Tactical app -- connected directly, no WiFi/MQTT broker
            // or T2 gateway needed -- can forward real (features, score)
            // pairs to OpenForge C2's vqc-sample endpoint for the offline
            // eval (tools/eval_threat_vqc.cpp). Independent of
            // PDL_INTEROP_ENABLED below: this only needs
            // PDL_BLE_COMPANION_ENABLED, and is a no-op (stub) otherwise.
            // Still purely observational -- does not change gating.
            {
                uint32_t vqc_ts_utc = timesync_is_synced()
                                    ? (uint32_t)(timesync_now_utc_ms() / 1000)
                                    : (uint32_t)(millis() / 1000);
                ble_companion_publish_vqc_sample(vqc_feats, vqc_threat_prob, vqc_ts_utc);
            }

#ifdef PDL_INTEROP_ENABLED
            // mqtt_client.h (mqtt_is_connected/mqtt_publish_text) only
            // exists in the interop build variant — see the #ifdef around
            // its #include above.
            if (mqtt_is_connected()) {
                // Same clock source as the mesh DetectionEvent.timestamp_utc
                // (see above) — matching against later detection/triage
                // records server-side depends on both using the same epoch.
                uint32_t ts_utc = timesync_is_synced()
                                ? (uint32_t)(timesync_now_utc_ms() / 1000)
                                : (uint32_t)(millis() / 1000);
                // gateway_id is deliberately left blank here — T1 doesn't
                // know which T2 gateway will receive this; the interop
                // bridge on the T2 side fills it in before forwarding to C2
                // (see gateway/interop_bridge.py's _on_vqc wiring).
                char vqc_json[192];
                snprintf(vqc_json, sizeof(vqc_json),
                    "{\"sensor_id\":\"%s\",\"gateway_id\":\"\",\"timestamp\":%lu,"
                    "\"feats\":[%.4f,%.4f,%.4f,%.4f,%.4f],\"vqc_score\":%.4f}",
                    SENSOR_NAME, (unsigned long)ts_utc,
                    vqc_feats[0], vqc_feats[1], vqc_feats[2], vqc_feats[3], vqc_feats[4],
                    vqc_threat_prob);
                mqtt_publish_text("vqc", vqc_json);
            }
#endif
        }

        // Publish composite threats at elevated priority (bypass FFPS)
        for (int t = 0; t < num_composites; t++) {
            if (!composite_threats[t].valid) continue;

            DetectionEvent comp_evt;
            correlator_build_event(composite_threats[t], s_sensor_id, comp_evt);
            comp_evt.battery_pct = health_read_battery_pct();

            mesh_publish_detection(comp_evt);
            health_increment_detections();
            mesh_start_receive();

            Serial.printf("[%s] COMPOSITE THREAT published: class=0x%02X conf=%d%%\n",
                          TAG, comp_evt.class_label,
                          (int)(composite_threats[t].combined_confidence * 100.0f));

            // Flash LED rapidly for composite threats
            for (int f = 0; f < 3; f++) {
                PDL_LED_WRITE(HIGH);
                delay(50);
                PDL_LED_WRITE(LOW);
                delay(50);
            }
        }
    }

    // ----------------------------------------------------------------
    // 5. Health reporting (every 60s)
    // ----------------------------------------------------------------
    health_tick();

    // ----------------------------------------------------------------
    // 6. OTA model update check
    // ----------------------------------------------------------------
    if (ota_model_ready()) {
        Serial.printf("[%s] Applying OTA model update...\n", TAG);
        if (ota_apply_model()) {
            Serial.printf("[%s] Model updated to version %d\n", TAG, ota_active_version());
        }
    }

    // ----------------------------------------------------------------
    // 7. Turn off LED (brief flash)
    // ----------------------------------------------------------------
    PDL_LED_WRITE(LOW);

end_cycle:
    s_loop_count++;

    // ----------------------------------------------------------------
    // Pending reboot check (from REBOOT command).
    // Placed after end_cycle so it still fires when FFPS has short-circuited
    // the main-cycle work via `goto end_cycle`.
    // ----------------------------------------------------------------
    if (commandHandlerPendingReboot()) {
        uint32_t reboot_at = commandHandlerRebootAt();
        if (reboot_at != 0 && millis() >= reboot_at) {
            Serial.printf("[%s] Command-requested reboot NOW\n", TAG);
            Serial.flush();
            delay(50);
            esp_restart();
        }
    }

    // ----------------------------------------------------------------
    // Maintain 1 Hz cycle time
    // ----------------------------------------------------------------
    uint32_t elapsed = millis() - cycle_start;
    if (elapsed < MAIN_LOOP_PERIOD_MS) {
        delay(MAIN_LOOP_PERIOD_MS - elapsed);
    } else if (s_loop_count % 100 == 0) {
        Serial.printf("[%s] WARNING: Cycle overrun %lu ms (target %d ms)\n",
                      TAG, elapsed, MAIN_LOOP_PERIOD_MS);
    }
}
