2205 lines
112 KiB
Markdown
2205 lines
112 KiB
Markdown
<p align="center">
|
||
<img src="https://img.shields.io/badge/Python-3.8+-blue?style=for-the-badge&logo=python&logoColor=white" alt="Python">
|
||
<img src="https://img.shields.io/badge/Tkinter-GUI-green?style=for-the-badge&logo=python&logoColor=white" alt="Tkinter">
|
||
<img src="https://img.shields.io/badge/TCP/IP-Multiplayer-orange?style=for-the-badge&logo=socketdotio&logoColor=white" alt="Sockets">
|
||
<img src="https://img.shields.io/badge/License-Educational-purple?style=for-the-badge" alt="License">
|
||
</p>
|
||
|
||
> **📹 [Vídeo Explicativo del Proyecto (YouTube - Privado)](https://youtu.be/c3L8mXMI4vI)**
|
||
>
|
||
> **📌 Nota:** Este proyecto realmente corresponde a "Proyecto2AVApsp", pero se mantuvo el nombre "Proyecto1AVApsp" en el repositorio.
|
||
|
||
<h1 align="center">💣 Minesweeper Multiplayer + Dashboard</h1>
|
||
|
||
<p align="center">
|
||
<strong>Un proyecto completo de programación de servicios y procesos</strong><br>
|
||
<em>Juego de buscaminas competitivo en red + Panel de control integral</em>
|
||
</p>
|
||
|
||
<p align="center">
|
||
<a href="#-características">Características</a> •
|
||
<a href="#-arquitectura">Arquitectura</a> •
|
||
<a href="#-instalación">Instalación</a> •
|
||
<a href="#-uso">Uso</a> •
|
||
<a href="#-mecánicas-del-juego">Mecánicas</a> •
|
||
<a href="#-tecnologías">Tecnologías</a>
|
||
</p>
|
||
|
||
---
|
||
|
||
## 📸 Vista Previa
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ 🎮 MINESWEEPER MULTIPLAYER DASHBOARD │
|
||
├──────────────┬──────────────────────────────────────┬───────────────────────┤
|
||
│ │ │ ┌─────────────────┐ │
|
||
│ ACCIONES │ ÁREA DE RESULTADOS │ │ 💣 MINESWEEPER │ │
|
||
│ ────────── │ │ │ MULTIPLAYER │ │
|
||
│ │ ┌────────────────────────────┐ │ ├─────────────────┤ │
|
||
│ > Wallapop │ │ 📊 Monitor Sistema │ │ │ Ronda: 3 │ │
|
||
│ > Scraping │ │ 📈 CPU: 45% │ │ │ 💣 Bombas: 9 │ │
|
||
│ > API Tiempo │ │ 💾 RAM: 2.1GB │ │ │ ❤️ Vidas: 2 │ │
|
||
│ │ └────────────────────────────┘ │ ├─────────────────┤ │
|
||
│ APPS │ │ │ ┌───┬───┬───┐ │ │
|
||
│ ────────── │ Tabs: [Resultados][Navegador] │ │ │ ▢ │ ▢ │ ✓ │ │ │
|
||
│ > VS Code │ [Correos][Bloc][Tareas] │ │ ├───┼───┼───┤ │ │
|
||
│ > Camellos │ [Alarmas][Enlaces] │ │ │ 💥│ ▢ │ ▢ │ │ │
|
||
│ │ │ │ └───┴───┴───┘ │ │
|
||
│ BATCH │ ┌────────────────────────────┐ │ ├─────────────────┤ │
|
||
│ ────────── │ │ 📝 Panel de Notas │ │ │ [Iniciar Juego] │ │
|
||
│ > Backups │ │ │ │ │ [Zona Despejada]│ │
|
||
│ │ └────────────────────────────┘ │ └─────────────────┘ │
|
||
└──────────────┴──────────────────────────────────────┴───────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## ✨ Características
|
||
|
||
### 🎮 **Juego Minesweeper Multijugador**
|
||
| Característica | Descripción |
|
||
|----------------|-------------|
|
||
| 🔥 **Competitivo** | 2+ jugadores compiten en tiempo real |
|
||
| 💣 **Colocación estratégica** | Cada jugador coloca bombas para el rival |
|
||
| 🔄 **Por turnos** | Sistema de turnos para colocar y buscar |
|
||
| 📈 **Dificultad progresiva** | 5 rondas con grids y bombas crecientes |
|
||
| ❤️ **Sistema de vidas** | 3 vidas por partida, ¡no las pierdas! |
|
||
|
||
### 📊 **Dashboard Integral**
|
||
- 📡 **Monitor del sistema** en tiempo real (CPU, RAM, hilos)
|
||
- 🌤️ **API del tiempo** para Jávea (OpenWeather)
|
||
- 🛒 **Análisis de Wallapop** - Scraping de anuncios
|
||
- ⏰ **Sistema de alarmas** programables
|
||
- 📝 **Bloc de notas** integrado
|
||
- 🔗 **Gestor de enlaces** rápidos
|
||
- 🎲 **Minijuego de camellos** con animaciones
|
||
|
||
### 📧 **Cliente de Correo Electrónico Completo**
|
||
| Característica | Descripción |
|
||
|----------------|-------------|
|
||
| 📬 **IMAP** | Lectura de correos desde servidor Webmin (Puerto 143) |
|
||
| 📤 **SMTP** | Envío de correos con autenticación (Puerto 25) |
|
||
| 💾 **Auto-guardado** | Credenciales guardadas con Base64 |
|
||
| 👥 **Múltiples destinatarios** | Envío a varios correos simultáneamente |
|
||
| 📎 **Adjuntos** | Soporte para imágenes, PDFs, docs, Excel, ZIP |
|
||
| 🖼️ **Imágenes inline** | Visualización de imágenes dentro del correo |
|
||
| 🗂️ **Carpetas** | Bandeja de entrada y enviados sincronizados |
|
||
| 🔵 **Indicadores** | Correos leídos/no leídos con marcadores visuales |
|
||
|
||
---
|
||
|
||
## 🏗️ Arquitectura
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ARQUITECTURA DEL SISTEMA │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
|
||
┌──────────────────────────┐
|
||
│ SERVIDOR TCP │
|
||
│ servidor.py │
|
||
│ ┌──────────────────┐ │
|
||
│ │ GameServer │ │
|
||
│ │ - Estado juego │ │
|
||
│ │ - Broadcast │ │
|
||
│ │ - Turnos │ │
|
||
│ └──────────────────┘ │
|
||
│ Puerto 3333 │
|
||
└────────────┬─────────────┘
|
||
│
|
||
┌────────────┴───────────────┐
|
||
│ Protocolo JSON │
|
||
│ sobre TCP/IP │
|
||
└────────────┬───────────────┘
|
||
┌─────────────────────┼─────────────────────┐
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||
│ CLIENTE 1 │ │ CLIENTE 2 │ │ CLIENTE N │
|
||
│ │ │ │ │ │
|
||
│ ┌────────────┐ │ │ ┌────────────┐ │ │ ┌────────────┐ │
|
||
│ │ app.py │ │ │ │ app.py │ │ │ │ cliente_ │ │
|
||
│ │ Dashboard │ │ │ │ Dashboard │ │ │ │ juego.py │ │
|
||
│ │ + Juego │ │ │ │ + Juego │ │ │ │ Standalone │ │
|
||
│ └────────────┘ │ │ └────────────┘ │ │ └────────────┘ │
|
||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||
```
|
||
|
||
### 📁 Estructura del Proyecto
|
||
|
||
```
|
||
Proyecto1AVApsp/
|
||
│
|
||
├── 📄 servidor.py # Servidor TCP del juego (371 líneas)
|
||
│ └── GameServer # Gestiona estado, turnos y broadcasts
|
||
│
|
||
├── 📄 app.py # Dashboard principal (2566 líneas)
|
||
│ ├── DashboardApp # Aplicación Tkinter completa
|
||
│ └── GameClient # Cliente TCP integrado
|
||
│
|
||
├── 📄 cliente_juego.py # Cliente standalone (220 líneas)
|
||
│ └── GameClient # Versión ligera para jugar
|
||
│
|
||
├── 📄 requirements.txt # Dependencias Python
|
||
└── 📄 README.md # Este archivo
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 Instalación
|
||
|
||
### Requisitos Previos
|
||
- **Python 3.8+**
|
||
- **pip** (gestor de paquetes)
|
||
|
||
### Paso 1: Clonar el Repositorio
|
||
```bash
|
||
git clone https://github.com/MarcosFerrandiz/Proyecto1AVApsp.git
|
||
cd Proyecto1AVApsp
|
||
```
|
||
|
||
### Paso 2: Crear Entorno Virtual (Recomendado)
|
||
```bash
|
||
python -m venv .venv
|
||
|
||
# Linux/macOS
|
||
source .venv/bin/activate
|
||
|
||
# Windows
|
||
.venv\Scripts\activate
|
||
```
|
||
|
||
### Paso 3: Instalar Dependencias
|
||
```bash
|
||
pip install -r requirements.txt
|
||
```
|
||
|
||
<details>
|
||
<summary>📦 <strong>Ver dependencias detalladas</strong></summary>
|
||
|
||
| Paquete | Versión | Uso |
|
||
|---------|---------|-----|
|
||
| `psutil` | ≥5.9.0 | Monitor de recursos del sistema |
|
||
| `matplotlib` | ≥3.5.0 | Gráficos en tiempo real |
|
||
| `pillow` | ≥9.0.0 | Procesamiento de imágenes |
|
||
| `pygame` | ≥2.1.0 | Reproducción de audio (opcional) |
|
||
| `requests` | ≥2.32.0 | Peticiones HTTP (API, Scraping) |
|
||
| `beautifulsoup4` | ≥4.12.0 | Parsing HTML (Scraping) |
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## 🎯 Uso
|
||
|
||
### 🖥️ Iniciar el Servidor
|
||
```bash
|
||
python servidor.py
|
||
```
|
||
> El servidor escuchará en `0.0.0.0:3333`
|
||
|
||
### 🎮 Opción A: Dashboard Completo
|
||
```bash
|
||
python app.py
|
||
```
|
||
Incluye el juego integrado + todas las funcionalidades del panel.
|
||
|
||
### 🎮 Opción B: Cliente Standalone
|
||
```bash
|
||
python cliente_juego.py
|
||
```
|
||
Cliente ligero solo para jugar al Minesweeper.
|
||
|
||
### 🔌 Conectar al Juego
|
||
1. Introduce el **Host** del servidor (por defecto: `127.0.0.1`)
|
||
2. Verifica el **Puerto** (por defecto: `3333`)
|
||
3. Pulsa **"Conectar"**
|
||
4. ¡Espera a otro jugador y pulsa **"Iniciar Juego"**!
|
||
|
||
---
|
||
|
||
## 💣 Mecánicas del Juego
|
||
|
||
### 🔄 Flujo de una Partida
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ FLUJO DEL JUEGO │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ LOBBY │────▶│ COLOCACIÓN │────▶│ BÚSQUEDA │ │
|
||
│ │ │ │ DE BOMBAS │ │ (PLAYING) │ │
|
||
│ └─────────┘ └─────────────┘ └──────┬──────┘ │
|
||
│ │ │ │
|
||
│ │ ┌───────────────────────────┘ │
|
||
│ │ │ │
|
||
│ │ ▼ │
|
||
│ │ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
|
||
│ │ │ ¿BOMBA? │───▶│ EXPLOSIÓN│───▶│ ¿VIDAS=0? │ │
|
||
│ │ └────┬────┘ └──────────┘ └─────┬──────┘ │
|
||
│ │ │ NO │ │
|
||
│ │ ▼ SÍ ▼ │
|
||
│ │ ┌─────────┐ ┌──────────┐ │
|
||
│ │ │ SAFE │ │ GAME OVER│ │
|
||
│ │ │(casilla │ └──────────┘ │
|
||
│ │ │ segura) │ │
|
||
│ │ └────┬────┘ │
|
||
│ │ │ │
|
||
│ │ ▼ │
|
||
│ │ ┿━━━━━━━━━━━◀────────────────────────┓ │
|
||
│ │ ┃ SIGUIENTE TURNO ┃ │
|
||
│ │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
|
||
│ │ │
|
||
│ └───────────────────▶ (Nueva Partida) │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 📊 Progresión de Dificultad
|
||
|
||
| Ronda | Tamaño Grid | Bombas/Jugador | Total Bombas* |
|
||
|:-----:|:-----------:|:--------------:|:-------------:|
|
||
| 1️⃣ | 3×3 | 3 | 6 |
|
||
| 2️⃣ | 5×5 | 5 | 10 |
|
||
| 3️⃣ | 8×8 | 9 | 18 |
|
||
| 4️⃣ | 11×11 | 12 | 24 |
|
||
| 5️⃣+ | 14×14 | 15 | 30 |
|
||
|
||
*\*Para 2 jugadores*
|
||
|
||
### 🎯 Fases del Juego
|
||
|
||
#### 1. 💣 Fase de Colocación (`PLACING`)
|
||
- Cada jugador coloca bombas **por turnos**
|
||
- Las bombas se muestran brevemente (1 segundo) a todos
|
||
- ¡Memoriza dónde pones TUS bombas y las del rival!
|
||
|
||
#### 2. 🔍 Fase de Búsqueda (`PLAYING`)
|
||
- Excava casillas por turnos
|
||
- **Casilla segura** → Se marca en verde ✅
|
||
- **Bomba** → ¡EXPLOSIÓN! Pierdes 1 vida 💔
|
||
|
||
### 🏆 Condiciones de Victoria
|
||
|
||
| Condición | Resultado |
|
||
|-----------|-----------|
|
||
| Rival pierde todas las vidas | **¡GANASTE!** 🎉 |
|
||
| Todas las casillas seguras reveladas | **¡Zona despejada!** |
|
||
| Superas la ronda 5 | **¡Victoria total!** 🏆 |
|
||
|
||
---
|
||
|
||
## 📡 Protocolo de Comunicación
|
||
|
||
### Mensajes JSON Cliente → Servidor
|
||
|
||
```javascript
|
||
// Iniciar partida
|
||
{ "type": "START_GAME" }
|
||
|
||
// Colocar bomba (fase PLACING)
|
||
{ "type": "PLACE_BOMB", "x": 2, "y": 1 }
|
||
|
||
// Excavar casilla (fase PLAYING)
|
||
{ "type": "CLICK_CELL", "x": 3, "y": 4 }
|
||
|
||
// Verificar zona despejada
|
||
{ "type": "CHECK_DUNGEON_CLEARED" }
|
||
```
|
||
|
||
### Mensajes JSON Servidor → Clientes
|
||
|
||
```javascript
|
||
// Nueva ronda
|
||
{
|
||
"type": "NEW_ROUND",
|
||
"round": 1,
|
||
"grid_size": 3,
|
||
"total_bombs_per_player": 3
|
||
}
|
||
|
||
// Notificación de turno
|
||
{
|
||
"type": "TURN_NOTIFY",
|
||
"active_player": "('127.0.0.1', 54321)",
|
||
"msg": "Turno de ... para poner bombas."
|
||
}
|
||
|
||
// Flash de bomba (visible 1 segundo)
|
||
{
|
||
"type": "BOMB_flash",
|
||
"x": 1, "y": 2,
|
||
"who": "('127.0.0.1', 54321)"
|
||
}
|
||
|
||
// Explosión
|
||
{
|
||
"type": "EXPLOSION",
|
||
"x": 1, "y": 2,
|
||
"who": "...",
|
||
"lives": 2
|
||
}
|
||
|
||
// Casilla segura
|
||
{ "type": "SAFE", "x": 0, "y": 0 }
|
||
|
||
// Game Over
|
||
{
|
||
"type": "GAME_OVER",
|
||
"loser": "...",
|
||
"msg": "💀 ¡... ha perdido todas sus vidas!"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 Documentación Técnica Detallada
|
||
|
||
### 🆔 Sistema de Identificación de Jugadores
|
||
|
||
El servidor **identifica a cada jugador mediante su dirección de socket**, que es una tupla única `(IP, Puerto)`:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ IDENTIFICACIÓN ÚNICA DE JUGADORES │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Cuando un cliente se conecta al servidor: │
|
||
│ │
|
||
│ Cliente 1 ──────► sock.accept() ──────► ('127.0.0.1', 49956) │
|
||
│ Cliente 2 ──────► sock.accept() ──────► ('127.0.0.1', 49968) │
|
||
│ │
|
||
│ Aunque ambos clientes estén en el MISMO ordenador (127.0.0.1) │
|
||
│ el PUERTO es diferente y único para cada conexión. │
|
||
│ │
|
||
│ Esta tupla (IP, Puerto) actúa como IDENTIFICADOR ÚNICO. │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### ¿Cómo funciona en el mismo ordenador?
|
||
|
||
```python
|
||
# servidor.py:62-65 - Al aceptar conexión
|
||
conn, addr = s.accept() # addr = ('127.0.0.1', 49956)
|
||
|
||
# El sistema operativo asigna un puerto efímero ÚNICO
|
||
# a cada nueva conexión de socket del cliente
|
||
```
|
||
|
||
| Cliente | IP | Puerto (asignado por SO) | Identificador Completo |
|
||
|---------|----|--------------------------|-----------------------|
|
||
| Dashboard 1 | 127.0.0.1 | 49956 | `('127.0.0.1', 49956)` |
|
||
| Dashboard 2 | 127.0.0.1 | 49968 | `('127.0.0.1', 49968)` |
|
||
| Cliente remoto | 192.168.1.50 | 52341 | `('192.168.1.50', 52341)` |
|
||
|
||
> **💡 Clave:** El puerto del cliente es asignado automáticamente por el sistema operativo y es **siempre diferente** para cada nueva conexión, incluso desde el mismo ordenador.
|
||
|
||
---
|
||
|
||
### 🔄 Gestión de Turnos
|
||
|
||
El servidor mantiene **dos índices de turno** para controlar quién juega:
|
||
|
||
```python
|
||
# servidor.py - Variables de control de turnos
|
||
self.placing_turn_index = 0 # Índice en fase PLACING
|
||
self.playing_turn_index = 0 # Índice en fase PLAYING
|
||
self.client_list = [] # Lista ordenada de direcciones
|
||
```
|
||
|
||
#### Flujo de Verificación de Turno
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ VERIFICACIÓN DE TURNO │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ client_list = [('127.0.0.1', 49956), ('127.0.0.1', 49968)] │
|
||
│ ↑ ↑ │
|
||
│ índice 0 índice 1 │
|
||
│ │
|
||
│ Si placing_turn_index = 0: │
|
||
│ → Solo ('127.0.0.1', 49956) puede poner bombas │
|
||
│ │
|
||
│ Cuando recibe PLACE_BOMB de un cliente: │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────┐ │
|
||
│ │ if str(addr) != str(current_turn_addr): │ │
|
||
│ │ return # No es su turno, ignorar │ │
|
||
│ └─────────────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### Código de Verificación
|
||
|
||
```python
|
||
# servidor.py:222-232 - Verificación de turno al colocar bomba
|
||
elif msg_type == 'PLACE_BOMB':
|
||
if self.state == STATE_PLACING:
|
||
# Obtener quién tiene el turno actual
|
||
current_turn_addr = self.client_list[self.placing_turn_index]
|
||
|
||
# Comparar con quién envió el mensaje
|
||
if str(addr) != str(current_turn_addr):
|
||
return # ¡No es tu turno! Ignorar mensaje
|
||
|
||
# Procesar la bomba...
|
||
```
|
||
|
||
---
|
||
|
||
### 👤 Cómo el Cliente Sabe si es su Turno
|
||
|
||
El cliente guarda su propia dirección al conectarse y la compara con los mensajes del servidor:
|
||
|
||
```python
|
||
# app.py:1221-1240 - Identificación del cliente y manejo de turno
|
||
# Al conectarse
|
||
self.my_address = str(sock.getsockname()) # Ej: "('127.0.0.1', 49956)"
|
||
|
||
# Al recibir TURN_NOTIFY del servidor
|
||
def handle_message(self, msg):
|
||
if mtype == 'TURN_NOTIFY':
|
||
active_player = msg.get('active_player') # "('127.0.0.1', 49956)"
|
||
|
||
# Comparar con mi dirección
|
||
if active_player == self.my_address:
|
||
self._log_game("🎯 ¡ES TU TURNO!")
|
||
# Habilitar controles...
|
||
else:
|
||
self._log_game(f"⏳ Turno de {active_player}")
|
||
# Deshabilitar controles...
|
||
```
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ FLUJO DE NOTIFICACIÓN DE TURNO │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ SERVIDOR CLIENTES │
|
||
│ ──────── ──────── │
|
||
│ │
|
||
│ broadcast({ Cliente 1 (49956): │
|
||
│ "type": "TURN_NOTIFY", ├─ my_address = 49956 │
|
||
│ "active_player": ├─ active_player = 49956 │
|
||
│ "('127.0.0.1', 49956)" └─ ✓ ¡ES MI TURNO! │
|
||
│ }) │
|
||
│ │ Cliente 2 (49968): │
|
||
│ └──────────────────────────► ├─ my_address = 49968 │
|
||
│ ├─ active_player = 49956 │
|
||
│ └─ ✗ No es mi turno │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 💣 Detección de Bombas Duplicadas
|
||
|
||
El servidor usa un **Set de Python** para almacenar las coordenadas de las bombas, lo que garantiza que no haya duplicados.
|
||
|
||
#### **Estructura de Datos: `self.bombs = set()`**
|
||
|
||
```python
|
||
# servidor.py:32 - Inicialización del set de bombas
|
||
class GameServer:
|
||
def __init__(self):
|
||
self.bombs = set() # Set de tuplas (x, y)
|
||
```
|
||
|
||
Un `set()` en Python **no permite elementos duplicados**. Si intentas agregar la misma tupla `(x, y)` dos veces, solo se guarda una vez.
|
||
|
||
#### **Validación al Colocar Bomba**
|
||
|
||
```python
|
||
# servidor.py:222-246 - Validación completa de colocación de bomba
|
||
elif msg_type == 'PLACE_BOMB':
|
||
if self.state == STATE_PLACING:
|
||
# 1. Verificar que es el turno del jugador
|
||
current_turn_addr = self.client_list[self.placing_turn_index]
|
||
if str(addr) != str(current_turn_addr):
|
||
return # ❌ No es su turno, ignorar
|
||
|
||
x, y = msg['x'], msg['y']
|
||
|
||
# 2. ⭐ VERIFICAR SI YA HAY BOMBA EN ESA CASILLA
|
||
if (x, y) in self.bombs:
|
||
return # ❌ Ya hay bomba aquí, ignorar clic
|
||
|
||
# 3. ✅ Agregar bomba al set
|
||
self.bombs.add((x, y))
|
||
self.current_player_bombs_placed += 1
|
||
|
||
# 4. Mostrar flash de bomba a todos (1 segundo)
|
||
self._broadcast_unlocked({
|
||
"type": "BOMB_flash",
|
||
"x": x, "y": y,
|
||
"who": str(addr)
|
||
})
|
||
|
||
# 5. Si el jugador completó sus bombas, pasar al siguiente
|
||
if self.current_player_bombs_placed >= self.bombs_to_place_per_player:
|
||
self.placing_turn_index += 1
|
||
self.next_placement_turn()
|
||
```
|
||
|
||
#### **Flujo Completo de Validación**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ VALIDACIÓN DE COLOCACIÓN DE BOMBA │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Jugador 1 hace clic en casilla (2, 1) │
|
||
│ │ │
|
||
│ ├──► Mensaje enviado: {"type": "PLACE_BOMB", "x": 2, "y": 1} │
|
||
│ │ │
|
||
│ └──► SERVIDOR recibe mensaje │
|
||
│ │ │
|
||
│ ├─► VALIDACIÓN 1: ¿Es el turno de este jugador? │
|
||
│ │ ├─ current_turn_addr = client_list[placing_index] │
|
||
│ │ └─ if addr != current_turn_addr: return ❌ │
|
||
│ │ │
|
||
│ ├─► VALIDACIÓN 2: ¿Ya hay bomba en (2, 1)? │
|
||
│ │ ├─ if (2, 1) in self.bombs: │
|
||
│ │ │ return # ❌ Ignorar clic │
|
||
│ │ └─ self.bombs = {(0,0), (1,1)} → NO contiene (2,1) │
|
||
│ │ ✅ VÁLIDO │
|
||
│ │ │
|
||
│ ├─► AGREGAR BOMBA: │
|
||
│ │ └─ self.bombs.add((2, 1)) │
|
||
│ │ → self.bombs = {(0,0), (1,1), (2,1)} │
|
||
│ │ │
|
||
│ ├─► BROADCAST a todos: │
|
||
│ │ └─ {"type": "BOMB_flash", "x": 2, "y": 1} │
|
||
│ │ │
|
||
│ └─► INCREMENTAR contador: │
|
||
│ └─ current_player_bombs_placed += 1 │
|
||
│ │
|
||
│ ───────────────────────────────────────────────────────────────── │
|
||
│ │
|
||
│ Jugador 1 hace clic OTRA VEZ en (2, 1) por error │
|
||
│ │ │
|
||
│ └──► SERVIDOR recibe: {"type": "PLACE_BOMB", "x": 2, "y": 1} │
|
||
│ │ │
|
||
│ ├─► VALIDACIÓN 1: ✅ Es su turno │
|
||
│ │ │
|
||
│ ├─► VALIDACIÓN 2: ¿Ya hay bomba en (2, 1)? │
|
||
│ │ └─ if (2, 1) in self.bombs: │
|
||
│ │ return # ❌ SÍ EXISTE, IGNORAR │
|
||
│ │ │
|
||
│ └─► 🚫 NO se procesa el clic │
|
||
│ 🚫 NO se envía BOMB_flash │
|
||
│ 🚫 NO se incrementa contador │
|
||
│ │
|
||
│ Resultado: El jugador puede hacer clic múltiples veces en la │
|
||
│ misma casilla, pero solo la primera vez cuenta. │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### **¿Por qué usar un Set?**
|
||
|
||
| Estructura | Ventaja | Operación `in` |
|
||
|------------|---------|----------------|
|
||
| **`set()`** | ✅ No permite duplicados automáticamente | O(1) - Instantáneo |
|
||
| `list()` | ❌ Permite duplicados, necesita validación manual | O(n) - Lento |
|
||
| `dict()` | ✅ Claves únicas, pero más memoria | O(1) - Instantáneo |
|
||
|
||
```python
|
||
# Ejemplo de eficiencia
|
||
|
||
# ❌ CON LISTA (Lento)
|
||
bombs_list = [(0,0), (1,1), (2,2)]
|
||
if (2, 1) in bombs_list: # Revisa TODA la lista: O(n)
|
||
return
|
||
|
||
# ✅ CON SET (Rápido)
|
||
bombs_set = {(0,0), (1,1), (2,2)}
|
||
if (2, 1) in bombs_set: # Hash lookup instantáneo: O(1)
|
||
return
|
||
```
|
||
|
||
#### **Ejemplo Práctico: Ronda 1 con 2 Jugadores**
|
||
|
||
```
|
||
Ronda 1: Grid 3×3, cada jugador coloca 3 bombas
|
||
|
||
Estado inicial:
|
||
self.bombs = set() # Vacío
|
||
self.bombs_to_place_per_player = 3
|
||
|
||
┌───────────────────────────────────────┐
|
||
│ TURNO JUGADOR 1 │
|
||
├───────────────────────────────────────┤
|
||
│ Clic en (0, 0): │
|
||
│ ├─ (0, 0) in bombs? → NO │
|
||
│ └─ bombs.add((0, 0)) │
|
||
│ → bombs = {(0,0)} │
|
||
│ │
|
||
│ Clic en (1, 1): │
|
||
│ ├─ (1, 1) in bombs? → NO │
|
||
│ └─ bombs.add((1, 1)) │
|
||
│ → bombs = {(0,0), (1,1)} │
|
||
│ │
|
||
│ Clic en (0, 0) por error: │
|
||
│ ├─ (0, 0) in bombs? → ✅ SÍ │
|
||
│ └─ return (IGNORADO) ❌ │
|
||
│ → bombs = {(0,0), (1,1)} │
|
||
│ → contador NO aumenta │
|
||
│ │
|
||
│ Clic en (2, 2): │
|
||
│ ├─ (2, 2) in bombs? → NO │
|
||
│ └─ bombs.add((2, 2)) │
|
||
│ → bombs = {(0,0), (1,1), (2,2)} │
|
||
│ → contador = 3 ✅ │
|
||
│ → TURNO COMPLETADO │
|
||
└───────────────────────────────────────┘
|
||
|
||
┌───────────────────────────────────────┐
|
||
│ TURNO JUGADOR 2 │
|
||
├───────────────────────────────────────┤
|
||
│ Clic en (0, 1): │
|
||
│ ├─ (0, 1) in bombs? → NO │
|
||
│ └─ bombs.add((0, 1)) │
|
||
│ → bombs = {(0,0),(1,1),(2,2), │
|
||
│ (0,1)} │
|
||
│ │
|
||
│ Clic en (1, 1) (donde J1 puso): │
|
||
│ ├─ (1, 1) in bombs? → ✅ SÍ │
|
||
│ └─ return (IGNORADO) ❌ │
|
||
│ → NO puede poner bomba encima │
|
||
│ │
|
||
│ Clic en (1, 2): │
|
||
│ └─ bombs.add((1, 2)) │
|
||
│ → bombs = {..., (1,2)} │
|
||
│ │
|
||
│ Clic en (2, 0): │
|
||
│ └─ bombs.add((2, 0)) │
|
||
│ → bombs = {(0,0),(1,1),(2,2), │
|
||
│ (0,1),(1,2),(2,0)} │
|
||
│ → 6 bombas total (3 por jugador)│
|
||
└───────────────────────────────────────┘
|
||
|
||
Grid final:
|
||
0 1 2
|
||
┌───┬───┬───┐
|
||
│ 💣│ 💣│ │ 0
|
||
├───┼───┼───┤
|
||
│ │ 💣│ 💣│ 1
|
||
├───┼───┼───┤
|
||
│ 💣│ │ 💣│ 2
|
||
└───┴───┴───┘
|
||
|
||
Total: 6 bombas únicas, sin duplicados
|
||
```
|
||
|
||
#### **Código del Cliente**
|
||
|
||
```python
|
||
# app.py:1310-1330 - Manejo de clic en casilla durante fase de colocación
|
||
def on_button_click(x, y):
|
||
"""Cuando el jugador hace clic en una casilla"""
|
||
if self.game_phase == 'PLACING':
|
||
# Solo permite clic si es tu turno
|
||
if self.my_turn:
|
||
# Enviar al servidor
|
||
self.game_client.send({"type": "PLACE_BOMB", "x": x, "y": y})
|
||
|
||
# ⚠️ IMPORTANTE: El cliente NO valida duplicados localmente
|
||
# El servidor se encarga de toda la validación
|
||
# Si el servidor ignora el mensaje (duplicado),
|
||
# simplemente no recibirás BOMB_flash
|
||
```
|
||
|
||
**Ventaja de validar en servidor:** Un jugador malicioso no puede modificar el cliente para hacer trampas. El servidor tiene la **única fuente de verdad**.
|
||
|
||
---
|
||
|
||
## 🔬 ANÁLISIS EN PROFUNDIDAD: SISTEMA DE DETECCIÓN DE BOMBAS DUPLICADAS
|
||
|
||
### 📐 Fundamentos Matemáticos y Computacionales
|
||
|
||
#### **1. Teoría de Conjuntos Aplicada**
|
||
|
||
El sistema de detección de bombas se basa en la **teoría de conjuntos matemáticos**, donde un conjunto es una colección de elementos únicos sin orden específico.
|
||
|
||
```
|
||
DEFINICIÓN MATEMÁTICA:
|
||
━━━━━━━━━━━━━━━━━━━━
|
||
|
||
Sea B el conjunto de bombas en el grid:
|
||
B = {(x₁, y₁), (x₂, y₂), ..., (xₙ, yₙ)}
|
||
|
||
Propiedad fundamental de conjuntos:
|
||
∀ elemento e, e ∈ B → e aparece exactamente 1 vez
|
||
|
||
Intentar agregar (x, y) cuando (x, y) ∈ B:
|
||
B ∪ {(x, y)} = B (no cambia el conjunto)
|
||
```
|
||
|
||
**Aplicación en Python:**
|
||
|
||
```python
|
||
# servidor.py:32-35
|
||
class GameServer:
|
||
def __init__(self):
|
||
self.bombs = set() # Implementación de conjunto matemático
|
||
```
|
||
|
||
El `set()` de Python implementa internamente una **tabla hash** que garantiza unicidad en tiempo O(1).
|
||
|
||
---
|
||
|
||
#### **2. Funcionamiento Interno de `set()` en Python**
|
||
|
||
**Estructura interna:**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ TABLA HASH INTERNA DE SET │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Cuando haces: self.bombs.add((2, 1)) │
|
||
│ │
|
||
│ 1. CÁLCULO DEL HASH │
|
||
│ ├─► hash((2, 1)) = hash_function(2, 1) │
|
||
│ └─► Resultado: 3713081631934410656 (entero único) │
|
||
│ │
|
||
│ 2. ÍNDICE EN TABLA │
|
||
│ ├─► índice = hash_value % tamaño_tabla │
|
||
│ └─► índice = 3713081631934410656 % 8 = 0 │
|
||
│ │
|
||
│ 3. ALMACENAMIENTO │
|
||
│ Tabla interna (simplificada): │
|
||
│ ┌────┬──────────────────────────────┐ │
|
||
│ │ 0 │ → (2, 1) │ ← Nuestra tupla │
|
||
│ ├────┼──────────────────────────────┤ │
|
||
│ │ 1 │ → (0, 0) │ │
|
||
│ ├────┼──────────────────────────────┤ │
|
||
│ │ 2 │ → None │ │
|
||
│ ├────┼──────────────────────────────┤ │
|
||
│ │ 3 │ → (1, 1) │ │
|
||
│ ├────┼──────────────────────────────┤ │
|
||
│ │ 4 │ → None │ │
|
||
│ ├────┼──────────────────────────────┤ │
|
||
│ │...│ ... │ │
|
||
│ └────┴──────────────────────────────┘ │
|
||
│ │
|
||
│ 4. VERIFICACIÓN DE DUPLICADO │
|
||
│ Cuando verificas: if (2, 1) in self.bombs: │
|
||
│ ├─► Calcula hash((2, 1)) nuevamente │
|
||
│ ├─► Busca en índice 0 │
|
||
│ ├─► Compara: (2, 1) == (2, 1) → True │
|
||
│ └─► Retorna: True (ya existe) │
|
||
│ │
|
||
│ TIEMPO DE EJECUCIÓN: O(1) - Constante │
|
||
│ No importa si hay 10 o 10,000 bombas, siempre es instantáneo │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Código de demostración:**
|
||
|
||
```python
|
||
# Ejemplo de hashing en Python
|
||
>>> tupla = (2, 1)
|
||
>>> hash(tupla)
|
||
3713081631934410656
|
||
|
||
>>> tupla2 = (2, 1) # Mismos valores
|
||
>>> hash(tupla2)
|
||
3713081631934410656 # Mismo hash!
|
||
|
||
>>> tupla3 = (1, 2) # Valores diferentes
|
||
>>> hash(tupla3)
|
||
3713081631934410657 # Hash diferente!
|
||
```
|
||
|
||
---
|
||
|
||
#### **3. Análisis de Complejidad Algorítmica**
|
||
|
||
**Operaciones críticas:**
|
||
|
||
| Operación | Código | Complejidad Temporal | Complejidad Espacial |
|
||
|-----------|--------|---------------------|---------------------|
|
||
| **Inicialización** | `self.bombs = set()` | O(1) | O(1) inicial |
|
||
| **Agregar bomba** | `self.bombs.add((x, y))` | O(1) promedio | O(n) total |
|
||
| **Verificar duplicado** | `(x, y) in self.bombs` | O(1) promedio | O(1) |
|
||
| **Tamaño del conjunto** | `len(self.bombs)` | O(1) | O(1) |
|
||
|
||
**Comparación con alternativas:**
|
||
|
||
```python
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# ALTERNATIVA 1: LISTA (❌ INEFICIENTE)
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
self.bombs = [] # Lista vacía
|
||
|
||
# Agregar bomba
|
||
x, y = 2, 1
|
||
if (x, y) not in self.bombs: # O(n) - Recorre TODA la lista
|
||
self.bombs.append((x, y)) # O(1)
|
||
|
||
# PROBLEMA: Con 100 bombas, verifica 100 elementos cada vez
|
||
# Tiempo total: O(n) por cada verificación
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# ALTERNATIVA 2: DICCIONARIO (✅ FUNCIONA PERO EXCESIVO)
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
self.bombs = {} # Diccionario vacío
|
||
|
||
# Agregar bomba
|
||
x, y = 2, 1
|
||
if (x, y) not in self.bombs: # O(1) - Hash lookup
|
||
self.bombs[(x, y)] = True # O(1)
|
||
|
||
# PROBLEMA: Desperdicia memoria almacenando valor inútil (True)
|
||
# Memoria: Clave (x,y) + Valor True + Overhead
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# SOLUCIÓN ÓPTIMA: SET (✅ PERFECTO)
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
self.bombs = set() # Conjunto vacío
|
||
|
||
# Agregar bomba
|
||
x, y = 2, 1
|
||
if (x, y) in self.bombs: # O(1) - Hash lookup
|
||
return
|
||
self.bombs.add((x, y)) # O(1)
|
||
|
||
# VENTAJAS:
|
||
# ✅ Verificación O(1)
|
||
# ✅ Memoria mínima (solo claves)
|
||
# ✅ Semántica clara (conjunto de coordenadas)
|
||
```
|
||
|
||
---
|
||
|
||
#### **4. Anatomía del Proceso de Validación (Paso a Paso)**
|
||
|
||
Vamos a analizar **exactamente** qué sucede en memoria cuando un jugador intenta colocar una bomba:
|
||
|
||
```python
|
||
# servidor.py:222-246 - CÓDIGO COMPLETO CON ANOTACIONES
|
||
|
||
elif msg_type == 'PLACE_BOMB':
|
||
if self.state == STATE_PLACING:
|
||
# ═══════════════════════════════════════════════════════════
|
||
# VALIDACIÓN 1: VERIFICAR TURNO
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
# Estado actual del servidor:
|
||
# self.placing_turn_index = 0
|
||
# self.client_list = [('127.0.0.1', 49956), ('127.0.0.1', 49968)]
|
||
|
||
current_turn_addr = self.client_list[self.placing_turn_index]
|
||
# → current_turn_addr = ('127.0.0.1', 49956)
|
||
|
||
# Mensaje recibido desde:
|
||
# addr = ('127.0.0.1', 49968) ← Jugador 2
|
||
|
||
if str(addr) != str(current_turn_addr):
|
||
# str(('127.0.0.1', 49968)) != str(('127.0.0.1', 49956))
|
||
# "('127.0.0.1', 49968)" != "('127.0.0.1', 49956)"
|
||
# True → NO es su turno
|
||
return # ❌ RECHAZAR mensaje, no procesar nada
|
||
|
||
# Si llegamos aquí: ✅ ES EL TURNO CORRECTO
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# VALIDACIÓN 2: VERIFICAR DUPLICADO
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
x, y = msg['x'], msg['y']
|
||
# Supongamos: x = 2, y = 1
|
||
|
||
# Estado actual de bombas:
|
||
# self.bombs = {(0, 0), (1, 1), (2, 2)}
|
||
|
||
# PROCESO INTERNO:
|
||
# 1. Python calcula: hash((2, 1))
|
||
# 2. Busca en tabla hash interna del set
|
||
# 3. Compara valor en esa posición
|
||
|
||
if (x, y) in self.bombs:
|
||
# Búsqueda O(1):
|
||
# hash((2, 1)) → buscar en tabla → No encontrado
|
||
# Resultado: False
|
||
# No entra al if, continúa...
|
||
pass
|
||
|
||
# Si (2, 1) YA existiera:
|
||
# hash((2, 1)) → buscar en tabla → Encontrado!
|
||
# Resultado: True
|
||
# Ejecuta: return ❌ RECHAZAR
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# PASO 3: AGREGAR BOMBA (SOLO SI PASÓ VALIDACIONES)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
self.bombs.add((x, y))
|
||
# INTERNAMENTE:
|
||
# 1. Calcula hash((2, 1))
|
||
# 2. Encuentra slot vacío en tabla
|
||
# 3. Almacena tupla (2, 1)
|
||
# 4. Incrementa contador interno: len(self.bombs) = 4
|
||
|
||
# Estado DESPUÉS:
|
||
# self.bombs = {(0, 0), (1, 1), (2, 2), (2, 1)}
|
||
|
||
self.current_player_bombs_placed += 1
|
||
# Contador del jugador actual: 1 → 2
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# PASO 4: NOTIFICAR A TODOS LOS CLIENTES
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
self._broadcast_unlocked({
|
||
"type": "BOMB_flash",
|
||
"x": x,
|
||
"y": y,
|
||
"who": str(addr)
|
||
})
|
||
# Envía mensaje JSON a TODOS los clientes conectados
|
||
# Cada cliente mostrará flash amarillo durante 1 segundo
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# PASO 5: VERIFICAR SI COMPLETÓ SUS BOMBAS
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
if self.current_player_bombs_placed >= self.bombs_to_place_per_player:
|
||
# Si colocó todas sus bombas (ej: 3/3)
|
||
self.placing_turn_index += 1 # Pasar al siguiente jugador
|
||
self.next_placement_turn() # Notificar nuevo turno
|
||
```
|
||
|
||
---
|
||
|
||
#### **5. Escenarios de Error y Manejo**
|
||
|
||
**Escenario A: Doble Clic Accidental**
|
||
|
||
```
|
||
SITUACIÓN: Usuario hace doble clic rápido en la misma casilla
|
||
|
||
TIMELINE:
|
||
─────────
|
||
|
||
t=0ms: Clic 1 en (2,1)
|
||
├─► Cliente envía: {"type": "PLACE_BOMB", "x": 2, "y": 1}
|
||
└─► Red: ~5ms latencia
|
||
|
||
t=5ms: Servidor recibe mensaje 1
|
||
├─► Validación turno: ✅ OK
|
||
├─► Validación duplicado: (2,1) in bombs → False ✅
|
||
├─► self.bombs.add((2,1)) → bombs = {..., (2,1)}
|
||
├─► Broadcast BOMB_flash
|
||
└─► current_player_bombs_placed = 1
|
||
|
||
t=50ms: Clic 2 en (2,1) (doble clic accidental)
|
||
├─► Cliente envía: {"type": "PLACE_BOMB", "x": 2, "y": 1}
|
||
└─► Red: ~5ms latencia
|
||
|
||
t=55ms: Servidor recibe mensaje 2
|
||
├─► Validación turno: ✅ OK (sigue siendo su turno)
|
||
├─► Validación duplicado: (2,1) in bombs → True ❌
|
||
└─► return (IGNORA el mensaje completamente)
|
||
|
||
RESULTADO:
|
||
• Solo la primera bomba se cuenta
|
||
• No hay feedback visual al usuario (silenciosamente ignorado)
|
||
• Contador permanece en 1 (no se incrementa)
|
||
• Usuario puede hacer clic en otra casilla
|
||
```
|
||
|
||
**Escenario B: Jugador Intenta Poner Bomba Donde Ya Puso Oponente**
|
||
|
||
```
|
||
ESTADO ACTUAL:
|
||
Jugador 1 ya colocó bomba en (1, 1)
|
||
self.bombs = {(0,0), (1,1), (2,2)}
|
||
Ahora es turno de Jugador 2
|
||
|
||
INTENTO:
|
||
Jugador 2 hace clic en (1, 1)
|
||
|
||
VALIDACIÓN:
|
||
├─► Turno: ✅ Es Jugador 2, correcto
|
||
├─► Duplicado: (1,1) in bombs → True ❌
|
||
└─► return (RECHAZADO)
|
||
|
||
EFECTO:
|
||
• Jugador 2 NO puede poner bomba ahí
|
||
• No recibe ningún feedback visual
|
||
• Debe elegir otra casilla
|
||
• El sistema protege la integridad del grid
|
||
```
|
||
|
||
**Escenario C: Condición de Carrera (Race Condition)**
|
||
|
||
```
|
||
PROBLEMA POTENCIAL (sin threading.Lock):
|
||
Dos clientes envían mensaje al MISMO TIEMPO
|
||
|
||
Cliente A (simultáneo) Cliente B (simultáneo)
|
||
│ │
|
||
├─► PLACE_BOMB (2,1) ├─► PLACE_BOMB (2,1)
|
||
│ │
|
||
▼ ▼
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ SERVIDOR (sin Lock) │
|
||
├─────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Hilo A: Hilo B: │
|
||
│ ├─ (2,1) in bombs → False ├─ (2,1) in bombs → False
|
||
│ ├─ bombs.add((2,1)) ├─ bombs.add((2,1)) │
|
||
│ └─ contador += 1 └─ contador += 1 │
|
||
│ │
|
||
│ RESULTADO: ¡AMBOS se agregan! (BUG) │
|
||
│ contador = 2 (cuando debería ser 1) │
|
||
└─────────────────────────────────────────────────────┘
|
||
|
||
SOLUCIÓN CON threading.Lock:
|
||
|
||
Cliente A Cliente B
|
||
│ │
|
||
├─► PLACE_BOMB (2,1) ├─► PLACE_BOMB (2,1)
|
||
│ │
|
||
▼ ▼
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ SERVIDOR (con Lock) │
|
||
├─────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Hilo A: │
|
||
│ ├─► with self.lock: ← ADQUIERE LOCK │
|
||
│ │ ├─ (2,1) in bombs → False │
|
||
│ │ ├─ bombs.add((2,1)) │
|
||
│ │ └─ contador += 1 │
|
||
│ └─► LIBERA LOCK │
|
||
│ │
|
||
│ Hilo B: │
|
||
│ ├─► with self.lock: ← ESPERA... ESPERA... │
|
||
│ │ (bloqueado hasta que A termine) │
|
||
│ └─► ADQUIERE LOCK cuando A termina │
|
||
│ ├─ (2,1) in bombs → True ✅ (A ya la puso) │
|
||
│ └─ return (RECHAZADO correctamente) │
|
||
│ │
|
||
│ RESULTADO: Solo A se agrega ✅ │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Implementación del Lock:**
|
||
|
||
```python
|
||
# servidor.py:115-125
|
||
def process_message(self, client, addr, msg):
|
||
with self.lock: # ← PUNTO CRÍTICO: Exclusión mutua
|
||
msg_type = msg.get('type')
|
||
|
||
if msg_type == 'PLACE_BOMB':
|
||
# Todo el código de validación aquí
|
||
# Solo UN hilo puede ejecutar esto a la vez
|
||
pass
|
||
```
|
||
|
||
---
|
||
|
||
#### **6. Prueba de Propiedades Matemáticas**
|
||
|
||
**Propiedad 1: Idempotencia**
|
||
|
||
```
|
||
DEFINICIÓN: Aplicar la misma operación múltiples veces
|
||
produce el mismo resultado que aplicarla una vez
|
||
|
||
PRUEBA:
|
||
Sea B = {(0,0), (1,1)}
|
||
|
||
Operación: Agregar (2,1)
|
||
|
||
B.add((2,1)) → B = {(0,0), (1,1), (2,1)}
|
||
B.add((2,1)) → B = {(0,0), (1,1), (2,1)} ← Mismo resultado
|
||
B.add((2,1)) → B = {(0,0), (1,1), (2,1)} ← Mismo resultado
|
||
|
||
∴ La operación add en set es IDEMPOTENTE ✅
|
||
```
|
||
|
||
**Propiedad 2: Consistencia de Estado**
|
||
|
||
```
|
||
INVARIANTE: El número de bombas en self.bombs debe ser igual
|
||
a la suma de bombas colocadas por todos los jugadores
|
||
|
||
PRUEBA POR INDUCCIÓN:
|
||
|
||
Base (n=0):
|
||
Inicio del juego
|
||
self.bombs = set() → len(bombs) = 0
|
||
Jugadores han colocado 0 bombas
|
||
0 = 0 ✅
|
||
|
||
Paso inductivo:
|
||
Supongamos cierto para k bombas: len(bombs) = k
|
||
|
||
Al colocar bomba k+1:
|
||
Si (x,y) ∉ bombs:
|
||
→ bombs.add((x,y))
|
||
→ len(bombs) = k + 1 ✅
|
||
|
||
Si (x,y) ∈ bombs:
|
||
→ return (no se agrega)
|
||
→ len(bombs) = k ✅ (mantiene invariante)
|
||
|
||
∴ La invariante se mantiene siempre ✅
|
||
```
|
||
|
||
---
|
||
|
||
#### **7. Ventajas de Arquitectura Cliente-Servidor**
|
||
|
||
**Validación en Servidor vs Cliente:**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ COMPARACIÓN DE ARQUITECTURAS │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ❌ VALIDACIÓN EN CLIENTE (INSEGURA) │
|
||
│ ════════════════════════════════════════ │
|
||
│ │
|
||
│ Cliente A Cliente B │
|
||
│ ├─ Valida localmente ├─ Valida localmente │
|
||
│ ├─ Envía si válido ├─ Envía si válido │
|
||
│ └─ PROBLEMA: └─ PROBLEMA: │
|
||
│ • Jugador malicioso • Clientes pueden │
|
||
│ modifica código desincronizarse │
|
||
│ • Envía bombas • Grid diferente en │
|
||
│ duplicadas cada cliente │
|
||
│ • Hace trampa • Inconsistencia │
|
||
│ │
|
||
│ ✅ VALIDACIÓN EN SERVIDOR (SEGURA) │
|
||
│ ══════════════════════════════════════ │
|
||
│ │
|
||
│ Cliente A Cliente B │
|
||
│ ├─ NO valida ├─ NO valida │
|
||
│ ├─ Envía TODO ├─ Envía TODO │
|
||
│ └─ Confía en servidor └─ Confía en servidor │
|
||
│ │
|
||
│ ▼ ▼ │
|
||
│ ┌────────────────────────────────┐ │
|
||
│ │ SERVIDOR │ │
|
||
│ │ ✅ Única fuente de verdad │ │
|
||
│ │ ✅ Valida TODO │ │
|
||
│ │ ✅ Estado consistente │ │
|
||
│ │ ✅ Anti-trampas │ │
|
||
│ └────────────────────────────────┘ │
|
||
│ │
|
||
│ VENTAJAS: │
|
||
│ • Imposible hacer trampa (servidor controla todo) │
|
||
│ • Todos los clientes ven el mismo grid │
|
||
│ • Un solo punto de validación (más fácil de mantener) │
|
||
│ • Cliente más simple (menos código, menos bugs) │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
#### **8. Optimizaciones y Consideraciones de Rendimiento**
|
||
|
||
**Análisis de Memoria:**
|
||
|
||
```
|
||
Estimación de memoria para self.bombs:
|
||
|
||
Tamaño de tupla (x, y):
|
||
• x: int (28 bytes en Python 3)
|
||
• y: int (28 bytes en Python 3)
|
||
• tupla overhead: ~40 bytes
|
||
• Total por tupla: ~96 bytes
|
||
|
||
Tamaño del set:
|
||
• Set overhead: ~232 bytes (tabla hash)
|
||
• Por elemento: ~96 bytes
|
||
|
||
Grid máximo (Ronda 5: 14×14):
|
||
• Máximo de casillas: 14 × 14 = 196
|
||
• Bombas típicas: ~30 (2 jugadores × 15 bombas)
|
||
• Memoria: 232 + (30 × 96) = 3,112 bytes ≈ 3 KB
|
||
|
||
CONCLUSIÓN: Memoria insignificante incluso para grids grandes ✅
|
||
```
|
||
|
||
**Optimización de Búsqueda:**
|
||
|
||
```python
|
||
# ¿Por qué O(1) en vez de O(n)?
|
||
|
||
# Con lista (O(n)):
|
||
for bomb in self.bombs: # Revisa CADA elemento
|
||
if bomb == (x, y):
|
||
return True
|
||
# Tiempo: n comparaciones
|
||
|
||
# Con set (O(1)):
|
||
hash_value = hash((x, y)) # 1 operación
|
||
index = hash_value % table_size # 1 operación
|
||
return table[index] == (x, y) # 1 comparación
|
||
# Tiempo: 3 operaciones (constante)
|
||
```
|
||
|
||
---
|
||
|
||
### 🎯 Conclusión Técnica
|
||
|
||
El sistema de detección de bombas duplicadas es un ejemplo perfecto de **ingeniería de software sólida**:
|
||
|
||
1. **Estructura de datos óptima**: Set con complejidad O(1)
|
||
2. **Validación centralizada**: Servidor como fuente única de verdad
|
||
3. **Sincronización correcta**: threading.Lock para evitar race conditions
|
||
4. **Arquitectura segura**: Cliente no valida, imposible hacer trampa
|
||
5. **Eficiencia**: Memoria mínima, velocidad máxima
|
||
|
||
Este diseño garantiza que **nunca** habrá dos bombas en la misma casilla, independientemente de:
|
||
- Cuántos jugadores haya
|
||
- Qué tan rápido hagan clic
|
||
- Si intentan hacer trampa modificando el cliente
|
||
- Cuántas bombas se coloquen en total
|
||
|
||
**La integridad del grid está matemáticamente garantizada.** ✅
|
||
|
||
---
|
||
|
||
### 🔒 Sincronización con Threading Lock
|
||
|
||
El servidor usa un `threading.Lock` para evitar condiciones de carrera cuando múltiples clientes envían mensajes simultáneamente:
|
||
|
||
```python
|
||
# servidor.py:28-47 - Sincronización con Lock
|
||
class GameServer:
|
||
def __init__(self):
|
||
self.lock = threading.Lock() # Mutex para sincronización
|
||
self.clients = {} # Diccionario compartido
|
||
self.state = STATE_LOBBY # Estado compartido
|
||
|
||
def process_message(self, client, addr, msg):
|
||
with self.lock: # Adquirir lock antes de modificar estado
|
||
if msg_type == 'PLACE_BOMB':
|
||
# Solo un hilo puede ejecutar esto a la vez
|
||
self.bombs.add((x, y))
|
||
self._broadcast_unlocked(...)
|
||
```
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ SINCRONIZACIÓN CON LOCK │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Hilo Cliente 1 ─────┐ │
|
||
│ ├──► with self.lock: ──► Ejecuta primero │
|
||
│ Hilo Cliente 2 ─────┘ │ │
|
||
│ ▼ │
|
||
│ (espera...) │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ Hilo 2 ejecuta después │
|
||
│ │
|
||
│ Esto evita que dos jugadores modifiquen el estado │
|
||
│ del juego al mismo tiempo (condiciones de carrera). │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 📡 Arquitectura de Hilos
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ARQUITECTURA DE HILOS │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ SERVIDOR (servidor.py) │
|
||
│ ══════════════════════ │
|
||
│ │
|
||
│ ┌─────────────────┐ │
|
||
│ │ Hilo Main │ ◄── Acepta conexiones (s.accept()) │
|
||
│ └────────┬────────┘ │
|
||
│ │ │
|
||
│ ├──► Hilo Cliente 1 ──► handle_client(sock1) │
|
||
│ ├──► Hilo Cliente 2 ──► handle_client(sock2) │
|
||
│ └──► Hilo Cliente N ──► handle_client(sockN) │
|
||
│ │
|
||
│ Cada cliente tiene su propio hilo daemon que: │
|
||
│ 1. Lee mensajes del socket (recv) │
|
||
│ 2. Procesa el mensaje (process_message) │
|
||
│ 3. Puede hacer broadcast a todos los clientes │
|
||
│ │
|
||
│ ───────────────────────────────────────────────────────── │
|
||
│ │
|
||
│ CLIENTE (app.py) │
|
||
│ ════════════════ │
|
||
│ │
|
||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||
│ │ Hilo Main │ │ Hilo Recv │ │
|
||
│ │ (Tkinter UI) │◄───│ (_recv_loop) │ │
|
||
│ └─────────────────┘ └─────────────────┘ │
|
||
│ │ ▲ │
|
||
│ │ msg_queue.put() │ sock.recv() │
|
||
│ ▼ │ │
|
||
│ ┌─────────────────┐ │ │
|
||
│ │ Cola de msgs │ ──────────┘ │
|
||
│ │ (thread-safe) │ │
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ El hilo de recepción pone mensajes en una cola. │
|
||
│ El hilo principal (UI) los procesa con after(). │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 🎮 Resumen: Jugar en el Mismo Ordenador
|
||
|
||
| Paso | Qué Sucede |
|
||
|------|------------|
|
||
| 1. Iniciar servidor | `python servidor.py` escucha en puerto 3333 |
|
||
| 2. Abrir Cliente 1 | Conecta → SO asigna puerto 49956 |
|
||
| 3. Abrir Cliente 2 | Conecta → SO asigna puerto 49968 |
|
||
| 4. Servidor registra | `clients = {sock1: ('127.0.0.1', 49956), sock2: ('127.0.0.1', 49968)}` |
|
||
| 5. Iniciar juego | `client_list = [addr1, addr2]` define orden de turnos |
|
||
| 6. Turno jugador 1 | Servidor envía `TURN_NOTIFY` con `active_player = addr1` |
|
||
| 7. Cliente 1 compara | `my_address == active_player` → ¡Es mi turno! |
|
||
| 8. Cliente 2 compara | `my_address != active_player` → Esperar |
|
||
| 9. Jugador 1 actúa | Envía `PLACE_BOMB` o `CLICK_CELL` |
|
||
| 10. Servidor valida | `addr == current_turn_addr` → Válido, procesar |
|
||
| 11. Avanzar turno | `placing_turn_index += 1` → Turno del siguiente |
|
||
|
||
---
|
||
|
||
|
||
|
||
## 🛠️ Tecnologías
|
||
|
||
<table>
|
||
<tr>
|
||
<td align="center" width="150">
|
||
<img src="https://raw.githubusercontent.com/devicons/devicon/master/icons/python/python-original.svg" width="48" height="48" alt="Python" />
|
||
<br><strong>Python 3</strong>
|
||
<br><em>Lenguaje base</em>
|
||
</td>
|
||
<td align="center" width="150">
|
||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Virtualbox_logo.png/64px-Virtualbox_logo.png" width="48" height="48" alt="Tkinter" />
|
||
<br><strong>Tkinter</strong>
|
||
<br><em>Interfaz gráfica</em>
|
||
</td>
|
||
<td align="center" width="150">
|
||
<img src="https://raw.githubusercontent.com/devicons/devicon/master/icons/socketio/socketio-original.svg" width="48" height="48" alt="Sockets" />
|
||
<br><strong>TCP Sockets</strong>
|
||
<br><em>Comunicación en red</em>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td align="center">
|
||
<img src="https://matplotlib.org/stable/_static/logo_light.svg" width="48" height="48" alt="Matplotlib" />
|
||
<br><strong>Matplotlib</strong>
|
||
<br><em>Gráficos</em>
|
||
</td>
|
||
<td align="center">
|
||
<img src="https://raw.githubusercontent.com/devicons/devicon/master/icons/json/json-original.svg" width="48" height="48" alt="JSON" />
|
||
<br><strong>JSON</strong>
|
||
<br><em>Protocolo mensajes</em>
|
||
</td>
|
||
<td align="center">
|
||
<img src="https://www.vectorlogo.zone/logos/pocoo_flask/pocoo_flask-icon.svg" width="48" height="48" alt="Requests" />
|
||
<br><strong>Requests</strong>
|
||
<br><em>APIs HTTP</em>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
### 🧵 Conceptos de PSP Aplicados
|
||
|
||
| Concepto | Implementación |
|
||
|----------|----------------|
|
||
| **Procesos** | Lanzamiento de aplicaciones externas (VS Code, Firefox) |
|
||
| **Threads** | Servidor multihilo, cliente con hilos de recepción |
|
||
| **Sockets TCP** | Comunicación cliente-servidor en red (Juego y Correo) |
|
||
| **Servicios** | API OpenWeather, scraping de Wallapop, IMAP/SMTP |
|
||
| **Sincronización** | Locks para acceso concurrente a estado compartido |
|
||
|
||
---
|
||
|
||
## 📧 CLIENTE DE CORREO ELECTRÓNICO: ARQUITECTURA Y FUNCIONAMIENTO
|
||
|
||
### 🏗️ Arquitectura del Sistema de Correo
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ ARQUITECTURA DEL CLIENTE DE CORREO │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
|
||
┌───────────────────────────┐
|
||
│ SERVIDOR WEBMIN │
|
||
│ 10.10.0.101 │
|
||
└─────────────┬─────────────┘
|
||
│
|
||
┌─────────────────────┴─────────────────────┐
|
||
│ │
|
||
┌───────▼────────┐ ┌────────▼────────┐
|
||
│ PUERTO 143 │ │ PUERTO 25 │
|
||
│ IMAP │ │ SMTP │
|
||
│ (Lectura) │ │ (Envío) │
|
||
└───────┬────────┘ └────────┬────────┘
|
||
│ │
|
||
└─────────────────┬───────────────────────┘
|
||
│
|
||
┌─────────▼──────────┐
|
||
│ app.py │
|
||
│ DashboardApp │
|
||
│ │
|
||
│ Tab "Correos" │
|
||
└────────────────────┘
|
||
│
|
||
┌─────────────────────┼─────────────────────┐
|
||
│ │ │
|
||
┌───────▼────────┐ ┌─────────▼────────┐ ┌────────▼────────┐
|
||
│ _connect_ │ │ _refresh_ │ │ _send_mail_ │
|
||
│ mail_server() │ │ mail_list() │ │ with_attach() │
|
||
│ │ │ │ │ │
|
||
│ IMAP Login │ │ IMAP FETCH │ │ SMTP Send │
|
||
└────────────────┘ └──────────────────┘ └─────────────────┘
|
||
```
|
||
|
||
### 📡 Protocolos Utilizados
|
||
|
||
#### **IMAP (Internet Message Access Protocol)**
|
||
Protocolo para **leer correos** del servidor. Puerto: **143** (sin TLS).
|
||
|
||
```python
|
||
# app.py:1578-1582 - Conexión IMAP
|
||
import imaplib
|
||
self.imap_connection = imaplib.IMAP4(host, port_num) # Puerto 143
|
||
self.imap_connection.login(username, password)
|
||
```
|
||
|
||
**Comandos IMAP utilizados:**
|
||
- `login(user, pass)` → Autenticación
|
||
- `select(mailbox)` → Seleccionar carpeta (INBOX, Sent, etc.)
|
||
- `search(None, 'ALL')` → Buscar todos los correos
|
||
- `fetch(id, '(BODY.PEEK[] FLAGS)')` → Obtener correo sin marcarlo como leído
|
||
- `store(id, '+FLAGS', '\\Seen')` → Marcar como leído
|
||
- `append(folder, flags, date, msg)` → Guardar correo en carpeta
|
||
|
||
#### **SMTP (Simple Mail Transfer Protocol)**
|
||
Protocolo para **enviar correos**. Puerto: **25** (sin TLS).
|
||
|
||
```python
|
||
# app.py:2949-2955 - Conexión y envío SMTP
|
||
import smtplib
|
||
with smtplib.SMTP(smtp_host, smtp_port_num, timeout=15) as server:
|
||
server.send_message(msg, to_addrs=recipients)
|
||
```
|
||
|
||
---
|
||
|
||
### 🔐 Sistema de Credenciales
|
||
|
||
#### **Guardado Automático con Base64**
|
||
|
||
```python
|
||
# app.py:1540-1557 - Función _save_mail_credentials()
|
||
import base64
|
||
config = {
|
||
'imap_host': '10.10.0.101',
|
||
'imap_port': '143',
|
||
'smtp_host': '10.10.0.101',
|
||
'smtp_port': '25',
|
||
'username': 'marcos@psp.es',
|
||
'password': base64.b64encode(password.encode()).decode() # Codificar
|
||
}
|
||
json.dump(config, open('.mail_config.json', 'w'), indent=2)
|
||
```
|
||
|
||
```python
|
||
# app.py:1512-1520 - Función _load_mail_credentials()
|
||
config = json.load(open('.mail_config.json', 'r'))
|
||
password = base64.b64decode(config['password']).decode() # Decodificar
|
||
```
|
||
|
||
**Flujo de guardado:**
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ FLUJO DE GUARDADO DE CREDENCIALES │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. Usuario ingresa credenciales │
|
||
│ └─► Usuario: marcos@psp.es │
|
||
│ └─► Password: 1234 │
|
||
│ │
|
||
│ 2. Marca checkbox "💾 Recordar credenciales" │
|
||
│ └─► mail_remember_var.get() = True │
|
||
│ │
|
||
│ 3. Al conectar exitosamente, se llama _save_mail_credentials() │
|
||
│ │
|
||
│ 4. Base64 encoding: │
|
||
│ Password "1234" → Bytes b'1234' │
|
||
│ → Base64 b'MTIzNA==' │
|
||
│ → String "MTIzNA==" │
|
||
│ │
|
||
│ 5. Se guarda en .mail_config.json: │
|
||
│ { │
|
||
│ "username": "marcos@psp.es", │
|
||
│ "password": "MTIzNA==" │
|
||
│ } │
|
||
│ │
|
||
│ 6. Al reiniciar app.py: │
|
||
│ └─► _load_mail_credentials() lee el archivo │
|
||
│ └─► Decodifica Base64: "MTIzNA==" → "1234" │
|
||
│ └─► Precarga los campos automáticamente │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**⚠️ IMPORTANTE:** Base64 NO es encriptación, solo ofuscación. El archivo `.mail_config.json` está protegido en `.gitignore` para no subirlo a Git.
|
||
|
||
---
|
||
|
||
### 📬 Lectura de Correos (IMAP)
|
||
|
||
#### **Función Principal: `_refresh_mail_list()` (líneas 1635-1801)**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ FLUJO DE LECTURA DE CORREOS │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. SELECT 'INBOX' │
|
||
│ └─► imap_connection.select('INBOX') │
|
||
│ │
|
||
│ 2. SEARCH ALL │
|
||
│ └─► status, messages = imap_connection.search(None, 'ALL') │
|
||
│ └─► mail_ids = messages[0].split() # [b'1', b'2', b'3', ...] │
|
||
│ │
|
||
│ 3. FETCH con BODY.PEEK[] (no marca como leído) │
|
||
│ for mail_id in mail_ids: │
|
||
│ status, msg_data = imap_connection.fetch( │
|
||
│ mail_id, │
|
||
│ '(BODY.PEEK[] FLAGS)' # ← PEEK es clave │
|
||
│ ) │
|
||
│ │
|
||
│ 4. EXTRAER FLAGS (\Seen) │
|
||
│ └─► Busca en respuesta IMAP: "FLAGS (\\Seen ...)" │
|
||
│ └─► is_seen = True si contiene "\\Seen" │
|
||
│ │
|
||
│ 5. PARSEAR EMAIL │
|
||
│ └─► msg = email.message_from_bytes(msg_data) │
|
||
│ └─► from_addr = decode_header(msg['From'])[0][0] │
|
||
│ └─► subject = decode_header(msg['Subject'])[0][0] │
|
||
│ │
|
||
│ 6. GUARDAR EN LISTA LOCAL │
|
||
│ self.mail_list.append({ │
|
||
│ 'id': mail_id, │
|
||
│ 'from': from_addr, │
|
||
│ 'subject': subject, │
|
||
│ 'date': date_str, │
|
||
│ 'msg': msg, # Objeto email completo │
|
||
│ 'is_seen': is_seen │
|
||
│ }) │
|
||
│ │
|
||
│ 7. MOSTRAR EN LISTBOX │
|
||
│ if is_seen: │
|
||
│ display = f' {from_addr} - {subject}' # Sin emoji │
|
||
│ itemconfig(idx, fg='#888888') # Gris │
|
||
│ else: │
|
||
│ display = f'🔵 {from_addr} - {subject}' # Con emoji │
|
||
│ itemconfig(idx, fg='#000000') # Negro │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### **¿Por qué BODY.PEEK[]?**
|
||
|
||
```python
|
||
# ❌ MAL: RFC822 marca el correo como leído automáticamente
|
||
fetch(mail_id, 'RFC822')
|
||
|
||
# ✅ BIEN: BODY.PEEK[] lee sin cambiar el flag \Seen
|
||
fetch(mail_id, '(BODY.PEEK[] FLAGS)')
|
||
```
|
||
|
||
---
|
||
|
||
### 📤 Envío de Correos (SMTP)
|
||
|
||
#### **Función: `_send_mail_with_attachments()` (líneas 2837-2977)**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ FLUJO DE ENVÍO DE CORREO │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. VALIDAR DESTINATARIOS │
|
||
│ to_addr_raw = "marcos@psp.es, user2@example.com" │
|
||
│ └─► Split por comas/punto y coma │
|
||
│ └─► recipients = ['marcos@psp.es', 'user2@example.com'] │
|
||
│ └─► Validar regex: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,} │
|
||
│ │
|
||
│ 2. CREAR MENSAJE MIME MULTIPART │
|
||
│ from email.mime.multipart import MIMEMultipart │
|
||
│ msg = MIMEMultipart() │
|
||
│ msg['From'] = 'marcos@psp.es' │
|
||
│ msg['To'] = 'marcos@psp.es, user2@example.com' │
|
||
│ msg['Subject'] = 'Asunto del correo' │
|
||
│ │
|
||
│ 3. ADJUNTAR CUERPO │
|
||
│ from email.mime.text import MIMEText │
|
||
│ msg.attach(MIMEText(body, 'plain', 'utf-8')) │
|
||
│ │
|
||
│ 4. ADJUNTAR ARCHIVOS │
|
||
│ for file_path in attachments: │
|
||
│ if file_ext == '.png': │
|
||
│ part = MIMEImage(file_data, name=filename) │
|
||
│ elif file_ext == '.pdf': │
|
||
│ part = MIMEApplication(file_data, _subtype='pdf') │
|
||
│ # ... otros tipos │
|
||
│ msg.attach(part) │
|
||
│ │
|
||
│ 5. CONECTAR SMTP Y ENVIAR │
|
||
│ with smtplib.SMTP('10.10.0.101', 25) as server: │
|
||
│ server.send_message(msg, to_addrs=recipients) │
|
||
│ │
|
||
│ 6. GUARDAR EN CARPETA SENT (IMAP) │
|
||
│ └─► _save_to_sent_folder(msg) # Ver siguiente sección │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### **Tipos MIME Soportados**
|
||
|
||
| Extensión | Tipo MIME | Clase Python |
|
||
|-----------|-----------|--------------|
|
||
| `.png`, `.jpg` | `image/*` | `MIMEImage` |
|
||
| `.pdf` | `application/pdf` | `MIMEApplication` |
|
||
| `.doc`, `.docx` | `application/msword` | `MIMEApplication` |
|
||
| `.xls`, `.xlsx` | `application/vnd.ms-excel` | `MIMEApplication` |
|
||
| `.zip`, `.rar` | `application/zip` | `MIMEApplication` |
|
||
| `.txt` | `text/plain` | `MIMEText` |
|
||
| Otros | `application/octet-stream` | `MIMEBase` |
|
||
|
||
---
|
||
|
||
### 💾 Guardado en Servidor IMAP
|
||
|
||
#### **Función: `_save_to_sent_folder()` (líneas 2979-3033)**
|
||
|
||
Después de enviar un correo por SMTP, se guarda una copia en la carpeta "Sent" del servidor IMAP para que aparezca en Webmin y otros clientes.
|
||
|
||
```python
|
||
# app.py:2990-3015 - Guardar correo en carpeta Sent del servidor
|
||
# Intentar múltiples nombres de carpeta
|
||
sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items']
|
||
|
||
for folder in sent_folders:
|
||
try:
|
||
# Intentar seleccionar la carpeta
|
||
status, _ = self.imap_connection.select(folder)
|
||
if status == 'OK':
|
||
# Guardar correo con APPEND
|
||
self.imap_connection.append(
|
||
folder,
|
||
'\\Seen', # Marcar como leído
|
||
imaplib.Time2Internaldate(time.time()),
|
||
msg.as_bytes()
|
||
)
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# Si no existe, crearla
|
||
if not folder_found:
|
||
self.imap_connection.create('Sent')
|
||
self.imap_connection.append('Sent', '\\Seen', date, msg_bytes)
|
||
```
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ GUARDADO EN CARPETA SENT DEL SERVIDOR │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. Usuario envía correo con SMTP │
|
||
│ └─► Correo enviado a marcos@psp.es │
|
||
│ │
|
||
│ 2. _save_to_sent_folder() se ejecuta automáticamente │
|
||
│ │
|
||
│ 3. Intenta conectar a carpeta "Sent": │
|
||
│ ├─► Intenta: SELECT 'Sent' ❌ Falla │
|
||
│ ├─► Intenta: SELECT 'INBOX.Sent' ✅ OK │
|
||
│ └─► Carpeta encontrada │
|
||
│ │
|
||
│ 4. Guarda el correo con APPEND: │
|
||
│ APPEND "INBOX.Sent" (\Seen) "19-Feb-2026 19:30:00" {bytes} │
|
||
│ │
|
||
│ 5. Resultado: │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ SERVIDOR WEBMIN │ │
|
||
│ │ ├─ INBOX (3 correos) │ │
|
||
│ │ └─ Sent (1 correo NUEVO) ← AQUÍ │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ 6. Al abrir Webmin: │
|
||
│ └─► El correo aparece en "Sent" │
|
||
│ └─► Otros clientes (Thunderbird, etc.) también lo ven │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 👥 Envío a Múltiples Destinatarios
|
||
|
||
#### **Validación y Parsing**
|
||
|
||
```python
|
||
# app.py:2703-2740 - Validación de múltiples destinatarios
|
||
# Entrada del usuario
|
||
to_addr_raw = "marcos@psp.es, user2@example.com; user3@test.org"
|
||
|
||
# 1. Split por comas O punto y coma
|
||
import re
|
||
recipients = re.split(r'[;,]\s*', to_addr_raw)
|
||
# → ['marcos@psp.es', 'user2@example.com', 'user3@test.org']
|
||
|
||
# 2. Limpiar espacios
|
||
recipients = [r.strip() for r in recipients if r.strip()]
|
||
|
||
# 3. Validar formato con regex
|
||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||
invalid_emails = [email for email in recipients if not re.match(email_pattern, email)]
|
||
|
||
if invalid_emails:
|
||
messagebox.showwarning('⚠️ Advertencia',
|
||
f'Los siguientes emails no son válidos:\n{", ".join(invalid_emails)}')
|
||
return
|
||
|
||
# 4. Confirmar si son múltiples
|
||
if len(recipients) > 1:
|
||
confirm = messagebox.askyesno('📧 Múltiples destinatarios',
|
||
f'¿Enviar correo a {len(recipients)} destinatarios?\n\n' +
|
||
'\n'.join(f' • {email}' for email in recipients))
|
||
if not confirm:
|
||
return
|
||
|
||
# 5. Enviar a todos
|
||
self._send_mail_with_attachments(recipients, subject, body, attachments)
|
||
```
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ ENVÍO A MÚLTIPLES DESTINATARIOS │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Usuario escribe en campo "Para:": │
|
||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||
│ │ marcos@psp.es, user2@test.com, user3@example.org │ │
|
||
│ └─────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Al hacer clic en "📤 ENVIAR": │
|
||
│ │
|
||
│ 1. Parse: Split por ',' o ';' │
|
||
│ ['marcos@psp.es', 'user2@test.com', 'user3@example.org'] │
|
||
│ │
|
||
│ 2. Validación regex de cada email │
|
||
│ ✅ marcos@psp.es → Válido │
|
||
│ ✅ user2@test.com → Válido │
|
||
│ ✅ user3@example.org → Válido │
|
||
│ │
|
||
│ 3. Diálogo de confirmación: │
|
||
│ ┌───────────────────────────────────┐ │
|
||
│ │ ¿Enviar a 3 destinatarios? │ │
|
||
│ │ │ │
|
||
│ │ • marcos@psp.es │ │
|
||
│ │ • user2@test.com │ │
|
||
│ │ • user3@example.org │ │
|
||
│ │ │ │
|
||
│ │ [Sí] [No] │ │
|
||
│ └───────────────────────────────────┘ │
|
||
│ │
|
||
│ 4. SMTP envía a todos: │
|
||
│ msg['To'] = 'marcos@psp.es, user2@test.com, user3@example.org' │
|
||
│ server.send_message(msg, to_addrs=[...]) │
|
||
│ │
|
||
│ 5. Mensaje de éxito: │
|
||
│ ✅ "Correo enviado correctamente a 3 destinatarios" │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 📎 Manejo de Adjuntos
|
||
|
||
#### **Adjuntar Archivos**
|
||
|
||
```python
|
||
# app.py:2568-2590 - Adjuntar archivos con diálogo
|
||
# Usuario hace clic en "📎 Adjuntar archivo"
|
||
file_paths = filedialog.askopenfilenames(
|
||
title='Seleccionar archivos',
|
||
filetypes=[
|
||
('Imágenes', '*.png *.jpg *.jpeg *.gif'),
|
||
('PDFs', '*.pdf'),
|
||
('Documentos', '*.doc *.docx *.xls *.xlsx'),
|
||
('Todos', '*.*')
|
||
]
|
||
)
|
||
|
||
# Se guardan en lista
|
||
attachments.append(file_path)
|
||
|
||
# Al enviar, se procesan (app.py:2895-2920):
|
||
for file_path in attachments:
|
||
file_name = os.path.basename(file_path) # "documento.pdf"
|
||
file_ext = os.path.splitext(file_path)[1] # ".pdf"
|
||
|
||
with open(file_path, 'rb') as f:
|
||
file_data = f.read()
|
||
|
||
if file_ext == '.pdf':
|
||
part = MIMEApplication(file_data, _subtype='pdf')
|
||
part.add_header('Content-Disposition', 'attachment', filename=file_name)
|
||
msg.attach(part)
|
||
```
|
||
|
||
#### **Imágenes Inline con Ctrl+V**
|
||
|
||
```python
|
||
# app.py:2609-2645 - Pegar imagen desde portapapeles
|
||
# Usuario copia una imagen y presiona Ctrl+V
|
||
def on_paste(event):
|
||
try:
|
||
# Obtener imagen del portapapeles
|
||
img = ImageGrab.grabclipboard()
|
||
|
||
if img:
|
||
# Guardar temporalmente
|
||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png')
|
||
img.save(temp_file.name)
|
||
|
||
# Mostrar miniatura en interfaz
|
||
thumbnail = img.resize((150, 150))
|
||
photo = ImageTk.PhotoImage(thumbnail)
|
||
label = tk.Label(frame, image=photo)
|
||
label.image = photo # Mantener referencia
|
||
label.pack()
|
||
|
||
# Agregar a lista de adjuntos
|
||
inline_images_data.append({'data': img_bytes})
|
||
except:
|
||
pass
|
||
```
|
||
|
||
---
|
||
|
||
### 🖼️ Visualización de Correos
|
||
|
||
#### **Función: `_display_mail()` (líneas 2055-2337)**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ VISUALIZACIÓN DE CORREO │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. Usuario hace clic en un correo de la lista │
|
||
│ └─► _on_mail_select() → _display_mail(mail_info) │
|
||
│ │
|
||
│ 2. Marcar como leído en servidor (si es INBOX y no leído) │
|
||
│ if not mail_info['is_seen']: │
|
||
│ imap_connection.store(mail_id, '+FLAGS', '\\Seen') │
|
||
│ # Actualizar visualmente: quitar 🔵, poner gris │
|
||
│ │
|
||
│ 3. Actualizar encabezados │
|
||
│ mail_from_label.config(text='De: marcos@psp.es') │
|
||
│ mail_subject_label.config(text='Asunto: Test') │
|
||
│ mail_date_label.config(text='Fecha: 19/02/2026') │
|
||
│ │
|
||
│ 4. Procesar contenido multipart │
|
||
│ if msg.is_multipart(): │
|
||
│ for part in msg.walk(): │
|
||
│ if content_type == 'text/plain': │
|
||
│ body = part.get_payload(decode=True).decode() │
|
||
│ elif part.get_filename(): # Adjunto │
|
||
│ attachments.append({...}) │
|
||
│ │
|
||
│ 5. Mostrar cuerpo en Text widget │
|
||
│ mail_body_text.delete('1.0', 'end') │
|
||
│ mail_body_text.insert('1.0', body) │
|
||
│ │
|
||
│ 6. Mostrar imágenes inline (si PIL está disponible) │
|
||
│ for att in images: │
|
||
│ img = Image.open(BytesIO(att['data'])) │
|
||
│ img.thumbnail((500, 500)) # Redimensionar │
|
||
│ photo = ImageTk.PhotoImage(img) │
|
||
│ mail_body_text.image_create('end', image=photo) │
|
||
│ │
|
||
│ 7. Mostrar otros adjuntos (PDFs, docs, etc.) │
|
||
│ for att in other_attachments: │
|
||
│ # Frame con icono, nombre, tamaño y botón "💾 Guardar" │
|
||
│ icon = '📄' if PDF else '📝' if Word else '📎' │
|
||
│ Button(text='💾 Guardar', command=save_file) │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 🔵 Sistema de Indicadores Visuales
|
||
|
||
```python
|
||
# app.py:1775-1790 - Indicadores visuales de correos leídos/no leídos
|
||
# Al cargar correos
|
||
for mail in mail_list:
|
||
if is_seen:
|
||
# Correo leído
|
||
display_text = f' {from_addr[:27]} - {subject[:37]}'
|
||
self.mail_listbox.insert('end', display_text)
|
||
idx = self.mail_listbox.size() - 1
|
||
self.mail_listbox.itemconfig(idx,
|
||
fg='#888888', # Gris
|
||
selectforeground='#666666'
|
||
)
|
||
else:
|
||
# Correo NO leído
|
||
display_text = f'🔵 {from_addr[:27]} - {subject[:37]}'
|
||
self.mail_listbox.insert('end', display_text)
|
||
idx = self.mail_listbox.size() - 1
|
||
self.mail_listbox.itemconfig(idx,
|
||
fg='#000000', # Negro
|
||
selectforeground='#1a73e8'
|
||
)
|
||
|
||
# Contador de no leídos
|
||
self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False))
|
||
self.unread_label.config(text=f'Correos sin leer: {self.unread_count}')
|
||
```
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ APARIENCIA VISUAL EN LISTBOX │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||
│ │ 📬 Bandeja de entrada Correos sin leer: 2 │ │
|
||
│ ├───────────────────────────────────────────────────────────────┤ │
|
||
│ │ │ │
|
||
│ │ 🔵 marcos@psp.es - Test para grabación ← NO LEÍDO (negro) │ │
|
||
│ │ 🔵 user@example.com - Propuesta proyecto ← NO LEÍDO (negro) │ │
|
||
│ │ admin@server.com - Notificación ← LEÍDO (gris) │ │
|
||
│ │ webmaster@test.org - Informe ← LEÍDO (gris) │ │
|
||
│ │ │ │
|
||
│ └───────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Al hacer clic en el primero (🔵 NO LEÍDO): │
|
||
│ 1. Se marca como leído en el servidor (STORE +FLAGS \Seen) │
|
||
│ 2. Se actualiza la visualización: │
|
||
│ ├─ Quita el emoji 🔵 │
|
||
│ ├─ Cambia color a gris │
|
||
│ └─ Decrementa contador: "Correos sin leer: 1" │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 🛡️ Manejo de Errores y Log
|
||
|
||
```python
|
||
# app.py:4304-4315 - Función _log() con verificación de widget
|
||
def _log(self, text: str) -> None:
|
||
# Verificar si estamos en hilo principal
|
||
if threading.current_thread() is not threading.main_thread():
|
||
self.after(0, lambda t=text: self._log(t))
|
||
return
|
||
|
||
# Verificar si el widget notes existe
|
||
if not hasattr(self, 'notes') or self.notes is None:
|
||
print(f'[LOG] {text}') # Consola durante inicialización
|
||
return
|
||
|
||
# Log normal en interfaz
|
||
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
|
||
self.notes.insert('end', f'[{timestamp}] {text}\n')
|
||
self.notes.see('end')
|
||
```
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ EJEMPLO DE LOG EN INTERFAZ │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||
│ │ 📝 Panel de Notas │ │
|
||
│ ├─────────────────────────────────────────────────────────────┤ │
|
||
│ │ [19:28:43] Conectando a 10.10.0.101:143... │ │
|
||
│ │ [19:28:44] Conexión IMAP establecida │ │
|
||
│ │ [19:28:44] Carpetas IMAP disponibles: ['INBOX', 'Sent'] │ │
|
||
│ │ [19:28:44] Credenciales guardadas correctamente │ │
|
||
│ │ [19:28:45] Cargando 8 correos... │ │
|
||
│ │ [19:28:46] 8 correos cargados (2 sin leer) │ │
|
||
│ │ [19:29:10] === CORREO SELECCIONADO #0: Test grabación === │ │
|
||
│ │ [19:29:10] Correo marcado como leído en el servidor │ │
|
||
│ │ [19:29:10] >>> Actualizando encabezados │ │
|
||
│ │ [19:29:10] Texto plano encontrado: 245 caracteres │ │
|
||
│ │ [19:29:10] Adjunto detectado: imagen.png (image/png) │ │
|
||
│ │ [19:29:10] Imagen inline mostrada: imagen.png │ │
|
||
│ │ [19:29:10] >>> _display_mail COMPLETADO OK │ │
|
||
│ └─────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 📊 Resumen de Funciones Clave
|
||
|
||
| Función | Líneas | Responsabilidad |
|
||
|---------|--------|-----------------|
|
||
| `_build_tab_correos()` | 632-850 | Construir toda la interfaz del tab Correos |
|
||
| `_load_mail_credentials()` | 1483-1525 | Cargar credenciales de `.mail_config.json` |
|
||
| `_save_mail_credentials()` | 1527-1559 | Guardar credenciales con Base64 |
|
||
| `_connect_mail_server()` | 1561-1613 | Conectar a IMAP, listar carpetas |
|
||
| `_refresh_mail_list()` | 1635-1801 | Cargar correos de INBOX con FETCH |
|
||
| `_show_inbox()` | 1803-1811 | Cambiar a bandeja de entrada |
|
||
| `_show_sent()` | 1828-1880 | Cambiar a carpeta de enviados |
|
||
| `_on_mail_select()` | 1982-2053 | Manejar clic en correo, marcar como leído |
|
||
| `_display_mail()` | 2055-2337 | Mostrar contenido, imágenes y adjuntos |
|
||
| `_open_compose_window()` | 2353-2788 | Abrir ventana de redacción |
|
||
| `_send_mail_with_attachments()` | 2837-2977 | Enviar correo por SMTP con adjuntos |
|
||
| `_save_to_sent_folder()` | 2979-3033 | Guardar copia en servidor IMAP |
|
||
|
||
---
|
||
|
||
### 🔄 Flujo Completo de Usuario
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ FLUJO COMPLETO: LEER Y ENVIAR CORREO │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. INICIAR APLICACIÓN │
|
||
│ └─► python3 app.py │
|
||
│ └─► _load_mail_credentials() precarga usuario y contraseña │
|
||
│ │
|
||
│ 2. IR A TAB "CORREOS" │
|
||
│ └─► _build_tab_correos() ya construyó la interfaz │
|
||
│ │
|
||
│ 3. CONECTAR │
|
||
│ └─► Clic en "🔗 Conectar" │
|
||
│ └─► _connect_mail_server() │
|
||
│ ├─ IMAP4('10.10.0.101', 143) │
|
||
│ ├─ login('marcos@psp.es', '1234') │
|
||
│ └─ list() → Muestra carpetas disponibles │
|
||
│ └─► _save_mail_credentials() si "Recordar" está marcado │
|
||
│ └─► _refresh_mail_list() carga correos automáticamente │
|
||
│ │
|
||
│ 4. LEER CORREO │
|
||
│ └─► Clic en correo de la lista │
|
||
│ └─► _on_mail_select() │
|
||
│ ├─ store(id, '+FLAGS', '\\Seen') si no leído │
|
||
│ └─ _display_mail(mail_info) │
|
||
│ ├─ Actualiza encabezados │
|
||
│ ├─ Muestra cuerpo │
|
||
│ ├─ Muestra imágenes inline │
|
||
│ └─ Muestra botones para guardar adjuntos │
|
||
│ │
|
||
│ 5. ENVIAR NUEVO CORREO │
|
||
│ └─► Clic en "✉️ Nuevo correo" │
|
||
│ └─► _open_compose_window() │
|
||
│ ├─ Ventana emergente con campos │
|
||
│ ├─ Botón "📎 Adjuntar archivo" │
|
||
│ ├─ Soporte Ctrl+V para imágenes │
|
||
│ └─ Botón "📤 ENVIAR CORREO" │
|
||
│ ├─ Validar destinatarios (regex) │
|
||
│ ├─ Confirmar si múltiples │
|
||
│ ├─ _send_mail_with_attachments() │
|
||
│ │ ├─ Crear MIMEMultipart │
|
||
│ │ ├─ Adjuntar archivos │
|
||
│ │ └─ SMTP send_message() │
|
||
│ └─ _save_to_sent_folder() │
|
||
│ └─ IMAP APPEND a 'Sent' │
|
||
│ │
|
||
│ 6. VERIFICAR EN WEBMIN │
|
||
│ └─► Abrir http://10.10.0.101:20000 │
|
||
│ └─► Carpeta "Sent" → Correo aparece ahí │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 Características del Dashboard
|
||
|
||
### 📊 Monitor del Sistema
|
||
- Gráfico de CPU en línea temporal
|
||
- Gráfico de memoria como área
|
||
- Contador de hilos del proceso
|
||
|
||
### 🌤️ API del Tiempo (Jávea)
|
||
- Temperatura actual y sensación térmica
|
||
- Humedad y velocidad del viento
|
||
- Descripción del clima
|
||
|
||
### 🛒 Análisis Wallapop
|
||
- Extracción de información de anuncios
|
||
- Headers personalizados para API
|
||
- Resultados formateados
|
||
|
||
### ⏰ Sistema de Alarmas
|
||
- Programación en minutos
|
||
- Notificaciones visuales
|
||
- Gestión de alarmas activas
|
||
|
||
---
|
||
|
||
## 🤝 Contribuir
|
||
|
||
1. **Fork** del proyecto
|
||
2. Crea tu **Feature Branch** (`git checkout -b feature/NuevaFuncion`)
|
||
3. **Commit** tus cambios (`git commit -m 'Add: Nueva función'`)
|
||
4. **Push** a la rama (`git push origin feature/NuevaFuncion`)
|
||
5. Abre un **Pull Request**
|
||
|
||
---
|
||
|
||
## 📝 Licencia
|
||
|
||
Este proyecto es de carácter **educativo** y fue desarrollado como parte del módulo de **Programación de Servicios y Procesos**.
|
||
|
||
---
|
||
|
||
<p align="center">
|
||
<strong>Desarrollado con ❤️ por Marcos Ferrandiz</strong>
|
||
</p>
|
||
|
||
<p align="center">
|
||
<em>Proyecto 1º Evaluación - PSP (Programación de Servicios y Procesos)</em>
|
||
</p>
|
||
|
||
---
|
||
|
||
<p align="center">
|
||
<img src="https://img.shields.io/badge/Estado-Completado-success?style=flat-square" alt="Estado">
|
||
<img src="https://img.shields.io/badge/Versión-1.0-blue?style=flat-square" alt="Versión">
|
||
<img src="https://img.shields.io/badge/Made%20with-Python-yellow?style=flat-square&logo=python" alt="Python">
|
||
</p>
|