diff --git a/.gitignore b/.gitignore index bd36677..a2aeab8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ tools/c3po/config.json **/config.local.json # Logs +.avi *.log logs/ espilon_bot/logs/ diff --git a/espilon_bot/components/mod_recon/cmd_recon.h b/espilon_bot/components/mod_recon/cmd_recon.h index 587f271..e9c94fc 100644 --- a/espilon_bot/components/mod_recon/cmd_recon.h +++ b/espilon_bot/components/mod_recon/cmd_recon.h @@ -1,4 +1,7 @@ #pragma once -void mod_ble_trilat_register_commands(void); -void mod_camera_register_commands(void); \ No newline at end of file +/* Camera module */ +void mod_camera_register_commands(void); + +/* MLAT (Multilateration) module */ +void mod_mlat_register_commands(void); diff --git a/espilon_bot/components/mod_recon/mod_mlat.c b/espilon_bot/components/mod_recon/mod_mlat.c new file mode 100644 index 0000000..143383e --- /dev/null +++ b/espilon_bot/components/mod_recon/mod_mlat.c @@ -0,0 +1,796 @@ +/** + * @file mod_mlat.c + * @brief Multilateration Scanner Module (BLE + WiFi) + * + * This module turns an ESP32 into an RSSI scanner for multilateration. + * Supports both BLE and WiFi modes, switchable at runtime from C2. + * Position is configured from C2, and RSSI readings are sent back via TCP. + * + * Supports two coordinate systems: + * - GPS (lat/lon in degrees) for outdoor tracking with real maps + * - Local (x/y in meters) for indoor tracking with floor plans + * + * Commands: + * mlat config gps - Set GPS position (degrees) + * mlat config local - Set local position (meters) + * mlat config - Backward compat: GPS mode + * mlat mode - Set scanning mode + * mlat start - Start scanning for target MAC + * mlat stop - Stop scanning + * mlat status - Show current config and state + * + * Data format sent to C2: + * MLAT:G;;; - GPS coordinates + * MLAT:L;;; - Local coordinates (meters) + */ + +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_log.h" +#include "esp_err.h" +#include "nvs_flash.h" + +/* BLE */ +#include "esp_bt.h" +#include "esp_gap_ble_api.h" +#include "esp_bt_main.h" + +/* WiFi */ +#include "esp_wifi.h" +#include "esp_event.h" + +#include "command.h" +#include "utils.h" + +#if defined(CONFIG_RECON_MODE_MLAT) + +/* ============================================================ + * CONFIG + * ============================================================ */ +#define TAG "MLAT" + +#define SEND_INTERVAL_MS 2000 /* Send aggregated RSSI every 2s */ +#define RSSI_HISTORY_SIZE 10 /* Keep last N readings for averaging */ +#define CHANNEL_HOP_MS 200 /* WiFi channel hop interval */ + +/* ============================================================ + * TYPES + * ============================================================ */ +typedef enum { + MLAT_MODE_NONE = 0, + MLAT_MODE_BLE, + MLAT_MODE_WIFI +} mlat_mode_t; + +typedef enum { + COORD_GPS = 0, /* lat/lon (degrees) */ + COORD_LOCAL /* x/y (meters) */ +} coord_type_t; + +/* WiFi frame header for promiscuous mode */ +typedef struct { + unsigned frame_ctrl:16; + unsigned duration_id:16; + uint8_t addr1[6]; /* Destination */ + uint8_t addr2[6]; /* Source */ + uint8_t addr3[6]; /* BSSID */ + unsigned seq_ctrl:16; +} __attribute__((packed)) wifi_mgmt_hdr_t; + +/* ============================================================ + * STATE + * ============================================================ */ +static bool mlat_configured = false; +static bool mlat_running = false; +static mlat_mode_t mlat_mode = MLAT_MODE_BLE; /* Default to BLE */ + +/* Hardware init state */ +static bool ble_initialized = false; +static bool wifi_promisc_enabled = false; + +/* Scanner position (set via mlat config) */ +static coord_type_t coord_type = COORD_GPS; +static double scanner_lat = 0.0; /* GPS latitude (degrees) */ +static double scanner_lon = 0.0; /* GPS longitude (degrees) */ +static double scanner_x = 0.0; /* Local X position (meters) */ +static double scanner_y = 0.0; /* Local Y position (meters) */ + +/* Target MAC */ +static uint8_t target_mac[6] = {0}; +static char target_mac_str[20] = {0}; + +/* RSSI history for averaging */ +static int8_t rssi_history[RSSI_HISTORY_SIZE]; +static size_t rssi_count = 0; +static size_t rssi_index = 0; + +/* Task handles */ +static TaskHandle_t send_task_handle = NULL; +static TaskHandle_t hop_task_handle = NULL; + +/* WiFi current channel */ +static uint8_t current_channel = 1; + +/* ============================================================ + * UTILS + * ============================================================ */ +static bool parse_mac_str(const char *input, uint8_t *mac_out) +{ + char clean[13] = {0}; + int j = 0; + + for (int i = 0; input[i] && j < 12; i++) { + char c = input[i]; + if (c == ':' || c == '-' || c == ' ') + continue; + if (!isxdigit((unsigned char)c)) + return false; + clean[j++] = toupper((unsigned char)c); + } + + if (j != 12) return false; + + for (int i = 0; i < 6; i++) { + char b[3] = { clean[i*2], clean[i*2+1], 0 }; + mac_out[i] = (uint8_t)strtol(b, NULL, 16); + } + return true; +} + +static void mac_to_str(const uint8_t *mac, char *out, size_t len) +{ + snprintf(out, len, "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +static int8_t get_average_rssi(void) +{ + if (rssi_count == 0) return 0; + + int32_t sum = 0; + size_t count = (rssi_count < RSSI_HISTORY_SIZE) ? rssi_count : RSSI_HISTORY_SIZE; + + for (size_t i = 0; i < count; i++) { + sum += rssi_history[i]; + } + + return (int8_t)(sum / (int32_t)count); +} + +static void add_rssi_reading(int8_t rssi) +{ + rssi_history[rssi_index] = rssi; + rssi_index = (rssi_index + 1) % RSSI_HISTORY_SIZE; + if (rssi_count < RSSI_HISTORY_SIZE) { + rssi_count++; + } +} + +static void reset_rssi_history(void) +{ + memset(rssi_history, 0, sizeof(rssi_history)); + rssi_count = 0; + rssi_index = 0; +} + +static const char *mode_to_str(mlat_mode_t mode) +{ + switch (mode) { + case MLAT_MODE_BLE: return "BLE"; + case MLAT_MODE_WIFI: return "WiFi"; + default: return "none"; + } +} + +/* ============================================================ + * BLE CALLBACK + * ============================================================ */ +static void ble_scan_cb(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t *param) +{ + if (!mlat_running || mlat_mode != MLAT_MODE_BLE) return; + + if (event != ESP_GAP_BLE_SCAN_RESULT_EVT || + param->scan_rst.search_evt != ESP_GAP_SEARCH_INQ_RES_EVT) + return; + + /* Check if this is our target */ + if (memcmp(param->scan_rst.bda, target_mac, 6) != 0) + return; + + /* Store RSSI reading */ + add_rssi_reading(param->scan_rst.rssi); +} + +/* ============================================================ + * WIFI PROMISCUOUS CALLBACK + * ============================================================ */ +static void IRAM_ATTR wifi_promisc_cb(void *buf, wifi_promiscuous_pkt_type_t type) +{ + if (!mlat_running || mlat_mode != MLAT_MODE_WIFI) return; + + /* Only interested in management frames (probe requests, etc.) */ + if (type != WIFI_PKT_MGMT) return; + + wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf; + wifi_mgmt_hdr_t *hdr = (wifi_mgmt_hdr_t *)pkt->payload; + + /* Check if source MAC (addr2) matches our target */ + if (memcmp(hdr->addr2, target_mac, 6) != 0) return; + + /* Store RSSI reading */ + add_rssi_reading(pkt->rx_ctrl.rssi); +} + +/* ============================================================ + * WIFI CHANNEL HOP TASK + * ============================================================ */ +static void channel_hop_task(void *arg) +{ + (void)arg; + + while (mlat_running && mlat_mode == MLAT_MODE_WIFI) { + vTaskDelay(pdMS_TO_TICKS(CHANNEL_HOP_MS)); + + if (!mlat_running || mlat_mode != MLAT_MODE_WIFI) break; + + current_channel = (current_channel % 13) + 1; + esp_wifi_set_channel(current_channel, WIFI_SECOND_CHAN_NONE); + } + + hop_task_handle = NULL; + ESP_LOGI(TAG, "channel hop task stopped"); + vTaskDelete(NULL); +} + +/* ============================================================ + * SEND TASK - Periodically send RSSI to C2 + * ============================================================ */ +static void mlat_send_task(void *arg) +{ + (void)arg; + + char msg[128]; + + while (mlat_running) { + vTaskDelay(pdMS_TO_TICKS(SEND_INTERVAL_MS)); + + if (!mlat_running) break; + + if (rssi_count > 0) { + int8_t avg_rssi = get_average_rssi(); + + /* + * Send MLAT data to C2 via msg_info + * Format GPS: MLAT:G;;; + * Format Local: MLAT:L;;; + * The C2 will parse messages starting with "MLAT:" and extract the data + */ + if (coord_type == COORD_GPS) { + snprintf(msg, sizeof(msg), "MLAT:G;%.6f;%.6f;%d", + scanner_lat, scanner_lon, avg_rssi); + ESP_LOGD(TAG, "sent: GPS=(%.6f,%.6f) rssi=%d (avg of %d)", + scanner_lat, scanner_lon, avg_rssi, rssi_count); + } else { + snprintf(msg, sizeof(msg), "MLAT:L;%.2f;%.2f;%d", + scanner_x, scanner_y, avg_rssi); + ESP_LOGD(TAG, "sent: local=(%.2f,%.2f)m rssi=%d (avg of %d)", + scanner_x, scanner_y, avg_rssi, rssi_count); + } + + msg_info(TAG, msg, NULL); + } + } + + send_task_handle = NULL; + ESP_LOGI(TAG, "send task stopped"); + vTaskDelete(NULL); +} + +/* ============================================================ + * BLE INIT / DEINIT + * ============================================================ */ +static bool ble_init(void) +{ + if (ble_initialized) { + return true; + } + + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + + esp_err_t ret = esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "bt mem release failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bt_controller_init(&bt_cfg); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bt controller init failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bt controller enable failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bluedroid_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_bluedroid_enable(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(ret)); + return false; + } + + ret = esp_ble_gap_register_callback(ble_scan_cb); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "gap register callback failed: %s", esp_err_to_name(ret)); + return false; + } + + esp_ble_scan_params_t scan_params = { + .scan_type = BLE_SCAN_TYPE_ACTIVE, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL, + .scan_interval = 0x50, + .scan_window = 0x30, + .scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE + }; + + ret = esp_ble_gap_set_scan_params(&scan_params); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set scan params failed: %s", esp_err_to_name(ret)); + return false; + } + + ble_initialized = true; + ESP_LOGI(TAG, "BLE initialized"); + return true; +} + +static bool ble_start_scan(void) +{ + esp_err_t ret = esp_ble_gap_start_scanning(0); /* 0 = continuous */ + if (ret != ESP_OK) { + ESP_LOGE(TAG, "start BLE scanning failed: %s", esp_err_to_name(ret)); + return false; + } + return true; +} + +static void ble_stop_scan(void) +{ + esp_ble_gap_stop_scanning(); +} + +/* ============================================================ + * WIFI PROMISCUOUS INIT / DEINIT + * ============================================================ */ +static bool wifi_promisc_init(void) +{ + if (wifi_promisc_enabled) { + return true; + } + + /* Enable promiscuous mode */ + esp_err_t ret = esp_wifi_set_promiscuous(true); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set promiscuous failed: %s", esp_err_to_name(ret)); + return false; + } + + /* Register callback */ + ret = esp_wifi_set_promiscuous_rx_cb(wifi_promisc_cb); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "set promiscuous cb failed: %s", esp_err_to_name(ret)); + esp_wifi_set_promiscuous(false); + return false; + } + + /* Filter only management frames */ + wifi_promiscuous_filter_t filter = { + .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT + }; + esp_wifi_set_promiscuous_filter(&filter); + + wifi_promisc_enabled = true; + ESP_LOGI(TAG, "WiFi promiscuous mode enabled"); + return true; +} + +static void wifi_promisc_deinit(void) +{ + if (!wifi_promisc_enabled) return; + + esp_wifi_set_promiscuous(false); + wifi_promisc_enabled = false; + ESP_LOGI(TAG, "WiFi promiscuous mode disabled"); +} + +/* ============================================================ + * START / STOP SCANNING + * ============================================================ */ +static bool start_scanning(void) +{ + reset_rssi_history(); + + if (mlat_mode == MLAT_MODE_BLE) { + if (!ble_init()) return false; + if (!ble_start_scan()) return false; + } + else if (mlat_mode == MLAT_MODE_WIFI) { + if (!wifi_promisc_init()) return false; + + /* Start channel hop task for WiFi */ + BaseType_t ret = xTaskCreate( + channel_hop_task, + "mlat_hop", + 2048, + NULL, + 4, + &hop_task_handle + ); + if (ret != pdPASS) { + ESP_LOGE(TAG, "failed to create hop task"); + wifi_promisc_deinit(); + return false; + } + } + + /* Start send task */ + BaseType_t ret = xTaskCreate( + mlat_send_task, + "mlat_send", + 4096, + NULL, + 5, + &send_task_handle + ); + if (ret != pdPASS) { + ESP_LOGE(TAG, "failed to create send task"); + if (mlat_mode == MLAT_MODE_BLE) { + ble_stop_scan(); + } else { + wifi_promisc_deinit(); + } + return false; + } + + return true; +} + +static void stop_scanning(void) +{ + if (mlat_mode == MLAT_MODE_BLE) { + ble_stop_scan(); + } + else if (mlat_mode == MLAT_MODE_WIFI) { + wifi_promisc_deinit(); + } +} + +/* ============================================================ + * COMMAND: mlat config + * mlat config gps - GPS coordinates (degrees) + * mlat config local - Local coordinates (meters) + * mlat config - Backward compat: GPS mode + * ============================================================ */ +static int cmd_mlat_config(int argc, char **argv, const char *req, void *ctx) +{ + (void)ctx; + + if (argc < 2) { + msg_error(TAG, "usage: mlat config [gps|local] ", req); + return -1; + } + + char msg[100]; + + /* Check if first arg is coordinate type */ + if (argc == 3 && strcasecmp(argv[0], "gps") == 0) { + /* GPS mode: mlat config gps */ + double lat = strtod(argv[1], NULL); + double lon = strtod(argv[2], NULL); + + if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { + msg_error(TAG, "invalid GPS coords (lat:-90~90, lon:-180~180)", req); + return -1; + } + + coord_type = COORD_GPS; + scanner_lat = lat; + scanner_lon = lon; + mlat_configured = true; + + snprintf(msg, sizeof(msg), "GPS position: (%.6f, %.6f)", lat, lon); + msg_info(TAG, msg, req); + ESP_LOGI(TAG, "configured GPS: lat=%.6f lon=%.6f", scanner_lat, scanner_lon); + } + else if (argc == 3 && strcasecmp(argv[0], "local") == 0) { + /* Local mode: mlat config local */ + double x = strtod(argv[1], NULL); + double y = strtod(argv[2], NULL); + + coord_type = COORD_LOCAL; + scanner_x = x; + scanner_y = y; + mlat_configured = true; + + snprintf(msg, sizeof(msg), "Local position: (%.2f, %.2f) meters", x, y); + msg_info(TAG, msg, req); + ESP_LOGI(TAG, "configured local: x=%.2f y=%.2f", scanner_x, scanner_y); + } + else if (argc == 2) { + /* Backward compat: mlat config -> GPS mode */ + double lat = strtod(argv[0], NULL); + double lon = strtod(argv[1], NULL); + + if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { + msg_error(TAG, "invalid GPS coords (lat:-90~90, lon:-180~180)", req); + return -1; + } + + coord_type = COORD_GPS; + scanner_lat = lat; + scanner_lon = lon; + mlat_configured = true; + + snprintf(msg, sizeof(msg), "GPS position: (%.6f, %.6f)", lat, lon); + msg_info(TAG, msg, req); + ESP_LOGI(TAG, "configured GPS: lat=%.6f lon=%.6f", scanner_lat, scanner_lon); + } + else { + msg_error(TAG, "usage: mlat config [gps|local] ", req); + return -1; + } + + return 0; +} + +/* ============================================================ + * COMMAND: mlat mode + * ============================================================ */ +static int cmd_mlat_mode(int argc, char **argv, const char *req, void *ctx) +{ + (void)ctx; + + if (argc != 1) { + msg_error(TAG, "usage: mlat mode ", req); + return -1; + } + + if (mlat_running) { + msg_error(TAG, "stop scanning first", req); + return -1; + } + + const char *mode_str = argv[0]; + + if (strcasecmp(mode_str, "ble") == 0) { + mlat_mode = MLAT_MODE_BLE; + } + else if (strcasecmp(mode_str, "wifi") == 0) { + mlat_mode = MLAT_MODE_WIFI; + } + else { + msg_error(TAG, "invalid mode (use: ble, wifi)", req); + return -1; + } + + char msg[32]; + snprintf(msg, sizeof(msg), "mode set to %s", mode_to_str(mlat_mode)); + msg_info(TAG, msg, req); + + ESP_LOGI(TAG, "mode changed to %s", mode_to_str(mlat_mode)); + return 0; +} + +/* ============================================================ + * COMMAND: mlat start + * ============================================================ */ +static int cmd_mlat_start(int argc, char **argv, const char *req, void *ctx) +{ + (void)ctx; + + if (argc != 1) { + msg_error(TAG, "usage: mlat start ", req); + return -1; + } + + if (mlat_running) { + msg_error(TAG, "already running", req); + return -1; + } + + if (!mlat_configured) { + msg_error(TAG, "not configured - run 'mlat config [gps|local] ' first", req); + return -1; + } + + /* Parse target MAC */ + if (!parse_mac_str(argv[0], target_mac)) { + msg_error(TAG, "invalid MAC address", req); + return -1; + } + mac_to_str(target_mac, target_mac_str, sizeof(target_mac_str)); + + mlat_running = true; + + if (!start_scanning()) { + mlat_running = false; + msg_error(TAG, "scan start failed", req); + return -1; + } + + char msg[128]; + if (coord_type == COORD_GPS) { + snprintf(msg, sizeof(msg), "scanning for %s at GPS(%.6f, %.6f) [%s]", + target_mac_str, scanner_lat, scanner_lon, mode_to_str(mlat_mode)); + ESP_LOGI(TAG, "started: target=%s GPS=(%.6f,%.6f) mode=%s", + target_mac_str, scanner_lat, scanner_lon, mode_to_str(mlat_mode)); + } else { + snprintf(msg, sizeof(msg), "scanning for %s at local(%.2f, %.2f)m [%s]", + target_mac_str, scanner_x, scanner_y, mode_to_str(mlat_mode)); + ESP_LOGI(TAG, "started: target=%s local=(%.2f,%.2f)m mode=%s", + target_mac_str, scanner_x, scanner_y, mode_to_str(mlat_mode)); + } + msg_info(TAG, msg, req); + + return 0; +} + +/* ============================================================ + * COMMAND: mlat stop + * ============================================================ */ +static int cmd_mlat_stop(int argc, char **argv, const char *req, void *ctx) +{ + (void)argc; + (void)argv; + (void)ctx; + + if (!mlat_running) { + msg_error(TAG, "not running", req); + return -1; + } + + mlat_running = false; + stop_scanning(); + + msg_info(TAG, "stopped", req); + ESP_LOGI(TAG, "stopped"); + return 0; +} + +/* ============================================================ + * COMMAND: mlat status + * ============================================================ */ +static int cmd_mlat_status(int argc, char **argv, const char *req, void *ctx) +{ + (void)argc; + (void)argv; + (void)ctx; + + char msg[180]; + const char *coord_str = (coord_type == COORD_GPS) ? "GPS" : "Local"; + + if (!mlat_configured) { + snprintf(msg, sizeof(msg), "not configured | mode=%s", mode_to_str(mlat_mode)); + msg_info(TAG, msg, req); + return 0; + } + + /* Format position based on coord type */ + char pos_str[60]; + if (coord_type == COORD_GPS) { + snprintf(pos_str, sizeof(pos_str), "GPS=(%.6f,%.6f)", scanner_lat, scanner_lon); + } else { + snprintf(pos_str, sizeof(pos_str), "local=(%.2f,%.2f)m", scanner_x, scanner_y); + } + + if (mlat_running) { + int8_t avg = get_average_rssi(); + if (mlat_mode == MLAT_MODE_WIFI) { + snprintf(msg, sizeof(msg), + "running [%s] | %s | target=%s | rssi=%d (%d) | ch=%d", + mode_to_str(mlat_mode), pos_str, + target_mac_str, avg, rssi_count, current_channel); + } else { + snprintf(msg, sizeof(msg), + "running [%s] | %s | target=%s | rssi=%d (%d samples)", + mode_to_str(mlat_mode), pos_str, + target_mac_str, avg, rssi_count); + } + } else { + snprintf(msg, sizeof(msg), + "stopped | mode=%s | %s", + mode_to_str(mlat_mode), pos_str); + } + + msg_info(TAG, msg, req); + return 0; +} + +/* ============================================================ + * COMMAND DEFINITIONS + * ============================================================ */ +static const command_t cmd_mlat_config_def = { + .name = "mlat", + .sub = "config", + .help = "Set position: mlat config [gps|local] ", + .handler = cmd_mlat_config, + .ctx = NULL, + .async = false, + .min_args = 2, + .max_args = 3 +}; + +static const command_t cmd_mlat_mode_def = { + .name = "mlat", + .sub = "mode", + .help = "Set scan mode: mlat mode ", + .handler = cmd_mlat_mode, + .ctx = NULL, + .async = false, + .min_args = 1, + .max_args = 1 +}; + +static const command_t cmd_mlat_start_def = { + .name = "mlat", + .sub = "start", + .help = "Start scanning: mlat start ", + .handler = cmd_mlat_start, + .ctx = NULL, + .async = false, + .min_args = 1, + .max_args = 1 +}; + +static const command_t cmd_mlat_stop_def = { + .name = "mlat", + .sub = "stop", + .help = "Stop scanning", + .handler = cmd_mlat_stop, + .ctx = NULL, + .async = false, + .min_args = 0, + .max_args = 0 +}; + +static const command_t cmd_mlat_status_def = { + .name = "mlat", + .sub = "status", + .help = "Show MLAT status", + .handler = cmd_mlat_status, + .ctx = NULL, + .async = false, + .min_args = 0, + .max_args = 0 +}; + +/* ============================================================ + * REGISTER + * ============================================================ */ +void mod_mlat_register_commands(void) +{ + command_register(&cmd_mlat_config_def); + command_register(&cmd_mlat_mode_def); + command_register(&cmd_mlat_start_def); + command_register(&cmd_mlat_stop_def); + command_register(&cmd_mlat_status_def); + ESP_LOGI(TAG, "commands registered (BLE+WiFi)"); +} + +#endif /* CONFIG_RECON_MODE_MLAT */ diff --git a/espilon_bot/main/Kconfig b/espilon_bot/main/Kconfig index ec0031e..cc9b9fb 100644 --- a/espilon_bot/main/Kconfig +++ b/espilon_bot/main/Kconfig @@ -102,9 +102,12 @@ config RECON_MODE_CAMERA bool "Enable Camera Reconnaissance" default n -config RECON_MODE_BLE_TRILAT - bool "Enable BLE Trilateration Reconnaissance" +config RECON_MODE_MLAT + bool "Enable MLAT (Multilateration) Module" default n + help + Enable multilateration positioning using RSSI measurements. + Mode (BLE or WiFi) is selected at runtime from C2. endmenu diff --git a/espilon_bot/main/bot-lwip.c b/espilon_bot/main/bot-lwip.c index 6a2a190..224b151 100644 --- a/espilon_bot/main/bot-lwip.c +++ b/espilon_bot/main/bot-lwip.c @@ -68,9 +68,9 @@ void app_main(void) mod_camera_register_commands(); ESP_LOGI(TAG, "Camera module loaded"); #endif - #ifdef CONFIG_RECON_MODE_BLE_TRILAT - mod_ble_trilat_register_commands(); - ESP_LOGI(TAG, "BLE Trilateration module loaded"); + #ifdef CONFIG_RECON_MODE_MLAT + mod_mlat_register_commands(); + ESP_LOGI(TAG, "MLAT module loaded"); #endif #endif diff --git a/tools/c2/cli/cli.py b/tools/c2/cli/cli.py index 28ca46e..8b6fe10 100644 --- a/tools/c2/cli/cli.py +++ b/tools/c2/cli/cli.py @@ -7,7 +7,10 @@ from utils.display import Display from cli.help import HelpManager from core.transport import Transport from proto.c2_pb2 import Command -from camera import CameraServer +from streams.udp_receiver import UDPReceiver +from streams.config import UDP_HOST, UDP_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN +from web.server import UnifiedWebServer +from web.mlat import MlatEngine DEV_MODE = True @@ -21,8 +24,10 @@ class CLI: self.help_manager = HelpManager(commands, DEV_MODE) self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}} - # Camera server instance - self.camera_server: Optional[CameraServer] = None + # Separate server instances + self.web_server: Optional[UnifiedWebServer] = None + self.udp_receiver: Optional[UDPReceiver] = None + self.mlat_engine = MlatEngine() readline.parse_and_bind("tab: complete") readline.set_completer(self._complete) @@ -36,7 +41,7 @@ class CLI: options = [] if len(parts) == 1: - options = ["send", "list", "group", "help", "clear", "exit", "active_commands", "camera"] + options = ["send", "list", "modules", "group", "help", "clear", "exit", "active_commands", "web", "camera"] elif parts[0] == "send": if len(parts) == 2: # Completing target (device ID, 'all', 'group') @@ -45,7 +50,10 @@ class CLI: options = list(self.groups.all_groups().keys()) elif (len(parts) == 3 and parts[1] != "group") or (len(parts) == 4 and parts[1] == "group"): # Completing command name options = self.commands.list() - # Add more logic here if commands have arguments that can be tab-completed + + elif parts[0] == "web": + if len(parts) == 2: + options = ["start", "stop", "status"] elif parts[0] == "camera": if len(parts) == 2: @@ -95,6 +103,10 @@ class CLI: self._handle_list() continue + if action == "modules": + self.help_manager.show_modules() + continue + if action == "group": self._handle_group(parts[1:]) continue @@ -107,6 +119,10 @@ class CLI: self._handle_active_commands() continue + if action == "web": + self._handle_web(parts[1:]) + continue + if action == "camera": self._handle_camera(parts[1:]) continue @@ -301,7 +317,66 @@ class CLI: elapsed_time ]) + def _handle_web(self, parts): + """Handle web server commands (frontend + multilateration API).""" + if not parts: + Display.error("Usage: web ") + return + + cmd = parts[0] + + if cmd == "start": + if self.web_server and self.web_server.is_running: + Display.system_message("Web server is already running.") + return + + self.web_server = UnifiedWebServer( + device_registry=self.registry, + mlat_engine=self.mlat_engine, + multilat_token=MULTILAT_AUTH_TOKEN + ) + + if self.web_server.start(): + Display.system_message(f"Web server started at {self.web_server.get_url()}") + else: + Display.error("Web server failed to start") + + elif cmd == "stop": + if not self.web_server or not self.web_server.is_running: + Display.system_message("Web server is not running.") + return + + self.web_server.stop() + Display.system_message("Web server stopped.") + self.web_server = None + + elif cmd == "status": + Display.system_message("Web Server Status:") + if self.web_server and self.web_server.is_running: + Display.system_message(f" Status: Running") + Display.system_message(f" URL: {self.web_server.get_url()}") + else: + Display.system_message(f" Status: Stopped") + + # MLAT stats + Display.system_message("MLAT Engine:") + state = self.mlat_engine.get_state() + Display.system_message(f" Mode: {state.get('coord_mode', 'gps').upper()}") + Display.system_message(f" Scanners: {state['scanners_count']}") + if state['target']: + pos = state['target']['position'] + if 'lat' in pos: + Display.system_message(f" Target: ({pos['lat']:.6f}, {pos['lon']:.6f})") + else: + Display.system_message(f" Target: ({pos['x']:.2f}m, {pos['y']:.2f}m)") + else: + Display.system_message(f" Target: Not calculated") + + else: + Display.error("Invalid web command. Use: start, stop, status") + def _handle_camera(self, parts): + """Handle camera UDP receiver commands.""" if not parts: Display.error("Usage: camera ") return @@ -309,49 +384,42 @@ class CLI: cmd = parts[0] if cmd == "start": - if self.camera_server and self.camera_server.is_running: - Display.system_message("Camera server is already running.") + if self.udp_receiver and self.udp_receiver.is_running: + Display.system_message("Camera UDP receiver is already running.") return - self.camera_server = CameraServer(device_registry=self.registry) - result = self.camera_server.start() + self.udp_receiver = UDPReceiver( + host=UDP_HOST, + port=UDP_PORT, + image_dir=IMAGE_DIR + ) - if result["udp"]["started"]: - Display.system_message(f"UDP receiver started on {result['udp']['host']}:{result['udp']['port']}") + if self.udp_receiver.start(): + Display.system_message(f"Camera UDP receiver started on {UDP_HOST}:{UDP_PORT}") else: - Display.error("UDP receiver failed to start (already running?)") - - if result["web"]["started"]: - Display.system_message(f"Web server started at {result['web']['url']}") - else: - Display.error("Web server failed to start (already running?)") + Display.error("Camera UDP receiver failed to start") elif cmd == "stop": - if not self.camera_server: - Display.system_message("Camera server is not running.") + if not self.udp_receiver or not self.udp_receiver.is_running: + Display.system_message("Camera UDP receiver is not running.") return - self.camera_server.stop() - Display.system_message("Camera server stopped.") - self.camera_server = None + self.udp_receiver.stop() + Display.system_message("Camera UDP receiver stopped.") + self.udp_receiver = None elif cmd == "status": - if not self.camera_server: - Display.system_message("Camera server is not running.") - return - - status = self.camera_server.get_status() - - Display.system_message("Camera Server Status:") - Display.system_message(f" UDP Receiver: {'Running' if status['udp']['running'] else 'Stopped'}") - if status['udp']['running']: - Display.system_message(f" - Host: {status['udp']['host']}:{status['udp']['port']}") - Display.system_message(f" - Frames received: {status['udp']['frames_received']}") - Display.system_message(f" - Active cameras: {status['udp']['active_cameras']}") - - Display.system_message(f" Web Server: {'Running' if status['web']['running'] else 'Stopped'}") - if status['web']['running']: - Display.system_message(f" - URL: {status['web']['url']}") + Display.system_message("Camera UDP Receiver Status:") + if self.udp_receiver and self.udp_receiver.is_running: + stats = self.udp_receiver.get_stats() + Display.system_message(f" Status: Running on {UDP_HOST}:{UDP_PORT}") + Display.system_message(f" Packets received: {stats['packets_received']}") + Display.system_message(f" Frames decoded: {stats['frames_received']}") + Display.system_message(f" Decode errors: {stats['decode_errors']}") + Display.system_message(f" Invalid tokens: {stats['invalid_tokens']}") + Display.system_message(f" Active cameras: {stats['active_cameras']}") + else: + Display.system_message(f" Status: Stopped") else: Display.error("Invalid camera command. Use: start, stop, status") diff --git a/tools/c2/cli/help.py b/tools/c2/cli/help.py index 80d99f0..0cdccb4 100644 --- a/tools/c2/cli/help.py +++ b/tools/c2/cli/help.py @@ -1,6 +1,54 @@ from utils.display import Display +# ESP32 Commands organized by module (matches Kconfig modules) +ESP_MODULES = { + "system": { + "description": "Core system commands", + "commands": { + "system_reboot": "Reboot the ESP32 device", + "system_mem": "Get memory info (heap, internal)", + "system_uptime": "Get device uptime", + } + }, + "network": { + "description": "Network tools", + "commands": { + "ping": "Ping a host (ping )", + "arp_scan": "ARP scan the local network", + "proxy_start": "Start TCP proxy (proxy_start )", + "proxy_stop": "Stop TCP proxy", + "dos_tcp": "TCP flood (dos_tcp )", + } + }, + "fakeap": { + "description": "Fake Access Point module", + "commands": { + "fakeap_start": "Start fake AP (fakeap_start [open|wpa2] [pass])", + "fakeap_stop": "Stop fake AP", + "fakeap_status": "Show fake AP status", + "fakeap_clients": "List connected clients", + "fakeap_portal_start": "Start captive portal", + "fakeap_portal_stop": "Stop captive portal", + "fakeap_sniffer_on": "Enable packet sniffer", + "fakeap_sniffer_off": "Disable packet sniffer", + } + }, + "recon": { + "description": "Reconnaissance module (Camera + MLAT)", + "commands": { + "cam_start": "Start camera streaming (cam_start )", + "cam_stop": "Stop camera streaming", + "mlat config": "Set position (mlat config [gps|local] )", + "mlat mode": "Set scan mode (mlat mode )", + "mlat start": "Start MLAT scanning (mlat start )", + "mlat stop": "Stop MLAT scanning", + "mlat status": "Show MLAT status", + } + } +} + + class HelpManager: def __init__(self, command_registry, dev_mode: bool = False): self.commands = command_registry @@ -12,67 +60,231 @@ class HelpManager: else: self._show_global_help() + def show_modules(self): + """Show ESP commands organized by module.""" + Display.system_message("=== ESP32 COMMANDS BY MODULE ===\n") + + for module_name, module_info in ESP_MODULES.items(): + print(f"\033[1;35m[{module_name.upper()}]\033[0m - {module_info['description']}") + for cmd_name, cmd_desc in module_info["commands"].items(): + print(f" \033[36m{cmd_name:<12}\033[0m {cmd_desc}") + print() + + print("\033[90mUse 'help ' for detailed help on a specific command.\033[0m") + print("\033[90mSend commands with: send [args...]\033[0m") + def _show_global_help(self): Display.system_message("=== ESPILON C2 HELP ===") - print("\nCLI Commands:") - print(" help [command] Show this help or help for a specific command") - print(" list List connected ESP devices") - print(" send Send a command to ESP device(s)") - print(" group Manage ESP device groups (add, remove, list, show)") - print(" active_commands List all currently running commands") - print(" clear Clear the terminal screen") - print(" exit Exit the C2 application") + print("\n\033[1mC2 Commands:\033[0m") + print(" \033[36mhelp\033[0m [command] Show help or help for a specific command") + print(" \033[36mlist\033[0m List connected ESP devices") + print(" \033[36mmodules\033[0m List ESP commands organized by module") + print(" \033[36msend\033[0m Send a command to ESP device(s)") + print(" \033[36mgroup\033[0m Manage device groups (add, remove, list, show)") + print(" \033[36mactive_commands\033[0m List currently running commands") + print(" \033[36mclear\033[0m Clear terminal screen") + print(" \033[36mexit\033[0m Exit C2") - print("\nESP Commands (available to send to devices):") - for name in self.commands.list(): - handler = self.commands.get(name) - print(f" {name:<15} {handler.description}") + print("\n\033[1mServer Commands:\033[0m") + print(" \033[36mweb\033[0m start|stop|status Web dashboard server") + print(" \033[36mcamera\033[0m start|stop|status Camera UDP receiver") + + print("\n\033[1mESP Commands:\033[0m (use 'modules' for detailed list)") + registered_cmds = self.commands.list() + if registered_cmds: + for name in registered_cmds: + handler = self.commands.get(name) + print(f" \033[36m{name:<15}\033[0m {handler.description}") + else: + print(" \033[90m(no registered commands - use 'send' with any ESP command)\033[0m") if self.dev_mode: - Display.system_message("\nDEV MODE ENABLED:") - print(" You can send arbitrary text commands: send ") + print("\n\033[33mDEV MODE:\033[0m Send arbitrary text: send ") def _show_command_help(self, command_name: str): + # CLI Commands if command_name == "list": Display.system_message("Help for 'list' command:") print(" Usage: list") - print(" Description: Displays a table of all currently connected ESP devices,") - print(" including their ID, IP address, connection duration, and last seen timestamp.") + print(" Description: Displays all connected ESP devices with ID, IP, status,") + print(" connection duration, and last seen timestamp.") + elif command_name == "send": Display.system_message("Help for 'send' command:") - print(" Usage: send > [args...]") + print(" Usage: send > [args...]") print(" Description: Sends a command to one or more ESP devices.") print(" Examples:") - print(" send 1234567890 reboot") - print(" send all get_status") - print(" send group my_group ping 8.8.8.8") + print(" send ESP_ABC123 reboot") + print(" send all wifi status") + print(" send group scanners mlat start AA:BB:CC:DD:EE:FF") + elif command_name == "group": Display.system_message("Help for 'group' command:") print(" Usage: group [args...]") print(" Actions:") - print(" add [device_id2...] - Add devices to a group.") - print(" remove [device_id2...] - Remove devices from a group.") - print(" list - List all defined groups and their members.") - print(" show - Show members of a specific group.") - print(" Examples:") - print(" group add my_group 1234567890 ABCDEF1234") - print(" group remove my_group 1234567890") - print(" group list") - print(" group show my_group") - elif command_name in ["clear", "exit"]: + print(" add [id2...] Add devices to a group") + print(" remove [id2...] Remove devices from a group") + print(" list List all groups") + print(" show Show group members") + + elif command_name == "web": + Display.system_message("Help for 'web' command:") + print(" Usage: web ") + print(" Description: Control the web dashboard server.") + print(" Actions:") + print(" start Start the web server (dashboard, cameras, MLAT)") + print(" stop Stop the web server") + print(" status Show server status and MLAT engine info") + print(" Default URL: http://127.0.0.1:5000") + + elif command_name == "camera": + Display.system_message("Help for 'camera' command:") + print(" Usage: camera ") + print(" Description: Control the camera UDP receiver.") + print(" Actions:") + print(" start Start UDP receiver for camera frames") + print(" stop Stop UDP receiver") + print(" status Show receiver stats (packets, frames, errors)") + print(" Default port: 12345") + + elif command_name == "modules": + Display.system_message("Help for 'modules' command:") + print(" Usage: modules") + print(" Description: List all ESP32 commands organized by module.") + print(" Modules: system, network, fakeap, recon") + + elif command_name in ["clear", "exit", "active_commands"]: Display.system_message(f"Help for '{command_name}' command:") print(f" Usage: {command_name}") - print(f" Description: {command_name.capitalize()}s the terminal screen." if command_name == "clear" else f" Description: {command_name.capitalize()}s the C2 application.") + descs = { + "clear": "Clear the terminal screen", + "exit": "Exit the C2 application", + "active_commands": "Show all commands currently being executed" + } + print(f" Description: {descs.get(command_name, '')}") + + # ESP Commands (by module or registered) else: - # Check if it's an ESP command + # Check in modules first + for module_name, module_info in ESP_MODULES.items(): + if command_name in module_info["commands"]: + Display.system_message(f"ESP Command '{command_name}' [{module_name.upper()}]:") + print(f" Description: {module_info['commands'][command_name]}") + self._show_esp_command_detail(command_name) + return + + # Check registered commands handler = self.commands.get(command_name) if handler: - Display.system_message(f"Help for ESP Command '{command_name}':") + Display.system_message(f"ESP Command '{command_name}':") print(f" Description: {handler.description}") - # Assuming ESP commands might have a usage string or more detailed help if hasattr(handler, 'usage'): print(f" Usage: {handler.usage}") - if hasattr(handler, 'long_description'): - print(f" Details: {handler.long_description}") else: - Display.error(f"No help available for command '{command_name}'.") + Display.error(f"No help available for '{command_name}'.") + + def _show_esp_command_detail(self, cmd: str): + """Show detailed help for specific ESP commands.""" + details = { + # MLAT subcommands + "mlat config": """ + Usage: send mlat config [gps|local] + GPS mode: mlat config gps - degrees + Local mode: mlat config local - meters + Examples: + send ESP1 mlat config gps 48.8566 2.3522 + send ESP1 mlat config local 10.0 5.5 + send ESP1 mlat config 48.8566 2.3522 (backward compat: GPS)""", + + "mlat mode": """ + Usage: send mlat mode + Example: send ESP1 mlat mode ble""", + + "mlat start": """ + Usage: send mlat start + Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF""", + + "mlat stop": """ + Usage: send mlat stop""", + + "mlat status": """ + Usage: send mlat status""", + + # Camera commands + "cam_start": """ + Usage: send cam_start + Description: Start camera streaming to C2 UDP receiver + Example: send ESP_CAM cam_start 192.168.1.100 12345""", + + "cam_stop": """ + Usage: send cam_stop + Description: Stop camera streaming""", + + # FakeAP commands + "fakeap_start": """ + Usage: send fakeap_start [open|wpa2] [password] + Examples: + send ESP1 fakeap_start FreeWiFi + send ESP1 fakeap_start SecureNet wpa2 mypassword""", + + "fakeap_stop": """ + Usage: send fakeap_stop""", + + "fakeap_status": """ + Usage: send fakeap_status + Shows: AP running, portal status, sniffer status, client count""", + + "fakeap_clients": """ + Usage: send fakeap_clients + Lists all connected clients to the fake AP""", + + "fakeap_portal_start": """ + Usage: send fakeap_portal_start + Description: Enable captive portal (requires fakeap running)""", + + "fakeap_portal_stop": """ + Usage: send fakeap_portal_stop""", + + "fakeap_sniffer_on": """ + Usage: send fakeap_sniffer_on + Description: Enable packet sniffing""", + + "fakeap_sniffer_off": """ + Usage: send fakeap_sniffer_off""", + + # Network commands + "ping": """ + Usage: send ping + Example: send ESP1 ping 8.8.8.8""", + + "arp_scan": """ + Usage: send arp_scan + Description: Scan local network for hosts""", + + "proxy_start": """ + Usage: send proxy_start + Example: send ESP1 proxy_start 192.168.1.100 8080""", + + "proxy_stop": """ + Usage: send proxy_stop""", + + "dos_tcp": """ + Usage: send dos_tcp + Example: send ESP1 dos_tcp 192.168.1.100 80 1000""", + + # System commands + "system_reboot": """ + Usage: send system_reboot + Description: Reboot the ESP32 device""", + + "system_mem": """ + Usage: send system_mem + Shows: heap_free, heap_min, internal_free""", + + "system_uptime": """ + Usage: send system_uptime + Shows: uptime in days/hours/minutes/seconds""" + } + + if cmd in details: + print(details[cmd]) diff --git a/tools/c2/core/transport.py b/tools/c2/core/transport.py index e063273..33fef6a 100644 --- a/tools/c2/core/transport.py +++ b/tools/c2/core/transport.py @@ -95,7 +95,18 @@ class Transport: else: Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}") elif msg.type == AgentMsgType.AGENT_INFO: - Display.device_event(device.id, f"INFO: {payload_str}") + # Check for MLAT data (format: MLAT:x;y;rssi) + if payload_str.startswith("MLAT:") and self.cli: + mlat_data = payload_str[5:] # Remove "MLAT:" prefix + if self.cli.mlat_engine.parse_mlat_message(device.id, mlat_data): + # Recalculate position if we have enough scanners + state = self.cli.mlat_engine.get_state() + if state["scanners_count"] >= 3: + self.cli.mlat_engine.calculate_position() + else: + Display.device_event(device.id, f"MLAT: Invalid data format: {mlat_data}") + else: + Display.device_event(device.id, f"INFO: {payload_str}") elif msg.type == AgentMsgType.AGENT_ERROR: Display.device_event(device.id, f"ERROR: {payload_str}") elif msg.type == AgentMsgType.AGENT_LOG: diff --git a/tools/c2/static/css/main.css b/tools/c2/static/css/main.css index 958d510..2ac8f53 100644 --- a/tools/c2/static/css/main.css +++ b/tools/c2/static/css/main.css @@ -319,30 +319,153 @@ main { color: var(--text-muted); } -/* ========== Multilateration Canvas ========== */ +/* ========== Header Stats ========== */ -.trilat-container { +.header-stats { + display: flex; + gap: 24px; +} + +.header-stats .stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.header-stats .stat-value { + font-size: 24px; + font-weight: 700; + color: var(--accent-primary); + font-family: 'JetBrains Mono', monospace; +} + +.header-stats .stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); +} + +/* ========== Lain Empty State ========== */ + +.empty-lain { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; + text-align: center; + padding: 40px 20px; +} + +.lain-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.lain-ascii { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + line-height: 1.2; + color: var(--accent-primary); + opacity: 0.7; + text-shadow: 0 0 10px var(--accent-primary-glow); + animation: pulse-glow 3s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 0.5; text-shadow: 0 0 10px var(--accent-primary-glow); } + 50% { opacity: 0.9; text-shadow: 0 0 20px var(--accent-primary-glow), 0 0 40px var(--accent-primary-glow); } +} + +.lain-message h2 { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.lain-message .typing { + font-size: 14px; + color: var(--accent-secondary); + margin-bottom: 16px; +} + +.lain-message .quote { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + opacity: 0.7; +} + +/* ========== MLAT Container ========== */ + +.mlat-container { display: grid; grid-template-columns: 1fr 320px; gap: 20px; } @media (max-width: 900px) { - .trilat-container { + .mlat-container { grid-template-columns: 1fr; } } -.trilat-canvas-wrapper { +/* View Toggle Buttons */ +.view-toggle { + display: flex; + gap: 4px; + background: var(--bg-secondary); + padding: 4px; + border-radius: 10px; + border: 1px solid var(--border-color); +} + +.view-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s ease; +} + +.view-btn:hover { + color: var(--text-primary); + background: var(--bg-elevated); +} + +.view-btn.active { + background: var(--accent-primary-bg); + color: var(--accent-primary); +} + +.view-btn svg { + opacity: 0.7; +} + +.view-btn.active svg { + opacity: 1; +} + +/* Map/Plan View Wrapper */ +.mlat-view-wrapper { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; - padding: 16px; - position: relative; overflow: hidden; + position: relative; } -.trilat-canvas-wrapper::before { +.mlat-view-wrapper::before { content: ''; position: absolute; top: 0; @@ -351,29 +474,153 @@ main { height: 1px; background: var(--gradient-primary); opacity: 0.5; + z-index: 10; } -#trilat-canvas { - width: 100%; +.mlat-view { + display: none; height: 500px; +} + +.mlat-view.active { + display: block; +} + +/* Leaflet Map */ +#leaflet-map { + width: 100%; + height: 100%; background: var(--bg-tertiary); +} + +/* Leaflet Dark Theme Override */ +.leaflet-container { + background: var(--bg-tertiary); + font-family: inherit; +} + +.leaflet-popup-content-wrapper { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); border-radius: 8px; } -.trilat-sidebar { +.leaflet-popup-tip { + background: var(--bg-secondary); +} + +.leaflet-control-zoom { + border: 1px solid var(--border-color) !important; +} + +.leaflet-control-zoom a { + background: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-bottom-color: var(--border-color) !important; +} + +.leaflet-control-zoom a:hover { + background: var(--bg-elevated) !important; +} + +.leaflet-control-attribution { + background: var(--bg-secondary) !important; + color: var(--text-muted) !important; + font-size: 10px; +} + +.leaflet-control-attribution a { + color: var(--accent-secondary) !important; +} + +/* Plan View */ +#plan-view { + display: flex; + flex-direction: column; +} + +.plan-controls { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border-color); +} + +.control-divider { + width: 1px; + height: 24px; + background: var(--border-color); + margin: 0 4px; +} + +.toggle-btn { + opacity: 0.5; + transition: opacity 0.2s ease; +} + +.toggle-btn.active { + opacity: 1; + background: var(--accent-primary-bg); + color: var(--accent-primary); +} + +.control-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.zoom-level, +.size-display { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + min-width: 55px; + text-align: center; + padding: 4px 8px; + background: var(--bg-tertiary); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.plan-canvas-wrapper { + flex: 1; + padding: 16px; + overflow: hidden; +} + +#plan-canvas { + width: 100%; + height: 100%; + background: var(--bg-tertiary); + border-radius: 8px; + cursor: grab; +} + +#plan-canvas:active { + cursor: grabbing; +} + +/* Sidebar */ +.mlat-sidebar { display: flex; flex-direction: column; gap: 16px; } -.trilat-panel { +.mlat-panel { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; } -.trilat-panel h3 { +.mlat-panel h3 { font-size: 12px; font-weight: 600; text-transform: uppercase; @@ -382,7 +629,7 @@ main { color: var(--text-secondary); } -.trilat-stat { +.mlat-stat { display: flex; justify-content: space-between; font-size: 13px; @@ -390,15 +637,15 @@ main { border-bottom: 1px solid var(--border-color); } -.trilat-stat:last-child { +.mlat-stat:last-child { border-bottom: none; } -.trilat-stat .label { +.mlat-stat .label { color: var(--text-muted); } -.trilat-stat .value { +.mlat-stat .value { color: var(--accent-primary); font-family: 'JetBrains Mono', monospace; font-weight: 500; @@ -412,6 +659,13 @@ main { overflow-y: auto; } +.scanner-list .empty { + padding: 20px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + .scanner-item { background: var(--bg-elevated); padding: 10px 12px; @@ -437,6 +691,48 @@ main { font-size: 11px; } +/* Button Group */ +.btn-group { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* Custom Leaflet Markers */ +.scanner-marker { + background: var(--accent-secondary); + border: 2px solid #fff; + border-radius: 50%; + width: 16px !important; + height: 16px !important; + margin-left: -8px !important; + margin-top: -8px !important; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.target-marker { + width: 24px !important; + height: 24px !important; + margin-left: -12px !important; + margin-top: -12px !important; +} + +.target-marker svg { + filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4)); +} + +/* Range Circle */ +.range-circle { + fill: rgba(129, 140, 248, 0.1); + stroke: rgba(129, 140, 248, 0.4); + stroke-width: 2; +} + /* ========== Config Panel ========== */ .config-row { diff --git a/tools/c2/static/js/mlat.js b/tools/c2/static/js/mlat.js new file mode 100644 index 0000000..0b32cfe --- /dev/null +++ b/tools/c2/static/js/mlat.js @@ -0,0 +1,830 @@ +/** + * MLAT (Multilateration) Visualization for ESPILON C2 + * Supports Map view (Leaflet/OSM) and Plan view (Canvas) + * Supports both GPS (lat/lon) and Local (x/y in meters) coordinates + */ + +// ============================================================ +// State +// ============================================================ +let currentView = 'map'; +let coordMode = 'gps'; // 'gps' or 'local' +let map = null; +let planCanvas = null; +let planCtx = null; +let planImage = null; + +// Plan settings for local coordinate mode +let planSettings = { + width: 50, // meters + height: 30, // meters + originX: 0, // meters offset + originY: 0 // meters offset +}; + +// Plan display options +let showGrid = true; +let showLabels = true; +let planZoom = 1.0; // 1.0 = 100% +let panOffset = { x: 0, y: 0 }; // Pan offset in pixels +let isPanning = false; +let lastPanPos = { x: 0, y: 0 }; + +// Markers +let scannerMarkers = {}; +let targetMarker = null; +let rangeCircles = {}; + +// Data +let scanners = []; +let target = null; + +// ============================================================ +// Map View (Leaflet) - GPS Mode +// ============================================================ +function initMap() { + if (map) return; + + const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566; + const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522; + const zoom = parseInt(document.getElementById('map-zoom').value) || 18; + + map = L.map('leaflet-map', { + center: [centerLat, centerLon], + zoom: zoom, + zoomControl: true + }); + + // Dark tile layer (CartoDB Dark Matter) + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© CARTO', + subdomains: 'abcd', + maxZoom: 20 + }).addTo(map); +} + +function createScannerIcon() { + return L.divIcon({ + className: 'scanner-marker', + iconSize: [16, 16], + iconAnchor: [8, 8] + }); +} + +function createTargetIcon() { + return L.divIcon({ + className: 'target-marker', + html: ` + + + + `, + iconSize: [24, 24], + iconAnchor: [12, 12] + }); +} + +function updateMapMarkers() { + if (!map) return; + + // Only show GPS mode scanners on map + const gpsFilteredScanners = scanners.filter(s => s.position && s.position.lat !== undefined); + const currentIds = new Set(gpsFilteredScanners.map(s => s.id)); + + // Remove old markers + for (const id in scannerMarkers) { + if (!currentIds.has(id)) { + map.removeLayer(scannerMarkers[id]); + delete scannerMarkers[id]; + if (rangeCircles[id]) { + map.removeLayer(rangeCircles[id]); + delete rangeCircles[id]; + } + } + } + + // Update/add scanner markers + for (const scanner of gpsFilteredScanners) { + const pos = scanner.position; + + if (scannerMarkers[scanner.id]) { + scannerMarkers[scanner.id].setLatLng([pos.lat, pos.lon]); + } else { + scannerMarkers[scanner.id] = L.marker([pos.lat, pos.lon], { + icon: createScannerIcon() + }).addTo(map); + + scannerMarkers[scanner.id].bindPopup(` + ${scanner.id}
+ RSSI: ${scanner.last_rssi || '-'} dBm
+ Distance: ${scanner.estimated_distance || '-'} m + `); + } + + // Update popup content + scannerMarkers[scanner.id].setPopupContent(` + ${scanner.id}
+ RSSI: ${scanner.last_rssi || '-'} dBm
+ Distance: ${scanner.estimated_distance || '-'} m + `); + + // Update range circle + if (scanner.estimated_distance) { + if (rangeCircles[scanner.id]) { + rangeCircles[scanner.id].setLatLng([pos.lat, pos.lon]); + rangeCircles[scanner.id].setRadius(scanner.estimated_distance); + } else { + rangeCircles[scanner.id] = L.circle([pos.lat, pos.lon], { + radius: scanner.estimated_distance, + color: 'rgba(129, 140, 248, 0.4)', + fillColor: 'rgba(129, 140, 248, 0.1)', + fillOpacity: 0.3, + weight: 2 + }).addTo(map); + } + } + } + + // Update target marker (GPS only) + if (target && target.lat !== undefined) { + if (targetMarker) { + targetMarker.setLatLng([target.lat, target.lon]); + } else { + targetMarker = L.marker([target.lat, target.lon], { + icon: createTargetIcon() + }).addTo(map); + } + } else if (targetMarker) { + map.removeLayer(targetMarker); + targetMarker = null; + } +} + +function centerMap() { + if (!map) return; + + const lat = parseFloat(document.getElementById('map-center-lat').value); + const lon = parseFloat(document.getElementById('map-center-lon').value); + const zoom = parseInt(document.getElementById('map-zoom').value); + + map.setView([lat, lon], zoom); +} + +function fitMapToBounds() { + if (!map || scanners.length === 0) return; + + const points = scanners + .filter(s => s.position && s.position.lat !== undefined) + .map(s => [s.position.lat, s.position.lon]); + + if (target && target.lat !== undefined) { + points.push([target.lat, target.lon]); + } + + if (points.length > 0) { + map.fitBounds(points, { padding: [50, 50] }); + } +} + +// ============================================================ +// Plan View (Canvas) - Supports both GPS and Local coords +// ============================================================ +function initPlanCanvas() { + planCanvas = document.getElementById('plan-canvas'); + if (!planCanvas) return; + + planCtx = planCanvas.getContext('2d'); + resizePlanCanvas(); + setupPlanPanning(); + window.addEventListener('resize', resizePlanCanvas); +} + +function resizePlanCanvas() { + if (!planCanvas) return; + + const wrapper = planCanvas.parentElement; + planCanvas.width = wrapper.clientWidth - 32; + planCanvas.height = wrapper.clientHeight - 32; + drawPlan(); +} + +function drawPlan() { + if (!planCtx) return; + + const ctx = planCtx; + const w = planCanvas.width; + const h = planCanvas.height; + + // Clear (before transform) + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = '#06060a'; + ctx.fillRect(0, 0, w, h); + + // Apply zoom and pan transform + const centerX = w / 2; + const centerY = h / 2; + ctx.setTransform(planZoom, 0, 0, planZoom, + centerX - centerX * planZoom + panOffset.x, + centerY - centerY * planZoom + panOffset.y); + + // Draw plan image if loaded + if (planImage) { + ctx.drawImage(planImage, 0, 0, w, h); + } + + // Draw grid (always when enabled, on top of image) + if (showGrid) { + drawGrid(ctx, w, h, !!planImage); + } + + // Draw range circles + for (const scanner of scanners) { + if (scanner.estimated_distance) { + drawPlanRangeCircle(ctx, scanner); + } + } + + // Draw scanners + for (const scanner of scanners) { + drawPlanScanner(ctx, scanner); + } + + // Draw target + if (target) { + drawPlanTarget(ctx); + } + + // Reset transform for any UI overlay + ctx.setTransform(1, 0, 0, 1, 0, 0); +} + +function drawGrid(ctx, w, h, hasImage = false) { + // More visible grid when over image + ctx.strokeStyle = hasImage ? 'rgba(129, 140, 248, 0.4)' : '#21262d'; + ctx.lineWidth = hasImage ? 1.5 : 1; + ctx.font = '10px monospace'; + ctx.fillStyle = hasImage ? 'rgba(200, 200, 200, 0.9)' : '#484f58'; + + if (coordMode === 'local') { + // Draw grid based on plan size in meters + const metersPerPixelX = planSettings.width / w; + const metersPerPixelY = planSettings.height / h; + + // Grid every 5 meters + const gridMeters = 5; + const gridPixelsX = gridMeters / metersPerPixelX; + const gridPixelsY = gridMeters / metersPerPixelY; + + // Vertical lines + for (let x = gridPixelsX; x < w; x += gridPixelsX) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + + // Label + if (showLabels) { + const meters = (x * metersPerPixelX + planSettings.originX).toFixed(0); + if (hasImage) { + // Background for readability + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(x + 1, 2, 25, 12); + ctx.fillStyle = 'rgba(200, 200, 200, 0.9)'; + } + ctx.fillText(`${meters}m`, x + 2, 12); + } + } + + // Horizontal lines + for (let y = gridPixelsY; y < h; y += gridPixelsY) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + + // Label + if (showLabels) { + const meters = (planSettings.height - y * metersPerPixelY + planSettings.originY).toFixed(0); + if (hasImage) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(1, y - 13, 25, 12); + ctx.fillStyle = 'rgba(200, 200, 200, 0.9)'; + } + ctx.fillText(`${meters}m`, 2, y - 2); + } + } + + // Size label + if (showLabels) { + ctx.fillStyle = hasImage ? 'rgba(129, 140, 248, 0.9)' : '#818cf8'; + if (hasImage) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(w - 65, h - 16, 62, 14); + ctx.fillStyle = 'rgba(129, 140, 248, 0.9)'; + } + ctx.fillText(`${planSettings.width}x${planSettings.height}m`, w - 60, h - 5); + } + } else { + // Simple grid for GPS mode + const gridSize = 50; + + for (let x = gridSize; x < w; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + + for (let y = gridSize; y < h; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + } +} + +function toggleGrid() { + showGrid = !showGrid; + document.getElementById('grid-toggle').classList.toggle('active', showGrid); + drawPlan(); +} + +function toggleLabels() { + showLabels = !showLabels; + document.getElementById('labels-toggle').classList.toggle('active', showLabels); + drawPlan(); +} + +function zoomPlan(direction) { + const zoomStep = 0.25; + const minZoom = 0.25; + const maxZoom = 4.0; + + if (direction > 0) { + planZoom = Math.min(maxZoom, planZoom + zoomStep); + } else { + planZoom = Math.max(minZoom, planZoom - zoomStep); + } + + updateZoomDisplay(); + drawPlan(); +} + +function resetZoom() { + planZoom = 1.0; + panOffset = { x: 0, y: 0 }; + updateZoomDisplay(); + drawPlan(); +} + +function updateZoomDisplay() { + const el = document.getElementById('zoom-level'); + if (el) { + el.textContent = Math.round(planZoom * 100) + '%'; + } +} + +function setupPlanPanning() { + if (!planCanvas) return; + + // Mouse wheel zoom + planCanvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + zoomPlan(direction); + }, { passive: false }); + + // Pan with mouse drag + planCanvas.addEventListener('mousedown', (e) => { + if (e.button === 0) { // Left click + isPanning = true; + lastPanPos = { x: e.clientX, y: e.clientY }; + planCanvas.style.cursor = 'grabbing'; + } + }); + + planCanvas.addEventListener('mousemove', (e) => { + if (isPanning) { + const dx = e.clientX - lastPanPos.x; + const dy = e.clientY - lastPanPos.y; + panOffset.x += dx; + panOffset.y += dy; + lastPanPos = { x: e.clientX, y: e.clientY }; + drawPlan(); + } + }); + + planCanvas.addEventListener('mouseup', () => { + isPanning = false; + planCanvas.style.cursor = 'grab'; + }); + + planCanvas.addEventListener('mouseleave', () => { + isPanning = false; + planCanvas.style.cursor = 'grab'; + }); + + planCanvas.style.cursor = 'grab'; +} + +function worldToCanvas(pos) { + const w = planCanvas.width; + const h = planCanvas.height; + + if (coordMode === 'local' || (pos.x !== undefined && pos.lat === undefined)) { + // Local coordinates (x, y in meters) + const x = pos.x !== undefined ? pos.x : 0; + const y = pos.y !== undefined ? pos.y : 0; + + const canvasX = ((x - planSettings.originX) / planSettings.width) * w; + const canvasY = h - ((y - planSettings.originY) / planSettings.height) * h; + + return { + x: Math.max(0, Math.min(w, canvasX)), + y: Math.max(0, Math.min(h, canvasY)) + }; + } else { + // GPS coordinates (lat, lon) + const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566; + const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522; + const range = 0.002; // ~200m + + const canvasX = ((pos.lon - centerLon + range) / (2 * range)) * w; + const canvasY = ((centerLat + range - pos.lat) / (2 * range)) * h; + + return { + x: Math.max(0, Math.min(w, canvasX)), + y: Math.max(0, Math.min(h, canvasY)) + }; + } +} + +function distanceToPixels(distance) { + if (coordMode === 'local') { + // Direct conversion: distance in meters to pixels + const pixelsPerMeter = planCanvas.width / planSettings.width; + return distance * pixelsPerMeter; + } else { + // GPS mode: approximate conversion + const range = 0.002; // degrees + const rangeMeters = range * 111000; // ~222m + const pixelsPerMeter = planCanvas.width / rangeMeters; + return distance * pixelsPerMeter; + } +} + +function drawPlanRangeCircle(ctx, scanner) { + const pos = scanner.position; + if (!pos) return; + + // Check if position is valid for current mode + if (coordMode === 'local' && pos.x === undefined && pos.lat !== undefined) return; + if (coordMode === 'gps' && pos.lat === undefined && pos.x !== undefined) return; + + const canvasPos = worldToCanvas(pos); + const radius = distanceToPixels(scanner.estimated_distance); + + ctx.beginPath(); + ctx.arc(canvasPos.x, canvasPos.y, radius, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(129, 140, 248, 0.3)'; + ctx.lineWidth = 2; + ctx.stroke(); +} + +function drawPlanScanner(ctx, scanner) { + const pos = scanner.position; + if (!pos) return; + + // Check if position is valid + const hasGPS = pos.lat !== undefined; + const hasLocal = pos.x !== undefined; + + if (!hasGPS && !hasLocal) return; + + const canvasPos = worldToCanvas(pos); + + // Dot + ctx.beginPath(); + ctx.arc(canvasPos.x, canvasPos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = '#818cf8'; + ctx.fill(); + + // Label + ctx.font = '12px monospace'; + ctx.fillStyle = '#c9d1d9'; + ctx.textAlign = 'center'; + ctx.fillText(scanner.id, canvasPos.x, canvasPos.y - 15); + + // RSSI + if (scanner.last_rssi !== null) { + ctx.font = '10px monospace'; + ctx.fillStyle = '#484f58'; + ctx.fillText(`${scanner.last_rssi} dBm`, canvasPos.x, canvasPos.y + 20); + } + + ctx.textAlign = 'left'; +} + +function drawPlanTarget(ctx) { + if (!target) return; + + const hasGPS = target.lat !== undefined; + const hasLocal = target.x !== undefined; + + if (!hasGPS && !hasLocal) return; + + const canvasPos = worldToCanvas(target); + + // Glow + ctx.beginPath(); + ctx.arc(canvasPos.x, canvasPos.y, 20, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(248, 113, 113, 0.3)'; + ctx.fill(); + + // Cross + ctx.strokeStyle = '#f87171'; + ctx.lineWidth = 3; + + ctx.beginPath(); + ctx.moveTo(canvasPos.x - 12, canvasPos.y - 12); + ctx.lineTo(canvasPos.x + 12, canvasPos.y + 12); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(canvasPos.x + 12, canvasPos.y - 12); + ctx.lineTo(canvasPos.x - 12, canvasPos.y + 12); + ctx.stroke(); + + // Label + ctx.font = 'bold 12px monospace'; + ctx.fillStyle = '#f87171'; + ctx.textAlign = 'center'; + ctx.fillText('TARGET', canvasPos.x, canvasPos.y - 25); + ctx.textAlign = 'left'; +} + +// ============================================================ +// Plan Image Upload & Calibration +// ============================================================ +function uploadPlanImage(input) { + const file = input.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + planImage = new Image(); + planImage.onload = function() { + document.getElementById('calibrate-btn').disabled = false; + drawPlan(); + }; + planImage.src = e.target.result; + }; + reader.readAsDataURL(file); +} + +function calibratePlan() { + alert('Calibration: Set the plan dimensions in Plan Settings panel.\n\nThe grid will map x,y meters to your uploaded image.'); + drawPlan(); +} + +function clearPlan() { + planImage = null; + document.getElementById('calibrate-btn').disabled = true; + drawPlan(); +} + +function applyPlanSettings() { + planSettings.width = parseFloat(document.getElementById('plan-width').value) || 50; + planSettings.height = parseFloat(document.getElementById('plan-height').value) || 30; + planSettings.originX = parseFloat(document.getElementById('plan-origin-x').value) || 0; + planSettings.originY = parseFloat(document.getElementById('plan-origin-y').value) || 0; + updateSizeDisplay(); + drawPlan(); +} + +function adjustPlanSize(delta) { + // Adjust both width and height proportionally + const minSize = 10; + const maxSize = 500; + + planSettings.width = Math.max(minSize, Math.min(maxSize, planSettings.width + delta)); + planSettings.height = Math.max(minSize, Math.min(maxSize, planSettings.height + Math.round(delta * 0.6))); + + // Update input fields in sidebar + document.getElementById('plan-width').value = planSettings.width; + document.getElementById('plan-height').value = planSettings.height; + + updateSizeDisplay(); + drawPlan(); +} + +function updateSizeDisplay() { + const el = document.getElementById('size-display'); + if (el) { + el.textContent = `${planSettings.width}x${planSettings.height}m`; + } +} + +// ============================================================ +// View Switching +// ============================================================ +function switchView(view) { + currentView = view; + + // Update buttons + document.querySelectorAll('.view-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.view === view); + }); + + // Update views + document.getElementById('map-view').classList.toggle('active', view === 'map'); + document.getElementById('plan-view').classList.toggle('active', view === 'plan'); + + // Show/hide settings panels based on view + document.getElementById('map-settings').style.display = view === 'map' ? 'block' : 'none'; + document.getElementById('plan-settings').style.display = view === 'plan' ? 'block' : 'none'; + + // Initialize view if needed + if (view === 'map') { + setTimeout(() => { + if (!map) initMap(); + else map.invalidateSize(); + updateMapMarkers(); + }, 100); + } else { + if (!planCanvas) initPlanCanvas(); + else resizePlanCanvas(); + } +} + +// ============================================================ +// UI Updates +// ============================================================ +function updateCoordMode(mode) { + coordMode = mode; + + const modeDisplay = document.getElementById('coord-mode'); + const coord1Label = document.getElementById('target-coord1-label'); + const coord2Label = document.getElementById('target-coord2-label'); + + if (mode === 'gps') { + modeDisplay.textContent = 'GPS'; + coord1Label.textContent = 'Latitude'; + coord2Label.textContent = 'Longitude'; + } else { + modeDisplay.textContent = 'Local'; + coord1Label.textContent = 'X (m)'; + coord2Label.textContent = 'Y (m)'; + } +} + +function updateTargetInfo(targetData) { + const coord1El = document.getElementById('target-coord1'); + const coord2El = document.getElementById('target-coord2'); + + if (targetData && targetData.position) { + const pos = targetData.position; + + if (pos.lat !== undefined) { + coord1El.textContent = pos.lat.toFixed(6); + coord2El.textContent = pos.lon.toFixed(6); + } else if (pos.x !== undefined) { + coord1El.textContent = pos.x.toFixed(2) + ' m'; + coord2El.textContent = pos.y.toFixed(2) + ' m'; + } else { + coord1El.textContent = '-'; + coord2El.textContent = '-'; + } + + document.getElementById('target-confidence').textContent = ((targetData.confidence || 0) * 100).toFixed(0) + '%'; + document.getElementById('target-age').textContent = (targetData.age_seconds || 0).toFixed(1) + 's ago'; + + // Store for rendering + target = pos; + } else { + coord1El.textContent = '-'; + coord2El.textContent = '-'; + document.getElementById('target-confidence').textContent = '-'; + document.getElementById('target-age').textContent = '-'; + target = null; + } +} + +function updateScannerList(scannersData) { + scanners = scannersData || []; + const list = document.getElementById('scanner-list'); + document.getElementById('scanner-count').textContent = scanners.length; + + if (scanners.length === 0) { + list.innerHTML = '
No scanners active
'; + return; + } + + list.innerHTML = scanners.map(s => { + const pos = s.position || {}; + let posStr; + + if (pos.lat !== undefined) { + posStr = `(${pos.lat.toFixed(4)}, ${pos.lon.toFixed(4)})`; + } else if (pos.x !== undefined) { + posStr = `(${pos.x.toFixed(1)}m, ${pos.y.toFixed(1)}m)`; + } else { + posStr = '(-, -)'; + } + + return ` +
+
${s.id}
+
+ Pos: ${posStr} | + RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} | + Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'} +
+
+ `; + }).join(''); +} + +function updateConfig(config) { + if (!config) return; + document.getElementById('config-rssi').value = config.rssi_at_1m || -40; + document.getElementById('config-n').value = config.path_loss_n || 2.5; + document.getElementById('config-smooth').value = config.smoothing_window || 5; +} + +// ============================================================ +// API Functions +// ============================================================ +async function fetchState() { + try { + const res = await fetch('/api/mlat/state'); + const state = await res.json(); + + // Update coordinate mode from server + if (state.coord_mode) { + updateCoordMode(state.coord_mode); + } + + updateTargetInfo(state.target); + updateScannerList(state.scanners); + + if (state.config) { + updateConfig(state.config); + } + + // Update visualization + if (currentView === 'map') { + updateMapMarkers(); + } else { + drawPlan(); + } + } catch (e) { + console.error('Failed to fetch MLAT state:', e); + } +} + +async function saveConfig() { + const config = { + rssi_at_1m: parseFloat(document.getElementById('config-rssi').value), + path_loss_n: parseFloat(document.getElementById('config-n').value), + smoothing_window: parseInt(document.getElementById('config-smooth').value) + }; + + try { + await fetch('/api/mlat/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + console.log('Config saved'); + } catch (e) { + console.error('Failed to save config:', e); + } +} + +async function clearData() { + try { + await fetch('/api/mlat/clear', { method: 'POST' }); + fetchState(); + } catch (e) { + console.error('Failed to clear data:', e); + } +} + +// ============================================================ +// Initialization +// ============================================================ +document.addEventListener('DOMContentLoaded', () => { + // Initialize map view by default + initMap(); + initPlanCanvas(); + + // Initialize displays + updateZoomDisplay(); + updateSizeDisplay(); + + // Start polling + fetchState(); + setInterval(fetchState, 2000); +}); diff --git a/tools/c2/static/js/multilateration.js b/tools/c2/static/js/multilateration.js deleted file mode 100644 index 2c3a125..0000000 --- a/tools/c2/static/js/multilateration.js +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Trilateration visualization for ESPILON C2 - * Renders scanner positions and target location on a 2D canvas - */ - -class TrilaterationViz { - constructor(canvasId) { - this.canvas = document.getElementById(canvasId); - this.ctx = this.canvas.getContext('2d'); - - // Coordinate system bounds (auto-adjusted based on data) - this.bounds = { minX: -2, maxX: 15, minY: -2, maxY: 15 }; - this.padding = 40; - - // Data - this.scanners = []; - this.target = null; - - // Colors - this.colors = { - background: '#010409', - grid: '#21262d', - gridText: '#484f58', - scanner: '#58a6ff', - scannerCircle: 'rgba(88, 166, 255, 0.15)', - target: '#f85149', - targetGlow: 'rgba(248, 81, 73, 0.3)', - text: '#c9d1d9' - }; - - this.resize(); - window.addEventListener('resize', () => this.resize()); - } - - resize() { - const rect = this.canvas.parentElement.getBoundingClientRect(); - this.canvas.width = rect.width - 32; // Account for padding - this.canvas.height = 500; - this.draw(); - } - - // Convert world coordinates to canvas coordinates - worldToCanvas(x, y) { - const w = this.canvas.width - this.padding * 2; - const h = this.canvas.height - this.padding * 2; - const rangeX = this.bounds.maxX - this.bounds.minX; - const rangeY = this.bounds.maxY - this.bounds.minY; - - return { - x: this.padding + ((x - this.bounds.minX) / rangeX) * w, - y: this.canvas.height - this.padding - ((y - this.bounds.minY) / rangeY) * h - }; - } - - // Convert distance to canvas pixels - distanceToPixels(distance) { - const w = this.canvas.width - this.padding * 2; - const rangeX = this.bounds.maxX - this.bounds.minX; - return (distance / rangeX) * w; - } - - updateBounds() { - if (this.scanners.length === 0) { - this.bounds = { minX: -2, maxX: 15, minY: -2, maxY: 15 }; - return; - } - - let minX = Infinity, maxX = -Infinity; - let minY = Infinity, maxY = -Infinity; - - for (const s of this.scanners) { - minX = Math.min(minX, s.position.x); - maxX = Math.max(maxX, s.position.x); - minY = Math.min(minY, s.position.y); - maxY = Math.max(maxY, s.position.y); - } - - if (this.target) { - minX = Math.min(minX, this.target.x); - maxX = Math.max(maxX, this.target.x); - minY = Math.min(minY, this.target.y); - maxY = Math.max(maxY, this.target.y); - } - - // Add margin - const marginX = Math.max(2, (maxX - minX) * 0.2); - const marginY = Math.max(2, (maxY - minY) * 0.2); - - this.bounds = { - minX: minX - marginX, - maxX: maxX + marginX, - minY: minY - marginY, - maxY: maxY + marginY - }; - } - - draw() { - const ctx = this.ctx; - const w = this.canvas.width; - const h = this.canvas.height; - - // Clear - ctx.fillStyle = this.colors.background; - ctx.fillRect(0, 0, w, h); - - // Draw grid - this.drawGrid(); - - // Draw scanner range circles - for (const scanner of this.scanners) { - if (scanner.estimated_distance) { - this.drawRangeCircle(scanner); - } - } - - // Draw scanners - for (const scanner of this.scanners) { - this.drawScanner(scanner); - } - - // Draw target - if (this.target) { - this.drawTarget(); - } - } - - drawGrid() { - const ctx = this.ctx; - ctx.strokeStyle = this.colors.grid; - ctx.lineWidth = 1; - ctx.font = '10px monospace'; - ctx.fillStyle = this.colors.gridText; - - // Determine grid spacing - const rangeX = this.bounds.maxX - this.bounds.minX; - const rangeY = this.bounds.maxY - this.bounds.minY; - const gridStep = Math.pow(10, Math.floor(Math.log10(Math.max(rangeX, rangeY) / 5))); - - // Vertical lines - for (let x = Math.ceil(this.bounds.minX / gridStep) * gridStep; x <= this.bounds.maxX; x += gridStep) { - const p = this.worldToCanvas(x, 0); - ctx.beginPath(); - ctx.moveTo(p.x, this.padding); - ctx.lineTo(p.x, this.canvas.height - this.padding); - ctx.stroke(); - ctx.fillText(x.toFixed(1), p.x - 10, this.canvas.height - this.padding + 15); - } - - // Horizontal lines - for (let y = Math.ceil(this.bounds.minY / gridStep) * gridStep; y <= this.bounds.maxY; y += gridStep) { - const p = this.worldToCanvas(0, y); - ctx.beginPath(); - ctx.moveTo(this.padding, p.y); - ctx.lineTo(this.canvas.width - this.padding, p.y); - ctx.stroke(); - ctx.fillText(y.toFixed(1), 5, p.y + 4); - } - } - - drawRangeCircle(scanner) { - const ctx = this.ctx; - const pos = this.worldToCanvas(scanner.position.x, scanner.position.y); - const radius = this.distanceToPixels(scanner.estimated_distance); - - ctx.beginPath(); - ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2); - ctx.strokeStyle = this.colors.scannerCircle; - ctx.lineWidth = 2; - ctx.stroke(); - } - - drawScanner(scanner) { - const ctx = this.ctx; - const pos = this.worldToCanvas(scanner.position.x, scanner.position.y); - - // Scanner dot - ctx.beginPath(); - ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); - ctx.fillStyle = this.colors.scanner; - ctx.fill(); - - // Label - ctx.font = '12px monospace'; - ctx.fillStyle = this.colors.text; - ctx.textAlign = 'center'; - ctx.fillText(scanner.id, pos.x, pos.y - 15); - - // RSSI info - if (scanner.last_rssi !== null) { - ctx.font = '10px monospace'; - ctx.fillStyle = this.colors.gridText; - ctx.fillText(`${scanner.last_rssi} dBm`, pos.x, pos.y + 20); - } - - ctx.textAlign = 'left'; - } - - drawTarget() { - const ctx = this.ctx; - const pos = this.worldToCanvas(this.target.x, this.target.y); - - // Glow effect - ctx.beginPath(); - ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2); - ctx.fillStyle = this.colors.targetGlow; - ctx.fill(); - - // Cross marker - ctx.strokeStyle = this.colors.target; - ctx.lineWidth = 3; - - ctx.beginPath(); - ctx.moveTo(pos.x - 12, pos.y - 12); - ctx.lineTo(pos.x + 12, pos.y + 12); - ctx.stroke(); - - ctx.beginPath(); - ctx.moveTo(pos.x + 12, pos.y - 12); - ctx.lineTo(pos.x - 12, pos.y + 12); - ctx.stroke(); - - // Label - ctx.font = 'bold 12px monospace'; - ctx.fillStyle = this.colors.target; - ctx.textAlign = 'center'; - ctx.fillText('TARGET', pos.x, pos.y - 25); - ctx.textAlign = 'left'; - } - - update(state) { - this.scanners = state.scanners || []; - this.target = state.target?.position || null; - this.updateBounds(); - this.draw(); - } -} - -// Initialize visualization -const viz = new TrilaterationViz('trilat-canvas'); - -// UI Update functions -function updateTargetInfo(target) { - if (target && target.position) { - document.getElementById('target-x').textContent = target.position.x.toFixed(2) + ' m'; - document.getElementById('target-y').textContent = target.position.y.toFixed(2) + ' m'; - document.getElementById('target-confidence').textContent = ((target.confidence || 0) * 100).toFixed(0) + '%'; - document.getElementById('target-age').textContent = (target.age_seconds || 0).toFixed(1) + 's ago'; - } else { - document.getElementById('target-x').textContent = '-'; - document.getElementById('target-y').textContent = '-'; - document.getElementById('target-confidence').textContent = '-'; - document.getElementById('target-age').textContent = '-'; - } -} - -function updateScannerList(scanners) { - const list = document.getElementById('scanner-list'); - document.getElementById('scanner-count').textContent = scanners.length; - - if (scanners.length === 0) { - list.innerHTML = '

No scanners active

'; - return; - } - - list.innerHTML = scanners.map(s => ` -
-
${s.id}
-
- Pos: (${s.position.x}, ${s.position.y}) | - RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} | - Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'} -
-
- `).join(''); -} - -function updateConfig(config) { - document.getElementById('config-rssi').value = config.rssi_at_1m; - document.getElementById('config-n').value = config.path_loss_n; - document.getElementById('config-smooth').value = config.smoothing_window; -} - -// API functions -async function fetchState() { - try { - const res = await fetch('/api/multilat/state'); - const state = await res.json(); - - viz.update(state); - updateTargetInfo(state.target); - updateScannerList(state.scanners); - - if (state.config) { - updateConfig(state.config); - } - } catch (e) { - console.error('Failed to fetch trilateration state:', e); - } -} - -async function saveConfig() { - const config = { - rssi_at_1m: parseFloat(document.getElementById('config-rssi').value), - path_loss_n: parseFloat(document.getElementById('config-n').value), - smoothing_window: parseInt(document.getElementById('config-smooth').value) - }; - - try { - await fetch('/api/multilat/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - console.log('Config saved'); - } catch (e) { - console.error('Failed to save config:', e); - } -} - -async function clearData() { - try { - await fetch('/api/multilat/clear', { method: 'POST' }); - fetchState(); - } catch (e) { - console.error('Failed to clear data:', e); - } -} - -// Start polling -fetchState(); -setInterval(fetchState, 2000); diff --git a/tools/c2/static/record.avi b/tools/c2/static/record.avi deleted file mode 100644 index e042023..0000000 Binary files a/tools/c2/static/record.avi and /dev/null differ diff --git a/tools/c2/camera/__init__.py b/tools/c2/streams/__init__.py similarity index 100% rename from tools/c2/camera/__init__.py rename to tools/c2/streams/__init__.py diff --git a/tools/c2/camera/config.py b/tools/c2/streams/config.py similarity index 100% rename from tools/c2/camera/config.py rename to tools/c2/streams/config.py diff --git a/tools/c2/camera/server.py b/tools/c2/streams/server.py similarity index 96% rename from tools/c2/camera/server.py rename to tools/c2/streams/server.py index 36c82e7..4282cc0 100644 --- a/tools/c2/camera/server.py +++ b/tools/c2/streams/server.py @@ -8,7 +8,7 @@ from .config import ( ) from .udp_receiver import UDPReceiver from web.server import UnifiedWebServer -from web.multilateration import MultilaterationEngine +from web.mlat import MlatEngine class CameraServer: @@ -42,7 +42,7 @@ class CameraServer: device_registry: DeviceRegistry instance for device listing on_frame: Optional callback when frame is received (camera_id, frame, addr) """ - self.multilat_engine = MultilaterationEngine() + self.mlat_engine = MlatEngine() self.udp_receiver = UDPReceiver( host=udp_host, @@ -60,7 +60,7 @@ class CameraServer: secret_key=FLASK_SECRET_KEY, multilat_token=MULTILAT_AUTH_TOKEN, device_registry=device_registry, - multilateration_engine=self.multilat_engine + mlat_engine=self.mlat_engine ) @property diff --git a/tools/c2/camera/udp_receiver.py b/tools/c2/streams/udp_receiver.py similarity index 100% rename from tools/c2/camera/udp_receiver.py rename to tools/c2/streams/udp_receiver.py diff --git a/tools/c2/camera/web_server.py b/tools/c2/streams/web_server.py similarity index 100% rename from tools/c2/camera/web_server.py rename to tools/c2/streams/web_server.py diff --git a/tools/c2/templates/base.html b/tools/c2/templates/base.html index 42cbd85..185cf4e 100644 --- a/tools/c2/templates/base.html +++ b/tools/c2/templates/base.html @@ -17,8 +17,8 @@ Cameras - - Multilateration + + MLAT
diff --git a/tools/c2/templates/dashboard.html b/tools/c2/templates/dashboard.html index 1e3a4cd..8577be3 100644 --- a/tools/c2/templates/dashboard.html +++ b/tools/c2/templates/dashboard.html @@ -5,15 +5,81 @@ {% block content %}
-