05-sep: [infra] configurar Raspberry Pi y documentar flota

This commit is contained in:
juanjo
2026-09-05 15:46:47 +02:00
parent 1e0ed24663
commit 770b9f7ee2
6 changed files with 311 additions and 8 deletions

View File

@@ -83,6 +83,7 @@ Start-Process powershell -WindowStyle Hidden -ArgumentList '-Command', 'python C
| `consumo` | Abrir https://opencode.ai/auth, esperar captura del panel Go y analizarla con image-vision | | `consumo` | Abrir https://opencode.ai/auth, esperar captura del panel Go y analizarla con image-vision |
| `memoria` | Protocolo completo: git pull, actualizar ficheros de memoria (MEMORIA_ABSOLUTA, ESTADO_DIARIO, AGENTS, DOCUMENTACION, README), Engram local + cloud, Obsidian sync, **trello-sync**, git push | | `memoria` | Protocolo completo: git pull, actualizar ficheros de memoria (MEMORIA_ABSOLUTA, ESTADO_DIARIO, AGENTS, DOCUMENTACION, README), Engram local + cloud, Obsidian sync, **trello-sync**, git push |
| `recordatorios` | Consultar API recordatorios en VPS: GET /todo (calendarios + recordatorios webhook) | | `recordatorios` | Consultar API recordatorios en VPS: GET /todo (calendarios + recordatorios webhook) |
| `raspberry` | Diagnosticar y mantener las Raspberry Pi del laboratorio |
| `idd-procesar` o `procesar IDD` | Fetch manual de mensajes pendientes del canal IDD Telegram via Bot API, guardarlos en Google Drive y clasificarlos localmente | | `idd-procesar` o `procesar IDD` | Fetch manual de mensajes pendientes del canal IDD Telegram via Bot API, guardarlos en Google Drive y clasificarlos localmente |
| `trello-sync` | Sincronizar pendientes con Trello: local→Trello (crea/actualiza cards) + Trello→local (elimina tareas archivadas en Trello de la base de conocimiento) | | `trello-sync` | Sincronizar pendientes con Trello: local→Trello (crea/actualiza cards) + Trello→local (elimina tareas archivadas en Trello de la base de conocimiento) |
| `pelicula <nombre>` | Buscar pelicula en el videoclub y descargar a NAS | | `pelicula <nombre>` | Buscar pelicula en el videoclub y descargar a NAS |

80
docs/raspberry_pi.md Normal file
View File

@@ -0,0 +1,80 @@
# Raspberry Pi del laboratorio
## Objetivo
Centralizar la configuracion, el acceso y el mantenimiento de una flota de Raspberry Pi. La primera unidad es `minipc`; el mismo procedimiento debe servir para las cinco unidades previstas.
## Inventario actual
| Campo | Valor |
|---|---|
| Hostname | `minipc` |
| Sistema | Raspbian GNU/Linux 13 (trixie) |
| Ethernet | `192.168.50.187` |
| Wi-Fi | `192.168.50.251` |
| Usuario operativo | `pi` |
| Codigo | `/home/pi/minipc/` |
| Servicio bot | `pi-bot.service` |
| Servicio tunel | `autossh-tunel.service` |
| Zona horaria | `Europe/Madrid` |
| Teclado | Espanol (`es`) |
No se documentan contrasenas, tokens ni claves privadas en este fichero.
## Comando unico de diagnostico
En la Raspberry:
```bash
bash /home/pi/minipc/vigilancia/raspberry_info.sh
```
El comando muestra identidad, red, teclado, locale, hora, servicios y recursos sin revelar secretos.
Desde el PC, pasando por el VPS:
```powershell
ssh -i C:\Users\juanm\Documents\GitHub\contabo root@185.187.169.109 "ssh -i /root/.ssh/pi_deploy -p 2222 pi@127.0.0.1 'bash /home/pi/minipc/vigilancia/raspberry_info.sh'"
```
## Configuracion aplicada en minipc
```bash
sudo raspi-config nonint do_configure_keyboard es
sudo localectl set-x11-keymap es pc105
sudo locale-gen es_ES.UTF-8
sudo localectl set-locale LANG=es_ES.UTF-8
sudo timedatectl set-timezone Europe/Madrid
sudo timedatectl set-ntp true
```
El cambio de idioma puede requerir cerrar la sesion y volver a entrar. El teclado de consola queda definido en `/etc/default/keyboard` con `XKBLAYOUT="es"`.
## Conectividad
- Ethernet es la ruta preferida y usa la IP `192.168.50.187`.
- Wi-Fi queda disponible como respaldo en `192.168.50.251`.
- El tunel inverso publica SSH de la Pi en el VPS como `127.0.0.1:2222`.
- El acceso remoto usa la clave privada del VPS configurada para el tunel; nunca se copia ni documenta aqui.
- Si la IP cambia, consultar `hostname -I` en la Pi y actualizar el inventario.
## Despliegue
La Pi no tiene Git operativo para este flujo. El despliegue se hace por el VPS y el tunel inverso:
1. Copiar el fichero al VPS.
2. Copiarlo por SSH a `pi@127.0.0.1:2222`.
3. Validar con `python3 -m py_compile` si es Python.
4. Reiniciar el servicio correspondiente.
5. Verificar `systemctl is-active` y los logs.
## Escalado a cinco Raspberry
Cada unidad debe tener un registro con hostname, MAC, IP Ethernet, IP Wi-Fi, servicios, funcion y ubicacion. Se recomienda:
- Reservas DHCP por MAC para evitar cambios de IP.
- Ethernet como red principal.
- Un hostname unico por unidad: `minipc-01`, `minipc-02`, etc.
- Un servicio `autossh-tunel` por unidad con un puerto de tunel distinto.
- Una clave SSH de despliegue independiente por unidad.
- Un script de diagnostico identico en todas las unidades.

View File

@@ -0,0 +1,35 @@
# Bitacora: Dashboard de descargas en Telegram
**Fecha:** 2026-09-05 · **Equipo:** sobremesa · **Agentes:** OpenCode
---
## Registro en tiempo real
### [14:35] Diseño inicial
- **Que:** Crear una vista de descargas dentro de Telegram.
- **Como:** Reutilizar la consulta existente a Download Station y editar un unico mensaje con botones inline.
- **Resultado:** OK
- **Decisiones:** No se crea una web externa ni una Mini App en esta primera version.
- **Gotchas/Lecciones:** El bot usa directamente la Bot API mediante `requests` y actualmente solo procesa mensajes normales.
### [14:42] Implementacion
- **Que:** Añadir panel editable, botones inline, callbacks y refresco automatico.
- **Como:** `pi_bot.py` mantiene un mensaje por chat y lo actualiza cada 20 segundos; `nas_dl.py` genera la barra Unicode usando el porcentaje de piezas descargadas.
- **Resultado:** OK
- **Decisiones:** `descargas` y `descargar` sin argumento abren el panel; `descargar <enlace>` mantiene la funcion de añadir tareas.
- **Gotchas/Lecciones:** Telegram necesita recibir y responder `callback_query` para que los botones funcionen correctamente.
### [14:47] Verificacion local
- **Que:** Comprobar sintaxis, barra de progreso y callbacks sin llamar a Telegram.
- **Como:** `py_compile`, prueba de extremos de `progress_bar` y callbacks simulados.
- **Resultado:** OK
- **Decisiones:** No se enviaron mensajes reales ni se modificaron tareas del NAS durante la prueba.
- **Gotchas/Lecciones:** La Pi no acepta conexiones SSH desde este equipo en `192.168.50.187:22`; el despliegue queda pendiente.
### [15:43] Configuracion Raspberry Pi y flota
- **Que:** Recuperar el acceso por clave, configurar teclado/locale espanoles, zona horaria y NTP, y centralizar el mantenimiento de Raspberry Pi.
- **Como:** Se uso el salto PC -> VPS -> tunel inverso -> Pi; se aplico `raspi-config` para teclado, `localectl` para locale/X11 y `timedatectl` para hora.
- **Resultado:** OK
- **Decisiones:** Ethernet queda como ruta preferida, Wi-Fi como respaldo; se crea `raspberry_info.sh` como comando unico de diagnostico sin secretos.
- **Gotchas/Lecciones:** El servicio correcto es `autossh-tunel.service`; la Pi tiene Raspbian 13 y dos IPs activas (`192.168.50.187` y `192.168.50.251`).

View File

@@ -149,6 +149,13 @@ def human_size(n):
return f"{n:.1f}PB" return f"{n:.1f}PB"
def progress_bar(percent, width=12):
"""Genera una barra de progreso compacta para mensajes de Telegram."""
percent = max(0.0, min(100.0, float(percent or 0)))
filled = int(round(percent * width / 100))
return "█" * filled + "░" * (width - filled)
def listar_descargas(hablar_solo_activas=True, session=None): def listar_descargas(hablar_solo_activas=True, session=None):
"""Devuelve (lineas_texto, activas). Lista las tareas de Download Station """Devuelve (lineas_texto, activas). Lista las tareas de Download Station
con estado, porcentaje de progreso, tamaño descargado y velocidad. con estado, porcentaje de progreso, tamaño descargado y velocidad.
@@ -181,7 +188,7 @@ def listar_descargas(hablar_solo_activas=True, session=None):
size = transfer.get("size_downloaded") or 0 size = transfer.get("size_downloaded") or 0
speed = transfer.get("speed_download") or 0 speed = transfer.get("speed_download") or 0
if estado == "downloading": if estado == "downloading":
activas.append(f"▶️ <b>{pct:.1f}%</b> {human_size(size)} ⬇️{speed // 1024} KB/s\n" activas.append(f"▶️ <b>{progress_bar(pct)} {pct:.1f}%</b> {human_size(size)} ⬇️{speed // 1024} KB/s\n"
f" {titulo}") f" {titulo}")
else: else:
resto.append((estado, pct, size, titulo)) resto.append((estado, pct, size, titulo))

View File

@@ -5,6 +5,7 @@ import sys
import threading import threading
import time import time
import urllib.parse import urllib.parse
import json
import requests import requests
@@ -13,6 +14,7 @@ ultima_titulos = {} # chat_id -> {'query':..., 'results':[...]}
ultimos_torrents = {} # chat_id -> {'titulo':..., 'torrents':[...]} ultimos_torrents = {} # chat_id -> {'titulo':..., 'torrents':[...]}
flujo_genero = {} # chat_id -> 'esperando_tipo' | 'esperando_genero' | None flujo_genero = {} # chat_id -> 'esperando_tipo' | 'esperando_genero' | None
flujo_genero_tipo = {} # chat_id -> 'movie' | 'series' (tipo ya elegido) flujo_genero_tipo = {} # chat_id -> 'movie' | 'series' (tipo ya elegido)
panel_descargas = {} # chat_id -> estado del panel editable
BASE = os.path.dirname(os.path.abspath(__file__)) BASE = os.path.dirname(os.path.abspath(__file__))
LOG = os.path.join(BASE, "bot.log") LOG = os.path.join(BASE, "bot.log")
@@ -100,6 +102,148 @@ def enviar(texto, chat_id):
return ultimo_id return ultimo_id
def teclado_descargas():
return {
"inline_keyboard": [
[
{"text": "🔄 Actualizar", "callback_data": "dl:refresh"},
{"text": "▶️ Activas", "callback_data": "dl:active"},
],
[
{"text": "📁 Todas", "callback_data": "dl:all"},
{"text": "✖️ Cerrar", "callback_data": "dl:close"},
],
]
}
def enviar_panel_descargas(texto, chat_id):
try:
r = requests.post(
f"https://api.telegram.org/bot{TOKEN}/sendMessage",
data={
"chat_id": chat_id,
"text": texto,
"parse_mode": "HTML",
"reply_markup": json.dumps(teclado_descargas()),
},
timeout=10,
)
d = r.json()
if d.get("ok"):
return d["result"].get("message_id")
log(f"Telegram panel send error: {d.get('description', d)}")
except Exception as e:
log(f"Telegram panel send exception: {e}")
return None
def editar_panel_descargas(chat_id, message_id, texto):
try:
r = requests.post(
f"https://api.telegram.org/bot{TOKEN}/editMessageText",
data={
"chat_id": chat_id,
"message_id": message_id,
"text": texto,
"parse_mode": "HTML",
"reply_markup": json.dumps(teclado_descargas()),
},
timeout=10,
)
d = r.json()
if not d.get("ok") and "not modified" not in d.get("description", "").lower():
log(f"Telegram panel edit error: {d.get('description', d)}")
return d.get("ok", False)
except Exception as e:
log(f"Telegram panel edit exception: {e}")
return False
def responder_callback(callback_id):
try:
requests.post(
f"https://api.telegram.org/bot{TOKEN}/answerCallbackQuery",
data={"callback_query_id": callback_id},
timeout=10,
)
except Exception as e:
log(f"Telegram callback error: {e}")
def texto_panel_descargas(modo="active"):
import nas_dl
lineas, activas = nas_dl.listar_descargas(
hablar_solo_activas=(modo == "active")
)
encabezado = "📥 <b>DESCARGAS DEL NAS</b>"
if modo == "active":
encabezado += " · ACTIVAS"
texto = encabezado + "\n\n" + "\n".join(lineas)
texto += f"\n\n🕒 Actualizado: <code>{time.strftime('%H:%M:%S')}</code>"
return texto, activas
def abrir_panel_descargas(chat_id):
anterior = panel_descargas.pop(chat_id, None)
if anterior:
anterior["stop"].set()
try:
texto, _activas = texto_panel_descargas()
message_id = enviar_panel_descargas(texto, chat_id)
if not message_id:
enviar("❌ No se pudo abrir el panel de descargas.", chat_id)
return
estado_panel = {
"message_id": message_id,
"modo": "active",
"stop": threading.Event(),
}
panel_descargas[chat_id] = estado_panel
threading.Thread(
target=monitor_panel_descargas,
args=(chat_id, estado_panel),
daemon=True,
).start()
except Exception as e:
log(f"panel descargas error: {e}")
enviar(f"❌ Error en el panel de descargas: {e}", chat_id)
def monitor_panel_descargas(chat_id, estado_panel):
while not estado_panel["stop"].wait(20):
try:
texto, activas = texto_panel_descargas(estado_panel["modo"])
editar_panel_descargas(chat_id, estado_panel["message_id"], texto)
if not activas:
break
except Exception as e:
log(f"monitor panel descargas error: {e}")
def procesar_callback(callback):
callback_id = callback.get("id")
mensaje = callback.get("message") or {}
chat_id = (mensaje.get("chat") or {}).get("id")
accion = (callback.get("data") or "").split(":", 1)[-1]
responder_callback(callback_id)
estado_panel = panel_descargas.get(chat_id)
if not estado_panel:
return
if accion == "close":
estado_panel["stop"].set()
panel_descargas.pop(chat_id, None)
borrar(chat_id, estado_panel["message_id"])
return
if accion in ("active", "all"):
estado_panel["modo"] = "active" if accion == "active" else "all"
try:
texto, _activas = texto_panel_descargas(estado_panel["modo"])
editar_panel_descargas(chat_id, estado_panel["message_id"], texto)
except Exception as e:
log(f"callback panel descargas error: {e}")
def borrar(chat_id, message_id): def borrar(chat_id, message_id):
try: try:
requests.post( requests.post(
@@ -213,6 +357,8 @@ def procesar(texto, chat_id):
else: else:
ok = mandar_youtube(url) ok = mandar_youtube(url)
enviar("🎬 YouTube lanzado en la TV ✅" if ok else "❌ No se pudo lanzar YouTube", chat_id) enviar("🎬 YouTube lanzado en la TV ✅" if ok else "❌ No se pudo lanzar YouTube", chat_id)
elif t in ("descargas", "/descargas") or (comando == "descargar" and not argumento):
abrir_panel_descargas(chat_id)
elif comando == "descargar": elif comando == "descargar":
try: try:
import nas_dl import nas_dl
@@ -220,11 +366,6 @@ def procesar(texto, chat_id):
ok = nas_dl.add_torrent(argumento) ok = nas_dl.add_torrent(argumento)
enviar("📥 Añadido a Download Station del NAS ✅" if ok enviar("📥 Añadido a Download Station del NAS ✅" if ok
else "❌ No se pudo añadir al NAS. Revisa <code>bot.log</code>.", chat_id) else "❌ No se pudo añadir al NAS. Revisa <code>bot.log</code>.", chat_id)
else:
# Incluye también pendientes, pausadas y terminadas para que
# el usuario pueda ver el estado real de cada torrent.
lineas, _act = nas_dl.listar_descargas(hablar_solo_activas=False)
enviar("\n".join(lineas), chat_id)
except Exception as e: except Exception as e:
log(f"descargar error: {e}") log(f"descargar error: {e}")
enviar(f"❌ Error en Download Station: {e}", chat_id) enviar(f"❌ Error en Download Station: {e}", chat_id)
@@ -269,8 +410,8 @@ def procesar(texto, chat_id):
"<b>7.</b> <code>novedades</code> — últimas novedades del catálogo\n" "<b>7.</b> <code>novedades</code> — últimas novedades del catálogo\n"
" ✏️ Ej: <code>/novedades</code>\n\n" " ✏️ Ej: <code>/novedades</code>\n\n"
"━━━ 🖥️ NAS / DESCARGAS ━━━\n" "━━━ 🖥️ NAS / DESCARGAS ━━━\n"
"<b>8.</b> <code>descargar</code> — torrents descargándose y su progreso\n" "<b>8.</b> <code>descargas</code> — panel con progreso y botones\n"
" ✏️ Ej: <code>/descargar</code>\n" " ✏️ Ej: <code>/descargas</code> o <code>/descargar</code>\n"
"<b>9.</b> <code>descargar &lt;enlace&gt;</code> — añade a Download Station\n" "<b>9.</b> <code>descargar &lt;enlace&gt;</code> — añade a Download Station\n"
" ✏️ Ej: <code>/descargar magnet:?xt=urn:btih:...</code>\n\n" " ✏️ Ej: <code>/descargar magnet:?xt=urn:btih:...</code>\n\n"
"━━━ 📺 TV ━━━\n" "━━━ 📺 TV ━━━\n"
@@ -646,6 +787,9 @@ def main():
offset = upd["update_id"] + 1 offset = upd["update_id"] + 1
guardar_offset(offset) guardar_offset(offset)
log(f"UPDATE {upd.get('update_id')}") log(f"UPDATE {upd.get('update_id')}")
if upd.get("callback_query"):
procesar_callback(upd["callback_query"])
continue
msg = upd.get("message") or upd.get("channel_post") msg = upd.get("message") or upd.get("channel_post")
if not msg: if not msg:
continue continue

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Diagnostico seguro de una Raspberry Pi del laboratorio.
# No muestra credenciales, tokens ni contenido de ficheros .env.
set -u
echo "== IDENTIDAD =="
hostname
cat /etc/os-release | sed -n 's/^\(PRETTY_NAME\)=/\1=/p'
echo "== RED =="
hostname -I
ip -br addr
ip route
echo "== TECLADO Y LOCALE =="
localectl status 2>&1
if [ -f /etc/default/keyboard ]; then
sed -n '/^XKBMODEL=/p;/^XKBLAYOUT=/p;/^XKBVARIANT=/p;/^XKBOPTIONS=/p' /etc/default/keyboard
fi
echo "== HORA =="
timedatectl status 2>&1
echo "== SERVICIOS =="
for service in ssh pi-bot autossh-tunel tv-api; do
printf '%-16s' "$service"
systemctl is-active "$service" 2>/dev/null || true
done
echo "== RECURSOS =="
uptime
free -h
df -h / | tail -n 1
if command -v vcgencmd >/dev/null 2>&1; then
vcgencmd measure_temp
fi