ε - Implémentation du module MLAT et upgrade du C2
This commit is contained in:
parent
3ee76bb605
commit
c2b4bb3463
1
.gitignore
vendored
1
.gitignore
vendored
@ -43,6 +43,7 @@ tools/c3po/config.json
|
||||
**/config.local.json
|
||||
|
||||
# Logs
|
||||
.avi
|
||||
*.log
|
||||
logs/
|
||||
espilon_bot/logs/
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
void mod_ble_trilat_register_commands(void);
|
||||
void mod_camera_register_commands(void);
|
||||
/* Camera module */
|
||||
void mod_camera_register_commands(void);
|
||||
|
||||
/* MLAT (Multilateration) module */
|
||||
void mod_mlat_register_commands(void);
|
||||
|
||||
796
espilon_bot/components/mod_recon/mod_mlat.c
Normal file
796
espilon_bot/components/mod_recon/mod_mlat.c
Normal file
@ -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 <lat> <lon> - Set GPS position (degrees)
|
||||
* mlat config local <x> <y> - Set local position (meters)
|
||||
* mlat config <lat> <lon> - Backward compat: GPS mode
|
||||
* mlat mode <ble|wifi> - Set scanning mode
|
||||
* mlat start <mac> - Start scanning for target MAC
|
||||
* mlat stop - Stop scanning
|
||||
* mlat status - Show current config and state
|
||||
*
|
||||
* Data format sent to C2:
|
||||
* MLAT:G;<lat>;<lon>;<rssi> - GPS coordinates
|
||||
* MLAT:L;<x>;<y>;<rssi> - Local coordinates (meters)
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <ctype.h>
|
||||
|
||||
#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;<lat>;<lon>;<rssi>
|
||||
* Format Local: MLAT:L;<x>;<y>;<rssi>
|
||||
* 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 <gps|local> <coord1> <coord2>
|
||||
* mlat config gps <lat> <lon> - GPS coordinates (degrees)
|
||||
* mlat config local <x> <y> - Local coordinates (meters)
|
||||
* mlat config <lat> <lon> - 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] <coord1> <coord2>", 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 <lat> <lon> */
|
||||
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 <x> <y> */
|
||||
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 <lat> <lon> -> 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] <coord1> <coord2>", req);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* COMMAND: mlat mode <ble|wifi>
|
||||
* ============================================================ */
|
||||
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 <ble|wifi>", 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 <mac>
|
||||
* ============================================================ */
|
||||
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 <mac>", 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] <c1> <c2>' 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] <c1> <c2>",
|
||||
.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 <ble|wifi>",
|
||||
.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 <mac>",
|
||||
.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 */
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 <start|stop|status>")
|
||||
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 <start|stop|status>")
|
||||
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")
|
||||
|
||||
@ -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 <host>)",
|
||||
"arp_scan": "ARP scan the local network",
|
||||
"proxy_start": "Start TCP proxy (proxy_start <ip> <port>)",
|
||||
"proxy_stop": "Stop TCP proxy",
|
||||
"dos_tcp": "TCP flood (dos_tcp <ip> <port> <count>)",
|
||||
}
|
||||
},
|
||||
"fakeap": {
|
||||
"description": "Fake Access Point module",
|
||||
"commands": {
|
||||
"fakeap_start": "Start fake AP (fakeap_start <ssid> [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 <ip> <port>)",
|
||||
"cam_stop": "Stop camera streaming",
|
||||
"mlat config": "Set position (mlat config [gps|local] <c1> <c2>)",
|
||||
"mlat mode": "Set scan mode (mlat mode <ble|wifi>)",
|
||||
"mlat start": "Start MLAT scanning (mlat start <mac>)",
|
||||
"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 <command>' for detailed help on a specific command.\033[0m")
|
||||
print("\033[90mSend commands with: send <device_id|all> <command> [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 <target> Send a command to ESP device(s)")
|
||||
print(" group <action> 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 <target> <cmd> Send a command to ESP device(s)")
|
||||
print(" \033[36mgroup\033[0m <action> 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 <target> <any text>")
|
||||
print("\n\033[33mDEV MODE:\033[0m Send arbitrary text: send <target> <any text>")
|
||||
|
||||
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 <device_id|all|group <group_name>> <command_name> [args...]")
|
||||
print(" Usage: send <device_id|all|group <name>> <command> [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 <action> [args...]")
|
||||
print(" Actions:")
|
||||
print(" add <group_name> <device_id1> [device_id2...] - Add devices to a group.")
|
||||
print(" remove <group_name> <device_id1> [device_id2...] - Remove devices from a group.")
|
||||
print(" list - List all defined groups and their members.")
|
||||
print(" show <group_name> - 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 <name> <id1> [id2...] Add devices to a group")
|
||||
print(" remove <name> <id1> [id2...] Remove devices from a group")
|
||||
print(" list List all groups")
|
||||
print(" show <name> Show group members")
|
||||
|
||||
elif command_name == "web":
|
||||
Display.system_message("Help for 'web' command:")
|
||||
print(" Usage: web <start|stop|status>")
|
||||
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 <start|stop|status>")
|
||||
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 <device> mlat config [gps|local] <coord1> <coord2>
|
||||
GPS mode: mlat config gps <lat> <lon> - degrees
|
||||
Local mode: mlat config local <x> <y> - 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 <device> mlat mode <ble|wifi>
|
||||
Example: send ESP1 mlat mode ble""",
|
||||
|
||||
"mlat start": """
|
||||
Usage: send <device> mlat start <mac>
|
||||
Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF""",
|
||||
|
||||
"mlat stop": """
|
||||
Usage: send <device> mlat stop""",
|
||||
|
||||
"mlat status": """
|
||||
Usage: send <device> mlat status""",
|
||||
|
||||
# Camera commands
|
||||
"cam_start": """
|
||||
Usage: send <device> cam_start <ip> <port>
|
||||
Description: Start camera streaming to C2 UDP receiver
|
||||
Example: send ESP_CAM cam_start 192.168.1.100 12345""",
|
||||
|
||||
"cam_stop": """
|
||||
Usage: send <device> cam_stop
|
||||
Description: Stop camera streaming""",
|
||||
|
||||
# FakeAP commands
|
||||
"fakeap_start": """
|
||||
Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]
|
||||
Examples:
|
||||
send ESP1 fakeap_start FreeWiFi
|
||||
send ESP1 fakeap_start SecureNet wpa2 mypassword""",
|
||||
|
||||
"fakeap_stop": """
|
||||
Usage: send <device> fakeap_stop""",
|
||||
|
||||
"fakeap_status": """
|
||||
Usage: send <device> fakeap_status
|
||||
Shows: AP running, portal status, sniffer status, client count""",
|
||||
|
||||
"fakeap_clients": """
|
||||
Usage: send <device> fakeap_clients
|
||||
Lists all connected clients to the fake AP""",
|
||||
|
||||
"fakeap_portal_start": """
|
||||
Usage: send <device> fakeap_portal_start
|
||||
Description: Enable captive portal (requires fakeap running)""",
|
||||
|
||||
"fakeap_portal_stop": """
|
||||
Usage: send <device> fakeap_portal_stop""",
|
||||
|
||||
"fakeap_sniffer_on": """
|
||||
Usage: send <device> fakeap_sniffer_on
|
||||
Description: Enable packet sniffing""",
|
||||
|
||||
"fakeap_sniffer_off": """
|
||||
Usage: send <device> fakeap_sniffer_off""",
|
||||
|
||||
# Network commands
|
||||
"ping": """
|
||||
Usage: send <device> ping <host>
|
||||
Example: send ESP1 ping 8.8.8.8""",
|
||||
|
||||
"arp_scan": """
|
||||
Usage: send <device> arp_scan
|
||||
Description: Scan local network for hosts""",
|
||||
|
||||
"proxy_start": """
|
||||
Usage: send <device> proxy_start <ip> <port>
|
||||
Example: send ESP1 proxy_start 192.168.1.100 8080""",
|
||||
|
||||
"proxy_stop": """
|
||||
Usage: send <device> proxy_stop""",
|
||||
|
||||
"dos_tcp": """
|
||||
Usage: send <device> dos_tcp <ip> <port> <count>
|
||||
Example: send ESP1 dos_tcp 192.168.1.100 80 1000""",
|
||||
|
||||
# System commands
|
||||
"system_reboot": """
|
||||
Usage: send <device> system_reboot
|
||||
Description: Reboot the ESP32 device""",
|
||||
|
||||
"system_mem": """
|
||||
Usage: send <device> system_mem
|
||||
Shows: heap_free, heap_min, internal_free""",
|
||||
|
||||
"system_uptime": """
|
||||
Usage: send <device> system_uptime
|
||||
Shows: uptime in days/hours/minutes/seconds"""
|
||||
}
|
||||
|
||||
if cmd in details:
|
||||
print(details[cmd])
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
830
tools/c2/static/js/mlat.js
Normal file
830
tools/c2/static/js/mlat.js
Normal file
@ -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: '© <a href="https://carto.com/">CARTO</a>',
|
||||
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: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="#f87171" fill-opacity="0.3"/>
|
||||
<circle cx="12" cy="12" r="6" fill="#f87171"/>
|
||||
<circle cx="12" cy="12" r="3" fill="#fff"/>
|
||||
</svg>`,
|
||||
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(`
|
||||
<strong>${scanner.id}</strong><br>
|
||||
RSSI: ${scanner.last_rssi || '-'} dBm<br>
|
||||
Distance: ${scanner.estimated_distance || '-'} m
|
||||
`);
|
||||
}
|
||||
|
||||
// Update popup content
|
||||
scannerMarkers[scanner.id].setPopupContent(`
|
||||
<strong>${scanner.id}</strong><br>
|
||||
RSSI: ${scanner.last_rssi || '-'} dBm<br>
|
||||
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 = '<div class="empty">No scanners active</div>';
|
||||
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 `
|
||||
<div class="scanner-item">
|
||||
<div class="scanner-id">${s.id}</div>
|
||||
<div class="scanner-details">
|
||||
Pos: ${posStr} |
|
||||
RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} |
|
||||
Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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);
|
||||
});
|
||||
@ -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 = '<div class="empty" style="padding: 20px;"><p>No scanners active</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = scanners.map(s => `
|
||||
<div class="scanner-item">
|
||||
<div class="scanner-id">${s.id}</div>
|
||||
<div class="scanner-details">
|
||||
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' : '-'}
|
||||
</div>
|
||||
</div>
|
||||
`).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);
|
||||
Binary file not shown.
@ -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
|
||||
@ -17,8 +17,8 @@
|
||||
<a href="/cameras" class="nav-link {% if active_page == 'cameras' %}active{% endif %}">
|
||||
Cameras
|
||||
</a>
|
||||
<a href="/multilateration" class="nav-link {% if active_page == 'multilateration' %}active{% endif %}">
|
||||
Multilateration
|
||||
<a href="/mlat" class="nav-link {% if active_page == 'mlat' %}active{% endif %}">
|
||||
MLAT
|
||||
</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
|
||||
@ -5,15 +5,81 @@
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-title">Dashboard <span>Connected Devices</span></div>
|
||||
<div class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="device-count">0</span>
|
||||
<span class="stat-label">Devices</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="active-count">0</span>
|
||||
<span class="stat-label">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="devices-grid" class="grid">
|
||||
<!-- Devices loaded via JavaScript -->
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty" style="display: none;">
|
||||
<h2>No devices connected</h2>
|
||||
<p>Waiting for ESP32 agents to connect to the C2 server</p>
|
||||
<div id="empty-state" class="empty-lain" style="display: none;">
|
||||
<div class="lain-container">
|
||||
<pre class="lain-ascii">
|
||||
⠠⡐⢠⠂⠥⠒⡌⠰⡈⢆⡑⢢⠘⡐⢢⠑⢢⠁⠦⢡⢂⠣⢌⠒⡄⢃⠆⡱⢌⠒⠌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⡀⢀⠀⠠⠀⠠⠀⠀⠀⠀⠀⠀⠀⠣⢘⡐⢢⢡⠒⡌⠒⠤⢃⠜⡰⢈⠔⢢⠑⢢⠑⡌⠒⡌⠰⢌⠒⡰⢈⠒⢌⠢⡑⢢⠁⠎⠤⡑⢂⠆⡑⠢⢌
|
||||
⠠⠑⣂⢉⠒⡥⠘⡡⢑⠢⡘⠤⡉⠔⡡⠊⡅⠚⡌⠢⠜⡰⢈⡒⠌⡆⡍⠐⠀⠀⠀⠀⠀⠂⠄⡐⠀⠀⠀⠐⠀⠀⠂⠈⠐⠀⠄⠂⠀⠂⠁⢀⠀⠠⢀⠀⠀⠀⡀⠀⠈⠢⢡⢊⠔⣉⠦⡁⢎⠰⡉⠆⡑⢊⠔⢃⠌⡱⢈⠣⡘⢄⠃⡡⠋⡄⢓⡈⢆⡉⠎⡰⢉⠆⡘⠡⢃⠌
|
||||
⠠⠓⡄⢊⠔⢢⠑⡐⠣⡑⢌⠢⠱⡘⢄⠓⡌⠱⢠⡉⠆⡅⢣⠘⠈⠀⠀⠀⠀⠀⠀⠀⠄⠠⠀⠠⠀⠁⠌⠀⠀⠈⠀⠈⠀⠐⠀⡀⠂⠀⠐⠀⠂⠁⡀⠠⠁⠀⠀⠀⠀⠀⠀⠈⠘⡄⢢⠑⡌⢢⠑⡌⠱⡈⠜⡐⣊⠔⡡⢒⠡⢊⠔⡡⠓⡈⠦⠘⠤⡘⢢⠑⡌⢢⠑⡃⢎⡘
|
||||
⠐⡅⢊⠤⡉⢆⠱⣈⠱⡈⢆⠡⡃⠜⡠⢃⠌⣑⠢⢌⡱⠈⠁⠀⠀⠀⠠⠈⠀⠀⡐⠈⢀⠠⠀⢀⠐⠀⠈⠀⠐⠀⢁⠀⠂⡀⠀⢀⠐⠠⠁⠈⠀⠀⠀⠀⠀⠡⠐⠀⠂⠀⠀⠀⠀⠀⠁⠊⠴⡁⢎⠰⢡⠘⢢⠑⡄⢊⠔⡡⢊⠔⡨⢐⠡⠜⡰⠉⢆⡑⠢⡑⣈⠆⡱⢈⠆⡘
|
||||
⠐⡌⢂⠒⣡⠊⡔⢠⠃⡜⢠⠃⡜⢠⠱⣈⠒⡌⢒⠢⠁⠀⠀⠀⠀⠄⠡⢀⠀⠀⠀⠂⠄⠀⠄⠀⢀⠀⠂⠈⠀⠡⠀⠐⠠⠀⠈⠀⠄⠀⠂⠀⠠⠀⠀⠐⠈⠐⠀⠡⢀⠈⠀⠄⠀⠀⠀⠀⠐⡁⢎⡘⠤⡉⢆⠡⡘⠤⢃⠔⡡⢎⠰⢉⠢⠱⣀⠋⠤⢌⠱⡐⠄⢎⠰⡁⢎⠰
|
||||
⠐⢌⠢⡑⢄⠣⢌⠢⡑⢌⠢⡑⢌⠢⡑⢄⠣⡘⠂⠀⠀⠀⠀⠁⠀⠀⢀⠀⡈⠄⠐⠠⠀⢀⠀⠄⠂⡀⠀⠄⠈⡀⠀⠂⠀⠐⠀⢁⠀⠁⠠⠈⠀⠀⡁⠀⠁⠀⠀⠀⠄⠀⠂⡀⠂⠌⡀⠁⠀⠈⠢⡘⠤⡑⢌⠢⠑⡌⢢⠘⡐⢢⠑⡌⢢⠑⠤⣉⠒⡌⢢⠡⡉⢆⠱⡐⢌⠱
|
||||
⡈⢆⠱⡈⢆⠱⡈⢔⡈⢆⠱⣈⢂⠆⡱⢈⢆⠁⠀⠀⠀⠐⠈⠀⠌⠐⡀⠀⠐⢀⠀⠂⠁⠄⠈⠀⡐⠀⠂⠈⠄⠐⠠⠀⠁⠄⡈⠠⠀⠂⢀⠠⠁⠄⠀⢈⠀⠀⡀⠠⢀⠀⠄⢀⠈⠄⠀⡀⠂⠀⠀⠁⠆⢍⠢⣉⠒⡌⢄⠣⡘⢄⠣⡐⢡⠊⡔⢠⠃⠜⣀⠣⡘⢄⠣⡘⢠⢃
|
||||
⠐⡌⠰⡁⢎⠰⡁⢆⡘⢄⠣⡐⢌⠢⡑⢌⠂⠀⠀⠀⠀⠁⢀⠈⠀⢀⠀⠌⠐⠀⠈⠐⠀⠂⠌⠀⡀⠀⠀⠠⠈⠀⠄⠈⠀⠂⠀⠐⠀⠈⡀⠠⠀⠈⢀⠀⠂⠀⡀⠀⢀⠀⠈⠀⠀⡀⠀⠄⠀⡁⠂⠀⠘⡄⠣⢄⠣⡘⢄⠊⡔⠌⢢⠉⢆⠱⣈⠤⣉⠒⡄⢣⠘⡄⢣⠘⡄⣊
|
||||
⠂⡌⠱⡈⠆⠥⡘⠤⡈⢆⠱⡈⢆⠱⡈⠎⠀⠀⠀⠀⠈⠄⠀⠀⠂⡀⠀⠠⠀⠂⠐⠈⠀⡁⠀⠀⠀⠀⠄⠁⠀⠀⠀⠀⠀⢀⠀⠄⡀⠠⠀⠀⠠⠁⠀⠄⠀⠄⠠⠐⠀⠀⠀⠄⠀⠄⡁⠠⠐⠀⠂⠀⠀⠨⡑⢌⢂⠱⣈⠒⡌⡘⠤⣉⢂⠒⡄⡒⢄⠣⡘⠄⢣⠘⡄⠣⠔⢢
|
||||
⠐⡨⠑⡌⣘⠢⡑⢢⠑⣈⠆⡱⢈⠦⡁⠀⠀⠄⠠⠐⠀⠀⠂⠀⡐⠀⠈⠀⠀⡁⠂⠐⠀⠀⠀⠀⢂⠀⠀⠠⠁⠀⠀⠀⠈⠀⠀⠐⠀⠀⠠⠀⠐⠀⠈⠀⠀⠀⠄⠐⠀⠌⠠⠀⠄⠀⡀⠀⠂⠐⡀⠁⠀⠀⠑⡌⢢⠑⡄⢣⠘⡄⢣⠐⡌⢒⡰⢁⠎⣐⠡⢊⠅⡒⢌⠱⡈⢆
|
||||
⠁⢆⠱⡐⢢⠑⡌⢢⠑⡂⠜⣀⠣⠂⠀⠀⠀⠀⠀⠀⠈⠀⢀⠀⠄⠀⠂⠁⠀⠄⠠⠀⠀⠀⠌⠀⠀⢠⡀⠀⠀⠀⠄⠀⠀⠠⠀⠂⡀⠄⠀⠀⠄⠈⠀⠀⠄⠀⠀⠀⠂⠠⠀⠀⡐⠠⠀⠁⠐⠀⠀⠐⠀⡀⠀⠘⡄⢣⠘⡄⢣⠘⡄⢣⠐⡡⢂⠥⢊⢄⠣⢌⢂⠱⡈⢆⠱⣈
|
||||
⢉⠢⢡⠘⣄⠊⡔⢡⠊⡜⢠⣁⠃⠀⠀⠀⠂⠁⡀⠀⠐⠀⡀⠠⠀⠂⠐⠠⠈⠀⠀⠀⢀⠁⠀⠀⠀⢰⣧⡟⠀⠀⢀⠀⠠⠀⠁⠀⠀⠀⠂⠁⠈⠀⠀⠄⠀⠀⠀⠀⠀⠠⢀⠁⠀⠀⠂⠈⠀⠠⠁⠀⠀⠀⠀⠀⠘⡄⢣⠘⡄⢣⠘⡄⢃⠆⡡⠘⣄⠊⡔⡈⢆⠡⢒⡈⢒⠤
|
||||
⢂⡑⢢⠑⡄⡊⠔⡡⢊⠔⡡⢂⠄⠀⠀⠡⠀⠐⠀⠀⠁⠐⢀⠁⠄⠀⢂⠀⠄⡀⠁⠈⠀⠀⠀⠀⠀⣸⣿⣿⡄⠈⠀⢈⠀⠀⠀⡀⠀⠀⢀⠈⠀⠀⠀⠀⡀⠄⠀⠀⠀⠐⡀⠈⠀⠄⠁⡐⠈⠀⠄⠠⠀⠀⠀⠀⠀⡜⢠⢃⠜⡠⠑⡌⢢⠘⡄⠣⢄⠣⡐⢡⠊⡔⢡⠘⡌⠒
|
||||
⠂⡌⢢⠉⡔⢡⠊⡔⢡⠊⡔⡁⠀⠀⡀⠀⠂⠀⢀⠂⠌⠀⠀⡀⠈⠐⠀⠄⠀⠀⠀⠀⠀⠂⠀⠀⠀⣾⣿⣿⡆⠀⠀⠀⡀⠀⠐⠀⢠⠀⠂⢀⠀⠀⠀⠀⠄⠐⠀⡁⢀⠀⠀⠁⠀⠀⠂⢀⠐⠈⡀⠐⠀⠈⠀⠀⠀⡜⢠⠊⡔⢡⠃⡜⠠⢃⠌⡑⢢⠡⡘⢄⠣⠌⢢⠡⠌⢣
|
||||
⠐⡌⢆⠱⣈⠢⡑⢌⠢⡑⡰⠁⠀⠁⠀⠐⢀⠀⠂⠀⠄⠐⠀⠀⠀⠂⢀⠀⠀⠁⡀⢀⠀⡀⠀⠀⠀⣿⣿⣿⣧⠀⠀⠀⠀⠀⠁⠀⠠⡇⠀⠀⠀⠀⣇⠀⠂⠀⠀⠀⠈⡄⠀⢀⠂⠀⠐⠀⠠⠀⡀⠀⠌⠀⠄⠀⠀⢈⠆⡱⢈⠆⡱⢈⠱⡈⠜⡠⠃⢆⠱⡈⢆⡉⢆⠱⡘⠤
|
||||
⠒⡨⢐⠢⡄⠣⢌⠢⡑⢢⠑⠀⠀⠀⠀⠐⠀⢈⠀⡀⠀⠁⠈⠠⢈⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡄⠀⠀⠐⠀⠀⠀⠀⣿⠀⠠⠀⠀⣯⠀⠀⠀⠀⠀⠀⡇⠀⠀⠄⠈⢀⠐⠀⠀⠄⠀⠀⠀⠀⠈⠀⠀⡎⠰⡁⢎⠰⣁⠲⡁⢎⠰⡁⢎⠰⣁⠢⡘⢄⠣⡘⡰
|
||||
⢂⠱⣈⠒⡌⠱⡈⢆⡑⠢⠍⠀⠀⠀⠀⠈⠐⠀⠂⠠⠀⠠⠐⠀⠀⠈⠀⠄⠀⠀⠀⠀⠀⠀⢰⠀⠀⣿⣿⣿⣿⣇⠀⢤⠀⠀⠀⠀⠀⢸⣟⡀⠀⠀⣿⣆⠀⠈⠀⠀⠀⢟⡀⠀⠠⠀⠀⡀⠀⠂⠀⠂⠀⠀⢂⠀⠀⠀⡜⢡⠘⠤⡁⢆⠡⡘⢄⠣⡘⢄⠣⢄⠱⡈⢆⠱⢠⠑
|
||||
⠄⡃⢄⠣⢌⠱⡈⠆⡌⢡⠃⠀⠀⠀⠀⠀⠈⠀⠌⠀⠈⠀⡐⠀⠀⠀⠀⠀⡀⠀⠀⡀⠀⠄⢸⠀⠀⣿⣿⣿⣿⣿⢂⢸⡀⠀⠀⠀⠀⠘⣿⣜⡄⠀⣿⣯⡄⣀⠀⠀⠀⠺⠅⠀⠐⠀⠀⠀⠁⠀⠠⠀⠁⠄⠀⠀⠀⠀⡜⢠⠋⡔⢡⠊⡔⢡⠊⡔⠡⢊⠔⢊⠰⡁⢎⠰⠁⢎
|
||||
⢄⠱⣈⠒⡌⢢⠑⡘⡄⣃⠆⠀⠀⠀⠀⠀⠀⠀⠠⠀⠄⠀⠀⢀⠀⠄⠀⠀⡁⠀⢀⠀⣤⠀⠘⡇⠀⢹⣿⣿⣿⣿⣯⣸⡴⠀⠀⠀⠀⢀⣻⣿⣬⣂⡋⢁⣤⢤⢶⣶⣤⣰⣶⠀⠀⠄⢀⠐⠀⠄⠁⡀⠠⠀⠀⠌⠀⠐⡘⡄⢣⠘⡄⢣⠘⡄⢃⠌⡱⢈⠜⡠⢃⠜⡠⢃⠍⢢
|
||||
⣀⠒⡄⢣⠘⣄⢃⡒⡌⣐⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠌⠀⠈⡀⠀⠀⠀⢰⡆⠁⠀⠘⠒⠁⣀⣉⠀⢀⣀⣉⣩⣿⡟⢿⣿⣽⣯⣿⣼⣿⣿⣿⠿⢀⡿⡹⠊⠋⠉⠁⠀⠈⠛⠄⢀⠀⠂⢀⠀⠂⠀⠀⠐⠀⠀⡀⠂⠠⡑⢌⠢⡑⢌⠢⡑⢌⠢⡘⢄⠃⣆⠱⡈⠆⡱⢈⡌⡡
|
||||
⢀⠣⠌⡄⠓⡄⣂⠒⡰⢈⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠂⢨⠄⠀⣔⣾⣿⡿⠿⠼⠆⠸⠿⣞⣱⡞⣿⣠⣹⣿⣿⣿⣿⣿⣿⡟⠰⢫⠗⡐⠀⠀⠀⠀⢄⠀⣶⣤⡀⠀⠀⠂⠀⠀⠀⠀⠐⠀⠀⠀⠀⠀⡱⢈⡔⠡⢊⠤⡑⢌⠢⡑⠌⡒⢠⢃⡘⠤⡑⢌⠰⢡
|
||||
⢀⠣⡘⠠⢍⠰⣀⢃⠒⡩⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⢀⢸⠀⠀⢸⡃⠘⢊⠉⠀⠀⠀⠀⠀⢀⡀⠀⢉⡙⠻⣿⣿⣿⣿⣿⣿⣿⣯⣀⣷⣏⡌⠀⠠⠀⠀⠀⢈⠀⣸⣿⣿⠄⠀⠀⠀⠀⡀⠄⠀⠀⠀⠀⠀⠀⣑⠢⣐⠡⢊⠔⢌⠢⡑⢄⠣⡘⢄⠢⡘⠤⡑⢌⡑⢢
|
||||
⠠⡑⢌⠱⣈⠒⡄⢣⠘⡔⢡⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠸⠆⠀⢸⠷⠊⢁⠀⠀⠄⠀⠀⠉⡀⢹⣷⡄⠻⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣿⡀⠁⠀⠄⢁⣴⣿⡿⢻⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠀⠀⢢⠑⡄⠣⢌⡘⢄⠣⡘⢄⠃⡜⠠⢃⠜⡠⢑⠢⡘⠤
|
||||
⢄⠱⡈⢆⢡⠊⡔⠡⢃⠜⠤⡀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠀⠀⠘⣇⠀⢸⠀⠘⣿⣇⠈⠆⠀⠀⢐⠀⣼⣿⣷⣄⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠶⣾⡿⠿⠟⣡⣾⡀⠀⠀⠀⠠⠀⢀⠀⠀⠀⠀⢀⠠⢅⠪⡐⢅⠢⡘⢄⠣⡘⢄⠣⢌⠱⡈⢆⠱⡈⢆⠱⢌
|
||||
⠄⡃⠜⡠⢂⠣⢌⠱⡈⠜⡰⢁⠆⠀⠀⠀⠀⠀⠈⡄⢳⡄⠀⠀⠀⠿⡄⢾⣿⣦⣘⠿⣷⣤⣁⣈⣴⣾⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣷⣾⣿⣿⠀⠀⠀⠠⢀⠀⠀⠀⠀⠀⠀⠤⢃⡌⢢⠑⡌⢢⠑⡌⢢⠑⡌⠒⡌⢢⠑⡌⢂⠅⡊⢔⠨
|
||||
⠤⠑⢌⡐⠣⡘⠄⢣⠘⡌⠔⡩⠘⡄⠀⠀⠀⠀⠀⢃⢻⣆⠈⠀⠀⣹⣡⢸⣿⣿⣿⣷⣬⣉⣙⣋⣩⣥⣴⣾⣿⣿⣿⣿⣿⣿⣿⡟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠈⠀⠀⠀⢀⡘⢢⢡⠘⡄⢣⠐⢢⠑⡈⢆⠒⢌⡑⢌⠢⡑⡈⠆⡌⠱⣈⠒
|
||||
⠠⢉⠆⡌⠱⡠⢉⠆⡱⢈⠆⡱⢉⠔⡀⠀⠀⠀⠀⠈⢆⣻⡇⣆⠈⠷⣜⣆⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢳⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⢀⠀⠀⠀⡄⣊⠔⣂⠣⠘⠤⡉⢆⢡⠱⡈⠜⡠⠒⡌⠒⠤⡑⢌⡐⠣⢄⠩
|
||||
⣀⠣⡘⢠⠃⡔⣉⠢⡑⢌⡘⢄⠣⡘⡁⠀⠀⠀⠀⠀⠈⠻⣷⡘⠆⠈⢳⠺⡄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣣⢗⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠔⠀⠀⠀⠀⠀⠰⢐⠡⢊⢄⠣⡉⢆⠱⡈⢆⠢⡑⠬⡐⡡⠌⡑⢢⠁⠆⡌⠱⣈⠱
|
||||
⡀⢆⡑⢢⠑⡰⢄⠱⡈⢆⡘⢄⠣⢔⡁⠀⠀⡄⠀⠀⠀⠀⠘⢻⣷⣄⠈⢫⡽⡄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⠀⠀⠀⠀⠀⠀⠀⠀⡱⢈⡒⠩⢄⠱⡈⢆⠡⡘⠤⡑⠌⢢⠑⡰⢡⠑⢢⠉⡜⢠⠃⡄⢣
|
||||
⠐⡂⠜⡠⢃⠒⡌⡰⢁⠆⡸⢀⠇⢢⠄⠀⠰⡀⠀⠀⠀⠀⠀⠀⠉⠛⠳⣄⠹⣹⢆⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠁⠀⠀⡔⠡⢌⠱⡈⢆⠱⡈⢆⠑⡢⢡⢉⠆⡱⢀⠣⡘⢄⠣⢌⠢⡑⢌⠢
|
||||
⠡⡘⠤⠑⡌⠒⠤⡑⠌⣂⠱⡈⢎⢢⠁⢀⡱⠰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠑⢯⠶⡘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣏⣡⣴⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠈⠀⠀⠀⠀⠀⠀⠀⠀⡰⢉⠆⡱⢈⠆⠱⡐⢌⠢⡑⠢⠌⡆⠱⡈⢆⠱⣈⠒⡄⢣⠘⡠⢃
|
||||
⠐⡌⢢⢉⡔⡉⢆⠱⡈⢄⢃⠜⡠⢆⠁⢠⢂⡱⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣵⣈⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⡑⢢⠘⡄⢣⢈⠱⡈⢆⠱⣈⠱⡘⢄⠣⡑⢌⠒⡠⠑⡌⢢⠑⡄⢣
|
||||
⠐⡌⢂⠦⡐⢡⠊⡔⢡⠊⡔⢨⡐⢌⠒⠤⢒⡰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⢼⣢⡙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠈⠀⠀⠀⡀⢄⠀⢑⡂⢣⠘⠤⡈⢆⠱⡈⠔⡠⢃⠜⡠⢃⠜⡠⢊⠅⠣⢌⠡⢊⠔⡡
|
||||
⠈⡔⢡⢂⡑⠆⡱⢈⠆⡱⢈⠆⡘⡠⢉⠜⡐⢢⠁⠀⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠧⢌⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠉⠀⠀⠀⠀⠀⠀⠀⢀⠀⠤⡑⢊⠔⢢⡘⢄⠣⢌⠱⣀⠣⡘⠰⣁⠣⣈⠱⠈⢆⠱⡈⢌⠱⡈⢆⠣⡘⠔
|
||||
⠐⡌⢂⠆⡱⢈⠔⡡⢊⠔⡡⢊⠔⡑⢌⠢⠱⣈⠒⡰⣀⠒⠤⣀⠀⡀⠀⠀⠀⠀⣈⠀⠀⠀⠀⠀⠀⠀⢤⡈⠐⠪⣙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⣠⠂⠀⠀⠀⠀⠀⡄⠀⠀⠀⠀⢆⡱⢨⠘⡄⠲⣈⠒⡌⠒⡄⠣⢌⠱⠠⡑⡄⢣⠉⢆⠢⡑⢌⢂⠱⡈⢆⠱⣈
|
||||
⠐⡌⢢⠘⡄⢣⢘⠰⡈⢆⠑⠢⢌⡑⢌⠒⡡⢂⡱⠐⢤⢉⠒⡌⢢⢡⠩⢌⠓⡌⢄⠣⢢⡐⠤⠠⠀⠀⢸⣚⡳⢧⡤⣌⡈⠛⠛⠿⢻⢟⠿⠿⠟⢋⣡⢴⡛⢶⠀⠀⠐⠂⠥⡉⠄⠀⠀⠀⠘⢠⠢⡑⡌⠰⢃⠄⠣⢌⠱⣈⠒⡌⢒⡡⡘⠤⡁⠎⡄⢃⠜⡠⢊⠔⡡⢊⠔⢢
|
||||
⢂⠌⡄⢣⠘⡄⢎⠰⡁⠎⡌⡑⠢⠌⡄⠣⠔⡃⢔⠩⡐⢊⠔⡌⣡⠢⡑⢌⠒⡌⢌⡒⠁⠈⠀⠀⠀⠀⠸⣴⢫⡗⡾⣡⢏⡷⢲⠖⡦⣴⠲⣖⣺⠹⣖⡣⣟⠾⠀⠀⠀⠀⢂⠵⡁⠀⠀⠀⡘⢄⠣⡐⢌⠱⡈⢌⠣⢌⠒⡄⢣⠘⡄⢢⠑⠤⡑⢌⠰⡁⢆⠱⣈⠢⡑⢌⠚⠤
|
||||
⠂⡜⢠⠃⡜⠰⢈⠆⡱⢈⠔⡨⠑⠬⡐⠱⡈⡔⣈⠒⡡⢊⠔⡨⢐⠢⡑⢌⠒⡌⠢⠜⡀⠀⠀⠀⠀⠀⠀⠞⣧⢻⠵⣋⢾⡱⣏⢿⡱⣎⡳⣝⢮⡻⠵⠋⠈⠀⠀⠀⠀⠀⢉⡒⡀⠀⠀⠀⠱⡈⢆⠱⡈⢆⡑⠢⡑⠢⡑⠌⢢⠑⡌⢢⠑⢢⠑⡌⡑⢌⢂⠒⡄⢃⠜⡠⣉⠒
|
||||
⠐⡄⢣⠘⡄⠓⡌⢢⠑⡌⢢⠡⡉⢆⠡⢃⠴⠐⡄⢣⠐⢣⠘⡄⢃⠆⡱⢈⡒⠌⣅⠃⠀⠀⠀⠀⠀⠀⠀⠀⠈⠋⠿⣱⢧⡝⣮⢧⡻⠜⠓⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠒⡄⠀⠀⢠⠓⡘⡄⢣⠘⠤⣈⠱⡈⣑⠨⡘⢄⠣⠘⠤⣉⠢⡑⠤⡑⢌⠢⡑⢌⡂⢎⡐⠤⣉
|
||||
⠐⡌⢢⠑⡌⠱⡈⠤⠃⡜⣀⠣⣘⠠⢃⠌⡂⢇⠸⢠⠉⢆⠱⡈⢆⠱⣀⠣⡘⠬⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠂⠉⠔⣈⠆⣉⠒⡄⠣⠔⡠⢃⠜⡠⢃⠍⡔⠄⢣⠘⠤⡑⢌⠢⡁⢆⡘⠤⡘⢰⠠
|
||||
⠐⡌⢂⠱⣈⠱⣈⠒⡡⢒⠠⢃⠄⠣⢌⠢⣉⠢⣁⠣⡘⢄⠣⡘⢄⠣⡄⠓⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠊⠔⠣⢌⡑⡊⠔⣡⠊⡔⢡⠊⠤⡙⠠⢍⠒⢌⠢⠑⡌⢢⠘⠤⡑⢢⠑
|
||||
⠐⢌⠡⠒⡄⠣⢄⠣⡐⢡⠊⡔⢊⠱⣈⠒⣄⠃⢆⠱⣈⠦⠱⠘⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠘⠸⢠⠑⡌⢢⠉⣆⠩⡑⠬⡘⢄⠣⡑⢄⠣⡘⠤⡑⢢⢉
|
||||
⠈⢆⠡⢃⠌⡑⢢⠑⡌⠡⢎⠰⡁⠎⡄⡓⠤⠙⠈⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠐⠁⠚⠤⡑⡌⠱⡈⢆⠱⡈⢆⠱⡈⢆⠱⡈⢆
|
||||
⢁⠊⡔⡁⢎⠰⡁⢎⠰⡉⢆⠣⠘⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⢌⢢⡁⠇⣌⠂⡅⢊⠤⡑⢌
|
||||
⠌⡒⠤⡑⢌⠢⡑⢌⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡌⢄⠣⠜⡠⢆⠱⣈
|
||||
⠒⢌⠰⢡⠊⡔⠡⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢆⡑⢊⠔⢢⠑⠤
|
||||
⡈⢆⡘⢂⠱⠨⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢌⡡⢊⠆⣉⠒
|
||||
⠐⢢⠘⠤⡉⡕⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠢⢅⡊⠤⣉
|
||||
⢈⠢⢉⠆⡱⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠒⡌⠱⡠
|
||||
</pre>
|
||||
<div class="lain-message">
|
||||
<h2>No devices in the Wired</h2>
|
||||
<p class="typing">Waiting for ESP32 agents to connect...</p>
|
||||
<p class="quote">"Present day... Present time... HAHAHA!"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -63,14 +129,23 @@
|
||||
|
||||
const grid = document.getElementById('devices-grid');
|
||||
const empty = document.getElementById('empty-state');
|
||||
const deviceCount = document.getElementById('device-count');
|
||||
const activeCount = document.getElementById('active-count');
|
||||
|
||||
if (data.devices && data.devices.length > 0) {
|
||||
grid.innerHTML = data.devices.map(createDeviceCard).join('');
|
||||
grid.style.display = 'grid';
|
||||
empty.style.display = 'none';
|
||||
|
||||
// Update stats
|
||||
deviceCount.textContent = data.devices.length;
|
||||
const active = data.devices.filter(d => d.status === 'Connected').length;
|
||||
activeCount.textContent = active;
|
||||
} else {
|
||||
grid.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
empty.style.display = 'flex';
|
||||
deviceCount.textContent = '0';
|
||||
activeCount.textContent = '0';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load devices:', e);
|
||||
|
||||
174
tools/c2/templates/mlat.html
Normal file
174
tools/c2/templates/mlat.html
Normal file
@ -0,0 +1,174 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}MLAT - ESPILON{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-title">MLAT <span>Multilateration Positioning</span></div>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" data-view="map" onclick="switchView('map')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
|
||||
</svg>
|
||||
Map
|
||||
</button>
|
||||
<button class="view-btn" data-view="plan" onclick="switchView('plan')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
|
||||
</svg>
|
||||
Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mlat-container">
|
||||
<!-- Map/Plan View -->
|
||||
<div class="mlat-view-wrapper">
|
||||
<!-- Leaflet Map View -->
|
||||
<div id="map-view" class="mlat-view active">
|
||||
<div id="leaflet-map"></div>
|
||||
</div>
|
||||
|
||||
<!-- Plan View (Canvas + Image) -->
|
||||
<div id="plan-view" class="mlat-view">
|
||||
<div class="plan-controls">
|
||||
<input type="file" id="plan-upload" accept="image/*" style="display:none" onchange="uploadPlanImage(this)">
|
||||
<button class="btn btn-sm" onclick="document.getElementById('plan-upload').click()">
|
||||
Upload Plan
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick="clearPlan()">
|
||||
Clear
|
||||
</button>
|
||||
<div class="control-divider"></div>
|
||||
<button class="btn btn-sm toggle-btn active" id="grid-toggle" onclick="toggleGrid()">
|
||||
Grid
|
||||
</button>
|
||||
<button class="btn btn-sm toggle-btn active" id="labels-toggle" onclick="toggleLabels()">
|
||||
Labels
|
||||
</button>
|
||||
<div class="control-divider"></div>
|
||||
<span class="control-label">Zoom:</span>
|
||||
<button class="btn btn-sm" onclick="zoomPlan(-1)" title="Zoom Out">-</button>
|
||||
<span class="zoom-level" id="zoom-level">100%</span>
|
||||
<button class="btn btn-sm" onclick="zoomPlan(1)" title="Zoom In">+</button>
|
||||
<button class="btn btn-sm" onclick="resetZoom()" title="Reset View">Reset</button>
|
||||
<div class="control-divider"></div>
|
||||
<span class="control-label">Size:</span>
|
||||
<button class="btn btn-sm" onclick="adjustPlanSize(-10)" title="Shrink Plan">-10m</button>
|
||||
<span class="size-display" id="size-display">50x30m</span>
|
||||
<button class="btn btn-sm" onclick="adjustPlanSize(10)" title="Enlarge Plan">+10m</button>
|
||||
</div>
|
||||
<div class="plan-canvas-wrapper">
|
||||
<canvas id="plan-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="mlat-sidebar">
|
||||
<!-- Target Position -->
|
||||
<div class="mlat-panel">
|
||||
<h3>Target Position</h3>
|
||||
<div class="mlat-stat" id="target-coord1-row">
|
||||
<span class="label" id="target-coord1-label">Latitude</span>
|
||||
<span class="value" id="target-coord1">-</span>
|
||||
</div>
|
||||
<div class="mlat-stat" id="target-coord2-row">
|
||||
<span class="label" id="target-coord2-label">Longitude</span>
|
||||
<span class="value" id="target-coord2">-</span>
|
||||
</div>
|
||||
<div class="mlat-stat">
|
||||
<span class="label">Confidence</span>
|
||||
<span class="value" id="target-confidence">-</span>
|
||||
</div>
|
||||
<div class="mlat-stat">
|
||||
<span class="label">Last Update</span>
|
||||
<span class="value" id="target-age">-</span>
|
||||
</div>
|
||||
<div class="mlat-stat">
|
||||
<span class="label">Mode</span>
|
||||
<span class="value" id="coord-mode">GPS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Scanners -->
|
||||
<div class="mlat-panel">
|
||||
<h3>Scanners (<span id="scanner-count">0</span>)</h3>
|
||||
<div class="scanner-list" id="scanner-list">
|
||||
<div class="empty">No scanners active</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Settings (GPS mode) -->
|
||||
<div class="mlat-panel" id="map-settings">
|
||||
<h3>Map Settings (GPS)</h3>
|
||||
<div class="config-row">
|
||||
<label>Center Lat</label>
|
||||
<input type="number" id="map-center-lat" value="48.8566" step="0.0001">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Center Lon</label>
|
||||
<input type="number" id="map-center-lon" value="2.3522" step="0.0001">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Zoom</label>
|
||||
<input type="number" id="map-zoom" value="18" min="1" max="20">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="centerMap()">Center Map</button>
|
||||
<button class="btn btn-sm" onclick="fitMapToBounds()">Fit to Scanners</button>
|
||||
</div>
|
||||
|
||||
<!-- Plan Settings (Local mode) -->
|
||||
<div class="mlat-panel" id="plan-settings" style="display:none">
|
||||
<h3>Plan Settings (Local)</h3>
|
||||
<div class="config-row">
|
||||
<label>Width (m)</label>
|
||||
<input type="number" id="plan-width" value="50" min="1" step="1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Height (m)</label>
|
||||
<input type="number" id="plan-height" value="30" min="1" step="1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Origin X (m)</label>
|
||||
<input type="number" id="plan-origin-x" value="0" step="0.1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Origin Y (m)</label>
|
||||
<input type="number" id="plan-origin-y" value="0" step="0.1">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="applyPlanSettings()">Apply</button>
|
||||
</div>
|
||||
|
||||
<!-- MLAT Configuration -->
|
||||
<div class="mlat-panel">
|
||||
<h3>MLAT Config</h3>
|
||||
<div class="config-row">
|
||||
<label>RSSI @ 1m</label>
|
||||
<input type="number" id="config-rssi" value="-40" step="1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Path Loss (n)</label>
|
||||
<input type="number" id="config-n" value="2.5" step="0.1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Smoothing</label>
|
||||
<input type="number" id="config-smooth" value="5" min="1" max="20">
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary btn-sm" onclick="saveConfig()">Save</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="clearData()">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/mlat.js') }}"></script>
|
||||
{% endblock %}
|
||||
@ -1,73 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Multilateration - ESPILON{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-title">Multilateration <span>BLE Positioning</span></div>
|
||||
</div>
|
||||
|
||||
<div class="trilat-container">
|
||||
<div class="trilat-canvas-wrapper">
|
||||
<canvas id="trilat-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="trilat-sidebar">
|
||||
<!-- Target Position -->
|
||||
<div class="trilat-panel">
|
||||
<h3>Target Position</h3>
|
||||
<div class="trilat-stat">
|
||||
<span class="label">X</span>
|
||||
<span class="value" id="target-x">-</span>
|
||||
</div>
|
||||
<div class="trilat-stat">
|
||||
<span class="label">Y</span>
|
||||
<span class="value" id="target-y">-</span>
|
||||
</div>
|
||||
<div class="trilat-stat">
|
||||
<span class="label">Confidence</span>
|
||||
<span class="value" id="target-confidence">-</span>
|
||||
</div>
|
||||
<div class="trilat-stat">
|
||||
<span class="label">Last Update</span>
|
||||
<span class="value" id="target-age">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Scanners -->
|
||||
<div class="trilat-panel">
|
||||
<h3>Active Scanners (<span id="scanner-count">0</span>)</h3>
|
||||
<div class="scanner-list" id="scanner-list">
|
||||
<div class="empty" style="padding: 20px;">
|
||||
<p>No scanners active</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="trilat-panel">
|
||||
<h3>Configuration</h3>
|
||||
<div class="config-row">
|
||||
<label>RSSI at 1m</label>
|
||||
<input type="number" id="config-rssi" value="-40" step="1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Path Loss (n)</label>
|
||||
<input type="number" id="config-n" value="2.5" step="0.1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label>Smoothing</label>
|
||||
<input type="number" id="config-smooth" value="5" min="1" max="20">
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px;">
|
||||
<button class="btn btn-primary" onclick="saveConfig()">Save</button>
|
||||
<button class="btn btn-secondary" onclick="clearData()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/multilateration.js') }}"></script>
|
||||
{% endblock %}
|
||||
@ -1,6 +1,6 @@
|
||||
"""Unified web server module for ESPILON C2."""
|
||||
|
||||
from .server import UnifiedWebServer
|
||||
from .multilateration import MultilaterationEngine
|
||||
from .mlat import MlatEngine
|
||||
|
||||
__all__ = ["UnifiedWebServer", "MultilaterationEngine"]
|
||||
__all__ = ["UnifiedWebServer", "MlatEngine"]
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
"""Multilateration engine for BLE device positioning."""
|
||||
"""MLAT (Multilateration) engine for device positioning with GPS support."""
|
||||
|
||||
import time
|
||||
import re
|
||||
from typing import Optional
|
||||
import math
|
||||
from typing import Optional, Tuple
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize
|
||||
|
||||
|
||||
class MultilaterationEngine:
|
||||
class MlatEngine:
|
||||
"""
|
||||
Calculates target position from multiple BLE scanner RSSI readings.
|
||||
Calculates target position from multiple scanner RSSI readings.
|
||||
|
||||
Supports both:
|
||||
- GPS coordinates (lat, lon) for outdoor tracking
|
||||
- Local coordinates (x, y in meters) for indoor tracking
|
||||
|
||||
Uses the log-distance path loss model to convert RSSI to distance,
|
||||
then weighted least squares optimization for position estimation.
|
||||
"""
|
||||
|
||||
# Earth radius in meters (for GPS calculations)
|
||||
EARTH_RADIUS = 6371000
|
||||
|
||||
def __init__(self, rssi_at_1m: float = -40, path_loss_n: float = 2.5, smoothing_window: int = 5):
|
||||
"""
|
||||
Initialize the trilateration engine.
|
||||
Initialize the MLAT engine.
|
||||
|
||||
Args:
|
||||
rssi_at_1m: RSSI value at 1 meter distance (calibration, typically -40 to -50)
|
||||
@ -28,19 +36,108 @@ class MultilaterationEngine:
|
||||
self.path_loss_n = path_loss_n
|
||||
self.smoothing_window = smoothing_window
|
||||
|
||||
# Scanner data: {scanner_id: {"position": (x, y), "rssi_history": [], "last_seen": timestamp}}
|
||||
# Scanner data: {scanner_id: {"position": {"lat": x, "lon": y} or {"x": x, "y": y}, ...}}
|
||||
self.scanners: dict = {}
|
||||
|
||||
# Last calculated target position
|
||||
self._last_target: Optional[dict] = None
|
||||
self._last_calculation: float = 0
|
||||
|
||||
# Coordinate mode: 'gps' or 'local'
|
||||
self._coord_mode = 'gps'
|
||||
|
||||
@staticmethod
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""
|
||||
Calculate distance between two GPS points using Haversine formula.
|
||||
|
||||
Args:
|
||||
lat1, lon1: First point (degrees)
|
||||
lat2, lon2: Second point (degrees)
|
||||
|
||||
Returns:
|
||||
Distance in meters
|
||||
"""
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
delta_lat = math.radians(lat2 - lat1)
|
||||
delta_lon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(delta_lat / 2) ** 2 +
|
||||
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return MlatEngine.EARTH_RADIUS * c
|
||||
|
||||
@staticmethod
|
||||
def meters_to_degrees(meters: float, latitude: float) -> Tuple[float, float]:
|
||||
"""
|
||||
Convert meters to approximate degrees at a given latitude.
|
||||
|
||||
Args:
|
||||
meters: Distance in meters
|
||||
latitude: Reference latitude (for longitude scaling)
|
||||
|
||||
Returns:
|
||||
(delta_lat, delta_lon) in degrees
|
||||
"""
|
||||
delta_lat = meters / 111320 # ~111.32 km per degree latitude
|
||||
delta_lon = meters / (111320 * math.cos(math.radians(latitude)))
|
||||
return delta_lat, delta_lon
|
||||
|
||||
def parse_mlat_message(self, scanner_id: str, message: str) -> bool:
|
||||
"""
|
||||
Parse MLAT message from ESP32 device.
|
||||
|
||||
New format with coordinate type prefix:
|
||||
MLAT:G;<lat>;<lon>;<rssi> - GPS coordinates
|
||||
MLAT:L;<x>;<y>;<rssi> - Local coordinates (meters)
|
||||
|
||||
Legacy format (backward compatible):
|
||||
MLAT:<lat>;<lon>;<rssi> - Treated as GPS
|
||||
|
||||
Args:
|
||||
scanner_id: Device ID that sent the message
|
||||
message: Raw message content (without MLAT: prefix)
|
||||
|
||||
Returns:
|
||||
True if successfully parsed, False otherwise
|
||||
"""
|
||||
# New format with type prefix: G;lat;lon;rssi or L;x;y;rssi
|
||||
pattern_new = re.compile(r'^([GL]);([0-9.+-]+);([0-9.+-]+);(-?\d+)$')
|
||||
match = pattern_new.match(message)
|
||||
|
||||
if match:
|
||||
coord_type = match.group(1)
|
||||
c1 = float(match.group(2))
|
||||
c2 = float(match.group(3))
|
||||
rssi = int(match.group(4))
|
||||
|
||||
if coord_type == 'G':
|
||||
self.add_reading_gps(scanner_id, c1, c2, rssi)
|
||||
else: # 'L' - local
|
||||
self.add_reading(scanner_id, c1, c2, rssi)
|
||||
return True
|
||||
|
||||
# Legacy format: lat;lon;rssi (backward compatible - treat as GPS)
|
||||
pattern_legacy = re.compile(r'^([0-9.+-]+);([0-9.+-]+);(-?\d+)$')
|
||||
match = pattern_legacy.match(message)
|
||||
|
||||
if match:
|
||||
lat = float(match.group(1))
|
||||
lon = float(match.group(2))
|
||||
rssi = int(match.group(3))
|
||||
self.add_reading_gps(scanner_id, lat, lon, rssi)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def parse_data(self, raw_data: str) -> int:
|
||||
"""
|
||||
Parse raw trilateration data from ESP32.
|
||||
Parse raw MLAT data from HTTP POST.
|
||||
|
||||
Format: ESP_ID;(x,y);rssi\n
|
||||
Example: ESP3;(10.0,0.0);-45
|
||||
Format: SCANNER_ID;(lat,lon);rssi
|
||||
Example: ESP3;(48.8566,2.3522);-45
|
||||
|
||||
Args:
|
||||
raw_data: Raw text data with one or more readings
|
||||
@ -60,23 +157,23 @@ class MultilaterationEngine:
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
scanner_id = match.group(1)
|
||||
x = float(match.group(2))
|
||||
y = float(match.group(3))
|
||||
lat = float(match.group(2))
|
||||
lon = float(match.group(3))
|
||||
rssi = int(match.group(4))
|
||||
|
||||
self.add_reading(scanner_id, x, y, rssi, timestamp)
|
||||
self.add_reading_gps(scanner_id, lat, lon, rssi, timestamp)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None):
|
||||
def add_reading_gps(self, scanner_id: str, lat: float, lon: float, rssi: int, timestamp: float = None):
|
||||
"""
|
||||
Add a new RSSI reading from a scanner.
|
||||
Add a new RSSI reading from a scanner with GPS coordinates.
|
||||
|
||||
Args:
|
||||
scanner_id: Unique identifier for the scanner (e.g., "ESP1")
|
||||
x: X coordinate of the scanner
|
||||
y: Y coordinate of the scanner
|
||||
scanner_id: Unique identifier for the scanner
|
||||
lat: Latitude of the scanner
|
||||
lon: Longitude of the scanner
|
||||
rssi: RSSI value (negative dBm)
|
||||
timestamp: Reading timestamp (defaults to current time)
|
||||
"""
|
||||
@ -85,13 +182,13 @@ class MultilaterationEngine:
|
||||
|
||||
if scanner_id not in self.scanners:
|
||||
self.scanners[scanner_id] = {
|
||||
"position": (x, y),
|
||||
"position": {"lat": lat, "lon": lon},
|
||||
"rssi_history": [],
|
||||
"last_seen": timestamp
|
||||
}
|
||||
|
||||
scanner = self.scanners[scanner_id]
|
||||
scanner["position"] = (x, y)
|
||||
scanner["position"] = {"lat": lat, "lon": lon}
|
||||
scanner["rssi_history"].append(rssi)
|
||||
scanner["last_seen"] = timestamp
|
||||
|
||||
@ -99,6 +196,39 @@ class MultilaterationEngine:
|
||||
if len(scanner["rssi_history"]) > self.smoothing_window:
|
||||
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
|
||||
|
||||
self._coord_mode = 'gps'
|
||||
|
||||
def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None):
|
||||
"""
|
||||
Add a new RSSI reading from a scanner with local coordinates.
|
||||
|
||||
Args:
|
||||
scanner_id: Unique identifier for the scanner
|
||||
x: X coordinate of the scanner (meters)
|
||||
y: Y coordinate of the scanner (meters)
|
||||
rssi: RSSI value (negative dBm)
|
||||
timestamp: Reading timestamp (defaults to current time)
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
if scanner_id not in self.scanners:
|
||||
self.scanners[scanner_id] = {
|
||||
"position": {"x": x, "y": y},
|
||||
"rssi_history": [],
|
||||
"last_seen": timestamp
|
||||
}
|
||||
|
||||
scanner = self.scanners[scanner_id]
|
||||
scanner["position"] = {"x": x, "y": y}
|
||||
scanner["rssi_history"].append(rssi)
|
||||
scanner["last_seen"] = timestamp
|
||||
|
||||
if len(scanner["rssi_history"]) > self.smoothing_window:
|
||||
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
|
||||
|
||||
self._coord_mode = 'local'
|
||||
|
||||
def rssi_to_distance(self, rssi: float) -> float:
|
||||
"""
|
||||
Convert RSSI to estimated distance using log-distance path loss model.
|
||||
@ -115,7 +245,7 @@ class MultilaterationEngine:
|
||||
|
||||
def calculate_position(self) -> dict:
|
||||
"""
|
||||
Calculate target position using trilateration.
|
||||
Calculate target position using multilateration.
|
||||
|
||||
Requires at least 3 active scanners with recent readings.
|
||||
Uses weighted least squares optimization.
|
||||
@ -135,13 +265,33 @@ class MultilaterationEngine:
|
||||
"scanners_count": len(active_scanners)
|
||||
}
|
||||
|
||||
# Determine coordinate mode from first scanner
|
||||
first_pos = active_scanners[0][1]["position"]
|
||||
is_gps = "lat" in first_pos
|
||||
|
||||
# Prepare data arrays
|
||||
positions = []
|
||||
distances = []
|
||||
weights = []
|
||||
|
||||
# Reference point for GPS conversion (centroid)
|
||||
if is_gps:
|
||||
ref_lat = sum(s["position"]["lat"] for _, s in active_scanners) / len(active_scanners)
|
||||
ref_lon = sum(s["position"]["lon"] for _, s in active_scanners) / len(active_scanners)
|
||||
|
||||
for scanner_id, scanner in active_scanners:
|
||||
x, y = scanner["position"]
|
||||
pos = scanner["position"]
|
||||
|
||||
if is_gps:
|
||||
# Convert GPS to local meters relative to reference
|
||||
x = self.haversine_distance(ref_lat, ref_lon, ref_lat, pos["lon"])
|
||||
if pos["lon"] < ref_lon:
|
||||
x = -x
|
||||
y = self.haversine_distance(ref_lat, ref_lon, pos["lat"], ref_lon)
|
||||
if pos["lat"] < ref_lat:
|
||||
y = -y
|
||||
else:
|
||||
x, y = pos["x"], pos["y"]
|
||||
|
||||
# Average RSSI for noise reduction
|
||||
avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"])
|
||||
@ -151,22 +301,21 @@ class MultilaterationEngine:
|
||||
distances.append(distance)
|
||||
|
||||
# Weight by signal strength (stronger signal = more reliable)
|
||||
# Using inverse square of absolute RSSI
|
||||
weights.append(1.0 / (abs(avg_rssi) ** 2))
|
||||
|
||||
positions = np.array(positions)
|
||||
distances = np.array(distances)
|
||||
weights = np.array(weights)
|
||||
weights = weights / weights.sum() # Normalize weights
|
||||
weights = weights / weights.sum()
|
||||
|
||||
# Cost function: weighted sum of squared distance errors
|
||||
# Cost function
|
||||
def cost_function(point):
|
||||
x, y = point
|
||||
estimated_distances = np.sqrt((positions[:, 0] - x)**2 + (positions[:, 1] - y)**2)
|
||||
errors = (estimated_distances - distances) ** 2
|
||||
return np.sum(weights * errors)
|
||||
|
||||
# Initial guess: weighted centroid of scanner positions
|
||||
# Initial guess: weighted centroid
|
||||
x0 = np.sum(weights * positions[:, 0])
|
||||
y0 = np.sum(weights * positions[:, 1])
|
||||
|
||||
@ -175,13 +324,24 @@ class MultilaterationEngine:
|
||||
|
||||
if result.success:
|
||||
target_x, target_y = result.x
|
||||
# Confidence: inverse of residual error (higher cost = lower confidence)
|
||||
confidence = 1.0 / (1.0 + result.fun)
|
||||
|
||||
self._last_target = {
|
||||
"x": round(float(target_x), 2),
|
||||
"y": round(float(target_y), 2)
|
||||
}
|
||||
if is_gps:
|
||||
# Convert back to GPS
|
||||
delta_lat, delta_lon = self.meters_to_degrees(1, ref_lat)
|
||||
target_lat = ref_lat + target_y * delta_lat
|
||||
target_lon = ref_lon + target_x * delta_lon
|
||||
|
||||
self._last_target = {
|
||||
"lat": round(float(target_lat), 6),
|
||||
"lon": round(float(target_lon), 6)
|
||||
}
|
||||
else:
|
||||
self._last_target = {
|
||||
"x": round(float(target_x), 2),
|
||||
"y": round(float(target_y), 2)
|
||||
}
|
||||
|
||||
self._last_calculation = time.time()
|
||||
|
||||
return {
|
||||
@ -198,7 +358,7 @@ class MultilaterationEngine:
|
||||
|
||||
def get_state(self) -> dict:
|
||||
"""
|
||||
Get the current state of the trilateration system.
|
||||
Get the current state of the MLAT system.
|
||||
|
||||
Returns:
|
||||
dict with scanner info and last target position
|
||||
@ -217,7 +377,7 @@ class MultilaterationEngine:
|
||||
|
||||
scanners_data.append({
|
||||
"id": scanner_id,
|
||||
"position": {"x": scanner["position"][0], "y": scanner["position"][1]},
|
||||
"position": scanner["position"],
|
||||
"last_rssi": avg_rssi,
|
||||
"estimated_distance": distance,
|
||||
"last_seen": scanner["last_seen"],
|
||||
@ -232,7 +392,8 @@ class MultilaterationEngine:
|
||||
"rssi_at_1m": self.rssi_at_1m,
|
||||
"path_loss_n": self.path_loss_n,
|
||||
"smoothing_window": self.smoothing_window
|
||||
}
|
||||
},
|
||||
"coord_mode": self._coord_mode
|
||||
}
|
||||
|
||||
# Add target if available
|
||||
@ -247,7 +408,7 @@ class MultilaterationEngine:
|
||||
|
||||
def update_config(self, rssi_at_1m: float = None, path_loss_n: float = None, smoothing_window: int = None):
|
||||
"""
|
||||
Update trilateration configuration parameters.
|
||||
Update MLAT configuration parameters.
|
||||
|
||||
Args:
|
||||
rssi_at_1m: New RSSI at 1m value
|
||||
@ -10,7 +10,7 @@ from typing import Optional
|
||||
from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from .multilateration import MultilaterationEngine
|
||||
from .mlat import MlatEngine
|
||||
|
||||
# Disable Flask/Werkzeug request logging
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
@ -35,7 +35,7 @@ class UnifiedWebServer:
|
||||
secret_key: str = "change_this_for_prod",
|
||||
multilat_token: str = "multilat_secret_token",
|
||||
device_registry=None,
|
||||
multilateration_engine: Optional[MultilaterationEngine] = None):
|
||||
mlat_engine: Optional[MlatEngine] = None):
|
||||
"""
|
||||
Initialize the unified web server.
|
||||
|
||||
@ -46,9 +46,9 @@ class UnifiedWebServer:
|
||||
username: Login username
|
||||
password: Login password
|
||||
secret_key: Flask session secret key
|
||||
multilat_token: Bearer token for multilateration API
|
||||
multilat_token: Bearer token for MLAT API
|
||||
device_registry: DeviceRegistry instance for device listing
|
||||
multilateration_engine: MultilaterationEngine instance (created if None)
|
||||
mlat_engine: MlatEngine instance (created if None)
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
@ -58,7 +58,12 @@ class UnifiedWebServer:
|
||||
self.secret_key = secret_key
|
||||
self.multilat_token = multilat_token
|
||||
self.device_registry = device_registry
|
||||
self.multilat = multilateration_engine or MultilaterationEngine()
|
||||
self.mlat = mlat_engine or MlatEngine()
|
||||
|
||||
# Ensure image directory exists
|
||||
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
full_image_dir = os.path.join(c2_root, self.image_dir)
|
||||
os.makedirs(full_image_dir, exist_ok=True)
|
||||
|
||||
self._app = self._create_app()
|
||||
self._server = None
|
||||
@ -105,7 +110,7 @@ class UnifiedWebServer:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if token == web_server.multilat_token:
|
||||
if token == web_server.mlat_token:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
@ -158,10 +163,10 @@ class UnifiedWebServer:
|
||||
|
||||
return render_template("cameras.html", active_page="cameras", image_files=image_files)
|
||||
|
||||
@app.route("/multilateration")
|
||||
@app.route("/mlat")
|
||||
@require_login
|
||||
def multilateration():
|
||||
return render_template("multilateration.html", active_page="multilateration")
|
||||
def mlat():
|
||||
return render_template("mlat.html", active_page="mlat")
|
||||
|
||||
# ========== Static Files ==========
|
||||
|
||||
@ -220,9 +225,9 @@ class UnifiedWebServer:
|
||||
|
||||
# ========== Trilateration API ==========
|
||||
|
||||
@app.route("/api/multilat/collect", methods=["POST"])
|
||||
@app.route("/api/mlat/collect", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_multilat_collect():
|
||||
def api_mlat_collect():
|
||||
"""
|
||||
Receive multilateration data from ESP32 scanners.
|
||||
|
||||
@ -231,26 +236,26 @@ class UnifiedWebServer:
|
||||
ESP3;(10.0,0.0);-45
|
||||
"""
|
||||
raw_data = request.get_data(as_text=True)
|
||||
count = web_server.multilat.parse_data(raw_data)
|
||||
count = web_server.mlat.parse_data(raw_data)
|
||||
|
||||
# Recalculate position after new data
|
||||
if count > 0:
|
||||
web_server.multilat.calculate_position()
|
||||
web_server.mlat.calculate_position()
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"readings_processed": count
|
||||
})
|
||||
|
||||
@app.route("/api/multilat/state")
|
||||
@app.route("/api/mlat/state")
|
||||
@require_api_auth
|
||||
def api_multilat_state():
|
||||
def api_mlat_state():
|
||||
"""Get current multilateration state (scanners + target)."""
|
||||
state = web_server.multilat.get_state()
|
||||
state = web_server.mlat.get_state()
|
||||
|
||||
# Include latest calculation if not present
|
||||
if state["target"] is None and state["scanners_count"] >= 3:
|
||||
result = web_server.multilat.calculate_position()
|
||||
result = web_server.mlat.calculate_position()
|
||||
if "position" in result:
|
||||
state["target"] = {
|
||||
"position": result["position"],
|
||||
@ -261,29 +266,29 @@ class UnifiedWebServer:
|
||||
|
||||
return jsonify(state)
|
||||
|
||||
@app.route("/api/multilat/config", methods=["GET", "POST"])
|
||||
@app.route("/api/mlat/config", methods=["GET", "POST"])
|
||||
@require_api_auth
|
||||
def api_multilat_config():
|
||||
def api_mlat_config():
|
||||
"""Get or update multilateration configuration."""
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
web_server.multilat.update_config(
|
||||
web_server.mlat.update_config(
|
||||
rssi_at_1m=data.get("rssi_at_1m"),
|
||||
path_loss_n=data.get("path_loss_n"),
|
||||
smoothing_window=data.get("smoothing_window")
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"rssi_at_1m": web_server.multilat.rssi_at_1m,
|
||||
"path_loss_n": web_server.multilat.path_loss_n,
|
||||
"smoothing_window": web_server.multilat.smoothing_window
|
||||
"rssi_at_1m": web_server.mlat.rssi_at_1m,
|
||||
"path_loss_n": web_server.mlat.path_loss_n,
|
||||
"smoothing_window": web_server.mlat.smoothing_window
|
||||
})
|
||||
|
||||
@app.route("/api/multilat/clear", methods=["POST"])
|
||||
@app.route("/api/mlat/clear", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_multilat_clear():
|
||||
def api_mlat_clear():
|
||||
"""Clear all multilateration data."""
|
||||
web_server.multilat.clear()
|
||||
web_server.mlat.clear()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# ========== Stats API ==========
|
||||
@ -305,7 +310,7 @@ class UnifiedWebServer:
|
||||
if web_server.device_registry:
|
||||
device_count = len(list(web_server.device_registry.all()))
|
||||
|
||||
multilat_state = web_server.multilat.get_state()
|
||||
multilat_state = web_server.mlat.get_state()
|
||||
|
||||
return jsonify({
|
||||
"active_cameras": camera_count,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user