ε - Implémentation du module MLAT et upgrade du C2

This commit is contained in:
Eun0us 2026-01-30 22:00:38 +01:00
parent 3ee76bb605
commit c2b4bb3463
24 changed files with 2806 additions and 575 deletions

1
.gitignore vendored
View File

@ -43,6 +43,7 @@ tools/c3po/config.json
**/config.local.json
# Logs
.avi
*.log
logs/
espilon_bot/logs/

View File

@ -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);

View 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 */

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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])

View File

@ -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:

View File

@ -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
View 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: '&copy; <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);
});

View File

@ -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.

View File

@ -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

View File

@ -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">

View File

@ -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);

View 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 %}

View File

@ -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 %}

View File

@ -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"]

View File

@ -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

View File

@ -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,