112 KiB
💣 Minesweeper Multiplayer + Dashboard
Un proyecto completo de programación de servicios y procesos
Juego de buscaminas competitivo en red + Panel de control integral
Características • Arquitectura • Instalación • Uso • Mecánicas • Tecnologías
📸 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
git clone https://github.com/MarcosFerrandiz/Proyecto1AVApsp.git
cd Proyecto1AVApsp
Paso 2: Crear Entorno Virtual (Recomendado)
python -m venv .venv
# Linux/macOS
source .venv/bin/activate
# Windows
.venv\Scripts\activate
Paso 3: Instalar Dependencias
pip install -r requirements.txt
📦 Ver dependencias detalladas
| 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) |
🎯 Uso
🖥️ Iniciar el Servidor
python servidor.py
El servidor escuchará en
0.0.0.0:3333
🎮 Opción A: Dashboard Completo
python app.py
Incluye el juego integrado + todas las funcionalidades del panel.
🎮 Opción B: Cliente Standalone
python cliente_juego.py
Cliente ligero solo para jugar al Minesweeper.
🔌 Conectar al Juego
- Introduce el Host del servidor (por defecto:
127.0.0.1) - Verifica el Puerto (por defecto:
3333) - Pulsa "Conectar"
- ¡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
// 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
// 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?
# 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:
# 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: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:
# 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()
# 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
# 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 |
# 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
# 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:
# 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:
# 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:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 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:
# 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:
# 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:
# ¿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:
- Estructura de datos óptima: Set con complejidad O(1)
- Validación centralizada: Servidor como fuente única de verdad
- Sincronización correcta: threading.Lock para evitar race conditions
- Arquitectura segura: Cliente no valida, imposible hacer trampa
- 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:
# 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
|
Python 3 Lenguaje base |
Tkinter Interfaz gráfica |
TCP Sockets Comunicación en red |
|
Matplotlib Gráficos |
JSON Protocolo mensajes |
Requests APIs HTTP |
🧵 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).
# 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ónselect(mailbox)→ Seleccionar carpeta (INBOX, Sent, etc.)search(None, 'ALL')→ Buscar todos los correosfetch(id, '(BODY.PEEK[] FLAGS)')→ Obtener correo sin marcarlo como leídostore(id, '+FLAGS', '\\Seen')→ Marcar como leídoappend(folder, flags, date, msg)→ Guardar correo en carpeta
SMTP (Simple Mail Transfer Protocol)
Protocolo para enviar correos. Puerto: 25 (sin TLS).
# 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
# 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)
# 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[]?
# ❌ 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.
# 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
# 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
# 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
# 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
# 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
# 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
- Fork del proyecto
- Crea tu Feature Branch (
git checkout -b feature/NuevaFuncion) - Commit tus cambios (
git commit -m 'Add: Nueva función') - Push a la rama (
git push origin feature/NuevaFuncion) - 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.
Desarrollado con ❤️ por Marcos Ferrandiz
Proyecto 1º Evaluación - PSP (Programación de Servicios y Procesos)