1412 lines
76 KiB
Markdown
1412 lines
76 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>
|
||
|
||
<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 - 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 (servidor.py)
|
||
|
||
```python
|
||
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 - 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 │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 🔒 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
|
||
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
|
||
# Conexión IMAP (app.py líneas 1578-1582)
|
||
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
|
||
# Conexión SMTP (app.py líneas 2949-2955)
|
||
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
|
||
# GUARDAR (app.py líneas 1540-1557)
|
||
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
|
||
# CARGAR (app.py líneas 1512-1520)
|
||
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
|
||
# 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 (líneas 2703-2740)**
|
||
|
||
```python
|
||
# 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
|
||
# 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:
|
||
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
|
||
# 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
|
||
# Al cargar correos (líneas 1775-1790)
|
||
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
|
||
# Función de log (líneas 4304-4315)
|
||
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>
|