ε - TUI multi-pane Textual + camera recording frontend + device naming fix
This commit is contained in:
parent
f2a5b50bfd
commit
ce6f00e24a
6
.gitignore
vendored
6
.gitignore
vendored
@ -43,12 +43,16 @@ tools/c3po/config.json
|
|||||||
**/config.local.json
|
**/config.local.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
.avi
|
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
espilon_bot/logs/
|
espilon_bot/logs/
|
||||||
sdkconfig
|
sdkconfig
|
||||||
|
|
||||||
|
# C2 Runtime files (camera streams, recordings)
|
||||||
|
tools/c2/static/streams/*.jpg
|
||||||
|
tools/c2/static/recordings/*.avi
|
||||||
|
*.avi
|
||||||
|
|
||||||
# IDE and Editor
|
# IDE and Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
static const char *TAG = "COMMAND";
|
static const char *TAG = "COMMAND";
|
||||||
|
|
||||||
@ -36,7 +37,45 @@ void command_register(const command_t *cmd)
|
|||||||
}
|
}
|
||||||
|
|
||||||
registry[registry_count++] = cmd;
|
registry[registry_count++] = cmd;
|
||||||
ESP_LOGI(TAG, "Registered command: %s", cmd->name);
|
#ifdef CONFIG_ESPILON_LOG_CMD_REG_VERBOSE
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "Registered command: %s", cmd->name);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
* Summary
|
||||||
|
* ========================================================= */
|
||||||
|
void command_log_registry_summary(void)
|
||||||
|
{
|
||||||
|
if (registry_count == 0) {
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "Registered commands: none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char buf[512];
|
||||||
|
int off = snprintf(
|
||||||
|
buf,
|
||||||
|
sizeof(buf),
|
||||||
|
"Registered commands (%d): ",
|
||||||
|
(int)registry_count
|
||||||
|
);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < registry_count; i++) {
|
||||||
|
const char *name = registry[i] && registry[i]->name
|
||||||
|
? registry[i]->name : "?";
|
||||||
|
const char *sep = (i == 0) ? "" : ", ";
|
||||||
|
int n = snprintf(buf + off, sizeof(buf) - (size_t)off,
|
||||||
|
"%s%s", sep, name);
|
||||||
|
if (n < 0 || n >= (int)(sizeof(buf) - (size_t)off)) {
|
||||||
|
if (off < (int)sizeof(buf) - 4) {
|
||||||
|
strcpy(buf + (sizeof(buf) - 4), "...");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
off += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "%s", buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
|
|||||||
@ -39,6 +39,7 @@ typedef struct {
|
|||||||
* Registry
|
* Registry
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
void command_register(const command_t *cmd);
|
void command_register(const command_t *cmd);
|
||||||
|
void command_log_registry_summary(void);
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* Dispatcher (called by process.c)
|
* Dispatcher (called by process.c)
|
||||||
|
|||||||
@ -62,7 +62,7 @@ void command_async_init(void)
|
|||||||
NULL
|
NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Async command system ready");
|
ESPILON_LOGI_PURPLE(TAG, "Async command system ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
|
|||||||
@ -36,6 +36,7 @@ void wifi_init(void)
|
|||||||
ESP_ERROR_CHECK(esp_netif_init());
|
ESP_ERROR_CHECK(esp_netif_init());
|
||||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
esp_netif_create_default_wifi_sta();
|
esp_netif_create_default_wifi_sta();
|
||||||
|
esp_netif_create_default_wifi_ap();
|
||||||
|
|
||||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||||
|
|||||||
@ -9,7 +9,7 @@ bool com_init(void)
|
|||||||
{
|
{
|
||||||
#ifdef CONFIG_NETWORK_WIFI
|
#ifdef CONFIG_NETWORK_WIFI
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Init WiFi backend");
|
ESPILON_LOGI_PURPLE(TAG, "Init WiFi backend");
|
||||||
|
|
||||||
wifi_init();
|
wifi_init();
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ bool com_init(void)
|
|||||||
|
|
||||||
#elif defined(CONFIG_NETWORK_GPRS)
|
#elif defined(CONFIG_NETWORK_GPRS)
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Init GPRS backend");
|
ESPILON_LOGI_PURPLE(TAG, "Init GPRS backend");
|
||||||
|
|
||||||
setup_uart();
|
setup_uart();
|
||||||
setup_modem();
|
setup_modem();
|
||||||
|
|||||||
@ -192,6 +192,7 @@ bool c2_decode_and_exec(const char *frame)
|
|||||||
free(plain);
|
free(plain);
|
||||||
|
|
||||||
/* 4) Log + dispatch */
|
/* 4) Log + dispatch */
|
||||||
|
#ifdef CONFIG_ESPILON_LOG_C2_VERBOSE
|
||||||
ESP_LOGI(TAG, "==== C2 COMMAND ====");
|
ESP_LOGI(TAG, "==== C2 COMMAND ====");
|
||||||
ESP_LOGI(TAG, "name: %s", cmd.command_name);
|
ESP_LOGI(TAG, "name: %s", cmd.command_name);
|
||||||
ESP_LOGI(TAG, "argc: %d", cmd.argv_count);
|
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, "arg[%d]=%s", i, cmd.argv[i]);
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "====================");
|
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);
|
process_command(&cmd);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -7,6 +7,9 @@ extern "C" {
|
|||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
#include "sdkconfig.h"
|
#include "sdkconfig.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
@ -21,6 +24,36 @@ extern "C" {
|
|||||||
#define MAX_ARGS 10
|
#define MAX_ARGS 10
|
||||||
#define MAX_RESPONSE_SIZE 1024
|
#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 */
|
/* Socket TCP global */
|
||||||
extern int sock;
|
extern int sock;
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
idf_component_register(SRCS "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
idf_component_register(SRCS "cmd_fakeAP.c" "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
||||||
INCLUDE_DIRS .
|
INCLUDE_DIRS .
|
||||||
REQUIRES esp_http_server
|
REQUIRES esp_http_server
|
||||||
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core)
|
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core command)
|
||||||
@ -17,3 +17,18 @@ void fakeap_mark_authenticated(ip4_addr_t ip);
|
|||||||
/* Internal use only - exported for mod_web_server.c */
|
/* Internal use only - exported for mod_web_server.c */
|
||||||
extern ip4_addr_t authenticated_clients[MAX_CLIENTS];
|
extern ip4_addr_t authenticated_clients[MAX_CLIENTS];
|
||||||
extern int authenticated_count;
|
extern int authenticated_count;
|
||||||
|
|
||||||
|
/* ===== ACCESS POINT ===== */
|
||||||
|
void start_access_point(const char *ssid, const char *password, bool open);
|
||||||
|
void stop_access_point(void);
|
||||||
|
|
||||||
|
/* ===== CAPTIVE PORTAL ===== */
|
||||||
|
void *start_captive_portal(void);
|
||||||
|
void stop_captive_portal(void);
|
||||||
|
|
||||||
|
/* ===== SNIFFER ===== */
|
||||||
|
void start_sniffer(void);
|
||||||
|
void stop_sniffer(void);
|
||||||
|
|
||||||
|
/* ===== CLIENTS ===== */
|
||||||
|
void list_connected_clients(void);
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_wifi.h"
|
#include "esp_wifi.h"
|
||||||
#include "esp_netif.h"
|
#include "esp_netif.h"
|
||||||
#include "lwip/lwip_napt.h"
|
#include "esp_event.h"
|
||||||
#include "lwip/sockets.h"
|
#include "lwip/sockets.h"
|
||||||
#include "lwip/netdb.h"
|
#include "lwip/netdb.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@ -16,6 +16,12 @@
|
|||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
|
|
||||||
static const char *TAG = "MODULE_FAKE_AP";
|
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 ================= */
|
/* ================= AUTH ================= */
|
||||||
ip4_addr_t authenticated_clients[MAX_CLIENTS]; /* exported for mod_web_server.c */
|
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);
|
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
|
* 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));
|
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};
|
wifi_config_t cfg = {0};
|
||||||
strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid));
|
strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid));
|
||||||
cfg.ap.ssid_len = strlen(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));
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg));
|
||||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||||
|
|
||||||
esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
if (!ap_netif) {
|
||||||
esp_netif_ip_info_t ip;
|
ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
||||||
esp_netif_get_ip_info(ap, &ip);
|
}
|
||||||
|
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(
|
esp_netif_dhcps_option(
|
||||||
ap,
|
ap_netif,
|
||||||
ESP_NETIF_OP_SET,
|
ESP_NETIF_OP_SET,
|
||||||
ESP_NETIF_DOMAIN_NAME_SERVER,
|
ESP_NETIF_DOMAIN_NAME_SERVER,
|
||||||
&ip.ip,
|
&ip.ip,
|
||||||
sizeof(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));
|
dns_param_t *p = calloc(1, sizeof(*p));
|
||||||
p->captive_portal = open;
|
p->captive_portal = open;
|
||||||
@ -198,7 +349,10 @@ void dns_forwarder_task(void *pv)
|
|||||||
ip4_addr_t ip;
|
ip4_addr_t ip;
|
||||||
ip.addr = cli.sin_addr.s_addr;
|
ip.addr = cli.sin_addr.s_addr;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "DNS query from %s", ip4addr_ntoa(&ip));
|
||||||
|
|
||||||
if (captive && !fakeap_is_authenticated(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));
|
send_dns_spoof(sock, &cli, l, buf, r, inet_addr(CAPTIVE_PORTAL_IP));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,6 +76,8 @@ static const char *LOGIN_PAGE =
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
static esp_err_t captive_portal_handler(httpd_req_t *req)
|
static esp_err_t captive_portal_handler(httpd_req_t *req)
|
||||||
{
|
{
|
||||||
|
ESP_LOGI(TAG, "HTTP request received: %s", req->uri);
|
||||||
|
|
||||||
struct sockaddr_in addr;
|
struct sockaddr_in addr;
|
||||||
socklen_t len = sizeof(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;
|
ip4_addr_t client_ip;
|
||||||
client_ip.addr = addr.sin_addr.s_addr;
|
client_ip.addr = addr.sin_addr.s_addr;
|
||||||
|
ESP_LOGI(TAG, "Client IP: %s", ip4addr_ntoa(&client_ip));
|
||||||
|
|
||||||
if (is_already_authenticated(client_ip)) {
|
if (is_already_authenticated(client_ip)) {
|
||||||
httpd_resp_set_status(req, "302 Found");
|
httpd_resp_set_status(req, "302 Found");
|
||||||
|
|||||||
@ -98,18 +98,79 @@ static int cmd_system_uptime(
|
|||||||
return 0;
|
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
|
* COMMAND REGISTRATION
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
static const command_t system_cmds[] = {
|
static const command_t system_cmds[] = {
|
||||||
{ "system_reboot", 0, 0, cmd_system_reboot, NULL, false },
|
{ "system_reboot", 0, 0, cmd_system_reboot, NULL, false },
|
||||||
{ "system_mem", 0, 0, cmd_system_mem, 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)
|
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++) {
|
for (size_t i = 0; i < sizeof(system_cmds)/sizeof(system_cmds[0]); i++) {
|
||||||
command_register(&system_cmds[i]);
|
command_register(&system_cmds[i]);
|
||||||
|
|||||||
@ -126,4 +126,52 @@ config CRYPTO_NONCE
|
|||||||
|
|
||||||
endmenu
|
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
|
endmenu
|
||||||
|
|||||||
@ -27,6 +27,31 @@
|
|||||||
|
|
||||||
static const char *TAG = "MAIN";
|
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)
|
static void init_nvs(void)
|
||||||
{
|
{
|
||||||
esp_err_t ret = nvs_flash_init();
|
esp_err_t ret = nvs_flash_init();
|
||||||
@ -40,10 +65,10 @@ static void init_nvs(void)
|
|||||||
|
|
||||||
void app_main(void)
|
void app_main(void)
|
||||||
{
|
{
|
||||||
|
espilon_log_init();
|
||||||
ESP_LOGI(TAG, "Booting system");
|
ESP_LOGI(TAG, "Booting system");
|
||||||
|
|
||||||
init_nvs();
|
init_nvs();
|
||||||
vTaskDelay(pdMS_TO_TICKS(1200));
|
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
* Command system
|
* Command system
|
||||||
@ -55,28 +80,31 @@ void app_main(void)
|
|||||||
/* Register enabled modules */
|
/* Register enabled modules */
|
||||||
#ifdef CONFIG_MODULE_NETWORK
|
#ifdef CONFIG_MODULE_NETWORK
|
||||||
mod_network_register_commands();
|
mod_network_register_commands();
|
||||||
ESP_LOGI(TAG, "Network module loaded");
|
ESPILON_LOGI_PURPLE(TAG, "Network module loaded");
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef CONFIG_MODULE_FAKEAP
|
#ifdef CONFIG_MODULE_FAKEAP
|
||||||
mod_fakeap_register_commands();
|
mod_fakeap_register_commands();
|
||||||
ESP_LOGI(TAG, "FakeAP module loaded");
|
ESPILON_LOGI_PURPLE(TAG, "FakeAP module loaded");
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef CONFIG_MODULE_RECON
|
#ifdef CONFIG_MODULE_RECON
|
||||||
#ifdef CONFIG_RECON_MODE_CAMERA
|
#ifdef CONFIG_RECON_MODE_CAMERA
|
||||||
mod_camera_register_commands();
|
mod_camera_register_commands();
|
||||||
ESP_LOGI(TAG, "Camera module loaded");
|
ESPILON_LOGI_PURPLE(TAG, "Camera module loaded");
|
||||||
#endif
|
#endif
|
||||||
#ifdef CONFIG_RECON_MODE_MLAT
|
#ifdef CONFIG_RECON_MODE_MLAT
|
||||||
mod_mlat_register_commands();
|
mod_mlat_register_commands();
|
||||||
ESP_LOGI(TAG, "MLAT module loaded");
|
ESPILON_LOGI_PURPLE(TAG, "MLAT module loaded");
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
command_log_registry_summary();
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
* Network backend
|
* Network backend
|
||||||
* ===================================================== */
|
* ===================================================== */
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1200));
|
||||||
if (!com_init()) {
|
if (!com_init()) {
|
||||||
ESP_LOGE(TAG, "Network backend init failed");
|
ESP_LOGE(TAG, "Network backend init failed");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -3,13 +3,8 @@ import socket
|
|||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time # Added missing import
|
import time
|
||||||
|
import argparse
|
||||||
#!/usr/bin/env python3
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from core.registry import DeviceRegistry
|
from core.registry import DeviceRegistry
|
||||||
from core.transport import Transport
|
from core.transport import Transport
|
||||||
@ -19,7 +14,7 @@ from commands.registry import CommandRegistry
|
|||||||
from commands.reboot import RebootCommand
|
from commands.reboot import RebootCommand
|
||||||
from core.groups import GroupRegistry
|
from core.groups import GroupRegistry
|
||||||
from utils.constant import HOST, PORT
|
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')
|
# Strict base64 validation (ESP sends BASE64 + '\n')
|
||||||
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
|
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
|
||||||
@ -88,32 +83,25 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
|
|||||||
# Main server
|
# Main server
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def main():
|
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 = """
|
header = """
|
||||||
|
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\\
|
||||||
$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
|
$$ __$$\\ $$ ___$$\\ $$ __$$\\ $$ __$$\\
|
||||||
|
$$ / \\__|\_/ $$ |$$ | $$ |$$ / $$ |
|
||||||
$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\
|
|
||||||
$$ _____|$$ __$$\ $$ __$$\ \_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
|
|
||||||
$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ |
|
|
||||||
$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ |
|
|
||||||
$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/
|
|
||||||
$$ | $$\ $$ |$$ | $$ | $$ | $$ | $$ |$$ |\$$$ | $$ | $$\ $$ |
|
|
||||||
$$$$$$$$\ \$$$$$$ |$$ | $$$$$$\ $$$$$$$$\ $$$$$$ |$$ | \$$ | \$$$$$$ |$$$$$$$$\
|
|
||||||
\________| \______/ \__| \______|\________|\______/ \__| \__| \______/ \________|
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\
|
|
||||||
$$ __$$\ $$ ___$$\ $$ __$$\ $$ __$$\
|
|
||||||
$$ / \__|\_/ $$ |$$ | $$ |$$ / $$ |
|
|
||||||
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
|
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
|
||||||
$$ | \___$$\ $$ ____/ $$ | $$ |
|
$$ | \\___$$\\ $$ ____/ $$ | $$ |
|
||||||
$$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
$$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
|
||||||
\$$$$$$ |\$$$$$$ |$$ | $$$$$$ |
|
\\$$$$$$ |\\$$$$$$ |$$ | $$$$$$ |
|
||||||
\______/ \______/ \__| \______/
|
\\______/ \\______/ \\__| \\______/
|
||||||
|
|
||||||
ESPILON C2 Framework - Command and Control Server
|
ESPILON C2 Framework - Command and Control Server
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if not args.tui:
|
||||||
Display.system_message(header)
|
Display.system_message(header)
|
||||||
Display.system_message("Initializing ESPILON C2 core...")
|
Display.system_message("Initializing ESPILON C2 core...")
|
||||||
|
|
||||||
@ -150,6 +138,8 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
server.listen()
|
server.listen()
|
||||||
|
|
||||||
|
if not args.tui:
|
||||||
Display.system_message(f"Server listening on {HOST}:{PORT}")
|
Display.system_message(f"Server listening on {HOST}:{PORT}")
|
||||||
|
|
||||||
# Function to periodically check device status
|
# Function to periodically check device status
|
||||||
@ -162,31 +152,52 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
|||||||
device.status = "Inactive"
|
device.status = "Inactive"
|
||||||
Display.device_event(device.id, "Status changed to Inactive (timeout)")
|
Display.device_event(device.id, "Status changed to Inactive (timeout)")
|
||||||
elif device.status == "Inactive" and now - device.last_seen <= DEVICE_TIMEOUT_SECONDS:
|
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"
|
device.status = "Connected"
|
||||||
Display.device_event(device.id, "Status changed to Connected (heartbeat received)")
|
Display.device_event(device.id, "Status changed to Connected (heartbeat received)")
|
||||||
time.sleep(HEARTBEAT_CHECK_INTERVAL)
|
time.sleep(HEARTBEAT_CHECK_INTERVAL)
|
||||||
|
|
||||||
# CLI thread
|
# Function to accept client connections
|
||||||
threading.Thread(target=cli.loop, daemon=True).start()
|
def accept_loop():
|
||||||
# Device status checker thread
|
|
||||||
threading.Thread(target=device_status_checker, daemon=True).start()
|
|
||||||
|
|
||||||
# Accept loop
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
sock, addr = server.accept()
|
sock, addr = server.accept()
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=client_thread,
|
target=client_thread,
|
||||||
args=(sock, addr, transport, registry), # Pass registry to client_thread
|
args=(sock, addr, transport, registry),
|
||||||
daemon=True
|
daemon=True
|
||||||
).start()
|
).start()
|
||||||
except KeyboardInterrupt:
|
except OSError:
|
||||||
Display.system_message("Shutdown requested. Exiting...")
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Display.error(f"Server error: {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()
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# TUI or CLI mode
|
||||||
|
# ============================
|
||||||
|
if args.tui:
|
||||||
|
try:
|
||||||
|
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...")
|
||||||
|
|
||||||
server.close()
|
server.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -85,47 +85,57 @@ class CLI:
|
|||||||
if not cmd:
|
if not cmd:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if cmd == "exit":
|
||||||
|
return
|
||||||
|
|
||||||
|
self.execute_command(cmd)
|
||||||
|
|
||||||
|
def execute_command(self, cmd: str):
|
||||||
|
"""Execute a command string. Used by both CLI loop and TUI."""
|
||||||
|
if not cmd:
|
||||||
|
return
|
||||||
|
|
||||||
parts = cmd.split()
|
parts = cmd.split()
|
||||||
action = parts[0]
|
action = parts[0]
|
||||||
|
|
||||||
if action == "help":
|
if action == "help":
|
||||||
self.help_manager.show(parts[1:])
|
self.help_manager.show(parts[1:])
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "exit":
|
if action == "exit":
|
||||||
return
|
return
|
||||||
|
|
||||||
if action == "clear":
|
if action == "clear":
|
||||||
os.system("cls" if os.name == "nt" else "clear")
|
os.system("cls" if os.name == "nt" else "clear")
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
self._handle_list()
|
self._handle_list()
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "modules":
|
if action == "modules":
|
||||||
self.help_manager.show_modules()
|
self.help_manager.show_modules()
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "group":
|
if action == "group":
|
||||||
self._handle_group(parts[1:])
|
self._handle_group(parts[1:])
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "send":
|
if action == "send":
|
||||||
self._handle_send(parts)
|
self._handle_send(parts)
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "active_commands":
|
if action == "active_commands":
|
||||||
self._handle_active_commands()
|
self._handle_active_commands()
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "web":
|
if action == "web":
|
||||||
self._handle_web(parts[1:])
|
self._handle_web(parts[1:])
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "camera":
|
if action == "camera":
|
||||||
self._handle_camera(parts[1:])
|
self._handle_camera(parts[1:])
|
||||||
continue
|
return
|
||||||
|
|
||||||
Display.error("Unknown command")
|
Display.error("Unknown command")
|
||||||
|
|
||||||
@ -333,7 +343,8 @@ class CLI:
|
|||||||
self.web_server = UnifiedWebServer(
|
self.web_server = UnifiedWebServer(
|
||||||
device_registry=self.registry,
|
device_registry=self.registry,
|
||||||
mlat_engine=self.mlat_engine,
|
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():
|
if self.web_server.start():
|
||||||
@ -391,11 +402,16 @@ class CLI:
|
|||||||
self.udp_receiver = UDPReceiver(
|
self.udp_receiver = UDPReceiver(
|
||||||
host=UDP_HOST,
|
host=UDP_HOST,
|
||||||
port=UDP_PORT,
|
port=UDP_PORT,
|
||||||
image_dir=IMAGE_DIR
|
image_dir=IMAGE_DIR,
|
||||||
|
device_registry=self.registry
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.udp_receiver.start():
|
if self.udp_receiver.start():
|
||||||
Display.system_message(f"Camera UDP receiver started on {UDP_HOST}:{UDP_PORT}")
|
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:
|
else:
|
||||||
Display.error("Camera UDP receiver failed to start")
|
Display.error("Camera UDP receiver failed to start")
|
||||||
|
|
||||||
@ -407,6 +423,9 @@ class CLI:
|
|||||||
self.udp_receiver.stop()
|
self.udp_receiver.stop()
|
||||||
Display.system_message("Camera UDP receiver stopped.")
|
Display.system_message("Camera UDP receiver stopped.")
|
||||||
self.udp_receiver = None
|
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":
|
elif cmd == "status":
|
||||||
Display.system_message("Camera UDP Receiver Status:")
|
Display.system_message("Camera UDP Receiver Status:")
|
||||||
|
|||||||
@ -54,6 +54,10 @@ class HelpManager:
|
|||||||
self.commands = command_registry
|
self.commands = command_registry
|
||||||
self.dev_mode = dev_mode
|
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]):
|
def show(self, args: list[str]):
|
||||||
if args:
|
if args:
|
||||||
self._show_command_help(args[0])
|
self._show_command_help(args[0])
|
||||||
@ -62,124 +66,128 @@ class HelpManager:
|
|||||||
|
|
||||||
def show_modules(self):
|
def show_modules(self):
|
||||||
"""Show ESP commands organized by module."""
|
"""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():
|
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():
|
for cmd_name, cmd_desc in module_info["commands"].items():
|
||||||
print(f" \033[36m{cmd_name:<12}\033[0m {cmd_desc}")
|
self._out(f" {cmd_name:<20} {cmd_desc}")
|
||||||
print()
|
self._out("")
|
||||||
|
|
||||||
print("\033[90mUse 'help <command>' for detailed help on a specific command.\033[0m")
|
self._out("Use 'help <command>' for detailed help on a specific command.")
|
||||||
print("\033[90mSend commands with: send <device_id|all> <command> [args...]\033[0m")
|
self._out("Send commands with: send <device_id|all> <command> [args...]")
|
||||||
|
|
||||||
def _show_global_help(self):
|
def _show_global_help(self):
|
||||||
Display.system_message("=== ESPILON C2 HELP ===")
|
self._out("=== ESPILON C2 HELP ===")
|
||||||
print("\n\033[1mC2 Commands:\033[0m")
|
self._out("")
|
||||||
print(" \033[36mhelp\033[0m [command] Show help or help for a specific command")
|
self._out("C2 Commands:")
|
||||||
print(" \033[36mlist\033[0m List connected ESP devices")
|
self._out(" help [command] Show help or help for a specific command")
|
||||||
print(" \033[36mmodules\033[0m List ESP commands organized by module")
|
self._out(" list List connected ESP devices")
|
||||||
print(" \033[36msend\033[0m <target> <cmd> Send a command to ESP device(s)")
|
self._out(" modules List ESP commands organized by module")
|
||||||
print(" \033[36mgroup\033[0m <action> Manage device groups (add, remove, list, show)")
|
self._out(" send <target> <cmd> Send a command to ESP device(s)")
|
||||||
print(" \033[36mactive_commands\033[0m List currently running commands")
|
self._out(" group <action> Manage device groups (add, remove, list, show)")
|
||||||
print(" \033[36mclear\033[0m Clear terminal screen")
|
self._out(" active_commands List currently running commands")
|
||||||
print(" \033[36mexit\033[0m Exit C2")
|
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()
|
registered_cmds = self.commands.list()
|
||||||
if registered_cmds:
|
if registered_cmds:
|
||||||
for name in registered_cmds:
|
for name in registered_cmds:
|
||||||
handler = self.commands.get(name)
|
handler = self.commands.get(name)
|
||||||
print(f" \033[36m{name:<15}\033[0m {handler.description}")
|
self._out(f" {name:<15} {handler.description}")
|
||||||
else:
|
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:
|
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):
|
def _show_command_help(self, command_name: str):
|
||||||
# CLI Commands
|
# CLI Commands
|
||||||
if command_name == "list":
|
if command_name == "list":
|
||||||
Display.system_message("Help for 'list' command:")
|
self._out("Help for 'list' command:")
|
||||||
print(" Usage: list")
|
self._out(" Usage: list")
|
||||||
print(" Description: Displays all connected ESP devices with ID, IP, status,")
|
self._out(" Description: Displays all connected ESP devices with ID, IP, status,")
|
||||||
print(" connection duration, and last seen timestamp.")
|
self._out(" connection duration, and last seen timestamp.")
|
||||||
|
|
||||||
elif command_name == "send":
|
elif command_name == "send":
|
||||||
Display.system_message("Help for 'send' command:")
|
self._out("Help for 'send' command:")
|
||||||
print(" Usage: send <device_id|all|group <name>> <command> [args...]")
|
self._out(" Usage: send <device_id|all|group <name>> <command> [args...]")
|
||||||
print(" Description: Sends a command to one or more ESP devices.")
|
self._out(" Description: Sends a command to one or more ESP devices.")
|
||||||
print(" Examples:")
|
self._out(" Examples:")
|
||||||
print(" send ESP_ABC123 reboot")
|
self._out(" send ESP_ABC123 reboot")
|
||||||
print(" send all wifi status")
|
self._out(" send all wifi status")
|
||||||
print(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
|
self._out(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
|
||||||
|
|
||||||
elif command_name == "group":
|
elif command_name == "group":
|
||||||
Display.system_message("Help for 'group' command:")
|
self._out("Help for 'group' command:")
|
||||||
print(" Usage: group <action> [args...]")
|
self._out(" Usage: group <action> [args...]")
|
||||||
print(" Actions:")
|
self._out(" Actions:")
|
||||||
print(" add <name> <id1> [id2...] Add devices to a group")
|
self._out(" add <name> <id1> [id2...] Add devices to a group")
|
||||||
print(" remove <name> <id1> [id2...] Remove devices from a group")
|
self._out(" remove <name> <id1> [id2...] Remove devices from a group")
|
||||||
print(" list List all groups")
|
self._out(" list List all groups")
|
||||||
print(" show <name> Show group members")
|
self._out(" show <name> Show group members")
|
||||||
|
|
||||||
elif command_name == "web":
|
elif command_name == "web":
|
||||||
Display.system_message("Help for 'web' command:")
|
self._out("Help for 'web' command:")
|
||||||
print(" Usage: web <start|stop|status>")
|
self._out(" Usage: web <start|stop|status>")
|
||||||
print(" Description: Control the web dashboard server.")
|
self._out(" Description: Control the web dashboard server.")
|
||||||
print(" Actions:")
|
self._out(" Actions:")
|
||||||
print(" start Start the web server (dashboard, cameras, MLAT)")
|
self._out(" start Start the web server (dashboard, cameras, MLAT)")
|
||||||
print(" stop Stop the web server")
|
self._out(" stop Stop the web server")
|
||||||
print(" status Show server status and MLAT engine info")
|
self._out(" status Show server status and MLAT engine info")
|
||||||
print(" Default URL: http://127.0.0.1:5000")
|
self._out(" Default URL: http://127.0.0.1:5000")
|
||||||
|
|
||||||
elif command_name == "camera":
|
elif command_name == "camera":
|
||||||
Display.system_message("Help for 'camera' command:")
|
self._out("Help for 'camera' command:")
|
||||||
print(" Usage: camera <start|stop|status>")
|
self._out(" Usage: camera <start|stop|status>")
|
||||||
print(" Description: Control the camera UDP receiver.")
|
self._out(" Description: Control the camera UDP receiver.")
|
||||||
print(" Actions:")
|
self._out(" Actions:")
|
||||||
print(" start Start UDP receiver for camera frames")
|
self._out(" start Start UDP receiver for camera frames")
|
||||||
print(" stop Stop UDP receiver")
|
self._out(" stop Stop UDP receiver")
|
||||||
print(" status Show receiver stats (packets, frames, errors)")
|
self._out(" status Show receiver stats (packets, frames, errors)")
|
||||||
print(" Default port: 12345")
|
self._out(" Default port: 12345")
|
||||||
|
|
||||||
elif command_name == "modules":
|
elif command_name == "modules":
|
||||||
Display.system_message("Help for 'modules' command:")
|
self._out("Help for 'modules' command:")
|
||||||
print(" Usage: modules")
|
self._out(" Usage: modules")
|
||||||
print(" Description: List all ESP32 commands organized by module.")
|
self._out(" Description: List all ESP32 commands organized by module.")
|
||||||
print(" Modules: system, network, fakeap, recon")
|
self._out(" Modules: system, network, fakeap, recon")
|
||||||
|
|
||||||
elif command_name in ["clear", "exit", "active_commands"]:
|
elif command_name in ["clear", "exit", "active_commands"]:
|
||||||
Display.system_message(f"Help for '{command_name}' command:")
|
self._out(f"Help for '{command_name}' command:")
|
||||||
print(f" Usage: {command_name}")
|
self._out(f" Usage: {command_name}")
|
||||||
descs = {
|
descs = {
|
||||||
"clear": "Clear the terminal screen",
|
"clear": "Clear the terminal screen",
|
||||||
"exit": "Exit the C2 application",
|
"exit": "Exit the C2 application",
|
||||||
"active_commands": "Show all commands currently being executed"
|
"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)
|
# ESP Commands (by module or registered)
|
||||||
else:
|
else:
|
||||||
# Check in modules first
|
# Check in modules first
|
||||||
for module_name, module_info in ESP_MODULES.items():
|
for module_name, module_info in ESP_MODULES.items():
|
||||||
if command_name in module_info["commands"]:
|
if command_name in module_info["commands"]:
|
||||||
Display.system_message(f"ESP Command '{command_name}' [{module_name.upper()}]:")
|
self._out(f"ESP Command '{command_name}' [{module_name.upper()}]:")
|
||||||
print(f" Description: {module_info['commands'][command_name]}")
|
self._out(f" Description: {module_info['commands'][command_name]}")
|
||||||
self._show_esp_command_detail(command_name)
|
self._show_esp_command_detail(command_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check registered commands
|
# Check registered commands
|
||||||
handler = self.commands.get(command_name)
|
handler = self.commands.get(command_name)
|
||||||
if handler:
|
if handler:
|
||||||
Display.system_message(f"ESP Command '{command_name}':")
|
self._out(f"ESP Command '{command_name}':")
|
||||||
print(f" Description: {handler.description}")
|
self._out(f" Description: {handler.description}")
|
||||||
if hasattr(handler, 'usage'):
|
if hasattr(handler, 'usage'):
|
||||||
print(f" Usage: {handler.usage}")
|
self._out(f" Usage: {handler.usage}")
|
||||||
else:
|
else:
|
||||||
Display.error(f"No help available for '{command_name}'.")
|
Display.error(f"No help available for '{command_name}'.")
|
||||||
|
|
||||||
@ -187,104 +195,101 @@ class HelpManager:
|
|||||||
"""Show detailed help for specific ESP commands."""
|
"""Show detailed help for specific ESP commands."""
|
||||||
details = {
|
details = {
|
||||||
# MLAT subcommands
|
# MLAT subcommands
|
||||||
"mlat config": """
|
"mlat config": [
|
||||||
Usage: send <device> mlat config [gps|local] <coord1> <coord2>
|
" Usage: send <device> mlat config [gps|local] <coord1> <coord2>",
|
||||||
GPS mode: mlat config gps <lat> <lon> - degrees
|
" GPS mode: mlat config gps <lat> <lon> - degrees",
|
||||||
Local mode: mlat config local <x> <y> - meters
|
" Local mode: mlat config local <x> <y> - meters",
|
||||||
Examples:
|
" Examples:",
|
||||||
send ESP1 mlat config gps 48.8566 2.3522
|
" send ESP1 mlat config gps 48.8566 2.3522",
|
||||||
send ESP1 mlat config local 10.0 5.5
|
" send ESP1 mlat config local 10.0 5.5",
|
||||||
send ESP1 mlat config 48.8566 2.3522 (backward compat: GPS)""",
|
],
|
||||||
|
"mlat mode": [
|
||||||
"mlat mode": """
|
" Usage: send <device> mlat mode <ble|wifi>",
|
||||||
Usage: send <device> mlat mode <ble|wifi>
|
" Example: send ESP1 mlat mode ble",
|
||||||
Example: send ESP1 mlat mode ble""",
|
],
|
||||||
|
"mlat start": [
|
||||||
"mlat start": """
|
" Usage: send <device> mlat start <mac>",
|
||||||
Usage: send <device> mlat start <mac>
|
" Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF",
|
||||||
Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF""",
|
],
|
||||||
|
"mlat stop": [
|
||||||
"mlat stop": """
|
" Usage: send <device> mlat stop",
|
||||||
Usage: send <device> mlat stop""",
|
],
|
||||||
|
"mlat status": [
|
||||||
"mlat status": """
|
" Usage: send <device> mlat status",
|
||||||
Usage: send <device> mlat status""",
|
],
|
||||||
|
"cam_start": [
|
||||||
# Camera commands
|
" Usage: send <device> cam_start <ip> <port>",
|
||||||
"cam_start": """
|
" Description: Start camera streaming to C2 UDP receiver",
|
||||||
Usage: send <device> cam_start <ip> <port>
|
" Example: send ESP_CAM cam_start 192.168.1.100 12345",
|
||||||
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",
|
||||||
"cam_stop": """
|
" Description: Stop camera streaming",
|
||||||
Usage: send <device> cam_stop
|
],
|
||||||
Description: Stop camera streaming""",
|
"fakeap_start": [
|
||||||
|
" Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]",
|
||||||
# FakeAP commands
|
" Examples:",
|
||||||
"fakeap_start": """
|
" send ESP1 fakeap_start FreeWiFi",
|
||||||
Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]
|
" send ESP1 fakeap_start SecureNet wpa2 mypassword",
|
||||||
Examples:
|
],
|
||||||
send ESP1 fakeap_start FreeWiFi
|
"fakeap_stop": [
|
||||||
send ESP1 fakeap_start SecureNet wpa2 mypassword""",
|
" Usage: send <device> fakeap_stop",
|
||||||
|
],
|
||||||
"fakeap_stop": """
|
"fakeap_status": [
|
||||||
Usage: send <device> fakeap_stop""",
|
" Usage: send <device> fakeap_status",
|
||||||
|
" Shows: AP running, portal status, sniffer status, client count",
|
||||||
"fakeap_status": """
|
],
|
||||||
Usage: send <device> fakeap_status
|
"fakeap_clients": [
|
||||||
Shows: AP running, portal status, sniffer status, client count""",
|
" Usage: send <device> fakeap_clients",
|
||||||
|
" Lists all connected clients to the fake AP",
|
||||||
"fakeap_clients": """
|
],
|
||||||
Usage: send <device> fakeap_clients
|
"fakeap_portal_start": [
|
||||||
Lists all connected clients to the fake AP""",
|
" Usage: send <device> fakeap_portal_start",
|
||||||
|
" Description: Enable captive portal (requires fakeap running)",
|
||||||
"fakeap_portal_start": """
|
],
|
||||||
Usage: send <device> fakeap_portal_start
|
"fakeap_portal_stop": [
|
||||||
Description: Enable captive portal (requires fakeap running)""",
|
" Usage: send <device> fakeap_portal_stop",
|
||||||
|
],
|
||||||
"fakeap_portal_stop": """
|
"fakeap_sniffer_on": [
|
||||||
Usage: send <device> fakeap_portal_stop""",
|
" Usage: send <device> fakeap_sniffer_on",
|
||||||
|
" Description: Enable packet sniffing",
|
||||||
"fakeap_sniffer_on": """
|
],
|
||||||
Usage: send <device> fakeap_sniffer_on
|
"fakeap_sniffer_off": [
|
||||||
Description: Enable packet sniffing""",
|
" Usage: send <device> fakeap_sniffer_off",
|
||||||
|
],
|
||||||
"fakeap_sniffer_off": """
|
"ping": [
|
||||||
Usage: send <device> fakeap_sniffer_off""",
|
" Usage: send <device> ping <host>",
|
||||||
|
" Example: send ESP1 ping 8.8.8.8",
|
||||||
# Network commands
|
],
|
||||||
"ping": """
|
"arp_scan": [
|
||||||
Usage: send <device> ping <host>
|
" Usage: send <device> arp_scan",
|
||||||
Example: send ESP1 ping 8.8.8.8""",
|
" Description: Scan local network for hosts",
|
||||||
|
],
|
||||||
"arp_scan": """
|
"proxy_start": [
|
||||||
Usage: send <device> arp_scan
|
" Usage: send <device> proxy_start <ip> <port>",
|
||||||
Description: Scan local network for hosts""",
|
" Example: send ESP1 proxy_start 192.168.1.100 8080",
|
||||||
|
],
|
||||||
"proxy_start": """
|
"proxy_stop": [
|
||||||
Usage: send <device> proxy_start <ip> <port>
|
" Usage: send <device> proxy_stop",
|
||||||
Example: send ESP1 proxy_start 192.168.1.100 8080""",
|
],
|
||||||
|
"dos_tcp": [
|
||||||
"proxy_stop": """
|
" Usage: send <device> dos_tcp <ip> <port> <count>",
|
||||||
Usage: send <device> proxy_stop""",
|
" Example: send ESP1 dos_tcp 192.168.1.100 80 1000",
|
||||||
|
],
|
||||||
"dos_tcp": """
|
"system_reboot": [
|
||||||
Usage: send <device> dos_tcp <ip> <port> <count>
|
" Usage: send <device> system_reboot",
|
||||||
Example: send ESP1 dos_tcp 192.168.1.100 80 1000""",
|
" Description: Reboot the ESP32 device",
|
||||||
|
],
|
||||||
# System commands
|
"system_mem": [
|
||||||
"system_reboot": """
|
" Usage: send <device> system_mem",
|
||||||
Usage: send <device> system_reboot
|
" Shows: heap_free, heap_min, internal_free",
|
||||||
Description: Reboot the ESP32 device""",
|
],
|
||||||
|
"system_uptime": [
|
||||||
"system_mem": """
|
" Usage: send <device> system_uptime",
|
||||||
Usage: send <device> system_mem
|
" Shows: uptime in days/hours/minutes/seconds",
|
||||||
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:
|
if cmd in details:
|
||||||
print(details[cmd])
|
for line in details[cmd]:
|
||||||
|
self._out(line)
|
||||||
|
|||||||
@ -14,7 +14,11 @@ class Device:
|
|||||||
|
|
||||||
connected_at: float = field(default_factory=time.time)
|
connected_at: float = field(default_factory=time.time)
|
||||||
last_seen: 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):
|
def touch(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -64,6 +64,7 @@ class Transport:
|
|||||||
# ==================================================
|
# ==================================================
|
||||||
def _dispatch(self, sock, addr, msg: AgentMessage):
|
def _dispatch(self, sock, addr, msg: AgentMessage):
|
||||||
device = self.registry.get(msg.device_id)
|
device = self.registry.get(msg.device_id)
|
||||||
|
is_new_device = False
|
||||||
|
|
||||||
if not device:
|
if not device:
|
||||||
device = Device(
|
device = Device(
|
||||||
@ -73,11 +74,63 @@ class Transport:
|
|||||||
)
|
)
|
||||||
self.registry.add(device)
|
self.registry.add(device)
|
||||||
Display.device_event(device.id, f"Connected from {addr[0]}")
|
Display.device_event(device.id, f"Connected from {addr[0]}")
|
||||||
|
is_new_device = True
|
||||||
else:
|
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()
|
device.touch()
|
||||||
|
|
||||||
self._handle_agent_message(device, msg)
|
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
|
# AGENT MESSAGE HANDLER
|
||||||
# ==================================================
|
# ==================================================
|
||||||
@ -90,13 +143,20 @@ class Transport:
|
|||||||
payload_str = repr(msg.payload)
|
payload_str = repr(msg.payload)
|
||||||
|
|
||||||
if msg.type == AgentMsgType.AGENT_CMD_RESULT:
|
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)
|
self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof)
|
||||||
else:
|
else:
|
||||||
Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}")
|
Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}")
|
||||||
elif msg.type == AgentMsgType.AGENT_INFO:
|
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)
|
# 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
|
mlat_data = payload_str[5:] # Remove "MLAT:" prefix
|
||||||
if self.cli.mlat_engine.parse_mlat_message(device.id, mlat_data):
|
if self.cli.mlat_engine.parse_mlat_message(device.id, mlat_data):
|
||||||
# Recalculate position if we have enough scanners
|
# Recalculate position if we have enough scanners
|
||||||
|
|||||||
BIN
tools/c2/static/images/no-signal.png
Normal file
BIN
tools/c2/static/images/no-signal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
@ -12,14 +12,18 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional, Callable, Dict
|
from typing import Optional, Callable, Dict
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
|
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
|
||||||
SECRET_TOKEN, IMAGE_DIR,
|
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:
|
class FrameAssembler:
|
||||||
"""Assembles JPEG frames from multiple UDP packets."""
|
"""Assembles JPEG frames from multiple UDP packets."""
|
||||||
@ -31,13 +35,11 @@ class FrameAssembler:
|
|||||||
self.receiving = False
|
self.receiving = False
|
||||||
|
|
||||||
def start_frame(self):
|
def start_frame(self):
|
||||||
"""Start receiving a new frame."""
|
|
||||||
self.buffer = bytearray()
|
self.buffer = bytearray()
|
||||||
self.start_time = time.time()
|
self.start_time = time.time()
|
||||||
self.receiving = True
|
self.receiving = True
|
||||||
|
|
||||||
def add_chunk(self, data: bytes) -> bool:
|
def add_chunk(self, data: bytes) -> bool:
|
||||||
"""Add a chunk to the frame buffer. Returns False if timed out."""
|
|
||||||
if not self.receiving:
|
if not self.receiving:
|
||||||
return False
|
return False
|
||||||
if self.start_time and (time.time() - self.start_time) > self.timeout:
|
if self.start_time and (time.time() - self.start_time) > self.timeout:
|
||||||
@ -47,7 +49,6 @@ class FrameAssembler:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def finish_frame(self) -> Optional[bytes]:
|
def finish_frame(self) -> Optional[bytes]:
|
||||||
"""Finish frame assembly and return complete data."""
|
|
||||||
if not self.receiving or len(self.buffer) == 0:
|
if not self.receiving or len(self.buffer) == 0:
|
||||||
return None
|
return None
|
||||||
data = bytes(self.buffer)
|
data = bytes(self.buffer)
|
||||||
@ -55,12 +56,89 @@ class FrameAssembler:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""Reset the assembler state."""
|
|
||||||
self.buffer = bytearray()
|
self.buffer = bytearray()
|
||||||
self.start_time = None
|
self.start_time = None
|
||||||
self.receiving = False
|
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:
|
class UDPReceiver:
|
||||||
"""Receives JPEG frames via UDP from ESP camera devices."""
|
"""Receives JPEG frames via UDP from ESP camera devices."""
|
||||||
|
|
||||||
@ -68,11 +146,13 @@ class UDPReceiver:
|
|||||||
host: str = UDP_HOST,
|
host: str = UDP_HOST,
|
||||||
port: int = UDP_PORT,
|
port: int = UDP_PORT,
|
||||||
image_dir: str = IMAGE_DIR,
|
image_dir: str = IMAGE_DIR,
|
||||||
on_frame: Optional[Callable] = None):
|
on_frame: Optional[Callable] = None,
|
||||||
|
device_registry=None):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.image_dir = image_dir
|
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._sock: Optional[socket.socket] = None
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@ -81,9 +161,12 @@ class UDPReceiver:
|
|||||||
# Frame assemblers per source address
|
# Frame assemblers per source address
|
||||||
self._assemblers: Dict[str, FrameAssembler] = {}
|
self._assemblers: Dict[str, FrameAssembler] = {}
|
||||||
|
|
||||||
# Video recording
|
# Per-camera recorders (keyed by device_id)
|
||||||
self._video_writer: Optional[cv2.VideoWriter] = None
|
self._recorders: Dict[str, CameraRecorder] = {}
|
||||||
self._video_size: Optional[tuple] = None
|
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
|
# Statistics
|
||||||
self.frames_received = 0
|
self.frames_received = 0
|
||||||
@ -91,10 +174,15 @@ class UDPReceiver:
|
|||||||
self.decode_errors = 0
|
self.decode_errors = 0
|
||||||
self.packets_received = 0
|
self.packets_received = 0
|
||||||
|
|
||||||
# Active cameras tracking
|
# Active cameras tracking: {device_id: {"last_frame": timestamp, "active": bool}}
|
||||||
self._active_cameras: dict = {} # {camera_id: last_frame_time}
|
self._active_cameras: Dict[str, dict] = {}
|
||||||
|
|
||||||
os.makedirs(self.image_dir, exist_ok=True)
|
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
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@ -102,21 +190,39 @@ class UDPReceiver:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def active_cameras(self) -> list:
|
def active_cameras(self) -> list:
|
||||||
"""Returns list of active camera identifiers."""
|
"""Returns list of active camera device IDs."""
|
||||||
return list(self._active_cameras.keys())
|
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:
|
def start(self) -> bool:
|
||||||
"""Start the UDP receiver thread."""
|
|
||||||
if self.is_running:
|
if self.is_running:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
|
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
|
# Start timeout checker
|
||||||
|
self._timeout_thread = threading.Thread(target=self._timeout_checker, daemon=True)
|
||||||
|
self._timeout_thread.start()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop the UDP receiver and cleanup."""
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
||||||
if self._sock:
|
if self._sock:
|
||||||
@ -126,15 +232,15 @@ class UDPReceiver:
|
|||||||
pass
|
pass
|
||||||
self._sock = None
|
self._sock = None
|
||||||
|
|
||||||
if self._video_writer is not None:
|
for recorder in self._recorders.values():
|
||||||
self._video_writer.release()
|
if recorder.is_recording:
|
||||||
self._video_writer = None
|
recorder.stop()
|
||||||
|
|
||||||
# Clean up frame files
|
|
||||||
self._cleanup_frames()
|
self._cleanup_frames()
|
||||||
|
|
||||||
self._active_cameras.clear()
|
self._active_cameras.clear()
|
||||||
self._assemblers.clear()
|
self._assemblers.clear()
|
||||||
|
self._recorders.clear()
|
||||||
|
self._ip_to_device.clear()
|
||||||
self.frames_received = 0
|
self.frames_received = 0
|
||||||
self.packets_received = 0
|
self.packets_received = 0
|
||||||
|
|
||||||
@ -147,15 +253,43 @@ class UDPReceiver:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
def _get_assembler(self, addr: tuple) -> FrameAssembler:
|
||||||
"""Get or create a frame assembler for the given address."""
|
|
||||||
key = f"{addr[0]}:{addr[1]}"
|
key = f"{addr[0]}:{addr[1]}"
|
||||||
if key not in self._assemblers:
|
if key not in self._assemblers:
|
||||||
self._assemblers[key] = FrameAssembler()
|
self._assemblers[key] = FrameAssembler()
|
||||||
return self._assemblers[key]
|
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):
|
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 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
self._sock.bind((self.host, self.port))
|
self._sock.bind((self.host, self.port))
|
||||||
@ -173,50 +307,45 @@ class UDPReceiver:
|
|||||||
|
|
||||||
self.packets_received += 1
|
self.packets_received += 1
|
||||||
|
|
||||||
# Validate token
|
|
||||||
if not data.startswith(SECRET_TOKEN):
|
if not data.startswith(SECRET_TOKEN):
|
||||||
self.invalid_tokens += 1
|
self.invalid_tokens += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Remove token prefix
|
|
||||||
payload = data[len(SECRET_TOKEN):]
|
payload = data[len(SECRET_TOKEN):]
|
||||||
assembler = self._get_assembler(addr)
|
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":
|
if payload == b"START":
|
||||||
assembler.start_frame()
|
assembler.start_frame()
|
||||||
continue
|
continue
|
||||||
elif payload == b"END":
|
elif payload == b"END":
|
||||||
frame_data = assembler.finish_frame()
|
frame_data = assembler.finish_frame()
|
||||||
if frame_data:
|
if frame_data:
|
||||||
self._process_complete_frame(camera_id, frame_data, addr)
|
self._process_complete_frame(device_id, frame_data, addr)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# Regular data chunk
|
|
||||||
if not assembler.receiving:
|
if not assembler.receiving:
|
||||||
# No START received, try as single-packet frame (legacy)
|
|
||||||
frame = self._decode_frame(payload)
|
frame = self._decode_frame(payload)
|
||||||
if frame is not None:
|
if frame is not None:
|
||||||
self._process_frame(camera_id, frame, addr)
|
self._process_frame(device_id, frame, addr)
|
||||||
else:
|
else:
|
||||||
self.decode_errors += 1
|
self.decode_errors += 1
|
||||||
else:
|
else:
|
||||||
assembler.add_chunk(payload)
|
assembler.add_chunk(payload)
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
if self._sock:
|
if self._sock:
|
||||||
self._sock.close()
|
self._sock.close()
|
||||||
self._sock = None
|
self._sock = None
|
||||||
|
|
||||||
if self._video_writer:
|
|
||||||
self._video_writer.release()
|
|
||||||
self._video_writer = None
|
|
||||||
|
|
||||||
print("[UDP] Receiver stopped")
|
print("[UDP] Receiver stopped")
|
||||||
|
|
||||||
def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple):
|
def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple):
|
||||||
"""Process a fully assembled frame."""
|
|
||||||
frame = self._decode_frame(frame_data)
|
frame = self._decode_frame(frame_data)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
self.decode_errors += 1
|
self.decode_errors += 1
|
||||||
@ -224,23 +353,27 @@ class UDPReceiver:
|
|||||||
self._process_frame(camera_id, frame, addr)
|
self._process_frame(camera_id, frame, addr)
|
||||||
|
|
||||||
def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple):
|
def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple):
|
||||||
"""Process a decoded frame."""
|
|
||||||
self.frames_received += 1
|
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
|
# Save frame
|
||||||
self._save_frame(camera_id, frame)
|
self._save_frame(camera_id, frame)
|
||||||
|
|
||||||
# Record video if enabled
|
# Record if recording is active for this camera
|
||||||
if VIDEO_ENABLED:
|
recorder = self._get_recorder(camera_id)
|
||||||
self._record_frame(frame)
|
if recorder.is_recording:
|
||||||
|
recorder.write_frame(frame)
|
||||||
|
|
||||||
# Callback
|
|
||||||
if self.on_frame:
|
if self.on_frame:
|
||||||
self.on_frame(camera_id, frame, addr)
|
self.on_frame(camera_id, frame, addr)
|
||||||
|
|
||||||
def _decode_frame(self, data: bytes) -> Optional[np.ndarray]:
|
def _decode_frame(self, data: bytes) -> Optional[np.ndarray]:
|
||||||
"""Decode JPEG data to OpenCV frame."""
|
|
||||||
try:
|
try:
|
||||||
npdata = np.frombuffer(data, np.uint8)
|
npdata = np.frombuffer(data, np.uint8)
|
||||||
frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR)
|
frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR)
|
||||||
@ -249,33 +382,87 @@ class UDPReceiver:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _save_frame(self, camera_id: str, frame: np.ndarray):
|
def _save_frame(self, camera_id: str, frame: np.ndarray):
|
||||||
"""Save frame as JPEG file."""
|
|
||||||
try:
|
try:
|
||||||
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
|
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
|
||||||
cv2.imwrite(filepath, frame)
|
cv2.imwrite(filepath, frame)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _record_frame(self, frame: np.ndarray):
|
# === Recording API ===
|
||||||
"""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
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._video_writer and self._video_writer.isOpened():
|
def start_recording(self, camera_id: str) -> dict:
|
||||||
self._video_writer.write(frame)
|
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:
|
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 {
|
return {
|
||||||
"running": self.is_running,
|
"running": self.is_running,
|
||||||
"packets_received": self.packets_received,
|
"packets_received": self.packets_received,
|
||||||
"frames_received": self.frames_received,
|
"frames_received": self.frames_received,
|
||||||
"invalid_tokens": self.invalid_tokens,
|
"invalid_tokens": self.invalid_tokens,
|
||||||
"decode_errors": self.decode_errors,
|
"decode_errors": self.decode_errors,
|
||||||
"active_cameras": len(self._active_cameras)
|
"active_cameras": active_count,
|
||||||
|
"active_recordings": recording_count
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,45 +14,257 @@
|
|||||||
{% if image_files %}
|
{% if image_files %}
|
||||||
<div class="grid grid-cameras" id="grid">
|
<div class="grid grid-cameras" id="grid">
|
||||||
{% for img in image_files %}
|
{% for img in image_files %}
|
||||||
<div class="card">
|
<div class="card" data-camera-id="{{ img.replace('.jpg', '') }}">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
|
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</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>
|
<span class="badge badge-live">LIVE</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body card-body-image">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">
|
<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>
|
<h2>No active cameras</h2>
|
||||||
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
|
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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>
|
<script>
|
||||||
|
// Recording state
|
||||||
|
const recordingState = {};
|
||||||
|
|
||||||
|
// Refresh camera images
|
||||||
function refresh() {
|
function refresh() {
|
||||||
const t = Date.now();
|
const t = Date.now();
|
||||||
document.querySelectorAll('.card-body-image img').forEach(img => {
|
document.querySelectorAll('.card-body-image img').forEach(img => {
|
||||||
|
// Only update if not showing default image
|
||||||
|
if (!img.src.includes('no-signal')) {
|
||||||
img.src = img.dataset.src + '?t=' + t;
|
img.src = img.dataset.src + '?t=' + t;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for new/removed cameras
|
||||||
async function checkCameras() {
|
async function checkCameras() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/cameras');
|
const res = await fetch('/api/cameras');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const current = document.querySelectorAll('.card').length;
|
const current = document.querySelectorAll('.card').length;
|
||||||
document.getElementById('camera-count').textContent = data.count || 0;
|
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();
|
if (data.count !== current) location.reload();
|
||||||
} catch (e) {}
|
} 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(refresh, 100);
|
||||||
setInterval(checkCameras, 5000);
|
setInterval(checkCameras, 5000);
|
||||||
|
setInterval(updateTimers, 1000);
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkCameras();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
4
tools/c2/tui/__init__.py
Normal file
4
tools/c2/tui/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from tui.app import C3POApp
|
||||||
|
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||||
|
|
||||||
|
__all__ = ["C3POApp", "tui_bridge", "TUIMessage", "MessageType"]
|
||||||
295
tools/c2/tui/app.py
Normal file
295
tools/c2/tui/app.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
"""
|
||||||
|
Main C3PO TUI Application using Textual.
|
||||||
|
Multi-device view: all connected devices visible simultaneously.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, Vertical, Container, ScrollableContainer
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||||
|
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
|
||||||
|
from tui.widgets.command_input import CommandInput
|
||||||
|
from tui.widgets.device_tabs import DeviceTabs
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceContainer(Container):
|
||||||
|
"""Container for a single device with border and title."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
DeviceContainer {
|
||||||
|
height: 1fr;
|
||||||
|
min-height: 6;
|
||||||
|
border: solid $secondary;
|
||||||
|
border-title-color: $text;
|
||||||
|
border-title-style: bold;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device_id: str, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.device_id = device_id
|
||||||
|
self.border_title = f"DEVICE: {device_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class C3POApp(App):
|
||||||
|
"""C3PO Command & Control TUI Application."""
|
||||||
|
|
||||||
|
CSS_PATH = Path(__file__).parent / "styles" / "c2.tcss"
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("alt+g", "toggle_global", "Global", show=True),
|
||||||
|
Binding("ctrl+l", "clear_global", "Clear", show=True),
|
||||||
|
Binding("ctrl+q", "quit", "Quit", show=True),
|
||||||
|
Binding("escape", "focus_input", "Input", show=False),
|
||||||
|
Binding("tab", "tab_complete", show=False, priority=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, registry=None, cli=None, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.registry = registry
|
||||||
|
self.cli = cli
|
||||||
|
self._device_panes: dict[str, DeviceLogPane] = {}
|
||||||
|
self._device_containers: dict[str, DeviceContainer] = {}
|
||||||
|
self._device_modules: dict[str, str] = {}
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield DeviceTabs(id="tab-bar")
|
||||||
|
|
||||||
|
with Horizontal(id="main-content"):
|
||||||
|
# Left side: all devices stacked vertically
|
||||||
|
with Vertical(id="devices-panel"):
|
||||||
|
yield Static("Waiting for devices...", id="no-device-placeholder")
|
||||||
|
|
||||||
|
# Right side: global logs
|
||||||
|
with Container(id="global-log-container") as global_container:
|
||||||
|
global_container.border_title = "GLOBAL LOGS"
|
||||||
|
yield GlobalLogPane(id="global-log")
|
||||||
|
|
||||||
|
with Vertical(id="input-container"):
|
||||||
|
yield Static(
|
||||||
|
"Alt+G:Toggle Global ^L:Clear Logs ^Q:Quit Tab:Complete",
|
||||||
|
id="shortcuts-bar"
|
||||||
|
)
|
||||||
|
yield CommandInput(id="command-input")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when app is mounted."""
|
||||||
|
tui_bridge.set_app(self)
|
||||||
|
self.set_interval(0.1, self.process_bridge_queue)
|
||||||
|
|
||||||
|
cmd_input = self.query_one("#command-input", CommandInput)
|
||||||
|
if self.cli:
|
||||||
|
cmd_input.set_completer(self._make_completer())
|
||||||
|
cmd_input.focus()
|
||||||
|
|
||||||
|
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||||
|
global_log.add_system(self._timestamp(), "C3PO TUI initialized - Multi-device view")
|
||||||
|
|
||||||
|
def _make_completer(self):
|
||||||
|
"""Create a completer function that works without readline."""
|
||||||
|
ESP_COMMANDS = [
|
||||||
|
"system_reboot", "system_mem", "system_uptime", "system_info",
|
||||||
|
"ping", "arp_scan", "proxy_start", "proxy_stop", "dos_tcp",
|
||||||
|
"fakeap_start", "fakeap_stop", "fakeap_status", "fakeap_clients",
|
||||||
|
"fakeap_portal_start", "fakeap_portal_stop",
|
||||||
|
"fakeap_sniffer_on", "fakeap_sniffer_off",
|
||||||
|
"cam_start", "cam_stop", "mlat", "trilat",
|
||||||
|
]
|
||||||
|
|
||||||
|
def completer(text: str, state: int) -> str | None:
|
||||||
|
if not self.cli:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd_input = self.query_one("#command-input", CommandInput)
|
||||||
|
buffer = cmd_input.value
|
||||||
|
parts = buffer.split()
|
||||||
|
|
||||||
|
options = []
|
||||||
|
|
||||||
|
if len(parts) <= 1 and not buffer.endswith(" "):
|
||||||
|
options = ["send", "list", "modules", "group", "help", "clear", "exit",
|
||||||
|
"active_commands", "web", "camera"]
|
||||||
|
|
||||||
|
elif parts[0] == "send":
|
||||||
|
if len(parts) == 2 and not buffer.endswith(" "):
|
||||||
|
options = ["all", "group"] + self.cli.registry.ids()
|
||||||
|
elif len(parts) == 2 and buffer.endswith(" "):
|
||||||
|
options = ["all", "group"] + self.cli.registry.ids()
|
||||||
|
elif len(parts) == 3 and parts[1] == "group" and not buffer.endswith(" "):
|
||||||
|
options = list(self.cli.groups.all_groups().keys())
|
||||||
|
elif len(parts) == 3 and parts[1] == "group" and buffer.endswith(" "):
|
||||||
|
options = ESP_COMMANDS
|
||||||
|
elif len(parts) == 3 and parts[1] != "group":
|
||||||
|
options = ESP_COMMANDS
|
||||||
|
elif len(parts) == 4 and parts[1] == "group":
|
||||||
|
options = ESP_COMMANDS
|
||||||
|
|
||||||
|
elif parts[0] == "web":
|
||||||
|
if len(parts) <= 2:
|
||||||
|
options = ["start", "stop", "status"]
|
||||||
|
|
||||||
|
elif parts[0] == "camera":
|
||||||
|
if len(parts) <= 2:
|
||||||
|
options = ["start", "stop", "status"]
|
||||||
|
|
||||||
|
elif parts[0] == "group":
|
||||||
|
if len(parts) == 2 and not buffer.endswith(" "):
|
||||||
|
options = ["add", "remove", "list", "show"]
|
||||||
|
elif len(parts) == 2 and buffer.endswith(" "):
|
||||||
|
options = ["add", "remove", "list", "show"]
|
||||||
|
elif parts[1] in ("remove", "show") and len(parts) >= 3:
|
||||||
|
options = list(self.cli.groups.all_groups().keys())
|
||||||
|
elif parts[1] == "add" and len(parts) >= 3:
|
||||||
|
options = self.cli.registry.ids()
|
||||||
|
|
||||||
|
matches = [o for o in options if o.startswith(text)]
|
||||||
|
return matches[state] if state < len(matches) else None
|
||||||
|
|
||||||
|
return completer
|
||||||
|
|
||||||
|
def _timestamp(self) -> str:
|
||||||
|
return time.strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
def process_bridge_queue(self) -> None:
|
||||||
|
for msg in tui_bridge.get_pending_messages():
|
||||||
|
self._handle_tui_message(msg)
|
||||||
|
|
||||||
|
def _handle_tui_message(self, msg: TUIMessage) -> None:
|
||||||
|
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||||
|
timestamp = time.strftime("%H:%M:%S", time.localtime(msg.timestamp))
|
||||||
|
|
||||||
|
if msg.msg_type == MessageType.SYSTEM_MESSAGE:
|
||||||
|
global_log.add_system(timestamp, msg.payload)
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.DEVICE_CONNECTED:
|
||||||
|
global_log.add_system(timestamp, f"{msg.device_id} connected")
|
||||||
|
self._add_device_pane(msg.device_id)
|
||||||
|
tabs = self.query_one("#tab-bar", DeviceTabs)
|
||||||
|
tabs.add_device(msg.device_id)
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.DEVICE_RECONNECTED:
|
||||||
|
global_log.add_system(timestamp, f"{msg.device_id} reconnected")
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.DEVICE_INFO_UPDATED:
|
||||||
|
self._device_modules[msg.device_id] = msg.payload
|
||||||
|
global_log.add_system(timestamp, f"{msg.device_id} modules: {msg.payload}")
|
||||||
|
self._update_device_title(msg.device_id)
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.DEVICE_DISCONNECTED:
|
||||||
|
global_log.add_system(timestamp, f"{msg.device_id} disconnected")
|
||||||
|
self._remove_device_pane(msg.device_id)
|
||||||
|
tabs = self.query_one("#tab-bar", DeviceTabs)
|
||||||
|
tabs.remove_device(msg.device_id)
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.DEVICE_EVENT:
|
||||||
|
global_log.add_device_event(timestamp, msg.device_id, msg.payload)
|
||||||
|
if msg.device_id in self._device_panes:
|
||||||
|
event_type = self._detect_event_type(msg.payload)
|
||||||
|
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, event_type)
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.COMMAND_SENT:
|
||||||
|
global_log.add_command_sent(timestamp, msg.device_id, msg.payload, msg.request_id)
|
||||||
|
if msg.device_id in self._device_panes:
|
||||||
|
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_sent")
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.COMMAND_RESPONSE:
|
||||||
|
global_log.add_command_response(timestamp, msg.device_id, msg.payload, msg.request_id)
|
||||||
|
if msg.device_id in self._device_panes:
|
||||||
|
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_resp")
|
||||||
|
|
||||||
|
elif msg.msg_type == MessageType.ERROR:
|
||||||
|
global_log.add_error(timestamp, msg.payload)
|
||||||
|
|
||||||
|
def _detect_event_type(self, payload: str) -> str:
|
||||||
|
payload_upper = payload.upper()
|
||||||
|
if payload_upper.startswith("INFO:"):
|
||||||
|
return "info"
|
||||||
|
elif payload_upper.startswith("LOG:"):
|
||||||
|
return "log"
|
||||||
|
elif payload_upper.startswith("ERROR:"):
|
||||||
|
return "error"
|
||||||
|
elif payload_upper.startswith("DATA:"):
|
||||||
|
return "data"
|
||||||
|
return "info"
|
||||||
|
|
||||||
|
def _add_device_pane(self, device_id: str) -> None:
|
||||||
|
"""Add a new device pane (visible immediately)."""
|
||||||
|
if device_id in self._device_panes:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Hide placeholder
|
||||||
|
placeholder = self.query_one("#no-device-placeholder", Static)
|
||||||
|
placeholder.display = False
|
||||||
|
|
||||||
|
# Create container with border for this device
|
||||||
|
container = DeviceContainer(device_id, id=f"device-container-{device_id}")
|
||||||
|
pane = DeviceLogPane(device_id, id=f"device-pane-{device_id}")
|
||||||
|
|
||||||
|
self._device_containers[device_id] = container
|
||||||
|
self._device_panes[device_id] = pane
|
||||||
|
|
||||||
|
# Mount in the devices panel
|
||||||
|
devices_panel = self.query_one("#devices-panel", Vertical)
|
||||||
|
devices_panel.mount(container)
|
||||||
|
container.mount(pane)
|
||||||
|
|
||||||
|
def _remove_device_pane(self, device_id: str) -> None:
|
||||||
|
"""Remove a device pane."""
|
||||||
|
if device_id in self._device_containers:
|
||||||
|
container = self._device_containers.pop(device_id)
|
||||||
|
container.remove()
|
||||||
|
self._device_panes.pop(device_id, None)
|
||||||
|
self._device_modules.pop(device_id, None)
|
||||||
|
|
||||||
|
# Show placeholder if no devices
|
||||||
|
if not self._device_containers:
|
||||||
|
placeholder = self.query_one("#no-device-placeholder", Static)
|
||||||
|
placeholder.display = True
|
||||||
|
|
||||||
|
def _update_device_title(self, device_id: str) -> None:
|
||||||
|
"""Update device container title with modules info."""
|
||||||
|
if device_id in self._device_containers:
|
||||||
|
modules = self._device_modules.get(device_id, "")
|
||||||
|
container = self._device_containers[device_id]
|
||||||
|
if modules:
|
||||||
|
container.border_title = f"DEVICE: {device_id} [{modules}]"
|
||||||
|
else:
|
||||||
|
container.border_title = f"DEVICE: {device_id}"
|
||||||
|
|
||||||
|
def on_command_input_completions_available(self, event: CommandInput.CompletionsAvailable) -> None:
|
||||||
|
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||||
|
completions_str = " ".join(event.completions)
|
||||||
|
global_log.add_system(self._timestamp(), f"Completions: {completions_str}")
|
||||||
|
|
||||||
|
def on_command_input_command_submitted(self, event: CommandInput.CommandSubmitted) -> None:
|
||||||
|
command = event.command
|
||||||
|
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||||
|
global_log.add_system(self._timestamp(), f"Executing: {command}")
|
||||||
|
|
||||||
|
if self.cli:
|
||||||
|
try:
|
||||||
|
self.cli.execute_command(command)
|
||||||
|
except Exception as e:
|
||||||
|
global_log.add_error(self._timestamp(), f"Command error: {e}")
|
||||||
|
|
||||||
|
def action_toggle_global(self) -> None:
|
||||||
|
"""Toggle global logs pane visibility."""
|
||||||
|
global_container = self.query_one("#global-log-container", Container)
|
||||||
|
global_container.display = not global_container.display
|
||||||
|
|
||||||
|
def action_clear_global(self) -> None:
|
||||||
|
"""Clear global logs pane only."""
|
||||||
|
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||||
|
global_log.clear()
|
||||||
|
|
||||||
|
def action_focus_input(self) -> None:
|
||||||
|
self.query_one("#command-input", CommandInput).focus()
|
||||||
|
|
||||||
|
def action_tab_complete(self) -> None:
|
||||||
|
cmd_input = self.query_one("#command-input", CommandInput)
|
||||||
|
cmd_input.focus()
|
||||||
|
cmd_input._handle_tab_completion()
|
||||||
65
tools/c2/tui/bridge.py
Normal file
65
tools/c2/tui/bridge.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Thread-safe bridge between sync threads and async Textual TUI.
|
||||||
|
"""
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(Enum):
|
||||||
|
DEVICE_CONNECTED = "device_connected"
|
||||||
|
DEVICE_DISCONNECTED = "device_disconnected"
|
||||||
|
DEVICE_RECONNECTED = "device_reconnected"
|
||||||
|
DEVICE_INFO_UPDATED = "device_info_updated"
|
||||||
|
DEVICE_EVENT = "device_event"
|
||||||
|
COMMAND_SENT = "command_sent"
|
||||||
|
COMMAND_RESPONSE = "command_response"
|
||||||
|
SYSTEM_MESSAGE = "system_message"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TUIMessage:
|
||||||
|
"""Message from sync thread to async TUI."""
|
||||||
|
msg_type: MessageType
|
||||||
|
payload: str
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
device_id: Optional[str] = None
|
||||||
|
request_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TUIBridge:
|
||||||
|
"""Thread-safe bridge between sync threads and async Textual app."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._queue: queue.Queue[TUIMessage] = queue.Queue()
|
||||||
|
self._app: Any = None
|
||||||
|
|
||||||
|
def set_app(self, app):
|
||||||
|
"""Called by TUI app on startup."""
|
||||||
|
self._app = app
|
||||||
|
|
||||||
|
def post_message(self, msg: TUIMessage):
|
||||||
|
"""Called by sync threads (Display class)."""
|
||||||
|
self._queue.put(msg)
|
||||||
|
if self._app:
|
||||||
|
try:
|
||||||
|
self._app.call_from_thread(self._app.process_bridge_queue)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_pending_messages(self) -> list[TUIMessage]:
|
||||||
|
"""Called by async TUI to drain the queue."""
|
||||||
|
messages = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
messages.append(self._queue.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
# Global bridge instance
|
||||||
|
tui_bridge = TUIBridge()
|
||||||
119
tools/c2/tui/styles/c2.tcss
Normal file
119
tools/c2/tui/styles/c2.tcss
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
/* C3PO TUI Stylesheet - Multi-device view */
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header/Tab bar */
|
||||||
|
#tab-bar {
|
||||||
|
height: 1;
|
||||||
|
dock: top;
|
||||||
|
background: $surface-darken-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
#main-content {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left panel: all devices stacked */
|
||||||
|
#devices-panel {
|
||||||
|
width: 1fr;
|
||||||
|
min-width: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
#no-device-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right panel: global logs */
|
||||||
|
#global-log-container {
|
||||||
|
width: 1fr;
|
||||||
|
min-width: 30;
|
||||||
|
border: solid $primary;
|
||||||
|
border-title-color: $text;
|
||||||
|
border-title-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input area */
|
||||||
|
#input-container {
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
background: $surface-darken-1;
|
||||||
|
border-top: solid $primary;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-input {
|
||||||
|
width: 1fr;
|
||||||
|
height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shortcuts-bar {
|
||||||
|
height: 1;
|
||||||
|
width: 100%;
|
||||||
|
background: $surface-darken-2;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device containers - each device in its own bordered box */
|
||||||
|
DeviceContainer {
|
||||||
|
height: 1fr;
|
||||||
|
min-height: 5;
|
||||||
|
border: solid $secondary;
|
||||||
|
border-title-color: $text;
|
||||||
|
border-title-style: bold;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log pane inside device container */
|
||||||
|
DeviceLogPane {
|
||||||
|
height: 100%;
|
||||||
|
scrollbar-size: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global log pane */
|
||||||
|
GlobalLogPane {
|
||||||
|
height: 100%;
|
||||||
|
scrollbar-size: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log colors */
|
||||||
|
.log-system {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-device {
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-command {
|
||||||
|
color: blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-response {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator */
|
||||||
|
.status-connected {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
5
tools/c2/tui/widgets/__init__.py
Normal file
5
tools/c2/tui/widgets/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
|
||||||
|
from tui.widgets.command_input import CommandInput
|
||||||
|
from tui.widgets.device_tabs import DeviceTabs
|
||||||
|
|
||||||
|
__all__ = ["GlobalLogPane", "DeviceLogPane", "CommandInput", "DeviceTabs"]
|
||||||
215
tools/c2/tui/widgets/command_input.py
Normal file
215
tools/c2/tui/widgets/command_input.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
Command input widget with history and zsh-style tab completion.
|
||||||
|
"""
|
||||||
|
from textual.widgets import Input
|
||||||
|
from textual.message import Message
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CommandInput(Input):
|
||||||
|
"""Command input with history and zsh-style tab completion."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
CommandInput {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
border: none;
|
||||||
|
background: $surface;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
CommandInput:focus {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class CommandSubmitted(Message):
|
||||||
|
"""Posted when a command is submitted."""
|
||||||
|
def __init__(self, command: str):
|
||||||
|
self.command = command
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
class CompletionsAvailable(Message):
|
||||||
|
"""Posted when multiple completions are available."""
|
||||||
|
def __init__(self, completions: list[str], word: str):
|
||||||
|
self.completions = completions
|
||||||
|
self.word = word
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
placeholder="c2:> Type command here...",
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
self._history: list[str] = []
|
||||||
|
self._history_index: int = -1
|
||||||
|
self._current_input: str = ""
|
||||||
|
self._completer: Optional[Callable[[str, int], Optional[str]]] = None
|
||||||
|
self._last_completion_text: str = ""
|
||||||
|
self._last_completions: list[str] = []
|
||||||
|
self._completion_cycle_index: int = 0
|
||||||
|
|
||||||
|
def set_completer(self, completer: Callable[[str, int], Optional[str]]):
|
||||||
|
"""Set the tab completion function (same signature as readline completer)."""
|
||||||
|
self._completer = completer
|
||||||
|
|
||||||
|
def on_key(self, event) -> None:
|
||||||
|
"""Handle special keys for history and completion."""
|
||||||
|
if event.key == "up":
|
||||||
|
event.prevent_default()
|
||||||
|
self._navigate_history(-1)
|
||||||
|
elif event.key == "down":
|
||||||
|
event.prevent_default()
|
||||||
|
self._navigate_history(1)
|
||||||
|
elif event.key == "tab":
|
||||||
|
event.prevent_default()
|
||||||
|
self._handle_tab_completion()
|
||||||
|
|
||||||
|
def _get_all_completions(self, word: str) -> list[str]:
|
||||||
|
"""Get all possible completions for a word."""
|
||||||
|
if not self._completer:
|
||||||
|
return []
|
||||||
|
|
||||||
|
completions = []
|
||||||
|
state = 0
|
||||||
|
while True:
|
||||||
|
completion = self._completer(word, state)
|
||||||
|
if completion is None:
|
||||||
|
break
|
||||||
|
completions.append(completion)
|
||||||
|
state += 1
|
||||||
|
return completions
|
||||||
|
|
||||||
|
def _find_common_prefix(self, strings: list[str]) -> str:
|
||||||
|
"""Find the longest common prefix among strings."""
|
||||||
|
if not strings:
|
||||||
|
return ""
|
||||||
|
if len(strings) == 1:
|
||||||
|
return strings[0]
|
||||||
|
|
||||||
|
prefix = strings[0]
|
||||||
|
for s in strings[1:]:
|
||||||
|
while not s.startswith(prefix):
|
||||||
|
prefix = prefix[:-1]
|
||||||
|
if not prefix:
|
||||||
|
return ""
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
def _handle_tab_completion(self):
|
||||||
|
"""Handle zsh-style tab completion."""
|
||||||
|
if not self._completer:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_text = self.value
|
||||||
|
cursor_pos = self.cursor_position
|
||||||
|
|
||||||
|
# Get the word being completed
|
||||||
|
text_before_cursor = current_text[:cursor_pos]
|
||||||
|
parts = text_before_cursor.split()
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
word_to_complete = ""
|
||||||
|
elif text_before_cursor.endswith(" "):
|
||||||
|
word_to_complete = ""
|
||||||
|
else:
|
||||||
|
word_to_complete = parts[-1]
|
||||||
|
|
||||||
|
# Check if context changed (new completion session)
|
||||||
|
context_changed = text_before_cursor != self._last_completion_text
|
||||||
|
|
||||||
|
if context_changed:
|
||||||
|
# New completion session - get all completions
|
||||||
|
self._last_completions = self._get_all_completions(word_to_complete)
|
||||||
|
self._completion_cycle_index = 0
|
||||||
|
self._last_completion_text = text_before_cursor
|
||||||
|
|
||||||
|
if not self._last_completions:
|
||||||
|
# No completions
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(self._last_completions) == 1:
|
||||||
|
# Single match - complete directly
|
||||||
|
self._apply_completion(self._last_completions[0], word_to_complete, cursor_pos)
|
||||||
|
self._last_completions = []
|
||||||
|
return
|
||||||
|
|
||||||
|
# Multiple matches - complete to common prefix and show options
|
||||||
|
common_prefix = self._find_common_prefix(self._last_completions)
|
||||||
|
|
||||||
|
if common_prefix and len(common_prefix) > len(word_to_complete):
|
||||||
|
# Complete to common prefix
|
||||||
|
self._apply_completion(common_prefix, word_to_complete, cursor_pos)
|
||||||
|
|
||||||
|
# Show all completions
|
||||||
|
self.post_message(self.CompletionsAvailable(
|
||||||
|
self._last_completions.copy(),
|
||||||
|
word_to_complete
|
||||||
|
))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Same context - cycle through completions
|
||||||
|
if not self._last_completions:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get next completion in cycle
|
||||||
|
completion = self._last_completions[self._completion_cycle_index]
|
||||||
|
self._apply_completion(completion, word_to_complete, cursor_pos)
|
||||||
|
|
||||||
|
# Advance cycle
|
||||||
|
self._completion_cycle_index = (self._completion_cycle_index + 1) % len(self._last_completions)
|
||||||
|
|
||||||
|
def _apply_completion(self, completion: str, word_to_complete: str, cursor_pos: int):
|
||||||
|
"""Apply a completion to the input."""
|
||||||
|
current_text = self.value
|
||||||
|
text_before_cursor = current_text[:cursor_pos]
|
||||||
|
|
||||||
|
if word_to_complete:
|
||||||
|
prefix = text_before_cursor[:-len(word_to_complete)]
|
||||||
|
else:
|
||||||
|
prefix = text_before_cursor
|
||||||
|
|
||||||
|
new_text = prefix + completion + current_text[cursor_pos:]
|
||||||
|
new_cursor = len(prefix) + len(completion)
|
||||||
|
|
||||||
|
self.value = new_text
|
||||||
|
self.cursor_position = new_cursor
|
||||||
|
self._last_completion_text = new_text[:new_cursor]
|
||||||
|
|
||||||
|
def _navigate_history(self, direction: int):
|
||||||
|
"""Navigate through command history."""
|
||||||
|
if not self._history:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._history_index == -1:
|
||||||
|
self._current_input = self.value
|
||||||
|
|
||||||
|
new_index = self._history_index + direction
|
||||||
|
|
||||||
|
if new_index < -1:
|
||||||
|
new_index = -1
|
||||||
|
elif new_index >= len(self._history):
|
||||||
|
new_index = len(self._history) - 1
|
||||||
|
|
||||||
|
self._history_index = new_index
|
||||||
|
|
||||||
|
if self._history_index == -1:
|
||||||
|
self.value = self._current_input
|
||||||
|
else:
|
||||||
|
self.value = self._history[-(self._history_index + 1)]
|
||||||
|
|
||||||
|
self.cursor_position = len(self.value)
|
||||||
|
|
||||||
|
def action_submit(self) -> None:
|
||||||
|
"""Submit the current command."""
|
||||||
|
command = self.value.strip()
|
||||||
|
if command:
|
||||||
|
self._history.append(command)
|
||||||
|
if len(self._history) > 100:
|
||||||
|
self._history.pop(0)
|
||||||
|
self.post_message(self.CommandSubmitted(command))
|
||||||
|
|
||||||
|
self.value = ""
|
||||||
|
self._history_index = -1
|
||||||
|
self._current_input = ""
|
||||||
|
self._last_completions = []
|
||||||
|
self._completion_cycle_index = 0
|
||||||
|
self._last_completion_text = ""
|
||||||
159
tools/c2/tui/widgets/device_tabs.py
Normal file
159
tools/c2/tui/widgets/device_tabs.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Dynamic device tabs widget.
|
||||||
|
"""
|
||||||
|
from textual.widgets import Static, Button
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.reactive import reactive
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTabs(Horizontal):
|
||||||
|
"""Tab bar for device switching with dynamic updates."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
DeviceTabs {
|
||||||
|
height: 1;
|
||||||
|
width: 100%;
|
||||||
|
background: $surface;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTabs .tab-label {
|
||||||
|
padding: 0 1;
|
||||||
|
height: 1;
|
||||||
|
min-width: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTabs .tab-label.active {
|
||||||
|
background: $primary;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTabs .tab-label:hover {
|
||||||
|
background: $primary-darken-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTabs .header-label {
|
||||||
|
padding: 0 1;
|
||||||
|
height: 1;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTabs .separator {
|
||||||
|
padding: 0;
|
||||||
|
height: 1;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTabs .device-count {
|
||||||
|
dock: right;
|
||||||
|
padding: 0 1;
|
||||||
|
height: 1;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
active_tab: reactive[str] = reactive("global")
|
||||||
|
devices_hidden: reactive[bool] = reactive(False)
|
||||||
|
|
||||||
|
class TabSelected(Message):
|
||||||
|
"""Posted when a tab is selected."""
|
||||||
|
def __init__(self, tab_id: str, device_id: str | None = None):
|
||||||
|
self.tab_id = tab_id
|
||||||
|
self.device_id = device_id
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._devices: list[str] = []
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
yield Static("C3PO", classes="header-label", id="c3po-label")
|
||||||
|
yield Static(" \u2500 ", classes="separator")
|
||||||
|
yield Static("[G]lobal", classes="tab-label active", id="tab-global")
|
||||||
|
yield Static(" [H]ide", classes="tab-label", id="tab-hide")
|
||||||
|
yield Static("", classes="device-count", id="device-count")
|
||||||
|
|
||||||
|
def add_device(self, device_id: str):
|
||||||
|
"""Add a device tab."""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
self._devices.append(device_id)
|
||||||
|
self._rebuild_tabs()
|
||||||
|
|
||||||
|
def remove_device(self, device_id: str):
|
||||||
|
"""Remove a device tab."""
|
||||||
|
if device_id in self._devices:
|
||||||
|
self._devices.remove(device_id)
|
||||||
|
if self.active_tab == device_id:
|
||||||
|
self.active_tab = "global"
|
||||||
|
self._rebuild_tabs()
|
||||||
|
|
||||||
|
def _rebuild_tabs(self):
|
||||||
|
"""Rebuild all tabs."""
|
||||||
|
for widget in list(self.children):
|
||||||
|
if hasattr(widget, 'id') and widget.id and widget.id.startswith("tab-device-"):
|
||||||
|
widget.remove()
|
||||||
|
|
||||||
|
hide_tab = self.query_one("#tab-hide", Static)
|
||||||
|
|
||||||
|
for i, device_id in enumerate(self._devices):
|
||||||
|
if i < 9:
|
||||||
|
label = f"[{i+1}]{device_id}"
|
||||||
|
tab = Static(
|
||||||
|
label,
|
||||||
|
classes="tab-label" + (" active" if self.active_tab == device_id else ""),
|
||||||
|
id=f"tab-device-{device_id}"
|
||||||
|
)
|
||||||
|
self.mount(tab, before=hide_tab)
|
||||||
|
|
||||||
|
count_label = self.query_one("#device-count", Static)
|
||||||
|
count_label.update(f"{len(self._devices)} device{'s' if len(self._devices) != 1 else ''}")
|
||||||
|
|
||||||
|
def select_tab(self, tab_id: str):
|
||||||
|
"""Select a tab by ID."""
|
||||||
|
if tab_id == "global":
|
||||||
|
self.active_tab = "global"
|
||||||
|
self.post_message(self.TabSelected("global"))
|
||||||
|
elif tab_id in self._devices:
|
||||||
|
self.active_tab = tab_id
|
||||||
|
self.post_message(self.TabSelected(tab_id, tab_id))
|
||||||
|
|
||||||
|
self._update_active_styles()
|
||||||
|
|
||||||
|
def select_by_index(self, index: int):
|
||||||
|
"""Select device tab by numeric index (1-9)."""
|
||||||
|
if 0 < index <= len(self._devices):
|
||||||
|
device_id = self._devices[index - 1]
|
||||||
|
self.select_tab(device_id)
|
||||||
|
|
||||||
|
def toggle_hide(self):
|
||||||
|
"""Toggle device panes visibility."""
|
||||||
|
self.devices_hidden = not self.devices_hidden
|
||||||
|
hide_tab = self.query_one("#tab-hide", Static)
|
||||||
|
hide_tab.update("[H]ide" if not self.devices_hidden else "[H]show")
|
||||||
|
|
||||||
|
def _update_active_styles(self):
|
||||||
|
"""Update tab styles to show active state."""
|
||||||
|
for tab in self.query(".tab-label"):
|
||||||
|
tab.remove_class("active")
|
||||||
|
|
||||||
|
if self.active_tab == "global":
|
||||||
|
self.query_one("#tab-global", Static).add_class("active")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.query_one(f"#tab-device-{self.active_tab}", Static).add_class("active")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_click(self, event) -> None:
|
||||||
|
"""Handle tab clicks."""
|
||||||
|
target = event.target
|
||||||
|
if hasattr(target, 'id') and target.id:
|
||||||
|
if target.id == "tab-global":
|
||||||
|
self.select_tab("global")
|
||||||
|
elif target.id == "tab-hide":
|
||||||
|
self.toggle_hide()
|
||||||
|
elif target.id.startswith("tab-device-"):
|
||||||
|
device_id = target.id.replace("tab-device-", "")
|
||||||
|
self.select_tab(device_id)
|
||||||
117
tools/c2/tui/widgets/log_pane.py
Normal file
117
tools/c2/tui/widgets/log_pane.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Log pane widgets for displaying device and global logs.
|
||||||
|
"""
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalLogPane(RichLog):
|
||||||
|
"""Combined log view for all devices and system messages."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
GlobalLogPane {
|
||||||
|
border: solid $primary;
|
||||||
|
height: 100%;
|
||||||
|
scrollbar-size: 1 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
highlight=True,
|
||||||
|
markup=True,
|
||||||
|
wrap=True,
|
||||||
|
max_lines=5000,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_system(self, timestamp: str, message: str):
|
||||||
|
"""Add a system message."""
|
||||||
|
text = Text()
|
||||||
|
text.append(f"{timestamp} ", style="dim")
|
||||||
|
text.append("[SYS] ", style="cyan bold")
|
||||||
|
text.append(message)
|
||||||
|
self.write(text)
|
||||||
|
|
||||||
|
def add_device_event(self, timestamp: str, device_id: str, event: str):
|
||||||
|
"""Add a device event."""
|
||||||
|
text = Text()
|
||||||
|
text.append(f"{timestamp} ", style="dim")
|
||||||
|
text.append(f"[{device_id}] ", style="yellow")
|
||||||
|
text.append(event)
|
||||||
|
self.write(text)
|
||||||
|
|
||||||
|
def add_command_sent(self, timestamp: str, device_id: str, command: str, request_id: str):
|
||||||
|
"""Add a command sent message."""
|
||||||
|
text = Text()
|
||||||
|
text.append(f"{timestamp} ", style="dim")
|
||||||
|
text.append("[CMD] ", style="blue bold")
|
||||||
|
text.append(f"{command} ", style="blue")
|
||||||
|
text.append(f"-> {device_id}", style="dim")
|
||||||
|
self.write(text)
|
||||||
|
|
||||||
|
def add_command_response(self, timestamp: str, device_id: str, response: str, request_id: str):
|
||||||
|
"""Add a command response."""
|
||||||
|
text = Text()
|
||||||
|
text.append(f"{timestamp} ", style="dim")
|
||||||
|
text.append(f"[{device_id}] ", style="green")
|
||||||
|
text.append(response, style="green")
|
||||||
|
self.write(text)
|
||||||
|
|
||||||
|
def add_error(self, timestamp: str, message: str):
|
||||||
|
"""Add an error message."""
|
||||||
|
text = Text()
|
||||||
|
text.append(f"{timestamp} ", style="dim")
|
||||||
|
text.append("[ERR] ", style="red bold")
|
||||||
|
text.append(message, style="red")
|
||||||
|
self.write(text)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceLogPane(RichLog):
|
||||||
|
"""Per-device log display with filtering."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
DeviceLogPane {
|
||||||
|
height: 100%;
|
||||||
|
scrollbar-size: 1 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device_id: str, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
highlight=True,
|
||||||
|
markup=True,
|
||||||
|
wrap=True,
|
||||||
|
max_lines=2000,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
self.device_id = device_id
|
||||||
|
|
||||||
|
def add_event(self, timestamp: str, event: str, event_type: str = "info"):
|
||||||
|
"""Add an event to this device's log."""
|
||||||
|
text = Text()
|
||||||
|
text.append(f"{timestamp} ", style="dim")
|
||||||
|
|
||||||
|
style_map = {
|
||||||
|
"info": "yellow",
|
||||||
|
"log": "white",
|
||||||
|
"error": "red",
|
||||||
|
"cmd_sent": "blue",
|
||||||
|
"cmd_resp": "green",
|
||||||
|
"data": "magenta",
|
||||||
|
}
|
||||||
|
style = style_map.get(event_type, "white")
|
||||||
|
|
||||||
|
prefix_map = {
|
||||||
|
"info": "> INFO: ",
|
||||||
|
"log": "> LOG: ",
|
||||||
|
"error": "> ERROR: ",
|
||||||
|
"cmd_sent": "> CMD: ",
|
||||||
|
"cmd_resp": "> RESP: ",
|
||||||
|
"data": "> DATA: ",
|
||||||
|
}
|
||||||
|
prefix = prefix_map.get(event_type, "> ")
|
||||||
|
|
||||||
|
text.append(prefix, style=f"{style} bold")
|
||||||
|
text.append(event, style=style)
|
||||||
|
self.write(text)
|
||||||
@ -1,29 +1,115 @@
|
|||||||
import time
|
import time
|
||||||
from utils.constant import _color
|
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:
|
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
|
@staticmethod
|
||||||
def _timestamp() -> str:
|
def _timestamp() -> str:
|
||||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def system_message(message: str):
|
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}")
|
print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def device_event(device_id: str, event: str):
|
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}")
|
print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def command_sent(device_id: str, command_name: str, request_id: str):
|
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}")
|
print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def command_response(request_id: str, device_id: str, response: str):
|
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}")
|
print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def error(message: str):
|
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}")
|
print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -22,7 +22,7 @@ class UnifiedWebServer:
|
|||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Dashboard: View connected ESP32 devices
|
- Dashboard: View connected ESP32 devices
|
||||||
- Cameras: View live camera streams
|
- Cameras: View live camera streams with recording
|
||||||
- Trilateration: Visualize BLE device positioning
|
- Trilateration: Visualize BLE device positioning
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -35,7 +35,8 @@ class UnifiedWebServer:
|
|||||||
secret_key: str = "change_this_for_prod",
|
secret_key: str = "change_this_for_prod",
|
||||||
multilat_token: str = "multilat_secret_token",
|
multilat_token: str = "multilat_secret_token",
|
||||||
device_registry=None,
|
device_registry=None,
|
||||||
mlat_engine: Optional[MlatEngine] = None):
|
mlat_engine: Optional[MlatEngine] = None,
|
||||||
|
camera_receiver=None):
|
||||||
"""
|
"""
|
||||||
Initialize the unified web server.
|
Initialize the unified web server.
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ class UnifiedWebServer:
|
|||||||
multilat_token: Bearer token for MLAT API
|
multilat_token: Bearer token for MLAT API
|
||||||
device_registry: DeviceRegistry instance for device listing
|
device_registry: DeviceRegistry instance for device listing
|
||||||
mlat_engine: MlatEngine instance (created if None)
|
mlat_engine: MlatEngine instance (created if None)
|
||||||
|
camera_receiver: UDPReceiver instance for camera control
|
||||||
"""
|
"""
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@ -59,6 +61,7 @@ class UnifiedWebServer:
|
|||||||
self.multilat_token = multilat_token
|
self.multilat_token = multilat_token
|
||||||
self.device_registry = device_registry
|
self.device_registry = device_registry
|
||||||
self.mlat = mlat_engine or MlatEngine()
|
self.mlat = mlat_engine or MlatEngine()
|
||||||
|
self.camera_receiver = camera_receiver
|
||||||
|
|
||||||
# Ensure image directory exists
|
# Ensure image directory exists
|
||||||
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@ -69,13 +72,16 @@ class UnifiedWebServer:
|
|||||||
self._server = None
|
self._server = None
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
|
||||||
|
def set_camera_receiver(self, receiver):
|
||||||
|
"""Set the camera receiver after initialization."""
|
||||||
|
self.camera_receiver = receiver
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
return self._thread is not None and self._thread.is_alive()
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|
||||||
def _create_app(self) -> Flask:
|
def _create_app(self) -> Flask:
|
||||||
"""Create and configure the Flask application."""
|
"""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__)))
|
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
template_dir = os.path.join(c2_root, "templates")
|
template_dir = os.path.join(c2_root, "templates")
|
||||||
static_dir = os.path.join(c2_root, "static")
|
static_dir = os.path.join(c2_root, "static")
|
||||||
@ -85,7 +91,6 @@ class UnifiedWebServer:
|
|||||||
static_folder=static_dir)
|
static_folder=static_dir)
|
||||||
app.secret_key = self.secret_key
|
app.secret_key = self.secret_key
|
||||||
|
|
||||||
# Store reference to self for route handlers
|
|
||||||
web_server = self
|
web_server = self
|
||||||
|
|
||||||
# ========== Auth Decorators ==========
|
# ========== Auth Decorators ==========
|
||||||
@ -99,18 +104,15 @@ class UnifiedWebServer:
|
|||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
def require_api_auth(f):
|
def require_api_auth(f):
|
||||||
"""Require session login OR Bearer token for API endpoints."""
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
# Check session
|
|
||||||
if session.get("logged_in"):
|
if session.get("logged_in"):
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
# Check Bearer token
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
token = auth_header[7:]
|
token = auth_header[7:]
|
||||||
if token == web_server.mlat_token:
|
if token == web_server.multilat_token:
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return jsonify({"error": "Unauthorized"}), 401
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
@ -151,7 +153,6 @@ class UnifiedWebServer:
|
|||||||
@app.route("/cameras")
|
@app.route("/cameras")
|
||||||
@require_login
|
@require_login
|
||||||
def cameras():
|
def cameras():
|
||||||
# List available camera images
|
|
||||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||||
try:
|
try:
|
||||||
image_files = sorted([
|
image_files = sorted([
|
||||||
@ -176,12 +177,17 @@ class UnifiedWebServer:
|
|||||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||||
return send_from_directory(full_image_dir, filename)
|
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 ==========
|
# ========== Device API ==========
|
||||||
|
|
||||||
@app.route("/api/devices")
|
@app.route("/api/devices")
|
||||||
@require_api_auth
|
@require_api_auth
|
||||||
def api_devices():
|
def api_devices():
|
||||||
"""Get list of connected devices."""
|
|
||||||
if web_server.device_registry is None:
|
if web_server.device_registry is None:
|
||||||
return jsonify({"error": "Device registry not available", "devices": []})
|
return jsonify({"error": "Device registry not available", "devices": []})
|
||||||
|
|
||||||
@ -210,7 +216,6 @@ class UnifiedWebServer:
|
|||||||
@app.route("/api/cameras")
|
@app.route("/api/cameras")
|
||||||
@require_api_auth
|
@require_api_auth
|
||||||
def api_cameras():
|
def api_cameras():
|
||||||
"""Get list of active cameras."""
|
|
||||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||||
try:
|
try:
|
||||||
cameras = [
|
cameras = [
|
||||||
@ -221,24 +226,68 @@ class UnifiedWebServer:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
cameras = []
|
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 ==========
|
# ========== Trilateration API ==========
|
||||||
|
|
||||||
@app.route("/api/mlat/collect", methods=["POST"])
|
@app.route("/api/mlat/collect", methods=["POST"])
|
||||||
@require_api_auth
|
@require_api_auth
|
||||||
def api_mlat_collect():
|
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)
|
raw_data = request.get_data(as_text=True)
|
||||||
count = web_server.mlat.parse_data(raw_data)
|
count = web_server.mlat.parse_data(raw_data)
|
||||||
|
|
||||||
# Recalculate position after new data
|
|
||||||
if count > 0:
|
if count > 0:
|
||||||
web_server.mlat.calculate_position()
|
web_server.mlat.calculate_position()
|
||||||
|
|
||||||
@ -250,10 +299,8 @@ class UnifiedWebServer:
|
|||||||
@app.route("/api/mlat/state")
|
@app.route("/api/mlat/state")
|
||||||
@require_api_auth
|
@require_api_auth
|
||||||
def api_mlat_state():
|
def api_mlat_state():
|
||||||
"""Get current multilateration state (scanners + target)."""
|
|
||||||
state = web_server.mlat.get_state()
|
state = web_server.mlat.get_state()
|
||||||
|
|
||||||
# Include latest calculation if not present
|
|
||||||
if state["target"] is None and state["scanners_count"] >= 3:
|
if state["target"] is None and state["scanners_count"] >= 3:
|
||||||
result = web_server.mlat.calculate_position()
|
result = web_server.mlat.calculate_position()
|
||||||
if "position" in result:
|
if "position" in result:
|
||||||
@ -269,7 +316,6 @@ class UnifiedWebServer:
|
|||||||
@app.route("/api/mlat/config", methods=["GET", "POST"])
|
@app.route("/api/mlat/config", methods=["GET", "POST"])
|
||||||
@require_api_auth
|
@require_api_auth
|
||||||
def api_mlat_config():
|
def api_mlat_config():
|
||||||
"""Get or update multilateration configuration."""
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
web_server.mlat.update_config(
|
web_server.mlat.update_config(
|
||||||
@ -287,7 +333,6 @@ class UnifiedWebServer:
|
|||||||
@app.route("/api/mlat/clear", methods=["POST"])
|
@app.route("/api/mlat/clear", methods=["POST"])
|
||||||
@require_api_auth
|
@require_api_auth
|
||||||
def api_mlat_clear():
|
def api_mlat_clear():
|
||||||
"""Clear all multilateration data."""
|
|
||||||
web_server.mlat.clear()
|
web_server.mlat.clear()
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
@ -296,7 +341,6 @@ class UnifiedWebServer:
|
|||||||
@app.route("/api/stats")
|
@app.route("/api/stats")
|
||||||
@require_api_auth
|
@require_api_auth
|
||||||
def api_stats():
|
def api_stats():
|
||||||
"""Get overall server statistics."""
|
|
||||||
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
full_image_dir = os.path.join(c2_root, web_server.image_dir)
|
||||||
try:
|
try:
|
||||||
camera_count = len([
|
camera_count = len([
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user