ε - TUI multi-pane Textual + camera recording frontend + device naming fix

This commit is contained in:
Eun0us 2026-02-06 09:52:20 +01:00
parent f2a5b50bfd
commit ce6f00e24a
34 changed files with 2373 additions and 366 deletions

6
.gitignore vendored
View File

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

View File

@ -4,6 +4,7 @@
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
static const char *TAG = "COMMAND";
@ -36,7 +37,45 @@ void command_register(const command_t *cmd)
}
registry[registry_count++] = cmd;
ESP_LOGI(TAG, "Registered command: %s", cmd->name);
#ifdef CONFIG_ESPILON_LOG_CMD_REG_VERBOSE
ESPILON_LOGI_PURPLE(TAG, "Registered command: %s", cmd->name);
#endif
}
/* =========================================================
* Summary
* ========================================================= */
void command_log_registry_summary(void)
{
if (registry_count == 0) {
ESPILON_LOGI_PURPLE(TAG, "Registered commands: none");
return;
}
char buf[512];
int off = snprintf(
buf,
sizeof(buf),
"Registered commands (%d): ",
(int)registry_count
);
for (size_t i = 0; i < registry_count; i++) {
const char *name = registry[i] && registry[i]->name
? registry[i]->name : "?";
const char *sep = (i == 0) ? "" : ", ";
int n = snprintf(buf + off, sizeof(buf) - (size_t)off,
"%s%s", sep, name);
if (n < 0 || n >= (int)(sizeof(buf) - (size_t)off)) {
if (off < (int)sizeof(buf) - 4) {
strcpy(buf + (sizeof(buf) - 4), "...");
}
break;
}
off += n;
}
ESPILON_LOGI_PURPLE(TAG, "%s", buf);
}
/* =========================================================

View File

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

View File

@ -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");
}
/* =========================================================

View File

@ -36,6 +36,7 @@ void wifi_init(void)
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
esp_netif_create_default_wifi_ap();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
@ -149,4 +150,4 @@ void tcp_client_task(void *pvParameters)
}
}
#endif /* CONFIG_NETWORK_WIFI */
#endif /* CONFIG_NETWORK_WIFI */

View File

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

View File

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

View File

@ -7,6 +7,9 @@ extern "C" {
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdarg.h>
#include <inttypes.h>
#include <stdio.h>
#include "sdkconfig.h"
#include "esp_log.h"
@ -21,6 +24,36 @@ extern "C" {
#define MAX_ARGS 10
#define MAX_RESPONSE_SIZE 1024
/* ============================================================
* LOG HELPERS
* ============================================================ */
#ifdef CONFIG_LOG_COLORS
#define ESPILON_LOG_PURPLE "\033[0;35m"
#define ESPILON_LOG_RESET "\033[0m"
#else
#define ESPILON_LOG_PURPLE ""
#define ESPILON_LOG_RESET ""
#endif
static inline void espilon_log_purple(
const char *tag,
const char *fmt,
...
) {
va_list args;
va_start(args, fmt);
printf(ESPILON_LOG_PURPLE "I (%" PRIu32 ") %s: ",
(uint32_t)esp_log_timestamp(), tag);
vprintf(fmt, args);
printf(ESPILON_LOG_RESET "\n");
va_end(args);
}
#define ESPILON_LOGI_PURPLE(tag, fmt, ...) \
espilon_log_purple(tag, fmt, ##__VA_ARGS__)
/* Socket TCP global */
extern int sock;

View File

@ -1,4 +1,4 @@
idf_component_register(SRCS "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
idf_component_register(SRCS "cmd_fakeAP.c" "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
INCLUDE_DIRS .
REQUIRES esp_http_server
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core)
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core command)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -54,6 +54,10 @@ class HelpManager:
self.commands = command_registry
self.dev_mode = dev_mode
def _out(self, text: str):
"""Output helper that works in both CLI and TUI mode."""
Display.system_message(text)
def show(self, args: list[str]):
if args:
self._show_command_help(args[0])
@ -62,124 +66,128 @@ class HelpManager:
def show_modules(self):
"""Show ESP commands organized by module."""
Display.system_message("=== ESP32 COMMANDS BY MODULE ===\n")
self._out("=== ESP32 COMMANDS BY MODULE ===")
self._out("")
for module_name, module_info in ESP_MODULES.items():
print(f"\033[1;35m[{module_name.upper()}]\033[0m - {module_info['description']}")
self._out(f"[{module_name.upper()}] - {module_info['description']}")
for cmd_name, cmd_desc in module_info["commands"].items():
print(f" \033[36m{cmd_name:<12}\033[0m {cmd_desc}")
print()
self._out(f" {cmd_name:<20} {cmd_desc}")
self._out("")
print("\033[90mUse 'help <command>' for detailed help on a specific command.\033[0m")
print("\033[90mSend commands with: send <device_id|all> <command> [args...]\033[0m")
self._out("Use 'help <command>' for detailed help on a specific command.")
self._out("Send commands with: send <device_id|all> <command> [args...]")
def _show_global_help(self):
Display.system_message("=== ESPILON C2 HELP ===")
print("\n\033[1mC2 Commands:\033[0m")
print(" \033[36mhelp\033[0m [command] Show help or help for a specific command")
print(" \033[36mlist\033[0m List connected ESP devices")
print(" \033[36mmodules\033[0m List ESP commands organized by module")
print(" \033[36msend\033[0m <target> <cmd> Send a command to ESP device(s)")
print(" \033[36mgroup\033[0m <action> Manage device groups (add, remove, list, show)")
print(" \033[36mactive_commands\033[0m List currently running commands")
print(" \033[36mclear\033[0m Clear terminal screen")
print(" \033[36mexit\033[0m Exit C2")
self._out("=== ESPILON C2 HELP ===")
self._out("")
self._out("C2 Commands:")
self._out(" help [command] Show help or help for a specific command")
self._out(" list List connected ESP devices")
self._out(" modules List ESP commands organized by module")
self._out(" send <target> <cmd> Send a command to ESP device(s)")
self._out(" group <action> Manage device groups (add, remove, list, show)")
self._out(" active_commands List currently running commands")
self._out(" clear Clear terminal screen")
self._out(" exit Exit C2")
self._out("")
self._out("Server Commands:")
self._out(" web start|stop|status Web dashboard server")
self._out(" camera start|stop|status Camera UDP receiver")
self._out("")
self._out("ESP Commands: (use 'modules' for detailed list)")
print("\n\033[1mServer Commands:\033[0m")
print(" \033[36mweb\033[0m start|stop|status Web dashboard server")
print(" \033[36mcamera\033[0m start|stop|status Camera UDP receiver")
print("\n\033[1mESP Commands:\033[0m (use 'modules' for detailed list)")
registered_cmds = self.commands.list()
if registered_cmds:
for name in registered_cmds:
handler = self.commands.get(name)
print(f" \033[36m{name:<15}\033[0m {handler.description}")
self._out(f" {name:<15} {handler.description}")
else:
print(" \033[90m(no registered commands - use 'send' with any ESP command)\033[0m")
self._out(" (no registered commands - use 'send' with any ESP command)")
if self.dev_mode:
print("\n\033[33mDEV MODE:\033[0m Send arbitrary text: send <target> <any text>")
self._out("")
self._out("DEV MODE: Send arbitrary text: send <target> <any text>")
def _show_command_help(self, command_name: str):
# CLI Commands
if command_name == "list":
Display.system_message("Help for 'list' command:")
print(" Usage: list")
print(" Description: Displays all connected ESP devices with ID, IP, status,")
print(" connection duration, and last seen timestamp.")
self._out("Help for 'list' command:")
self._out(" Usage: list")
self._out(" Description: Displays all connected ESP devices with ID, IP, status,")
self._out(" connection duration, and last seen timestamp.")
elif command_name == "send":
Display.system_message("Help for 'send' command:")
print(" Usage: send <device_id|all|group <name>> <command> [args...]")
print(" Description: Sends a command to one or more ESP devices.")
print(" Examples:")
print(" send ESP_ABC123 reboot")
print(" send all wifi status")
print(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
self._out("Help for 'send' command:")
self._out(" Usage: send <device_id|all|group <name>> <command> [args...]")
self._out(" Description: Sends a command to one or more ESP devices.")
self._out(" Examples:")
self._out(" send ESP_ABC123 reboot")
self._out(" send all wifi status")
self._out(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
elif command_name == "group":
Display.system_message("Help for 'group' command:")
print(" Usage: group <action> [args...]")
print(" Actions:")
print(" add <name> <id1> [id2...] Add devices to a group")
print(" remove <name> <id1> [id2...] Remove devices from a group")
print(" list List all groups")
print(" show <name> Show group members")
self._out("Help for 'group' command:")
self._out(" Usage: group <action> [args...]")
self._out(" Actions:")
self._out(" add <name> <id1> [id2...] Add devices to a group")
self._out(" remove <name> <id1> [id2...] Remove devices from a group")
self._out(" list List all groups")
self._out(" show <name> Show group members")
elif command_name == "web":
Display.system_message("Help for 'web' command:")
print(" Usage: web <start|stop|status>")
print(" Description: Control the web dashboard server.")
print(" Actions:")
print(" start Start the web server (dashboard, cameras, MLAT)")
print(" stop Stop the web server")
print(" status Show server status and MLAT engine info")
print(" Default URL: http://127.0.0.1:5000")
self._out("Help for 'web' command:")
self._out(" Usage: web <start|stop|status>")
self._out(" Description: Control the web dashboard server.")
self._out(" Actions:")
self._out(" start Start the web server (dashboard, cameras, MLAT)")
self._out(" stop Stop the web server")
self._out(" status Show server status and MLAT engine info")
self._out(" Default URL: http://127.0.0.1:5000")
elif command_name == "camera":
Display.system_message("Help for 'camera' command:")
print(" Usage: camera <start|stop|status>")
print(" Description: Control the camera UDP receiver.")
print(" Actions:")
print(" start Start UDP receiver for camera frames")
print(" stop Stop UDP receiver")
print(" status Show receiver stats (packets, frames, errors)")
print(" Default port: 12345")
self._out("Help for 'camera' command:")
self._out(" Usage: camera <start|stop|status>")
self._out(" Description: Control the camera UDP receiver.")
self._out(" Actions:")
self._out(" start Start UDP receiver for camera frames")
self._out(" stop Stop UDP receiver")
self._out(" status Show receiver stats (packets, frames, errors)")
self._out(" Default port: 12345")
elif command_name == "modules":
Display.system_message("Help for 'modules' command:")
print(" Usage: modules")
print(" Description: List all ESP32 commands organized by module.")
print(" Modules: system, network, fakeap, recon")
self._out("Help for 'modules' command:")
self._out(" Usage: modules")
self._out(" Description: List all ESP32 commands organized by module.")
self._out(" Modules: system, network, fakeap, recon")
elif command_name in ["clear", "exit", "active_commands"]:
Display.system_message(f"Help for '{command_name}' command:")
print(f" Usage: {command_name}")
self._out(f"Help for '{command_name}' command:")
self._out(f" Usage: {command_name}")
descs = {
"clear": "Clear the terminal screen",
"exit": "Exit the C2 application",
"active_commands": "Show all commands currently being executed"
}
print(f" Description: {descs.get(command_name, '')}")
self._out(f" Description: {descs.get(command_name, '')}")
# ESP Commands (by module or registered)
else:
# Check in modules first
for module_name, module_info in ESP_MODULES.items():
if command_name in module_info["commands"]:
Display.system_message(f"ESP Command '{command_name}' [{module_name.upper()}]:")
print(f" Description: {module_info['commands'][command_name]}")
self._out(f"ESP Command '{command_name}' [{module_name.upper()}]:")
self._out(f" Description: {module_info['commands'][command_name]}")
self._show_esp_command_detail(command_name)
return
# Check registered commands
handler = self.commands.get(command_name)
if handler:
Display.system_message(f"ESP Command '{command_name}':")
print(f" Description: {handler.description}")
self._out(f"ESP Command '{command_name}':")
self._out(f" Description: {handler.description}")
if hasattr(handler, 'usage'):
print(f" Usage: {handler.usage}")
self._out(f" Usage: {handler.usage}")
else:
Display.error(f"No help available for '{command_name}'.")
@ -187,104 +195,101 @@ class HelpManager:
"""Show detailed help for specific ESP commands."""
details = {
# MLAT subcommands
"mlat config": """
Usage: send <device> mlat config [gps|local] <coord1> <coord2>
GPS mode: mlat config gps <lat> <lon> - degrees
Local mode: mlat config local <x> <y> - meters
Examples:
send ESP1 mlat config gps 48.8566 2.3522
send ESP1 mlat config local 10.0 5.5
send ESP1 mlat config 48.8566 2.3522 (backward compat: GPS)""",
"mlat mode": """
Usage: send <device> mlat mode <ble|wifi>
Example: send ESP1 mlat mode ble""",
"mlat start": """
Usage: send <device> mlat start <mac>
Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF""",
"mlat stop": """
Usage: send <device> mlat stop""",
"mlat status": """
Usage: send <device> mlat status""",
# Camera commands
"cam_start": """
Usage: send <device> cam_start <ip> <port>
Description: Start camera streaming to C2 UDP receiver
Example: send ESP_CAM cam_start 192.168.1.100 12345""",
"cam_stop": """
Usage: send <device> cam_stop
Description: Stop camera streaming""",
# FakeAP commands
"fakeap_start": """
Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]
Examples:
send ESP1 fakeap_start FreeWiFi
send ESP1 fakeap_start SecureNet wpa2 mypassword""",
"fakeap_stop": """
Usage: send <device> fakeap_stop""",
"fakeap_status": """
Usage: send <device> fakeap_status
Shows: AP running, portal status, sniffer status, client count""",
"fakeap_clients": """
Usage: send <device> fakeap_clients
Lists all connected clients to the fake AP""",
"fakeap_portal_start": """
Usage: send <device> fakeap_portal_start
Description: Enable captive portal (requires fakeap running)""",
"fakeap_portal_stop": """
Usage: send <device> fakeap_portal_stop""",
"fakeap_sniffer_on": """
Usage: send <device> fakeap_sniffer_on
Description: Enable packet sniffing""",
"fakeap_sniffer_off": """
Usage: send <device> fakeap_sniffer_off""",
# Network commands
"ping": """
Usage: send <device> ping <host>
Example: send ESP1 ping 8.8.8.8""",
"arp_scan": """
Usage: send <device> arp_scan
Description: Scan local network for hosts""",
"proxy_start": """
Usage: send <device> proxy_start <ip> <port>
Example: send ESP1 proxy_start 192.168.1.100 8080""",
"proxy_stop": """
Usage: send <device> proxy_stop""",
"dos_tcp": """
Usage: send <device> dos_tcp <ip> <port> <count>
Example: send ESP1 dos_tcp 192.168.1.100 80 1000""",
# System commands
"system_reboot": """
Usage: send <device> system_reboot
Description: Reboot the ESP32 device""",
"system_mem": """
Usage: send <device> system_mem
Shows: heap_free, heap_min, internal_free""",
"system_uptime": """
Usage: send <device> system_uptime
Shows: uptime in days/hours/minutes/seconds"""
"mlat config": [
" Usage: send <device> mlat config [gps|local] <coord1> <coord2>",
" GPS mode: mlat config gps <lat> <lon> - degrees",
" Local mode: mlat config local <x> <y> - meters",
" Examples:",
" send ESP1 mlat config gps 48.8566 2.3522",
" send ESP1 mlat config local 10.0 5.5",
],
"mlat mode": [
" Usage: send <device> mlat mode <ble|wifi>",
" Example: send ESP1 mlat mode ble",
],
"mlat start": [
" Usage: send <device> mlat start <mac>",
" Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF",
],
"mlat stop": [
" Usage: send <device> mlat stop",
],
"mlat status": [
" Usage: send <device> mlat status",
],
"cam_start": [
" Usage: send <device> cam_start <ip> <port>",
" Description: Start camera streaming to C2 UDP receiver",
" Example: send ESP_CAM cam_start 192.168.1.100 12345",
],
"cam_stop": [
" Usage: send <device> cam_stop",
" Description: Stop camera streaming",
],
"fakeap_start": [
" Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]",
" Examples:",
" send ESP1 fakeap_start FreeWiFi",
" send ESP1 fakeap_start SecureNet wpa2 mypassword",
],
"fakeap_stop": [
" Usage: send <device> fakeap_stop",
],
"fakeap_status": [
" Usage: send <device> fakeap_status",
" Shows: AP running, portal status, sniffer status, client count",
],
"fakeap_clients": [
" Usage: send <device> fakeap_clients",
" Lists all connected clients to the fake AP",
],
"fakeap_portal_start": [
" Usage: send <device> fakeap_portal_start",
" Description: Enable captive portal (requires fakeap running)",
],
"fakeap_portal_stop": [
" Usage: send <device> fakeap_portal_stop",
],
"fakeap_sniffer_on": [
" Usage: send <device> fakeap_sniffer_on",
" Description: Enable packet sniffing",
],
"fakeap_sniffer_off": [
" Usage: send <device> fakeap_sniffer_off",
],
"ping": [
" Usage: send <device> ping <host>",
" Example: send ESP1 ping 8.8.8.8",
],
"arp_scan": [
" Usage: send <device> arp_scan",
" Description: Scan local network for hosts",
],
"proxy_start": [
" Usage: send <device> proxy_start <ip> <port>",
" Example: send ESP1 proxy_start 192.168.1.100 8080",
],
"proxy_stop": [
" Usage: send <device> proxy_stop",
],
"dos_tcp": [
" Usage: send <device> dos_tcp <ip> <port> <count>",
" Example: send ESP1 dos_tcp 192.168.1.100 80 1000",
],
"system_reboot": [
" Usage: send <device> system_reboot",
" Description: Reboot the ESP32 device",
],
"system_mem": [
" Usage: send <device> system_mem",
" Shows: heap_free, heap_min, internal_free",
],
"system_uptime": [
" Usage: send <device> system_uptime",
" Shows: uptime in days/hours/minutes/seconds",
],
}
if cmd in details:
print(details[cmd])
for line in details[cmd]:
self._out(line)

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -12,14 +12,18 @@ import threading
import time
import cv2
import numpy as np
from datetime import datetime
from typing import Optional, Callable, Dict
from .config import (
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
SECRET_TOKEN, IMAGE_DIR,
VIDEO_ENABLED, VIDEO_PATH, VIDEO_FPS, VIDEO_CODEC
VIDEO_FPS, VIDEO_CODEC
)
# Camera timeout - mark as inactive after this many seconds without frames
CAMERA_TIMEOUT_SECONDS = 5
class FrameAssembler:
"""Assembles JPEG frames from multiple UDP packets."""
@ -31,13 +35,11 @@ class FrameAssembler:
self.receiving = False
def start_frame(self):
"""Start receiving a new frame."""
self.buffer = bytearray()
self.start_time = time.time()
self.receiving = True
def add_chunk(self, data: bytes) -> bool:
"""Add a chunk to the frame buffer. Returns False if timed out."""
if not self.receiving:
return False
if self.start_time and (time.time() - self.start_time) > self.timeout:
@ -47,7 +49,6 @@ class FrameAssembler:
return True
def finish_frame(self) -> Optional[bytes]:
"""Finish frame assembly and return complete data."""
if not self.receiving or len(self.buffer) == 0:
return None
data = bytes(self.buffer)
@ -55,12 +56,89 @@ class FrameAssembler:
return data
def reset(self):
"""Reset the assembler state."""
self.buffer = bytearray()
self.start_time = None
self.receiving = False
class CameraRecorder:
"""Handles video recording for a single camera."""
def __init__(self, camera_id: str, output_dir: str):
self.camera_id = camera_id
self.output_dir = output_dir
self._writer: Optional[cv2.VideoWriter] = None
self._video_size: Optional[tuple] = None
self._recording = False
self._filename: Optional[str] = None
self._frame_count = 0
self._start_time: Optional[float] = None
@property
def is_recording(self) -> bool:
return self._recording
@property
def filename(self) -> Optional[str]:
return self._filename
@property
def duration(self) -> float:
if self._start_time:
return time.time() - self._start_time
return 0
@property
def frame_count(self) -> int:
return self._frame_count
def start(self) -> str:
if self._recording:
return self._filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_id = self.camera_id.replace(":", "_").replace(".", "_")
self._filename = f"recording_{safe_id}_{timestamp}.avi"
self._recording = True
self._frame_count = 0
self._start_time = time.time()
return self._filename
def stop(self) -> dict:
if not self._recording:
return {"error": "Not recording"}
self._recording = False
result = {
"filename": self._filename,
"frames": self._frame_count,
"duration": self.duration
}
if self._writer:
self._writer.release()
self._writer = None
self._video_size = None
return result
def write_frame(self, frame: np.ndarray):
if not self._recording:
return
if self._writer is None:
self._video_size = (frame.shape[1], frame.shape[0])
fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC)
video_path = os.path.join(self.output_dir, self._filename)
self._writer = cv2.VideoWriter(
video_path, fourcc, VIDEO_FPS, self._video_size
)
if self._writer and self._writer.isOpened():
self._writer.write(frame)
self._frame_count += 1
class UDPReceiver:
"""Receives JPEG frames via UDP from ESP camera devices."""
@ -68,11 +146,13 @@ class UDPReceiver:
host: str = UDP_HOST,
port: int = UDP_PORT,
image_dir: str = IMAGE_DIR,
on_frame: Optional[Callable] = None):
on_frame: Optional[Callable] = None,
device_registry=None):
self.host = host
self.port = port
self.image_dir = image_dir
self.on_frame = on_frame # Callback when frame received
self.on_frame = on_frame
self.device_registry = device_registry
self._sock: Optional[socket.socket] = None
self._thread: Optional[threading.Thread] = None
@ -81,9 +161,12 @@ class UDPReceiver:
# Frame assemblers per source address
self._assemblers: Dict[str, FrameAssembler] = {}
# Video recording
self._video_writer: Optional[cv2.VideoWriter] = None
self._video_size: Optional[tuple] = None
# Per-camera recorders (keyed by device_id)
self._recorders: Dict[str, CameraRecorder] = {}
self._recordings_dir = os.path.join(os.path.dirname(image_dir), "recordings")
# IP to device_id mapping cache
self._ip_to_device: Dict[str, str] = {}
# Statistics
self.frames_received = 0
@ -91,10 +174,15 @@ class UDPReceiver:
self.decode_errors = 0
self.packets_received = 0
# Active cameras tracking
self._active_cameras: dict = {} # {camera_id: last_frame_time}
# Active cameras tracking: {device_id: {"last_frame": timestamp, "active": bool}}
self._active_cameras: Dict[str, dict] = {}
os.makedirs(self.image_dir, exist_ok=True)
os.makedirs(self._recordings_dir, exist_ok=True)
def set_device_registry(self, registry):
"""Set device registry for IP to device_id lookup."""
self.device_registry = registry
@property
def is_running(self) -> bool:
@ -102,21 +190,39 @@ class UDPReceiver:
@property
def active_cameras(self) -> list:
"""Returns list of active camera identifiers."""
return list(self._active_cameras.keys())
"""Returns list of active camera device IDs."""
return [cid for cid, info in self._active_cameras.items() if info.get("active", False)]
def _get_device_id_from_ip(self, ip: str) -> Optional[str]:
"""Look up device_id from IP address using device registry."""
# Check cache first
if ip in self._ip_to_device:
return self._ip_to_device[ip]
# Look up in device registry
if self.device_registry:
for device in self.device_registry.all():
if device.address and device.address[0] == ip:
self._ip_to_device[ip] = device.id
return device.id
return None
def start(self) -> bool:
"""Start the UDP receiver thread."""
if self.is_running:
return False
self._stop_event.clear()
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
self._thread.start()
# Start timeout checker
self._timeout_thread = threading.Thread(target=self._timeout_checker, daemon=True)
self._timeout_thread.start()
return True
def stop(self):
"""Stop the UDP receiver and cleanup."""
self._stop_event.set()
if self._sock:
@ -126,15 +232,15 @@ class UDPReceiver:
pass
self._sock = None
if self._video_writer is not None:
self._video_writer.release()
self._video_writer = None
for recorder in self._recorders.values():
if recorder.is_recording:
recorder.stop()
# Clean up frame files
self._cleanup_frames()
self._active_cameras.clear()
self._assemblers.clear()
self._recorders.clear()
self._ip_to_device.clear()
self.frames_received = 0
self.packets_received = 0
@ -147,15 +253,43 @@ class UDPReceiver:
except Exception:
pass
def _timeout_checker(self):
"""Check for camera timeouts and mark them as inactive."""
while not self._stop_event.is_set():
time.sleep(1)
now = time.time()
for camera_id, info in list(self._active_cameras.items()):
last_frame = info.get("last_frame", 0)
was_active = info.get("active", False)
if now - last_frame > CAMERA_TIMEOUT_SECONDS:
if was_active:
self._active_cameras[camera_id]["active"] = False
# Remove the frame file so frontend shows default image
self._remove_camera_frame(camera_id)
def _remove_camera_frame(self, camera_id: str):
"""Remove the frame file for a camera."""
try:
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
if os.path.exists(filepath):
os.remove(filepath)
except Exception:
pass
def _get_assembler(self, addr: tuple) -> FrameAssembler:
"""Get or create a frame assembler for the given address."""
key = f"{addr[0]}:{addr[1]}"
if key not in self._assemblers:
self._assemblers[key] = FrameAssembler()
return self._assemblers[key]
def _get_recorder(self, camera_id: str) -> CameraRecorder:
if camera_id not in self._recorders:
self._recorders[camera_id] = CameraRecorder(camera_id, self._recordings_dir)
return self._recorders[camera_id]
def _receive_loop(self):
"""Main UDP receive loop with START/END protocol handling."""
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind((self.host, self.port))
@ -173,50 +307,45 @@ class UDPReceiver:
self.packets_received += 1
# Validate token
if not data.startswith(SECRET_TOKEN):
self.invalid_tokens += 1
continue
# Remove token prefix
payload = data[len(SECRET_TOKEN):]
assembler = self._get_assembler(addr)
camera_id = f"{addr[0]}_{addr[1]}"
# Handle protocol markers
# Try to get device_id from IP, fallback to IP if not found
ip = addr[0]
device_id = self._get_device_id_from_ip(ip)
if not device_id:
# Fallback: use IP (without port to avoid duplicates)
device_id = ip.replace(".", "_")
if payload == b"START":
assembler.start_frame()
continue
elif payload == b"END":
frame_data = assembler.finish_frame()
if frame_data:
self._process_complete_frame(camera_id, frame_data, addr)
self._process_complete_frame(device_id, frame_data, addr)
continue
else:
# Regular data chunk
if not assembler.receiving:
# No START received, try as single-packet frame (legacy)
frame = self._decode_frame(payload)
if frame is not None:
self._process_frame(camera_id, frame, addr)
self._process_frame(device_id, frame, addr)
else:
self.decode_errors += 1
else:
assembler.add_chunk(payload)
# Cleanup
if self._sock:
self._sock.close()
self._sock = None
if self._video_writer:
self._video_writer.release()
self._video_writer = None
print("[UDP] Receiver stopped")
def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple):
"""Process a fully assembled frame."""
frame = self._decode_frame(frame_data)
if frame is None:
self.decode_errors += 1
@ -224,23 +353,27 @@ class UDPReceiver:
self._process_frame(camera_id, frame, addr)
def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple):
"""Process a decoded frame."""
self.frames_received += 1
self._active_cameras[camera_id] = time.time()
# Update camera tracking
self._active_cameras[camera_id] = {
"last_frame": time.time(),
"active": True,
"addr": addr
}
# Save frame
self._save_frame(camera_id, frame)
# Record video if enabled
if VIDEO_ENABLED:
self._record_frame(frame)
# Record if recording is active for this camera
recorder = self._get_recorder(camera_id)
if recorder.is_recording:
recorder.write_frame(frame)
# Callback
if self.on_frame:
self.on_frame(camera_id, frame, addr)
def _decode_frame(self, data: bytes) -> Optional[np.ndarray]:
"""Decode JPEG data to OpenCV frame."""
try:
npdata = np.frombuffer(data, np.uint8)
frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR)
@ -249,33 +382,87 @@ class UDPReceiver:
return None
def _save_frame(self, camera_id: str, frame: np.ndarray):
"""Save frame as JPEG file."""
try:
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
cv2.imwrite(filepath, frame)
except Exception:
pass
def _record_frame(self, frame: np.ndarray):
"""Record frame to video file."""
if self._video_writer is None:
self._video_size = (frame.shape[1], frame.shape[0])
fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC)
video_path = os.path.join(os.path.dirname(self.image_dir), VIDEO_PATH.split('/')[-1])
self._video_writer = cv2.VideoWriter(
video_path, fourcc, VIDEO_FPS, self._video_size
)
# === Recording API ===
if self._video_writer and self._video_writer.isOpened():
self._video_writer.write(frame)
def start_recording(self, camera_id: str) -> dict:
if camera_id not in self._active_cameras or not self._active_cameras[camera_id].get("active"):
return {"error": f"Camera {camera_id} not active"}
recorder = self._get_recorder(camera_id)
if recorder.is_recording:
return {"error": "Already recording", "filename": recorder.filename}
filename = recorder.start()
return {"status": "recording", "filename": filename, "camera_id": camera_id}
def stop_recording(self, camera_id: str) -> dict:
if camera_id not in self._recorders:
return {"error": f"No recorder for {camera_id}"}
recorder = self._recorders[camera_id]
if not recorder.is_recording:
return {"error": "Not recording"}
result = recorder.stop()
result["camera_id"] = camera_id
result["path"] = os.path.join(self._recordings_dir, result["filename"])
return result
def get_recording_status(self, camera_id: str = None) -> dict:
if camera_id:
if camera_id not in self._recorders:
return {"camera_id": camera_id, "recording": False}
recorder = self._recorders[camera_id]
return {
"camera_id": camera_id,
"recording": recorder.is_recording,
"filename": recorder.filename,
"duration": recorder.duration,
"frames": recorder.frame_count
}
result = {}
for cid, info in self._active_cameras.items():
if info.get("active"):
recorder = self._get_recorder(cid)
result[cid] = {
"recording": recorder.is_recording,
"filename": recorder.filename if recorder.is_recording else None,
"duration": recorder.duration if recorder.is_recording else 0
}
return result
def list_recordings(self) -> list:
try:
files = []
for f in os.listdir(self._recordings_dir):
if f.endswith(".avi"):
path = os.path.join(self._recordings_dir, f)
stat = os.stat(path)
files.append({
"filename": f,
"size": stat.st_size,
"created": stat.st_mtime
})
return sorted(files, key=lambda x: x["created"], reverse=True)
except Exception:
return []
def get_stats(self) -> dict:
"""Return receiver statistics."""
recording_count = sum(1 for r in self._recorders.values() if r.is_recording)
active_count = sum(1 for info in self._active_cameras.values() if info.get("active"))
return {
"running": self.is_running,
"packets_received": self.packets_received,
"frames_received": self.frames_received,
"invalid_tokens": self.invalid_tokens,
"decode_errors": self.decode_errors,
"active_cameras": len(self._active_cameras)
"active_cameras": active_count,
"active_recordings": recording_count
}

View File

@ -14,45 +14,257 @@
{% if image_files %}
<div class="grid grid-cameras" id="grid">
{% for img in image_files %}
<div class="card">
<div class="card" data-camera-id="{{ img.replace('.jpg', '') }}">
<div class="card-header">
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
<span class="badge badge-live">LIVE</span>
<div class="card-actions">
<button class="btn-record" data-camera="{{ img.replace('.jpg', '') }}" title="Start Recording">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8"/>
</svg>
</button>
<span class="badge badge-live">LIVE</span>
</div>
</div>
<div class="card-body card-body-image">
<img src="/streams/{{ img }}?t=0" data-src="/streams/{{ img }}">
<img src="/streams/{{ img }}?t=0"
data-src="/streams/{{ img }}"
data-default="/static/images/no-signal.png"
onerror="this.src=this.dataset.default">
</div>
<div class="record-indicator" style="display: none;">
<span class="record-dot"></span>
<span class="record-time">00:00</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">
<h2>No active cameras</h2>
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
<div class="empty-cameras">
<div class="no-signal-container">
<img src="/static/images/no-signal.png" alt="No Signal" class="no-signal-img">
<h2>No active cameras</h2>
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<style>
.card-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-record {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: var(--bg-elevated);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-record:hover {
background: var(--status-error-bg);
color: var(--status-error);
}
.btn-record.recording {
background: var(--status-error);
color: white;
animation: pulse-record 1.5s infinite;
}
@keyframes pulse-record {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.record-indicator {
padding: 8px 16px;
background: var(--bg-elevated);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--status-error);
}
.record-dot {
width: 8px;
height: 8px;
background: var(--status-error);
border-radius: 50%;
animation: pulse-record 1s infinite;
}
.record-time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.empty-cameras {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.no-signal-container {
text-align: center;
}
.no-signal-img {
max-width: 300px;
margin-bottom: 24px;
opacity: 0.8;
border-radius: 12px;
}
.no-signal-container h2 {
font-size: 20px;
color: var(--text-primary);
margin-bottom: 8px;
}
.no-signal-container p {
color: var(--text-muted);
font-size: 14px;
}
.card-body-image img {
min-height: 180px;
object-fit: contain;
background: var(--bg-tertiary);
}
</style>
<script>
// Recording state
const recordingState = {};
// Refresh camera images
function refresh() {
const t = Date.now();
document.querySelectorAll('.card-body-image img').forEach(img => {
img.src = img.dataset.src + '?t=' + t;
// Only update if not showing default image
if (!img.src.includes('no-signal')) {
img.src = img.dataset.src + '?t=' + t;
}
});
}
// Check for new/removed cameras
async function checkCameras() {
try {
const res = await fetch('/api/cameras');
const data = await res.json();
const current = document.querySelectorAll('.card').length;
document.getElementById('camera-count').textContent = data.count || 0;
// Update recording states
if (data.cameras) {
data.cameras.forEach(cam => {
updateRecordingUI(cam.id, cam.recording);
});
}
if (data.count !== current) location.reload();
} catch (e) {}
}
// Update recording UI
function updateRecordingUI(cameraId, isRecording) {
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
if (!card) return;
const btn = card.querySelector('.btn-record');
const indicator = card.querySelector('.record-indicator');
if (isRecording) {
btn.classList.add('recording');
btn.title = 'Stop Recording';
indicator.style.display = 'flex';
// Start timer if not already
if (!recordingState[cameraId]) {
recordingState[cameraId] = { startTime: Date.now() };
}
} else {
btn.classList.remove('recording');
btn.title = 'Start Recording';
indicator.style.display = 'none';
delete recordingState[cameraId];
}
}
// Update recording timers
function updateTimers() {
for (const [cameraId, state] of Object.entries(recordingState)) {
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
if (!card) continue;
const timeEl = card.querySelector('.record-time');
if (timeEl) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
const secs = (elapsed % 60).toString().padStart(2, '0');
timeEl.textContent = `${mins}:${secs}`;
}
}
}
// Toggle recording
async function toggleRecording(cameraId) {
const btn = document.querySelector(`[data-camera="${cameraId}"]`);
const isRecording = btn.classList.contains('recording');
try {
const endpoint = isRecording ? 'stop' : 'start';
const res = await fetch(`/api/recording/${endpoint}/${cameraId}`, {
method: 'POST'
});
const data = await res.json();
if (data.error) {
console.error('Recording error:', data.error);
return;
}
updateRecordingUI(cameraId, !isRecording);
if (!isRecording) {
recordingState[cameraId] = { startTime: Date.now() };
}
} catch (e) {
console.error('Recording toggle failed:', e);
}
}
// Event listeners
document.querySelectorAll('.btn-record').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const cameraId = btn.dataset.camera;
toggleRecording(cameraId);
});
});
// Intervals
setInterval(refresh, 100);
setInterval(checkCameras, 5000);
setInterval(updateTimers, 1000);
// Initial check
checkCameras();
</script>
{% endblock %}

4
tools/c2/tui/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from tui.app import C3POApp
from tui.bridge import tui_bridge, TUIMessage, MessageType
__all__ = ["C3POApp", "tui_bridge", "TUIMessage", "MessageType"]

295
tools/c2/tui/app.py Normal file
View File

@ -0,0 +1,295 @@
"""
Main C3PO TUI Application using Textual.
Multi-device view: all connected devices visible simultaneously.
"""
import time
from pathlib import Path
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, Container, ScrollableContainer
from textual.widgets import Static
from tui.bridge import tui_bridge, TUIMessage, MessageType
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
from tui.widgets.command_input import CommandInput
from tui.widgets.device_tabs import DeviceTabs
class DeviceContainer(Container):
"""Container for a single device with border and title."""
DEFAULT_CSS = """
DeviceContainer {
height: 1fr;
min-height: 6;
border: solid $secondary;
border-title-color: $text;
border-title-style: bold;
}
"""
def __init__(self, device_id: str, **kwargs):
super().__init__(**kwargs)
self.device_id = device_id
self.border_title = f"DEVICE: {device_id}"
class C3POApp(App):
"""C3PO Command & Control TUI Application."""
CSS_PATH = Path(__file__).parent / "styles" / "c2.tcss"
BINDINGS = [
Binding("alt+g", "toggle_global", "Global", show=True),
Binding("ctrl+l", "clear_global", "Clear", show=True),
Binding("ctrl+q", "quit", "Quit", show=True),
Binding("escape", "focus_input", "Input", show=False),
Binding("tab", "tab_complete", show=False, priority=True),
]
def __init__(self, registry=None, cli=None, **kwargs):
super().__init__(**kwargs)
self.registry = registry
self.cli = cli
self._device_panes: dict[str, DeviceLogPane] = {}
self._device_containers: dict[str, DeviceContainer] = {}
self._device_modules: dict[str, str] = {}
def compose(self) -> ComposeResult:
yield DeviceTabs(id="tab-bar")
with Horizontal(id="main-content"):
# Left side: all devices stacked vertically
with Vertical(id="devices-panel"):
yield Static("Waiting for devices...", id="no-device-placeholder")
# Right side: global logs
with Container(id="global-log-container") as global_container:
global_container.border_title = "GLOBAL LOGS"
yield GlobalLogPane(id="global-log")
with Vertical(id="input-container"):
yield Static(
"Alt+G:Toggle Global ^L:Clear Logs ^Q:Quit Tab:Complete",
id="shortcuts-bar"
)
yield CommandInput(id="command-input")
def on_mount(self) -> None:
"""Called when app is mounted."""
tui_bridge.set_app(self)
self.set_interval(0.1, self.process_bridge_queue)
cmd_input = self.query_one("#command-input", CommandInput)
if self.cli:
cmd_input.set_completer(self._make_completer())
cmd_input.focus()
global_log = self.query_one("#global-log", GlobalLogPane)
global_log.add_system(self._timestamp(), "C3PO TUI initialized - Multi-device view")
def _make_completer(self):
"""Create a completer function that works without readline."""
ESP_COMMANDS = [
"system_reboot", "system_mem", "system_uptime", "system_info",
"ping", "arp_scan", "proxy_start", "proxy_stop", "dos_tcp",
"fakeap_start", "fakeap_stop", "fakeap_status", "fakeap_clients",
"fakeap_portal_start", "fakeap_portal_stop",
"fakeap_sniffer_on", "fakeap_sniffer_off",
"cam_start", "cam_stop", "mlat", "trilat",
]
def completer(text: str, state: int) -> str | None:
if not self.cli:
return None
cmd_input = self.query_one("#command-input", CommandInput)
buffer = cmd_input.value
parts = buffer.split()
options = []
if len(parts) <= 1 and not buffer.endswith(" "):
options = ["send", "list", "modules", "group", "help", "clear", "exit",
"active_commands", "web", "camera"]
elif parts[0] == "send":
if len(parts) == 2 and not buffer.endswith(" "):
options = ["all", "group"] + self.cli.registry.ids()
elif len(parts) == 2 and buffer.endswith(" "):
options = ["all", "group"] + self.cli.registry.ids()
elif len(parts) == 3 and parts[1] == "group" and not buffer.endswith(" "):
options = list(self.cli.groups.all_groups().keys())
elif len(parts) == 3 and parts[1] == "group" and buffer.endswith(" "):
options = ESP_COMMANDS
elif len(parts) == 3 and parts[1] != "group":
options = ESP_COMMANDS
elif len(parts) == 4 and parts[1] == "group":
options = ESP_COMMANDS
elif parts[0] == "web":
if len(parts) <= 2:
options = ["start", "stop", "status"]
elif parts[0] == "camera":
if len(parts) <= 2:
options = ["start", "stop", "status"]
elif parts[0] == "group":
if len(parts) == 2 and not buffer.endswith(" "):
options = ["add", "remove", "list", "show"]
elif len(parts) == 2 and buffer.endswith(" "):
options = ["add", "remove", "list", "show"]
elif parts[1] in ("remove", "show") and len(parts) >= 3:
options = list(self.cli.groups.all_groups().keys())
elif parts[1] == "add" and len(parts) >= 3:
options = self.cli.registry.ids()
matches = [o for o in options if o.startswith(text)]
return matches[state] if state < len(matches) else None
return completer
def _timestamp(self) -> str:
return time.strftime("%H:%M:%S")
def process_bridge_queue(self) -> None:
for msg in tui_bridge.get_pending_messages():
self._handle_tui_message(msg)
def _handle_tui_message(self, msg: TUIMessage) -> None:
global_log = self.query_one("#global-log", GlobalLogPane)
timestamp = time.strftime("%H:%M:%S", time.localtime(msg.timestamp))
if msg.msg_type == MessageType.SYSTEM_MESSAGE:
global_log.add_system(timestamp, msg.payload)
elif msg.msg_type == MessageType.DEVICE_CONNECTED:
global_log.add_system(timestamp, f"{msg.device_id} connected")
self._add_device_pane(msg.device_id)
tabs = self.query_one("#tab-bar", DeviceTabs)
tabs.add_device(msg.device_id)
elif msg.msg_type == MessageType.DEVICE_RECONNECTED:
global_log.add_system(timestamp, f"{msg.device_id} reconnected")
elif msg.msg_type == MessageType.DEVICE_INFO_UPDATED:
self._device_modules[msg.device_id] = msg.payload
global_log.add_system(timestamp, f"{msg.device_id} modules: {msg.payload}")
self._update_device_title(msg.device_id)
elif msg.msg_type == MessageType.DEVICE_DISCONNECTED:
global_log.add_system(timestamp, f"{msg.device_id} disconnected")
self._remove_device_pane(msg.device_id)
tabs = self.query_one("#tab-bar", DeviceTabs)
tabs.remove_device(msg.device_id)
elif msg.msg_type == MessageType.DEVICE_EVENT:
global_log.add_device_event(timestamp, msg.device_id, msg.payload)
if msg.device_id in self._device_panes:
event_type = self._detect_event_type(msg.payload)
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, event_type)
elif msg.msg_type == MessageType.COMMAND_SENT:
global_log.add_command_sent(timestamp, msg.device_id, msg.payload, msg.request_id)
if msg.device_id in self._device_panes:
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_sent")
elif msg.msg_type == MessageType.COMMAND_RESPONSE:
global_log.add_command_response(timestamp, msg.device_id, msg.payload, msg.request_id)
if msg.device_id in self._device_panes:
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_resp")
elif msg.msg_type == MessageType.ERROR:
global_log.add_error(timestamp, msg.payload)
def _detect_event_type(self, payload: str) -> str:
payload_upper = payload.upper()
if payload_upper.startswith("INFO:"):
return "info"
elif payload_upper.startswith("LOG:"):
return "log"
elif payload_upper.startswith("ERROR:"):
return "error"
elif payload_upper.startswith("DATA:"):
return "data"
return "info"
def _add_device_pane(self, device_id: str) -> None:
"""Add a new device pane (visible immediately)."""
if device_id in self._device_panes:
return
# Hide placeholder
placeholder = self.query_one("#no-device-placeholder", Static)
placeholder.display = False
# Create container with border for this device
container = DeviceContainer(device_id, id=f"device-container-{device_id}")
pane = DeviceLogPane(device_id, id=f"device-pane-{device_id}")
self._device_containers[device_id] = container
self._device_panes[device_id] = pane
# Mount in the devices panel
devices_panel = self.query_one("#devices-panel", Vertical)
devices_panel.mount(container)
container.mount(pane)
def _remove_device_pane(self, device_id: str) -> None:
"""Remove a device pane."""
if device_id in self._device_containers:
container = self._device_containers.pop(device_id)
container.remove()
self._device_panes.pop(device_id, None)
self._device_modules.pop(device_id, None)
# Show placeholder if no devices
if not self._device_containers:
placeholder = self.query_one("#no-device-placeholder", Static)
placeholder.display = True
def _update_device_title(self, device_id: str) -> None:
"""Update device container title with modules info."""
if device_id in self._device_containers:
modules = self._device_modules.get(device_id, "")
container = self._device_containers[device_id]
if modules:
container.border_title = f"DEVICE: {device_id} [{modules}]"
else:
container.border_title = f"DEVICE: {device_id}"
def on_command_input_completions_available(self, event: CommandInput.CompletionsAvailable) -> None:
global_log = self.query_one("#global-log", GlobalLogPane)
completions_str = " ".join(event.completions)
global_log.add_system(self._timestamp(), f"Completions: {completions_str}")
def on_command_input_command_submitted(self, event: CommandInput.CommandSubmitted) -> None:
command = event.command
global_log = self.query_one("#global-log", GlobalLogPane)
global_log.add_system(self._timestamp(), f"Executing: {command}")
if self.cli:
try:
self.cli.execute_command(command)
except Exception as e:
global_log.add_error(self._timestamp(), f"Command error: {e}")
def action_toggle_global(self) -> None:
"""Toggle global logs pane visibility."""
global_container = self.query_one("#global-log-container", Container)
global_container.display = not global_container.display
def action_clear_global(self) -> None:
"""Clear global logs pane only."""
global_log = self.query_one("#global-log", GlobalLogPane)
global_log.clear()
def action_focus_input(self) -> None:
self.query_one("#command-input", CommandInput).focus()
def action_tab_complete(self) -> None:
cmd_input = self.query_one("#command-input", CommandInput)
cmd_input.focus()
cmd_input._handle_tab_completion()

65
tools/c2/tui/bridge.py Normal file
View File

@ -0,0 +1,65 @@
"""
Thread-safe bridge between sync threads and async Textual TUI.
"""
import queue
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Any
class MessageType(Enum):
DEVICE_CONNECTED = "device_connected"
DEVICE_DISCONNECTED = "device_disconnected"
DEVICE_RECONNECTED = "device_reconnected"
DEVICE_INFO_UPDATED = "device_info_updated"
DEVICE_EVENT = "device_event"
COMMAND_SENT = "command_sent"
COMMAND_RESPONSE = "command_response"
SYSTEM_MESSAGE = "system_message"
ERROR = "error"
@dataclass
class TUIMessage:
"""Message from sync thread to async TUI."""
msg_type: MessageType
payload: str
timestamp: float = field(default_factory=time.time)
device_id: Optional[str] = None
request_id: Optional[str] = None
class TUIBridge:
"""Thread-safe bridge between sync threads and async Textual app."""
def __init__(self):
self._queue: queue.Queue[TUIMessage] = queue.Queue()
self._app: Any = None
def set_app(self, app):
"""Called by TUI app on startup."""
self._app = app
def post_message(self, msg: TUIMessage):
"""Called by sync threads (Display class)."""
self._queue.put(msg)
if self._app:
try:
self._app.call_from_thread(self._app.process_bridge_queue)
except Exception:
pass
def get_pending_messages(self) -> list[TUIMessage]:
"""Called by async TUI to drain the queue."""
messages = []
while True:
try:
messages.append(self._queue.get_nowait())
except queue.Empty:
break
return messages
# Global bridge instance
tui_bridge = TUIBridge()

119
tools/c2/tui/styles/c2.tcss Normal file
View File

@ -0,0 +1,119 @@
/* C3PO TUI Stylesheet - Multi-device view */
Screen {
background: $surface;
}
/* Header/Tab bar */
#tab-bar {
height: 1;
dock: top;
background: $surface-darken-1;
}
/* Main content area */
#main-content {
height: 1fr;
}
/* Left panel: all devices stacked */
#devices-panel {
width: 1fr;
min-width: 30;
}
#no-device-placeholder {
width: 100%;
height: 100%;
content-align: center middle;
color: $text-muted;
}
/* Right panel: global logs */
#global-log-container {
width: 1fr;
min-width: 30;
border: solid $primary;
border-title-color: $text;
border-title-style: bold;
}
/* Input area */
#input-container {
height: 3;
dock: bottom;
background: $surface-darken-1;
border-top: solid $primary;
padding: 0;
}
#command-input {
width: 1fr;
height: 1;
margin: 0;
padding: 0 1;
}
#shortcuts-bar {
height: 1;
width: 100%;
background: $surface-darken-2;
color: $text-muted;
padding: 0 1;
}
/* Device containers - each device in its own bordered box */
DeviceContainer {
height: 1fr;
min-height: 5;
border: solid $secondary;
border-title-color: $text;
border-title-style: bold;
margin-bottom: 0;
}
/* Log pane inside device container */
DeviceLogPane {
height: 100%;
scrollbar-size: 1 1;
}
/* Global log pane */
GlobalLogPane {
height: 100%;
scrollbar-size: 1 1;
}
/* Log colors */
.log-system {
color: cyan;
}
.log-device {
color: yellow;
}
.log-error {
color: red;
}
.log-command {
color: blue;
}
.log-response {
color: green;
}
/* Status indicator */
.status-connected {
color: green;
}
.status-inactive {
color: yellow;
}
.status-disconnected {
color: red;
}

View File

@ -0,0 +1,5 @@
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
from tui.widgets.command_input import CommandInput
from tui.widgets.device_tabs import DeviceTabs
__all__ = ["GlobalLogPane", "DeviceLogPane", "CommandInput", "DeviceTabs"]

View File

@ -0,0 +1,215 @@
"""
Command input widget with history and zsh-style tab completion.
"""
from textual.widgets import Input
from textual.message import Message
from typing import Callable, Optional
class CommandInput(Input):
"""Command input with history and zsh-style tab completion."""
DEFAULT_CSS = """
CommandInput {
dock: bottom;
height: 1;
border: none;
background: $surface;
padding: 0 1;
}
CommandInput:focus {
border: none;
}
"""
class CommandSubmitted(Message):
"""Posted when a command is submitted."""
def __init__(self, command: str):
self.command = command
super().__init__()
class CompletionsAvailable(Message):
"""Posted when multiple completions are available."""
def __init__(self, completions: list[str], word: str):
self.completions = completions
self.word = word
super().__init__()
def __init__(self, **kwargs):
super().__init__(
placeholder="c2:> Type command here...",
**kwargs
)
self._history: list[str] = []
self._history_index: int = -1
self._current_input: str = ""
self._completer: Optional[Callable[[str, int], Optional[str]]] = None
self._last_completion_text: str = ""
self._last_completions: list[str] = []
self._completion_cycle_index: int = 0
def set_completer(self, completer: Callable[[str, int], Optional[str]]):
"""Set the tab completion function (same signature as readline completer)."""
self._completer = completer
def on_key(self, event) -> None:
"""Handle special keys for history and completion."""
if event.key == "up":
event.prevent_default()
self._navigate_history(-1)
elif event.key == "down":
event.prevent_default()
self._navigate_history(1)
elif event.key == "tab":
event.prevent_default()
self._handle_tab_completion()
def _get_all_completions(self, word: str) -> list[str]:
"""Get all possible completions for a word."""
if not self._completer:
return []
completions = []
state = 0
while True:
completion = self._completer(word, state)
if completion is None:
break
completions.append(completion)
state += 1
return completions
def _find_common_prefix(self, strings: list[str]) -> str:
"""Find the longest common prefix among strings."""
if not strings:
return ""
if len(strings) == 1:
return strings[0]
prefix = strings[0]
for s in strings[1:]:
while not s.startswith(prefix):
prefix = prefix[:-1]
if not prefix:
return ""
return prefix
def _handle_tab_completion(self):
"""Handle zsh-style tab completion."""
if not self._completer:
return
current_text = self.value
cursor_pos = self.cursor_position
# Get the word being completed
text_before_cursor = current_text[:cursor_pos]
parts = text_before_cursor.split()
if not parts:
word_to_complete = ""
elif text_before_cursor.endswith(" "):
word_to_complete = ""
else:
word_to_complete = parts[-1]
# Check if context changed (new completion session)
context_changed = text_before_cursor != self._last_completion_text
if context_changed:
# New completion session - get all completions
self._last_completions = self._get_all_completions(word_to_complete)
self._completion_cycle_index = 0
self._last_completion_text = text_before_cursor
if not self._last_completions:
# No completions
return
if len(self._last_completions) == 1:
# Single match - complete directly
self._apply_completion(self._last_completions[0], word_to_complete, cursor_pos)
self._last_completions = []
return
# Multiple matches - complete to common prefix and show options
common_prefix = self._find_common_prefix(self._last_completions)
if common_prefix and len(common_prefix) > len(word_to_complete):
# Complete to common prefix
self._apply_completion(common_prefix, word_to_complete, cursor_pos)
# Show all completions
self.post_message(self.CompletionsAvailable(
self._last_completions.copy(),
word_to_complete
))
else:
# Same context - cycle through completions
if not self._last_completions:
return
# Get next completion in cycle
completion = self._last_completions[self._completion_cycle_index]
self._apply_completion(completion, word_to_complete, cursor_pos)
# Advance cycle
self._completion_cycle_index = (self._completion_cycle_index + 1) % len(self._last_completions)
def _apply_completion(self, completion: str, word_to_complete: str, cursor_pos: int):
"""Apply a completion to the input."""
current_text = self.value
text_before_cursor = current_text[:cursor_pos]
if word_to_complete:
prefix = text_before_cursor[:-len(word_to_complete)]
else:
prefix = text_before_cursor
new_text = prefix + completion + current_text[cursor_pos:]
new_cursor = len(prefix) + len(completion)
self.value = new_text
self.cursor_position = new_cursor
self._last_completion_text = new_text[:new_cursor]
def _navigate_history(self, direction: int):
"""Navigate through command history."""
if not self._history:
return
if self._history_index == -1:
self._current_input = self.value
new_index = self._history_index + direction
if new_index < -1:
new_index = -1
elif new_index >= len(self._history):
new_index = len(self._history) - 1
self._history_index = new_index
if self._history_index == -1:
self.value = self._current_input
else:
self.value = self._history[-(self._history_index + 1)]
self.cursor_position = len(self.value)
def action_submit(self) -> None:
"""Submit the current command."""
command = self.value.strip()
if command:
self._history.append(command)
if len(self._history) > 100:
self._history.pop(0)
self.post_message(self.CommandSubmitted(command))
self.value = ""
self._history_index = -1
self._current_input = ""
self._last_completions = []
self._completion_cycle_index = 0
self._last_completion_text = ""

View File

@ -0,0 +1,159 @@
"""
Dynamic device tabs widget.
"""
from textual.widgets import Static, Button
from textual.containers import Horizontal
from textual.message import Message
from textual.reactive import reactive
class DeviceTabs(Horizontal):
"""Tab bar for device switching with dynamic updates."""
DEFAULT_CSS = """
DeviceTabs {
height: 1;
width: 100%;
background: $surface;
padding: 0;
}
DeviceTabs .tab-label {
padding: 0 1;
height: 1;
min-width: 8;
}
DeviceTabs .tab-label.active {
background: $primary;
color: $text;
text-style: bold;
}
DeviceTabs .tab-label:hover {
background: $primary-darken-1;
}
DeviceTabs .header-label {
padding: 0 1;
height: 1;
color: $text-muted;
}
DeviceTabs .separator {
padding: 0;
height: 1;
color: $text-muted;
}
DeviceTabs .device-count {
dock: right;
padding: 0 1;
height: 1;
color: $text-muted;
}
"""
active_tab: reactive[str] = reactive("global")
devices_hidden: reactive[bool] = reactive(False)
class TabSelected(Message):
"""Posted when a tab is selected."""
def __init__(self, tab_id: str, device_id: str | None = None):
self.tab_id = tab_id
self.device_id = device_id
super().__init__()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._devices: list[str] = []
def compose(self):
yield Static("C3PO", classes="header-label", id="c3po-label")
yield Static(" \u2500 ", classes="separator")
yield Static("[G]lobal", classes="tab-label active", id="tab-global")
yield Static(" [H]ide", classes="tab-label", id="tab-hide")
yield Static("", classes="device-count", id="device-count")
def add_device(self, device_id: str):
"""Add a device tab."""
if device_id not in self._devices:
self._devices.append(device_id)
self._rebuild_tabs()
def remove_device(self, device_id: str):
"""Remove a device tab."""
if device_id in self._devices:
self._devices.remove(device_id)
if self.active_tab == device_id:
self.active_tab = "global"
self._rebuild_tabs()
def _rebuild_tabs(self):
"""Rebuild all tabs."""
for widget in list(self.children):
if hasattr(widget, 'id') and widget.id and widget.id.startswith("tab-device-"):
widget.remove()
hide_tab = self.query_one("#tab-hide", Static)
for i, device_id in enumerate(self._devices):
if i < 9:
label = f"[{i+1}]{device_id}"
tab = Static(
label,
classes="tab-label" + (" active" if self.active_tab == device_id else ""),
id=f"tab-device-{device_id}"
)
self.mount(tab, before=hide_tab)
count_label = self.query_one("#device-count", Static)
count_label.update(f"{len(self._devices)} device{'s' if len(self._devices) != 1 else ''}")
def select_tab(self, tab_id: str):
"""Select a tab by ID."""
if tab_id == "global":
self.active_tab = "global"
self.post_message(self.TabSelected("global"))
elif tab_id in self._devices:
self.active_tab = tab_id
self.post_message(self.TabSelected(tab_id, tab_id))
self._update_active_styles()
def select_by_index(self, index: int):
"""Select device tab by numeric index (1-9)."""
if 0 < index <= len(self._devices):
device_id = self._devices[index - 1]
self.select_tab(device_id)
def toggle_hide(self):
"""Toggle device panes visibility."""
self.devices_hidden = not self.devices_hidden
hide_tab = self.query_one("#tab-hide", Static)
hide_tab.update("[H]ide" if not self.devices_hidden else "[H]show")
def _update_active_styles(self):
"""Update tab styles to show active state."""
for tab in self.query(".tab-label"):
tab.remove_class("active")
if self.active_tab == "global":
self.query_one("#tab-global", Static).add_class("active")
else:
try:
self.query_one(f"#tab-device-{self.active_tab}", Static).add_class("active")
except Exception:
pass
def on_click(self, event) -> None:
"""Handle tab clicks."""
target = event.target
if hasattr(target, 'id') and target.id:
if target.id == "tab-global":
self.select_tab("global")
elif target.id == "tab-hide":
self.toggle_hide()
elif target.id.startswith("tab-device-"):
device_id = target.id.replace("tab-device-", "")
self.select_tab(device_id)

View File

@ -0,0 +1,117 @@
"""
Log pane widgets for displaying device and global logs.
"""
from textual.widgets import RichLog
from rich.text import Text
class GlobalLogPane(RichLog):
"""Combined log view for all devices and system messages."""
DEFAULT_CSS = """
GlobalLogPane {
border: solid $primary;
height: 100%;
scrollbar-size: 1 1;
}
"""
def __init__(self, **kwargs):
super().__init__(
highlight=True,
markup=True,
wrap=True,
max_lines=5000,
**kwargs
)
def add_system(self, timestamp: str, message: str):
"""Add a system message."""
text = Text()
text.append(f"{timestamp} ", style="dim")
text.append("[SYS] ", style="cyan bold")
text.append(message)
self.write(text)
def add_device_event(self, timestamp: str, device_id: str, event: str):
"""Add a device event."""
text = Text()
text.append(f"{timestamp} ", style="dim")
text.append(f"[{device_id}] ", style="yellow")
text.append(event)
self.write(text)
def add_command_sent(self, timestamp: str, device_id: str, command: str, request_id: str):
"""Add a command sent message."""
text = Text()
text.append(f"{timestamp} ", style="dim")
text.append("[CMD] ", style="blue bold")
text.append(f"{command} ", style="blue")
text.append(f"-> {device_id}", style="dim")
self.write(text)
def add_command_response(self, timestamp: str, device_id: str, response: str, request_id: str):
"""Add a command response."""
text = Text()
text.append(f"{timestamp} ", style="dim")
text.append(f"[{device_id}] ", style="green")
text.append(response, style="green")
self.write(text)
def add_error(self, timestamp: str, message: str):
"""Add an error message."""
text = Text()
text.append(f"{timestamp} ", style="dim")
text.append("[ERR] ", style="red bold")
text.append(message, style="red")
self.write(text)
class DeviceLogPane(RichLog):
"""Per-device log display with filtering."""
DEFAULT_CSS = """
DeviceLogPane {
height: 100%;
scrollbar-size: 1 1;
}
"""
def __init__(self, device_id: str, **kwargs):
super().__init__(
highlight=True,
markup=True,
wrap=True,
max_lines=2000,
**kwargs
)
self.device_id = device_id
def add_event(self, timestamp: str, event: str, event_type: str = "info"):
"""Add an event to this device's log."""
text = Text()
text.append(f"{timestamp} ", style="dim")
style_map = {
"info": "yellow",
"log": "white",
"error": "red",
"cmd_sent": "blue",
"cmd_resp": "green",
"data": "magenta",
}
style = style_map.get(event_type, "white")
prefix_map = {
"info": "> INFO: ",
"log": "> LOG: ",
"error": "> ERROR: ",
"cmd_sent": "> CMD: ",
"cmd_resp": "> RESP: ",
"data": "> DATA: ",
}
prefix = prefix_map.get(event_type, "> ")
text.append(prefix, style=f"{style} bold")
text.append(event, style=style)
self.write(text)

View File

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

View File

@ -22,7 +22,7 @@ class UnifiedWebServer:
Provides:
- Dashboard: View connected ESP32 devices
- Cameras: View live camera streams
- Cameras: View live camera streams with recording
- Trilateration: Visualize BLE device positioning
"""
@ -35,7 +35,8 @@ class UnifiedWebServer:
secret_key: str = "change_this_for_prod",
multilat_token: str = "multilat_secret_token",
device_registry=None,
mlat_engine: Optional[MlatEngine] = None):
mlat_engine: Optional[MlatEngine] = None,
camera_receiver=None):
"""
Initialize the unified web server.
@ -49,6 +50,7 @@ class UnifiedWebServer:
multilat_token: Bearer token for MLAT API
device_registry: DeviceRegistry instance for device listing
mlat_engine: MlatEngine instance (created if None)
camera_receiver: UDPReceiver instance for camera control
"""
self.host = host
self.port = port
@ -59,6 +61,7 @@ class UnifiedWebServer:
self.multilat_token = multilat_token
self.device_registry = device_registry
self.mlat = mlat_engine or MlatEngine()
self.camera_receiver = camera_receiver
# Ensure image directory exists
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -69,13 +72,16 @@ class UnifiedWebServer:
self._server = None
self._thread = None
def set_camera_receiver(self, receiver):
"""Set the camera receiver after initialization."""
self.camera_receiver = receiver
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _create_app(self) -> Flask:
"""Create and configure the Flask application."""
# Get the c2 root directory for templates
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
template_dir = os.path.join(c2_root, "templates")
static_dir = os.path.join(c2_root, "static")
@ -85,7 +91,6 @@ class UnifiedWebServer:
static_folder=static_dir)
app.secret_key = self.secret_key
# Store reference to self for route handlers
web_server = self
# ========== Auth Decorators ==========
@ -99,18 +104,15 @@ class UnifiedWebServer:
return decorated
def require_api_auth(f):
"""Require session login OR Bearer token for API endpoints."""
@wraps(f)
def decorated(*args, **kwargs):
# Check session
if session.get("logged_in"):
return f(*args, **kwargs)
# Check Bearer token
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if token == web_server.mlat_token:
if token == web_server.multilat_token:
return f(*args, **kwargs)
return jsonify({"error": "Unauthorized"}), 401
@ -151,7 +153,6 @@ class UnifiedWebServer:
@app.route("/cameras")
@require_login
def cameras():
# List available camera images
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
image_files = sorted([
@ -176,12 +177,17 @@ class UnifiedWebServer:
full_image_dir = os.path.join(c2_root, web_server.image_dir)
return send_from_directory(full_image_dir, filename)
@app.route("/recordings/<filename>")
@require_login
def download_recording(filename):
recordings_dir = os.path.join(c2_root, "static", "recordings")
return send_from_directory(recordings_dir, filename, as_attachment=True)
# ========== Device API ==========
@app.route("/api/devices")
@require_api_auth
def api_devices():
"""Get list of connected devices."""
if web_server.device_registry is None:
return jsonify({"error": "Device registry not available", "devices": []})
@ -210,7 +216,6 @@ class UnifiedWebServer:
@app.route("/api/cameras")
@require_api_auth
def api_cameras():
"""Get list of active cameras."""
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
cameras = [
@ -221,24 +226,68 @@ class UnifiedWebServer:
except FileNotFoundError:
cameras = []
return jsonify({"cameras": cameras, "count": len(cameras)})
# Add recording status if receiver available
result = {"cameras": [], "count": len(cameras)}
for cam_id in cameras:
cam_info = {"id": cam_id, "recording": False}
if web_server.camera_receiver:
status = web_server.camera_receiver.get_recording_status(cam_id)
cam_info["recording"] = status.get("recording", False)
cam_info["filename"] = status.get("filename")
result["cameras"].append(cam_info)
result["count"] = len(result["cameras"])
return jsonify(result)
# ========== Recording API ==========
@app.route("/api/recording/start/<camera_id>", methods=["POST"])
@require_api_auth
def api_recording_start(camera_id):
if not web_server.camera_receiver:
return jsonify({"error": "Camera receiver not available"}), 503
result = web_server.camera_receiver.start_recording(camera_id)
if "error" in result:
return jsonify(result), 400
return jsonify(result)
@app.route("/api/recording/stop/<camera_id>", methods=["POST"])
@require_api_auth
def api_recording_stop(camera_id):
if not web_server.camera_receiver:
return jsonify({"error": "Camera receiver not available"}), 503
result = web_server.camera_receiver.stop_recording(camera_id)
if "error" in result:
return jsonify(result), 400
return jsonify(result)
@app.route("/api/recording/status")
@require_api_auth
def api_recording_status():
if not web_server.camera_receiver:
return jsonify({"error": "Camera receiver not available"}), 503
camera_id = request.args.get("camera_id")
return jsonify(web_server.camera_receiver.get_recording_status(camera_id))
@app.route("/api/recordings")
@require_api_auth
def api_recordings_list():
if not web_server.camera_receiver:
return jsonify({"recordings": []})
return jsonify({"recordings": web_server.camera_receiver.list_recordings()})
# ========== Trilateration API ==========
@app.route("/api/mlat/collect", methods=["POST"])
@require_api_auth
def api_mlat_collect():
"""
Receive multilateration data from ESP32 scanners.
Expected format (text/plain):
ESP_ID;(x,y);rssi
ESP3;(10.0,0.0);-45
"""
raw_data = request.get_data(as_text=True)
count = web_server.mlat.parse_data(raw_data)
# Recalculate position after new data
if count > 0:
web_server.mlat.calculate_position()
@ -250,10 +299,8 @@ class UnifiedWebServer:
@app.route("/api/mlat/state")
@require_api_auth
def api_mlat_state():
"""Get current multilateration state (scanners + target)."""
state = web_server.mlat.get_state()
# Include latest calculation if not present
if state["target"] is None and state["scanners_count"] >= 3:
result = web_server.mlat.calculate_position()
if "position" in result:
@ -269,7 +316,6 @@ class UnifiedWebServer:
@app.route("/api/mlat/config", methods=["GET", "POST"])
@require_api_auth
def api_mlat_config():
"""Get or update multilateration configuration."""
if request.method == "POST":
data = request.get_json() or {}
web_server.mlat.update_config(
@ -287,7 +333,6 @@ class UnifiedWebServer:
@app.route("/api/mlat/clear", methods=["POST"])
@require_api_auth
def api_mlat_clear():
"""Clear all multilateration data."""
web_server.mlat.clear()
return jsonify({"status": "ok"})
@ -296,7 +341,6 @@ class UnifiedWebServer:
@app.route("/api/stats")
@require_api_auth
def api_stats():
"""Get overall server statistics."""
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
camera_count = len([