ε - Implentation module camera in c2 + multilateration on web front start with camera start
This commit is contained in:
parent
b931c81a13
commit
3ee76bb605
@ -1,13 +1,25 @@
|
||||
#include "command.h"
|
||||
#include "utils.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static const char *TAG = "COMMAND";
|
||||
|
||||
static const command_t *registry[MAX_COMMANDS];
|
||||
static size_t registry_count = 0;
|
||||
|
||||
/* Max longueur lue/copied par arg (sécurité si non \0) */
|
||||
#ifndef COMMAND_MAX_ARG_LEN
|
||||
#define COMMAND_MAX_ARG_LEN 128
|
||||
#endif
|
||||
|
||||
/* Max args temporaires qu’on accepte ici (doit couvrir tes commandes) */
|
||||
#ifndef COMMAND_MAX_ARGS
|
||||
#define COMMAND_MAX_ARGS 16
|
||||
#endif
|
||||
|
||||
/* =========================================================
|
||||
* Register command
|
||||
* ========================================================= */
|
||||
@ -28,15 +40,78 @@ void command_register(const command_t *cmd)
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* Dispatch protobuf command
|
||||
* Helpers: deep-copy argv into one arena + argv[] pointers
|
||||
* ========================================================= */
|
||||
static bool deepcopy_argv(char *const *argv_in,
|
||||
int argc,
|
||||
char ***argv_out,
|
||||
char **arena_out,
|
||||
const char *req_id)
|
||||
{
|
||||
*argv_out = NULL;
|
||||
*arena_out = NULL;
|
||||
|
||||
if (argc < 0) {
|
||||
msg_error("cmd", "Invalid argc", req_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (argc == 0) {
|
||||
char **argv0 = (char **)calloc(1, sizeof(char *));
|
||||
if (!argv0) {
|
||||
msg_error("cmd", "OOM copying argv", req_id);
|
||||
return false;
|
||||
}
|
||||
*argv_out = argv0;
|
||||
*arena_out = NULL;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t total = 0;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char *s = (argv_in && argv_in[i]) ? argv_in[i] : "";
|
||||
size_t n = strnlen(s, COMMAND_MAX_ARG_LEN);
|
||||
total += (n + 1);
|
||||
}
|
||||
|
||||
char *arena = (char *)malloc(total ? total : 1);
|
||||
char **argv_copy = (char **)malloc((size_t)argc * sizeof(char *));
|
||||
if (!arena || !argv_copy) {
|
||||
free(arena);
|
||||
free(argv_copy);
|
||||
msg_error("cmd", "OOM copying argv", req_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t off = 0;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char *s = (argv_in && argv_in[i]) ? argv_in[i] : "";
|
||||
size_t n = strnlen(s, COMMAND_MAX_ARG_LEN);
|
||||
|
||||
argv_copy[i] = &arena[off];
|
||||
memcpy(&arena[off], s, n);
|
||||
arena[off + n] = '\0';
|
||||
off += (n + 1);
|
||||
}
|
||||
|
||||
*argv_out = argv_copy;
|
||||
*arena_out = arena;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* Dispatch nanopb command
|
||||
* ========================================================= */
|
||||
void command_process_pb(const c2_Command *cmd)
|
||||
{
|
||||
if (!cmd) return;
|
||||
|
||||
const char *name = cmd->command_name;
|
||||
/* nanopb: tableaux fixes => jamais NULL */
|
||||
const char *name = cmd->command_name;
|
||||
const char *reqid = cmd->request_id;
|
||||
const char *reqid_or_null = (reqid[0] ? reqid : NULL);
|
||||
|
||||
int argc = cmd->argv_count;
|
||||
char **argv = (char **)cmd->argv;
|
||||
|
||||
for (size_t i = 0; i < registry_count; i++) {
|
||||
const command_t *c = registry[i];
|
||||
@ -44,22 +119,48 @@ void command_process_pb(const c2_Command *cmd)
|
||||
if (strcmp(c->name, name) != 0)
|
||||
continue;
|
||||
|
||||
/* Validate argc */
|
||||
if (argc < c->min_args || argc > c->max_args) {
|
||||
msg_error("cmd", "Invalid argument count",
|
||||
cmd->request_id);
|
||||
msg_error("cmd", "Invalid argument count", reqid_or_null);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Execute: %s (argc=%d)", name, argc);
|
||||
|
||||
if (c->async) {
|
||||
/* Ton async copie déjà argv/request_id dans une queue => OK */
|
||||
command_async_enqueue(c, cmd);
|
||||
} else {
|
||||
c->handler(argc, argv, cmd->request_id, c->ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
* SYNC PATH (FIX):
|
||||
* Ne PAS caster cmd->argv en char**
|
||||
* On construit argv_ptrs[] depuis cmd->argv[i]
|
||||
* ================================ */
|
||||
if (argc > COMMAND_MAX_ARGS) {
|
||||
msg_error("cmd", "Too many args", reqid_or_null);
|
||||
return;
|
||||
}
|
||||
|
||||
char *argv_ptrs[COMMAND_MAX_ARGS] = {0};
|
||||
for (int a = 0; a < argc; a++) {
|
||||
/* Fonctionne que cmd->argv soit char*[N] ou char[N][M] */
|
||||
argv_ptrs[a] = (char *)cmd->argv[a];
|
||||
}
|
||||
|
||||
/* Deep-copy pour rendre sync aussi safe que async */
|
||||
char **argv_copy = NULL;
|
||||
char *arena = NULL;
|
||||
|
||||
if (!deepcopy_argv(argv_ptrs, argc, &argv_copy, &arena, reqid_or_null))
|
||||
return;
|
||||
|
||||
c->handler(argc, argv_copy, reqid_or_null, c->ctx);
|
||||
|
||||
free(argv_copy);
|
||||
free(arena);
|
||||
return;
|
||||
}
|
||||
|
||||
msg_error("cmd", "Unknown command", cmd->request_id);
|
||||
msg_error("cmd", "Unknown command", reqid_or_null);
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
|
||||
#include "utils.h" /* CONFIG_*, base64, crypto */
|
||||
#include "command.h" /* process_command */
|
||||
#include "crypto.h" /* c2_decode_and_exec */
|
||||
|
||||
#ifdef CONFIG_NETWORK_GPRS
|
||||
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <ctype.h>
|
||||
|
||||
#include "command.h"
|
||||
#include "utils.h"
|
||||
@ -23,7 +25,7 @@
|
||||
#define TAG "CAMERA"
|
||||
#define MAX_UDP_SIZE 2034
|
||||
|
||||
#if defined(CONFIG_MODULE_RECON) && defined(CONFIG_RECON_MODE_CAMERA)
|
||||
#if defined(CONFIG_RECON_MODE_CAMERA)
|
||||
/* ================= CAMERA PINS ================= */
|
||||
#define CAM_PIN_PWDN 32
|
||||
#define CAM_PIN_RESET -1
|
||||
@ -108,6 +110,8 @@ static void udp_stream_task(void *arg)
|
||||
|
||||
const size_t token_len = strlen(token);
|
||||
uint8_t buf[MAX_UDP_SIZE + 32];
|
||||
uint32_t frame_count = 0;
|
||||
uint32_t error_count = 0;
|
||||
|
||||
while (streaming_active) {
|
||||
|
||||
@ -118,14 +122,34 @@ static void udp_stream_task(void *arg)
|
||||
continue;
|
||||
}
|
||||
|
||||
frame_count++;
|
||||
size_t num_chunks = (fb->len + MAX_UDP_SIZE - 1) / MAX_UDP_SIZE;
|
||||
|
||||
/* DEBUG: Log frame info every 10 frames */
|
||||
if (frame_count % 10 == 1) {
|
||||
ESP_LOGI(TAG, "frame #%lu: %u bytes, %u chunks, sock=%d",
|
||||
frame_count, fb->len, num_chunks, udp_sock);
|
||||
}
|
||||
|
||||
/* Check socket validity */
|
||||
if (udp_sock < 0) {
|
||||
ESP_LOGE(TAG, "socket invalid (sock=%d), stopping", udp_sock);
|
||||
esp_camera_fb_return(fb);
|
||||
break;
|
||||
}
|
||||
|
||||
/* START */
|
||||
memcpy(buf, token, token_len);
|
||||
memcpy(buf + token_len, "START", 5);
|
||||
sendto(udp_sock, buf, token_len + 5, 0,
|
||||
ssize_t ret = sendto(udp_sock, buf, token_len + 5, 0,
|
||||
(struct sockaddr *)&dest_addr, sizeof(dest_addr));
|
||||
if (ret < 0) {
|
||||
ESP_LOGE(TAG, "START send failed: errno=%d (%s)", errno, strerror(errno));
|
||||
}
|
||||
|
||||
size_t off = 0;
|
||||
size_t rem = fb->len;
|
||||
size_t chunk_num = 0;
|
||||
|
||||
while (rem > 0 && streaming_active) {
|
||||
size_t chunk = rem > MAX_UDP_SIZE ? MAX_UDP_SIZE : rem;
|
||||
@ -133,23 +157,39 @@ static void udp_stream_task(void *arg)
|
||||
memcpy(buf, token, token_len);
|
||||
memcpy(buf + token_len, fb->buf + off, chunk);
|
||||
|
||||
if (sendto(udp_sock, buf, token_len + chunk, 0,
|
||||
ret = sendto(udp_sock, buf, token_len + chunk, 0,
|
||||
(struct sockaddr *)&dest_addr,
|
||||
sizeof(dest_addr)) < 0) {
|
||||
msg_error(TAG, "udp send failed", NULL);
|
||||
sizeof(dest_addr));
|
||||
|
||||
if (ret < 0) {
|
||||
error_count++;
|
||||
ESP_LOGE(TAG, "chunk %u/%u send failed: errno=%d (%s), errors=%lu",
|
||||
chunk_num, num_chunks, errno, strerror(errno), error_count);
|
||||
|
||||
/* Stop after too many consecutive errors */
|
||||
if (error_count > 50) {
|
||||
ESP_LOGE(TAG, "too many errors, stopping stream");
|
||||
streaming_active = false;
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
error_count = 0; /* Reset on success */
|
||||
}
|
||||
|
||||
off += chunk;
|
||||
rem -= chunk;
|
||||
chunk_num++;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
|
||||
/* END */
|
||||
memcpy(buf, token, token_len);
|
||||
memcpy(buf + token_len, "END", 3);
|
||||
sendto(udp_sock, buf, token_len + 3, 0,
|
||||
ret = sendto(udp_sock, buf, token_len + 3, 0,
|
||||
(struct sockaddr *)&dest_addr, sizeof(dest_addr));
|
||||
if (ret < 0) {
|
||||
ESP_LOGE(TAG, "END send failed: errno=%d (%s)", errno, strerror(errno));
|
||||
}
|
||||
|
||||
esp_camera_fb_return(fb);
|
||||
vTaskDelay(pdMS_TO_TICKS(140)); /* ~7 FPS */
|
||||
@ -160,6 +200,7 @@ static void udp_stream_task(void *arg)
|
||||
udp_sock = -1;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "stream stopped after %lu frames", frame_count);
|
||||
msg_info(TAG, "stream stopped", NULL);
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
@ -169,31 +210,62 @@ static void udp_stream_task(void *arg)
|
||||
* ============================================================ */
|
||||
static void start_stream(const char *ip, uint16_t port)
|
||||
{
|
||||
ESP_LOGI(TAG, "start_stream called: ip=%s port=%u", ip ? ip : "(null)", port);
|
||||
|
||||
if (streaming_active) {
|
||||
msg_error(TAG, "stream already active", NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!camera_initialized) {
|
||||
if (!init_camera())
|
||||
return;
|
||||
camera_initialized = true;
|
||||
}
|
||||
|
||||
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
if (udp_sock < 0) {
|
||||
msg_error(TAG, "udp socket failed", NULL);
|
||||
if (!ip || ip[0] == '\0') {
|
||||
ESP_LOGE(TAG, "invalid IP: null/empty");
|
||||
msg_error(TAG, "invalid ip", NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (port == 0) {
|
||||
ESP_LOGE(TAG, "invalid port: 0");
|
||||
msg_error(TAG, "invalid port", NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!camera_initialized) {
|
||||
ESP_LOGI(TAG, "initializing camera...");
|
||||
if (!init_camera()) {
|
||||
msg_error(TAG, "camera init failed", NULL);
|
||||
return;
|
||||
}
|
||||
camera_initialized = true;
|
||||
}
|
||||
|
||||
// Create UDP socket
|
||||
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
if (udp_sock < 0) {
|
||||
ESP_LOGE(TAG, "socket() failed: errno=%d (%s)", errno, strerror(errno));
|
||||
msg_error(TAG, "udp socket failed", NULL);
|
||||
return;
|
||||
}
|
||||
ESP_LOGI(TAG, "socket created: fd=%d", udp_sock);
|
||||
|
||||
// Build destination address (use inet_pton instead of inet_addr)
|
||||
memset(&dest_addr, 0, sizeof(dest_addr));
|
||||
dest_addr.sin_family = AF_INET;
|
||||
dest_addr.sin_port = htons(port);
|
||||
dest_addr.sin_addr.s_addr = inet_addr(ip);
|
||||
dest_addr.sin_port = htons(port);
|
||||
|
||||
if (inet_pton(AF_INET, ip, &dest_addr.sin_addr) != 1) {
|
||||
ESP_LOGE(TAG, "invalid IP address: '%s'", ip);
|
||||
close(udp_sock);
|
||||
udp_sock = -1;
|
||||
msg_error(TAG, "invalid ip", NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "target: %s:%u (addr=0x%08x)",
|
||||
ip, port, (unsigned)dest_addr.sin_addr.s_addr);
|
||||
|
||||
streaming_active = true;
|
||||
|
||||
xTaskCreatePinnedToCore(
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
udp_stream_task,
|
||||
"cam_stream",
|
||||
8192,
|
||||
@ -202,25 +274,35 @@ static void start_stream(const char *ip, uint16_t port)
|
||||
NULL,
|
||||
0
|
||||
);
|
||||
|
||||
if (ret != pdPASS) {
|
||||
ESP_LOGE(TAG, "failed to create stream task");
|
||||
streaming_active = false;
|
||||
close(udp_sock);
|
||||
udp_sock = -1;
|
||||
msg_error(TAG, "task create failed", NULL);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static void stop_stream(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "stop_stream called, active=%d", streaming_active);
|
||||
|
||||
if (!streaming_active) {
|
||||
msg_error(TAG, "no active stream", NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
streaming_active = false;
|
||||
ESP_LOGI(TAG, "stream stop requested");
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* COMMAND HANDLERS
|
||||
* ============================================================ */
|
||||
static int cmd_cam_start(int argc,
|
||||
char **argv,
|
||||
const char *req,
|
||||
void *ctx)
|
||||
static int cmd_cam_start(int argc, char **argv, const char *req, void *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
|
||||
@ -229,10 +311,56 @@ static int cmd_cam_start(int argc,
|
||||
return -1;
|
||||
}
|
||||
|
||||
start_stream(argv[0], (uint16_t)atoi(argv[1]));
|
||||
// Copie défensive (au cas où argv pointe vers un buffer volatile)
|
||||
char ip[32] = {0};
|
||||
char port_s[32] = {0};
|
||||
strlcpy(ip, argv[0] ? argv[0] : "", sizeof(ip));
|
||||
strlcpy(port_s, argv[1] ? argv[1] : "", sizeof(port_s));
|
||||
|
||||
// Trim espaces (début/fin) pour gérer "5000\r\n" etc.
|
||||
char *p = port_s;
|
||||
while (*p && isspace((unsigned char)*p)) p++;
|
||||
|
||||
// Extraire uniquement les digits au début
|
||||
char digits[8] = {0}; // "65535" max
|
||||
size_t di = 0;
|
||||
while (*p && isdigit((unsigned char)*p) && di < sizeof(digits) - 1) {
|
||||
digits[di++] = *p++;
|
||||
}
|
||||
digits[di] = '\0';
|
||||
|
||||
// Si aucun digit trouvé -> invalid
|
||||
if (di == 0) {
|
||||
ESP_LOGE(TAG, "invalid port (raw='%s')", port_s);
|
||||
// Dump hex pour debug (hyper utile)
|
||||
ESP_LOG_BUFFER_HEX(TAG, port_s, strnlen(port_s, sizeof(port_s)));
|
||||
msg_error(TAG, "invalid port", req);
|
||||
return -1;
|
||||
}
|
||||
|
||||
unsigned long port_ul = strtoul(digits, NULL, 10);
|
||||
if (port_ul == 0 || port_ul > 65535) {
|
||||
ESP_LOGE(TAG, "invalid port value (digits='%s')", digits);
|
||||
msg_error(TAG, "invalid port", req);
|
||||
return -1;
|
||||
}
|
||||
uint16_t port = (uint16_t)port_ul;
|
||||
|
||||
// IP check via inet_pton (robuste)
|
||||
struct in_addr addr;
|
||||
if (inet_pton(AF_INET, ip, &addr) != 1) {
|
||||
ESP_LOGE(TAG, "invalid IP address: '%s'", ip);
|
||||
msg_error(TAG, "invalid ip", req);
|
||||
return -1;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "parsed: ip='%s' port=%u (raw_port='%s')", ip, port, port_s);
|
||||
start_stream(ip, port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static int cmd_cam_stop(int argc,
|
||||
char **argv,
|
||||
const char *req,
|
||||
|
||||
@ -12,6 +12,19 @@
|
||||
#include "command.h"
|
||||
#include "cmd_system.h"
|
||||
|
||||
/* Module headers */
|
||||
#ifdef CONFIG_MODULE_NETWORK
|
||||
#include "cmd_network.h"
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_MODULE_FAKEAP
|
||||
#include "cmd_fakeAP.h"
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_MODULE_RECON
|
||||
#include "cmd_recon.h"
|
||||
#endif
|
||||
|
||||
static const char *TAG = "MAIN";
|
||||
|
||||
static void init_nvs(void)
|
||||
@ -39,20 +52,25 @@ void app_main(void)
|
||||
command_async_init(); // Async worker (Core 1)
|
||||
mod_system_register_commands();
|
||||
|
||||
/* Register enabled modules */
|
||||
#ifdef CONFIG_MODULE_NETWORK
|
||||
#include "cmd_network.h"
|
||||
mod_network_register_commands();
|
||||
ESP_LOGI(TAG, "Network module loaded");
|
||||
#endif
|
||||
|
||||
#elif defined(CONFIG_MODULE_FAKEAP)
|
||||
#include "cmd_fakeAP.h"
|
||||
#ifdef CONFIG_MODULE_FAKEAP
|
||||
mod_fakeap_register_commands();
|
||||
ESP_LOGI(TAG, "FakeAP module loaded");
|
||||
#endif
|
||||
|
||||
#elif defined(CONFIG_MODULE_RECON)
|
||||
#include "cmd_recon.h"
|
||||
#ifdef CONFIG_MODULE_RECON
|
||||
#ifdef CONFIG_RECON_MODE_CAMERA
|
||||
mod_camera_register_commands();
|
||||
#elif defined(CONFIG_RECON_MODE_BLE_TRILAT)
|
||||
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");
|
||||
#endif
|
||||
#endif
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ import sys
|
||||
|
||||
from core.registry import DeviceRegistry
|
||||
from core.transport import Transport
|
||||
from logs.manager import LogManager
|
||||
from log.manager import LogManager
|
||||
from cli.cli import CLI
|
||||
from commands.registry import CommandRegistry
|
||||
from commands.reboot import RebootCommand
|
||||
@ -25,7 +25,7 @@ from utils.display import Display # Import Display utility
|
||||
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
|
||||
|
||||
RX_BUF_SIZE = 4096
|
||||
DEVICE_TIMEOUT_SECONDS = 60 # Devices are considered inactive after 60 seconds without a heartbeat
|
||||
DEVICE_TIMEOUT_SECONDS = 300 # Devices are considered inactive after 5 minutes without a heartbeat
|
||||
HEARTBEAT_CHECK_INTERVAL = 10 # Check every 10 seconds
|
||||
|
||||
|
||||
|
||||
@ -60,3 +60,6 @@ VIDEO_ENABLED = _get_bool("VIDEO_ENABLED", True)
|
||||
VIDEO_PATH = os.getenv("VIDEO_PATH", "static/streams/record.avi")
|
||||
VIDEO_FPS = _get_int("VIDEO_FPS", 10)
|
||||
VIDEO_CODEC = os.getenv("VIDEO_CODEC", "MJPG")
|
||||
|
||||
# Multilateration
|
||||
MULTILAT_AUTH_TOKEN = os.getenv("MULTILAT_AUTH_TOKEN", "multilat_secret_token")
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
"""Main camera server combining UDP receiver and web server."""
|
||||
"""Main camera server combining UDP receiver and unified web server."""
|
||||
|
||||
from typing import Optional, Callable
|
||||
|
||||
from .config import UDP_HOST, UDP_PORT, WEB_HOST, WEB_PORT, IMAGE_DIR, DEFAULT_USERNAME, DEFAULT_PASSWORD
|
||||
from .config import (
|
||||
UDP_HOST, UDP_PORT, WEB_HOST, WEB_PORT, IMAGE_DIR,
|
||||
DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY, MULTILAT_AUTH_TOKEN
|
||||
)
|
||||
from .udp_receiver import UDPReceiver
|
||||
from .web_server import WebServer
|
||||
from web.server import UnifiedWebServer
|
||||
from web.multilateration import MultilaterationEngine
|
||||
|
||||
|
||||
class CameraServer:
|
||||
"""
|
||||
Combined camera server that manages both:
|
||||
- UDP receiver for incoming camera frames from ESP devices
|
||||
- Web server for viewing the camera streams
|
||||
- Unified web server for dashboard, cameras, and trilateration
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
@ -22,6 +26,7 @@ class CameraServer:
|
||||
image_dir: str = IMAGE_DIR,
|
||||
username: str = DEFAULT_USERNAME,
|
||||
password: str = DEFAULT_PASSWORD,
|
||||
device_registry=None,
|
||||
on_frame: Optional[Callable] = None):
|
||||
"""
|
||||
Initialize the camera server.
|
||||
@ -34,8 +39,11 @@ class CameraServer:
|
||||
image_dir: Directory to store camera frames
|
||||
username: Web interface username
|
||||
password: Web interface password
|
||||
device_registry: DeviceRegistry instance for device listing
|
||||
on_frame: Optional callback when frame is received (camera_id, frame, addr)
|
||||
"""
|
||||
self.multilat_engine = MultilaterationEngine()
|
||||
|
||||
self.udp_receiver = UDPReceiver(
|
||||
host=udp_host,
|
||||
port=udp_port,
|
||||
@ -43,12 +51,16 @@ class CameraServer:
|
||||
on_frame=on_frame
|
||||
)
|
||||
|
||||
self.web_server = WebServer(
|
||||
self.web_server = UnifiedWebServer(
|
||||
host=web_host,
|
||||
port=web_port,
|
||||
image_dir=image_dir,
|
||||
username=username,
|
||||
password=password
|
||||
password=password,
|
||||
secret_key=FLASK_SECRET_KEY,
|
||||
multilat_token=MULTILAT_AUTH_TOKEN,
|
||||
device_registry=device_registry,
|
||||
multilateration_engine=self.multilat_engine
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
"""UDP server for receiving camera frames from ESP devices."""
|
||||
"""UDP server for receiving camera frames from ESP devices.
|
||||
|
||||
Protocol from ESP32:
|
||||
- TOKEN + "START" -> Start of new frame
|
||||
- TOKEN + chunk -> JPEG data chunk
|
||||
- TOKEN + "END" -> End of frame, decode and process
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import cv2
|
||||
import numpy as np
|
||||
from typing import Optional, Callable
|
||||
from typing import Optional, Callable, Dict
|
||||
|
||||
from .config import (
|
||||
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
|
||||
@ -14,6 +21,46 @@ from .config import (
|
||||
)
|
||||
|
||||
|
||||
class FrameAssembler:
|
||||
"""Assembles JPEG frames from multiple UDP packets."""
|
||||
|
||||
def __init__(self, timeout: float = 5.0):
|
||||
self.timeout = timeout
|
||||
self.buffer = bytearray()
|
||||
self.start_time: Optional[float] = None
|
||||
self.receiving = False
|
||||
|
||||
def start_frame(self):
|
||||
"""Start receiving a new frame."""
|
||||
self.buffer = bytearray()
|
||||
self.start_time = time.time()
|
||||
self.receiving = True
|
||||
|
||||
def add_chunk(self, data: bytes) -> bool:
|
||||
"""Add a chunk to the frame buffer. Returns False if timed out."""
|
||||
if not self.receiving:
|
||||
return False
|
||||
if self.start_time and (time.time() - self.start_time) > self.timeout:
|
||||
self.reset()
|
||||
return False
|
||||
self.buffer.extend(data)
|
||||
return True
|
||||
|
||||
def finish_frame(self) -> Optional[bytes]:
|
||||
"""Finish frame assembly and return complete data."""
|
||||
if not self.receiving or len(self.buffer) == 0:
|
||||
return None
|
||||
data = bytes(self.buffer)
|
||||
self.reset()
|
||||
return data
|
||||
|
||||
def reset(self):
|
||||
"""Reset the assembler state."""
|
||||
self.buffer = bytearray()
|
||||
self.start_time = None
|
||||
self.receiving = False
|
||||
|
||||
|
||||
class UDPReceiver:
|
||||
"""Receives JPEG frames via UDP from ESP camera devices."""
|
||||
|
||||
@ -31,6 +78,9 @@ class UDPReceiver:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Frame assemblers per source address
|
||||
self._assemblers: Dict[str, FrameAssembler] = {}
|
||||
|
||||
# Video recording
|
||||
self._video_writer: Optional[cv2.VideoWriter] = None
|
||||
self._video_size: Optional[tuple] = None
|
||||
@ -39,6 +89,7 @@ class UDPReceiver:
|
||||
self.frames_received = 0
|
||||
self.invalid_tokens = 0
|
||||
self.decode_errors = 0
|
||||
self.packets_received = 0
|
||||
|
||||
# Active cameras tracking
|
||||
self._active_cameras: dict = {} # {camera_id: last_frame_time}
|
||||
@ -83,7 +134,9 @@ class UDPReceiver:
|
||||
self._cleanup_frames()
|
||||
|
||||
self._active_cameras.clear()
|
||||
self._assemblers.clear()
|
||||
self.frames_received = 0
|
||||
self.packets_received = 0
|
||||
|
||||
def _cleanup_frames(self):
|
||||
"""Remove all .jpg files from image directory."""
|
||||
@ -94,13 +147,22 @@ class UDPReceiver:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_assembler(self, addr: tuple) -> FrameAssembler:
|
||||
"""Get or create a frame assembler for the given address."""
|
||||
key = f"{addr[0]}:{addr[1]}"
|
||||
if key not in self._assemblers:
|
||||
self._assemblers[key] = FrameAssembler()
|
||||
return self._assemblers[key]
|
||||
|
||||
def _receive_loop(self):
|
||||
"""Main UDP receive loop."""
|
||||
"""Main UDP receive loop with START/END protocol handling."""
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self._sock.bind((self.host, self.port))
|
||||
self._sock.settimeout(1.0)
|
||||
|
||||
print(f"[UDP] Receiver started on {self.host}:{self.port}")
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
data, addr = self._sock.recvfrom(UDP_BUFFER_SIZE)
|
||||
@ -109,34 +171,38 @@ class UDPReceiver:
|
||||
except OSError:
|
||||
break
|
||||
|
||||
self.packets_received += 1
|
||||
|
||||
# Validate token
|
||||
if not data.startswith(SECRET_TOKEN):
|
||||
self.invalid_tokens += 1
|
||||
continue
|
||||
|
||||
# Remove token prefix
|
||||
frame_data = data[len(SECRET_TOKEN):]
|
||||
|
||||
# Decode JPEG
|
||||
frame = self._decode_frame(frame_data)
|
||||
if frame is None:
|
||||
self.decode_errors += 1
|
||||
continue
|
||||
|
||||
self.frames_received += 1
|
||||
payload = data[len(SECRET_TOKEN):]
|
||||
assembler = self._get_assembler(addr)
|
||||
camera_id = f"{addr[0]}_{addr[1]}"
|
||||
self._active_cameras[camera_id] = True
|
||||
|
||||
# Save frame
|
||||
self._save_frame(camera_id, frame)
|
||||
|
||||
# Record video if enabled
|
||||
if VIDEO_ENABLED:
|
||||
self._record_frame(frame)
|
||||
|
||||
# Callback
|
||||
if self.on_frame:
|
||||
self.on_frame(camera_id, frame, addr)
|
||||
# Handle protocol markers
|
||||
if payload == b"START":
|
||||
assembler.start_frame()
|
||||
continue
|
||||
elif payload == b"END":
|
||||
frame_data = assembler.finish_frame()
|
||||
if frame_data:
|
||||
self._process_complete_frame(camera_id, frame_data, addr)
|
||||
continue
|
||||
else:
|
||||
# Regular data chunk
|
||||
if not assembler.receiving:
|
||||
# No START received, try as single-packet frame (legacy)
|
||||
frame = self._decode_frame(payload)
|
||||
if frame is not None:
|
||||
self._process_frame(camera_id, frame, addr)
|
||||
else:
|
||||
self.decode_errors += 1
|
||||
else:
|
||||
assembler.add_chunk(payload)
|
||||
|
||||
# Cleanup
|
||||
if self._sock:
|
||||
@ -147,6 +213,32 @@ class UDPReceiver:
|
||||
self._video_writer.release()
|
||||
self._video_writer = None
|
||||
|
||||
print("[UDP] Receiver stopped")
|
||||
|
||||
def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple):
|
||||
"""Process a fully assembled frame."""
|
||||
frame = self._decode_frame(frame_data)
|
||||
if frame is None:
|
||||
self.decode_errors += 1
|
||||
return
|
||||
self._process_frame(camera_id, frame, addr)
|
||||
|
||||
def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple):
|
||||
"""Process a decoded frame."""
|
||||
self.frames_received += 1
|
||||
self._active_cameras[camera_id] = time.time()
|
||||
|
||||
# Save frame
|
||||
self._save_frame(camera_id, frame)
|
||||
|
||||
# Record video if enabled
|
||||
if VIDEO_ENABLED:
|
||||
self._record_frame(frame)
|
||||
|
||||
# Callback
|
||||
if self.on_frame:
|
||||
self.on_frame(camera_id, frame, addr)
|
||||
|
||||
def _decode_frame(self, data: bytes) -> Optional[np.ndarray]:
|
||||
"""Decode JPEG data to OpenCV frame."""
|
||||
try:
|
||||
@ -181,6 +273,7 @@ class UDPReceiver:
|
||||
"""Return receiver statistics."""
|
||||
return {
|
||||
"running": self.is_running,
|
||||
"packets_received": self.packets_received,
|
||||
"frames_received": self.frames_received,
|
||||
"invalid_tokens": self.invalid_tokens,
|
||||
"decode_errors": self.decode_errors,
|
||||
|
||||
@ -313,7 +313,7 @@ class CLI:
|
||||
Display.system_message("Camera server is already running.")
|
||||
return
|
||||
|
||||
self.camera_server = CameraServer()
|
||||
self.camera_server = CameraServer(device_registry=self.registry)
|
||||
result = self.camera_server.start()
|
||||
|
||||
if result["udp"]["started"]:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from core.crypto import CryptoContext
|
||||
from core.device import Device
|
||||
from core.registry import DeviceRegistry
|
||||
from logs.manager import LogManager
|
||||
from log.manager import LogManager
|
||||
from utils.display import Display
|
||||
|
||||
from proto.c2_pb2 import Command, AgentMessage, AgentMsgType
|
||||
|
||||
3
tools/c2/log/__init__.py
Normal file
3
tools/c2/log/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .manager import LogManager
|
||||
|
||||
__all__ = ["LogManager"]
|
||||
66
tools/c2/log/manager.py
Normal file
66
tools/c2/log/manager.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Log manager for storing device messages."""
|
||||
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEntry:
|
||||
"""A single log entry from a device."""
|
||||
timestamp: float
|
||||
device_id: str
|
||||
msg_type: str
|
||||
source: str
|
||||
payload: str
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
class LogManager:
|
||||
"""Manages log storage for device messages."""
|
||||
|
||||
def __init__(self, max_entries_per_device: int = 1000):
|
||||
self.max_entries = max_entries_per_device
|
||||
self._logs: Dict[str, List[LogEntry]] = {}
|
||||
|
||||
def add(self, device_id: str, msg_type: str, source: str, payload: str, request_id: str = None):
|
||||
if device_id not in self._logs:
|
||||
self._logs[device_id] = []
|
||||
|
||||
entry = LogEntry(
|
||||
timestamp=time.time(),
|
||||
device_id=device_id,
|
||||
msg_type=msg_type,
|
||||
source=source,
|
||||
payload=payload,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
self._logs[device_id].append(entry)
|
||||
|
||||
if len(self._logs[device_id]) > self.max_entries:
|
||||
self._logs[device_id] = self._logs[device_id][-self.max_entries:]
|
||||
|
||||
def get_logs(self, device_id: str, limit: int = 100) -> List[LogEntry]:
|
||||
if device_id not in self._logs:
|
||||
return []
|
||||
return self._logs[device_id][-limit:]
|
||||
|
||||
def get_all_logs(self, limit: int = 100) -> List[LogEntry]:
|
||||
all_entries = []
|
||||
for entries in self._logs.values():
|
||||
all_entries.extend(entries)
|
||||
all_entries.sort(key=lambda e: e.timestamp)
|
||||
return all_entries[-limit:]
|
||||
|
||||
def clear(self, device_id: str = None):
|
||||
if device_id:
|
||||
self._logs.pop(device_id, None)
|
||||
else:
|
||||
self._logs.clear()
|
||||
|
||||
def device_count(self) -> int:
|
||||
return len(self._logs)
|
||||
|
||||
def total_entries(self) -> int:
|
||||
return sum(len(entries) for entries in self._logs.values())
|
||||
639
tools/c2/static/css/main.css
Normal file
639
tools/c2/static/css/main.css
Normal file
@ -0,0 +1,639 @@
|
||||
/* ESPILON C2 - Violet Theme */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Background colors - deep dark with violet undertones */
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-tertiary: #06060a;
|
||||
--bg-elevated: #1a1a25;
|
||||
|
||||
/* Border colors */
|
||||
--border-color: #2a2a3d;
|
||||
--border-light: #3d3d55;
|
||||
|
||||
/* Text colors */
|
||||
--text-primary: #e4e4ed;
|
||||
--text-secondary: #8888a0;
|
||||
--text-muted: #5a5a70;
|
||||
|
||||
/* Accent colors - violet palette */
|
||||
--accent-primary: #a855f7;
|
||||
--accent-primary-hover: #c084fc;
|
||||
--accent-primary-bg: rgba(168, 85, 247, 0.15);
|
||||
--accent-primary-glow: rgba(168, 85, 247, 0.4);
|
||||
|
||||
--accent-secondary: #818cf8;
|
||||
--accent-secondary-bg: rgba(129, 140, 248, 0.15);
|
||||
|
||||
/* Status colors */
|
||||
--status-online: #22d3ee;
|
||||
--status-online-bg: rgba(34, 211, 238, 0.15);
|
||||
--status-warning: #fbbf24;
|
||||
--status-warning-bg: rgba(251, 191, 36, 0.15);
|
||||
--status-error: #f87171;
|
||||
--status-error-bg: rgba(248, 113, 113, 0.15);
|
||||
--status-success: #4ade80;
|
||||
--status-success-bg: rgba(74, 222, 128, 0.15);
|
||||
|
||||
/* Button colors */
|
||||
--btn-primary: #7c3aed;
|
||||
--btn-primary-hover: #8b5cf6;
|
||||
--btn-secondary: #1e1e2e;
|
||||
--btn-secondary-hover: #2a2a3d;
|
||||
|
||||
/* Gradients */
|
||||
--gradient-primary: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
|
||||
--gradient-glow: radial-gradient(circle at center, var(--accent-primary-glow) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ========== Header ========== */
|
||||
|
||||
header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
background: var(--gradient-primary);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--accent-primary);
|
||||
background: var(--accent-primary-bg);
|
||||
}
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -13px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-online);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--status-online);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.logout {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ========== Main Content ========== */
|
||||
|
||||
main {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.page-title span {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* ========== Cards Grid ========== */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-cameras {
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-header .name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-header .badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-live {
|
||||
color: var(--status-online);
|
||||
background: var(--status-online-bg);
|
||||
}
|
||||
|
||||
.badge-connected {
|
||||
color: var(--status-success);
|
||||
background: var(--status-success-bg);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
color: var(--status-warning);
|
||||
background: var(--status-warning-bg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-body-image {
|
||||
background: var(--bg-tertiary);
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-body-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ========== Device Card ========== */
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.device-row .label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.device-row .value {
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========== Empty State ========== */
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ========== Multilateration Canvas ========== */
|
||||
|
||||
.trilat-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.trilat-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.trilat-canvas-wrapper {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trilat-canvas-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#trilat-canvas {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.trilat-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.trilat-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.trilat-panel h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.trilat-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.trilat-stat:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.trilat-stat .label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trilat-stat .value {
|
||||
color: var(--accent-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scanner-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.scanner-item {
|
||||
background: var(--bg-elevated);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.scanner-item:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.scanner-item .scanner-id {
|
||||
font-weight: 600;
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.scanner-item .scanner-details {
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ========== Config Panel ========== */
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.config-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-row label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-row input {
|
||||
width: 80px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
text-align: right;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.config-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px var(--accent-primary-bg);
|
||||
}
|
||||
|
||||
/* ========== Buttons ========== */
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px var(--accent-primary-bg);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px var(--accent-primary-glow);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--btn-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--btn-secondary-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
/* ========== Login Page ========== */
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: var(--gradient-glow);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.login-box .logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--status-error-bg);
|
||||
border: 1px solid var(--status-error);
|
||||
color: var(--status-error);
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px var(--accent-primary-bg);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
background: var(--gradient-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 10px var(--accent-primary-bg);
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px var(--accent-primary-glow);
|
||||
}
|
||||
|
||||
/* ========== Scrollbar ========== */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-light);
|
||||
}
|
||||
|
||||
/* ========== Selection ========== */
|
||||
|
||||
::selection {
|
||||
background: var(--accent-primary-bg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
331
tools/c2/static/js/multilateration.js
Normal file
331
tools/c2/static/js/multilateration.js
Normal file
@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 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);
|
||||
BIN
tools/c2/static/record.avi
Normal file
BIN
tools/c2/static/record.avi
Normal file
Binary file not shown.
BIN
tools/c2/static/streams/192.168.1.47_58642.jpg
Normal file
BIN
tools/c2/static/streams/192.168.1.47_58642.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
52
tools/c2/templates/base.html
Normal file
52
tools/c2/templates/base.html
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}ESPILON{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">ESPILON</div>
|
||||
<nav class="main-nav">
|
||||
<a href="/dashboard" class="nav-link {% if active_page == 'dashboard' %}active{% endif %}">
|
||||
Dashboard
|
||||
</a>
|
||||
<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>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div class="status">
|
||||
<div class="status-dot"></div>
|
||||
<span id="device-count">-</span> device(s)
|
||||
</div>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Update device count in header
|
||||
async function updateStats() {
|
||||
try {
|
||||
const res = await fetch('/api/stats');
|
||||
const data = await res.json();
|
||||
document.getElementById('device-count').textContent = data.connected_devices || 0;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
updateStats();
|
||||
setInterval(updateStats, 10000);
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
58
tools/c2/templates/cameras.html
Normal file
58
tools/c2/templates/cameras.html
Normal file
@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Cameras - ESPILON{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-title">Cameras <span>Live Feed</span></div>
|
||||
<div class="status">
|
||||
<div class="status-dot"></div>
|
||||
<span id="camera-count">{{ image_files|length }}</span> camera(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if image_files %}
|
||||
<div class="grid grid-cameras" id="grid">
|
||||
{% for img in image_files %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
|
||||
<span class="badge badge-live">LIVE</span>
|
||||
</div>
|
||||
<div class="card-body card-body-image">
|
||||
<img src="/streams/{{ img }}?t=0" data-src="/streams/{{ img }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<h2>No active cameras</h2>
|
||||
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function refresh() {
|
||||
const t = Date.now();
|
||||
document.querySelectorAll('.card-body-image img').forEach(img => {
|
||||
img.src = img.dataset.src + '?t=' + t;
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCameras() {
|
||||
try {
|
||||
const res = await fetch('/api/cameras');
|
||||
const data = await res.json();
|
||||
const current = document.querySelectorAll('.card').length;
|
||||
document.getElementById('camera-count').textContent = data.count || 0;
|
||||
if (data.count !== current) location.reload();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
setInterval(refresh, 100);
|
||||
setInterval(checkCameras, 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
83
tools/c2/templates/dashboard.html
Normal file
83
tools/c2/templates/dashboard.html
Normal file
@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - ESPILON{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-title">Dashboard <span>Connected Devices</span></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>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) return Math.round(seconds) + 's';
|
||||
if (seconds < 3600) return Math.round(seconds / 60) + 'm';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.round((seconds % 3600) / 60);
|
||||
return hours + 'h ' + mins + 'm';
|
||||
}
|
||||
|
||||
function createDeviceCard(device) {
|
||||
const statusClass = device.status === 'Connected' ? 'badge-connected' : 'badge-inactive';
|
||||
|
||||
return `
|
||||
<div class="card" data-device-id="${device.id}">
|
||||
<div class="card-header">
|
||||
<span class="name">${device.id}</span>
|
||||
<span class="badge ${statusClass}">${device.status}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="device-info">
|
||||
<div class="device-row">
|
||||
<span class="label">IP Address</span>
|
||||
<span class="value">${device.ip}:${device.port}</span>
|
||||
</div>
|
||||
<div class="device-row">
|
||||
<span class="label">Connected</span>
|
||||
<span class="value">${formatDuration(device.connected_for_seconds)}</span>
|
||||
</div>
|
||||
<div class="device-row">
|
||||
<span class="label">Last Seen</span>
|
||||
<span class="value">${formatDuration(device.last_seen_ago_seconds)} ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const res = await fetch('/api/devices');
|
||||
const data = await res.json();
|
||||
|
||||
const grid = document.getElementById('devices-grid');
|
||||
const empty = document.getElementById('empty-state');
|
||||
|
||||
if (data.devices && data.devices.length > 0) {
|
||||
grid.innerHTML = data.devices.map(createDeviceCard).join('');
|
||||
grid.style.display = 'grid';
|
||||
empty.style.display = 'none';
|
||||
} else {
|
||||
grid.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load devices:', e);
|
||||
}
|
||||
}
|
||||
|
||||
loadDevices();
|
||||
setInterval(loadDevices, 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,212 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cameras - ESPILON</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3fb950;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logout {
|
||||
color: #8b949e;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.title span {
|
||||
color: #c9d1d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-header .name {
|
||||
font-family: monospace;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.card-header .badge {
|
||||
font-size: 11px;
|
||||
color: #3fb950;
|
||||
background: #3fb95026;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: #010409;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-body img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">ESPILON</div>
|
||||
<div class="header-right">
|
||||
<div class="status">
|
||||
<div class="status-dot"></div>
|
||||
<span id="camera-count">{{ image_files|length }}</span> camera(s)
|
||||
</div>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<div class="title">Cameras <span>Live Feed</span></div>
|
||||
</div>
|
||||
|
||||
{% if image_files %}
|
||||
<div class="grid" id="grid">
|
||||
{% for img in image_files %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
|
||||
<span class="badge">LIVE</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<img src="/streams/{{ img }}?t=0" data-src="/streams/{{ img }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<h2>No active cameras</h2>
|
||||
<p>Waiting for ESP devices to send frames on UDP port 5000</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function refresh() {
|
||||
const t = Date.now();
|
||||
document.querySelectorAll('.card-body img').forEach(img => {
|
||||
img.src = img.dataset.src + '?t=' + t;
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCameras() {
|
||||
try {
|
||||
const res = await fetch('/api/cameras');
|
||||
const data = await res.json();
|
||||
const current = document.querySelectorAll('.card').length;
|
||||
document.getElementById('camera-count').textContent = data.cameras.length;
|
||||
if (data.cameras.length !== current) location.reload();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
setInterval(refresh, 100);
|
||||
setInterval(checkCameras, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -4,92 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - ESPILON</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
color: #c9d1d9;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8514926;
|
||||
border: 1px solid #f85149;
|
||||
color: #f85149;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: #c9d1d9;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: #238636;
|
||||
border: 1px solid #238636;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2ea043;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<body class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="logo">ESPILON</div>
|
||||
|
||||
@ -108,7 +25,7 @@
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Sign in</button>
|
||||
<button type="submit" class="btn-login">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
73
tools/c2/templates/multilateration.html
Normal file
73
tools/c2/templates/multilateration.html
Normal file
@ -0,0 +1,73 @@
|
||||
{% 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 %}
|
||||
54
tools/c2/test_udp.py
Normal file
54
tools/c2/test_udp.py
Normal file
@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple UDP test server to debug camera streaming."""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 5000
|
||||
TOKEN = b"Sup3rS3cretT0k3n"
|
||||
|
||||
def main():
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else PORT
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((HOST, port))
|
||||
|
||||
print(f"[UDP] Listening on {HOST}:{port}")
|
||||
print(f"[UDP] Token: {TOKEN.decode()}")
|
||||
print("[UDP] Waiting for packets...\n")
|
||||
|
||||
packet_count = 0
|
||||
frame_count = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
data, addr = sock.recvfrom(65535)
|
||||
packet_count += 1
|
||||
|
||||
# Check token
|
||||
if data.startswith(TOKEN):
|
||||
payload = data[len(TOKEN):]
|
||||
|
||||
if payload == b"START":
|
||||
print(f"[{addr[0]}:{addr[1]}] START (new frame)")
|
||||
elif payload == b"END":
|
||||
frame_count += 1
|
||||
print(f"[{addr[0]}:{addr[1]}] END (frame #{frame_count} complete)")
|
||||
else:
|
||||
print(f"[{addr[0]}:{addr[1]}] CHUNK: {len(payload)} bytes")
|
||||
else:
|
||||
print(f"[{addr[0]}:{addr[1]}] INVALID TOKEN: {data[:20]}...")
|
||||
|
||||
# Stats every 100 packets
|
||||
if packet_count % 100 == 0:
|
||||
print(f"\n--- Stats: {packet_count} packets, {frame_count} frames ---\n")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n[UDP] Stopped. Total: {packet_count} packets, {frame_count} frames")
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
tools/c2/web/__init__.py
Normal file
6
tools/c2/web/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Unified web server module for ESPILON C2."""
|
||||
|
||||
from .server import UnifiedWebServer
|
||||
from .multilateration import MultilaterationEngine
|
||||
|
||||
__all__ = ["UnifiedWebServer", "MultilaterationEngine"]
|
||||
268
tools/c2/web/multilateration.py
Normal file
268
tools/c2/web/multilateration.py
Normal file
@ -0,0 +1,268 @@
|
||||
"""Multilateration engine for BLE device positioning."""
|
||||
|
||||
import time
|
||||
import re
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize
|
||||
|
||||
|
||||
class MultilaterationEngine:
|
||||
"""
|
||||
Calculates target position from multiple BLE scanner RSSI readings.
|
||||
|
||||
Uses the log-distance path loss model to convert RSSI to distance,
|
||||
then weighted least squares optimization for position estimation.
|
||||
"""
|
||||
|
||||
def __init__(self, rssi_at_1m: float = -40, path_loss_n: float = 2.5, smoothing_window: int = 5):
|
||||
"""
|
||||
Initialize the trilateration engine.
|
||||
|
||||
Args:
|
||||
rssi_at_1m: RSSI value at 1 meter distance (calibration, typically -40 to -50)
|
||||
path_loss_n: Path loss exponent (2.0 free space, 2.5-3.5 indoors)
|
||||
smoothing_window: Number of readings to average for noise reduction
|
||||
"""
|
||||
self.rssi_at_1m = rssi_at_1m
|
||||
self.path_loss_n = path_loss_n
|
||||
self.smoothing_window = smoothing_window
|
||||
|
||||
# Scanner data: {scanner_id: {"position": (x, y), "rssi_history": [], "last_seen": timestamp}}
|
||||
self.scanners: dict = {}
|
||||
|
||||
# Last calculated target position
|
||||
self._last_target: Optional[dict] = None
|
||||
self._last_calculation: float = 0
|
||||
|
||||
def parse_data(self, raw_data: str) -> int:
|
||||
"""
|
||||
Parse raw trilateration data from ESP32.
|
||||
|
||||
Format: ESP_ID;(x,y);rssi\n
|
||||
Example: ESP3;(10.0,0.0);-45
|
||||
|
||||
Args:
|
||||
raw_data: Raw text data with one or more readings
|
||||
|
||||
Returns:
|
||||
Number of readings successfully processed
|
||||
"""
|
||||
pattern = re.compile(r'^(\w+);\(([0-9.+-]+),([0-9.+-]+)\);(-?\d+)$')
|
||||
count = 0
|
||||
timestamp = time.time()
|
||||
|
||||
for line in raw_data.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
scanner_id = match.group(1)
|
||||
x = float(match.group(2))
|
||||
y = float(match.group(3))
|
||||
rssi = int(match.group(4))
|
||||
|
||||
self.add_reading(scanner_id, x, y, rssi, timestamp)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None):
|
||||
"""
|
||||
Add a new RSSI reading from a scanner.
|
||||
|
||||
Args:
|
||||
scanner_id: Unique identifier for the scanner (e.g., "ESP1")
|
||||
x: X coordinate of the scanner
|
||||
y: Y coordinate of the scanner
|
||||
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, y),
|
||||
"rssi_history": [],
|
||||
"last_seen": timestamp
|
||||
}
|
||||
|
||||
scanner = self.scanners[scanner_id]
|
||||
scanner["position"] = (x, y)
|
||||
scanner["rssi_history"].append(rssi)
|
||||
scanner["last_seen"] = timestamp
|
||||
|
||||
# Keep only recent readings for smoothing
|
||||
if len(scanner["rssi_history"]) > self.smoothing_window:
|
||||
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
|
||||
|
||||
def rssi_to_distance(self, rssi: float) -> float:
|
||||
"""
|
||||
Convert RSSI to estimated distance using log-distance path loss model.
|
||||
|
||||
d = 10^((RSSI_1m - RSSI) / (10 * n))
|
||||
|
||||
Args:
|
||||
rssi: RSSI value (negative dBm)
|
||||
|
||||
Returns:
|
||||
Estimated distance in meters
|
||||
"""
|
||||
return 10 ** ((self.rssi_at_1m - rssi) / (10 * self.path_loss_n))
|
||||
|
||||
def calculate_position(self) -> dict:
|
||||
"""
|
||||
Calculate target position using trilateration.
|
||||
|
||||
Requires at least 3 active scanners with recent readings.
|
||||
Uses weighted least squares optimization.
|
||||
|
||||
Returns:
|
||||
dict with position, confidence, and scanner info, or error
|
||||
"""
|
||||
# Get active scanners (those with readings)
|
||||
active_scanners = [
|
||||
(sid, s) for sid, s in self.scanners.items()
|
||||
if s["rssi_history"]
|
||||
]
|
||||
|
||||
if len(active_scanners) < 3:
|
||||
return {
|
||||
"error": f"Need at least 3 active scanners (have {len(active_scanners)})",
|
||||
"scanners_count": len(active_scanners)
|
||||
}
|
||||
|
||||
# Prepare data arrays
|
||||
positions = []
|
||||
distances = []
|
||||
weights = []
|
||||
|
||||
for scanner_id, scanner in active_scanners:
|
||||
x, y = scanner["position"]
|
||||
|
||||
# Average RSSI for noise reduction
|
||||
avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"])
|
||||
distance = self.rssi_to_distance(avg_rssi)
|
||||
|
||||
positions.append([x, y])
|
||||
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
|
||||
|
||||
# Cost function: weighted sum of squared distance errors
|
||||
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
|
||||
x0 = np.sum(weights * positions[:, 0])
|
||||
y0 = np.sum(weights * positions[:, 1])
|
||||
|
||||
# Optimize
|
||||
result = minimize(cost_function, [x0, y0], method='L-BFGS-B')
|
||||
|
||||
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)
|
||||
}
|
||||
self._last_calculation = time.time()
|
||||
|
||||
return {
|
||||
"position": self._last_target,
|
||||
"confidence": round(float(confidence), 3),
|
||||
"scanners_used": len(active_scanners),
|
||||
"calculated_at": self._last_calculation
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": "Optimization failed",
|
||||
"details": result.message
|
||||
}
|
||||
|
||||
def get_state(self) -> dict:
|
||||
"""
|
||||
Get the current state of the trilateration system.
|
||||
|
||||
Returns:
|
||||
dict with scanner info and last target position
|
||||
"""
|
||||
now = time.time()
|
||||
scanners_data = []
|
||||
|
||||
for scanner_id, scanner in self.scanners.items():
|
||||
avg_rssi = None
|
||||
distance = None
|
||||
|
||||
if scanner["rssi_history"]:
|
||||
avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"])
|
||||
distance = round(self.rssi_to_distance(avg_rssi), 2)
|
||||
avg_rssi = round(avg_rssi, 1)
|
||||
|
||||
scanners_data.append({
|
||||
"id": scanner_id,
|
||||
"position": {"x": scanner["position"][0], "y": scanner["position"][1]},
|
||||
"last_rssi": avg_rssi,
|
||||
"estimated_distance": distance,
|
||||
"last_seen": scanner["last_seen"],
|
||||
"age_seconds": round(now - scanner["last_seen"], 1)
|
||||
})
|
||||
|
||||
result = {
|
||||
"scanners": scanners_data,
|
||||
"scanners_count": len(scanners_data),
|
||||
"target": None,
|
||||
"config": {
|
||||
"rssi_at_1m": self.rssi_at_1m,
|
||||
"path_loss_n": self.path_loss_n,
|
||||
"smoothing_window": self.smoothing_window
|
||||
}
|
||||
}
|
||||
|
||||
# Add target if available
|
||||
if self._last_target and (now - self._last_calculation) < 60:
|
||||
result["target"] = {
|
||||
"position": self._last_target,
|
||||
"calculated_at": self._last_calculation,
|
||||
"age_seconds": round(now - self._last_calculation, 1)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def update_config(self, rssi_at_1m: float = None, path_loss_n: float = None, smoothing_window: int = None):
|
||||
"""
|
||||
Update trilateration configuration parameters.
|
||||
|
||||
Args:
|
||||
rssi_at_1m: New RSSI at 1m value
|
||||
path_loss_n: New path loss exponent
|
||||
smoothing_window: New smoothing window size
|
||||
"""
|
||||
if rssi_at_1m is not None:
|
||||
self.rssi_at_1m = rssi_at_1m
|
||||
if path_loss_n is not None:
|
||||
self.path_loss_n = path_loss_n
|
||||
if smoothing_window is not None:
|
||||
self.smoothing_window = max(1, smoothing_window)
|
||||
|
||||
def clear(self):
|
||||
"""Clear all scanner data and reset state."""
|
||||
self.scanners.clear()
|
||||
self._last_target = None
|
||||
self._last_calculation = 0
|
||||
338
tools/c2/web/server.py
Normal file
338
tools/c2/web/server.py
Normal file
@ -0,0 +1,338 @@
|
||||
"""Unified Flask web server for ESPILON C2 dashboard."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
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
|
||||
|
||||
# Disable Flask/Werkzeug request logging
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
|
||||
|
||||
class UnifiedWebServer:
|
||||
"""
|
||||
Unified Flask-based web server for ESPILON C2.
|
||||
|
||||
Provides:
|
||||
- Dashboard: View connected ESP32 devices
|
||||
- Cameras: View live camera streams
|
||||
- Trilateration: Visualize BLE device positioning
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8000,
|
||||
image_dir: str = "static/streams",
|
||||
username: str = "admin",
|
||||
password: str = "admin",
|
||||
secret_key: str = "change_this_for_prod",
|
||||
multilat_token: str = "multilat_secret_token",
|
||||
device_registry=None,
|
||||
multilateration_engine: Optional[MultilaterationEngine] = None):
|
||||
"""
|
||||
Initialize the unified web server.
|
||||
|
||||
Args:
|
||||
host: Host to bind the server
|
||||
port: Port for the web server
|
||||
image_dir: Directory containing camera frame images
|
||||
username: Login username
|
||||
password: Login password
|
||||
secret_key: Flask session secret key
|
||||
multilat_token: Bearer token for multilateration API
|
||||
device_registry: DeviceRegistry instance for device listing
|
||||
multilateration_engine: MultilaterationEngine instance (created if None)
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.image_dir = image_dir
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.secret_key = secret_key
|
||||
self.multilat_token = multilat_token
|
||||
self.device_registry = device_registry
|
||||
self.multilat = multilateration_engine or MultilaterationEngine()
|
||||
|
||||
self._app = self._create_app()
|
||||
self._server = None
|
||||
self._thread = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def _create_app(self) -> Flask:
|
||||
"""Create and configure the Flask application."""
|
||||
# Get the c2 root directory for templates
|
||||
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
template_dir = os.path.join(c2_root, "templates")
|
||||
static_dir = os.path.join(c2_root, "static")
|
||||
|
||||
app = Flask(__name__,
|
||||
template_folder=template_dir,
|
||||
static_folder=static_dir)
|
||||
app.secret_key = self.secret_key
|
||||
|
||||
# Store reference to self for route handlers
|
||||
web_server = self
|
||||
|
||||
# ========== Auth Decorators ==========
|
||||
|
||||
def require_login(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("logged_in"):
|
||||
return redirect(url_for("login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
def require_api_auth(f):
|
||||
"""Require session login OR Bearer token for API endpoints."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# Check session
|
||||
if session.get("logged_in"):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Check Bearer token
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if token == web_server.multilat_token:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return decorated
|
||||
|
||||
# ========== Auth Routes ==========
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
if username == web_server.username and password == web_server.password:
|
||||
session["logged_in"] = True
|
||||
return redirect(url_for("dashboard"))
|
||||
else:
|
||||
error = "Invalid credentials."
|
||||
return render_template("login.html", error=error)
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.pop("logged_in", None)
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# ========== Page Routes ==========
|
||||
|
||||
@app.route("/")
|
||||
@require_login
|
||||
def index():
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
@app.route("/dashboard")
|
||||
@require_login
|
||||
def dashboard():
|
||||
return render_template("dashboard.html", active_page="dashboard")
|
||||
|
||||
@app.route("/cameras")
|
||||
@require_login
|
||||
def cameras():
|
||||
# List available camera images
|
||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||
try:
|
||||
image_files = sorted([
|
||||
f for f in os.listdir(full_image_dir)
|
||||
if f.endswith(".jpg")
|
||||
])
|
||||
except FileNotFoundError:
|
||||
image_files = []
|
||||
|
||||
return render_template("cameras.html", active_page="cameras", image_files=image_files)
|
||||
|
||||
@app.route("/multilateration")
|
||||
@require_login
|
||||
def multilateration():
|
||||
return render_template("multilateration.html", active_page="multilateration")
|
||||
|
||||
# ========== Static Files ==========
|
||||
|
||||
@app.route("/streams/<filename>")
|
||||
@require_login
|
||||
def stream_image(filename):
|
||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||
return send_from_directory(full_image_dir, filename)
|
||||
|
||||
# ========== Device API ==========
|
||||
|
||||
@app.route("/api/devices")
|
||||
@require_api_auth
|
||||
def api_devices():
|
||||
"""Get list of connected devices."""
|
||||
if web_server.device_registry is None:
|
||||
return jsonify({"error": "Device registry not available", "devices": []})
|
||||
|
||||
now = time.time()
|
||||
devices = []
|
||||
|
||||
for d in web_server.device_registry.all():
|
||||
devices.append({
|
||||
"id": d.id,
|
||||
"ip": d.address[0] if d.address else "unknown",
|
||||
"port": d.address[1] if d.address else 0,
|
||||
"status": d.status,
|
||||
"connected_at": d.connected_at,
|
||||
"last_seen": d.last_seen,
|
||||
"connected_for_seconds": round(now - d.connected_at, 1),
|
||||
"last_seen_ago_seconds": round(now - d.last_seen, 1)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"devices": devices,
|
||||
"count": len(devices)
|
||||
})
|
||||
|
||||
# ========== Camera API ==========
|
||||
|
||||
@app.route("/api/cameras")
|
||||
@require_api_auth
|
||||
def api_cameras():
|
||||
"""Get list of active cameras."""
|
||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||
try:
|
||||
cameras = [
|
||||
f.replace(".jpg", "")
|
||||
for f in os.listdir(full_image_dir)
|
||||
if f.endswith(".jpg")
|
||||
]
|
||||
except FileNotFoundError:
|
||||
cameras = []
|
||||
|
||||
return jsonify({"cameras": cameras, "count": len(cameras)})
|
||||
|
||||
# ========== Trilateration API ==========
|
||||
|
||||
@app.route("/api/multilat/collect", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_multilat_collect():
|
||||
"""
|
||||
Receive multilateration data from ESP32 scanners.
|
||||
|
||||
Expected format (text/plain):
|
||||
ESP_ID;(x,y);rssi
|
||||
ESP3;(10.0,0.0);-45
|
||||
"""
|
||||
raw_data = request.get_data(as_text=True)
|
||||
count = web_server.multilat.parse_data(raw_data)
|
||||
|
||||
# Recalculate position after new data
|
||||
if count > 0:
|
||||
web_server.multilat.calculate_position()
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"readings_processed": count
|
||||
})
|
||||
|
||||
@app.route("/api/multilat/state")
|
||||
@require_api_auth
|
||||
def api_multilat_state():
|
||||
"""Get current multilateration state (scanners + target)."""
|
||||
state = web_server.multilat.get_state()
|
||||
|
||||
# Include latest calculation if not present
|
||||
if state["target"] is None and state["scanners_count"] >= 3:
|
||||
result = web_server.multilat.calculate_position()
|
||||
if "position" in result:
|
||||
state["target"] = {
|
||||
"position": result["position"],
|
||||
"confidence": result.get("confidence", 0),
|
||||
"calculated_at": result.get("calculated_at", time.time()),
|
||||
"age_seconds": 0
|
||||
}
|
||||
|
||||
return jsonify(state)
|
||||
|
||||
@app.route("/api/multilat/config", methods=["GET", "POST"])
|
||||
@require_api_auth
|
||||
def api_multilat_config():
|
||||
"""Get or update multilateration configuration."""
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
web_server.multilat.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
|
||||
})
|
||||
|
||||
@app.route("/api/multilat/clear", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_multilat_clear():
|
||||
"""Clear all multilateration data."""
|
||||
web_server.multilat.clear()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# ========== Stats API ==========
|
||||
|
||||
@app.route("/api/stats")
|
||||
@require_api_auth
|
||||
def api_stats():
|
||||
"""Get overall server statistics."""
|
||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||
try:
|
||||
camera_count = len([
|
||||
f for f in os.listdir(full_image_dir)
|
||||
if f.endswith(".jpg")
|
||||
])
|
||||
except FileNotFoundError:
|
||||
camera_count = 0
|
||||
|
||||
device_count = 0
|
||||
if web_server.device_registry:
|
||||
device_count = len(list(web_server.device_registry.all()))
|
||||
|
||||
multilat_state = web_server.multilat.get_state()
|
||||
|
||||
return jsonify({
|
||||
"active_cameras": camera_count,
|
||||
"connected_devices": device_count,
|
||||
"multilateration_scanners": multilat_state["scanners_count"],
|
||||
"server_running": True
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the web server in a background thread."""
|
||||
if self.is_running:
|
||||
return False
|
||||
|
||||
self._server = make_server(self.host, self.port, self._app, threaded=True)
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""Stop the web server."""
|
||||
if self._server:
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
self._thread = None
|
||||
|
||||
def get_url(self) -> str:
|
||||
"""Get the server URL."""
|
||||
return f"http://{self.host}:{self.port}"
|
||||
Loading…
Reference in New Issue
Block a user