Programación para un mapa de la Red Meshtastic


Presentación

Al observar los distintos mapas disponibles para posicionar los nodos de la red Meshtastic, se me ocurrió la idea de armar uno de carácter local (Argentina), tomando los datos del servidor MQTT mesharg.innova.ar y volcándolos en un mapa.

Otro objetivo que me planteé fue que el sistema fuera fácilmente replicable, especialmente en lo que respecta al desarrollo de la página web, de modo que pueda funcionar incluso en servidores muy modestos.

Con esas premisas se desarrolló un proyecto en el que toda la captación y el procesamiento de la información se realizan en una computadora, y luego se sube un archivo .json que contiene los datos que serán consumidos por el mapa.

Por ello, el proyecto tiene dos áreas bien definidas:

Un Sistema de recepción y registro de mensajes Meshtastic, complementado con la programación necesaria para subir la información ya procesada a la página web.

Una Página web que, mediante la integración de la API proporcionada por OpenStreetMap, utiliza scripts en JavaScript para posicionar los nodos y mostrar la información pertinente de cada uno.

Finalmente esta previsto desarrollar un Sistema de actualizacion de datos interactivo que sera agregado cuando este en funcionamiento y que permitira a los ususarios de cada nodo personalizar los datos del mismo, modificando el valor de campos existente o agregando nuevos campos e incluso invisibilizar el nodo en el mapa.

Sistema de Recepción y Registro de Mensajes Meshtastic

Este proyecto consiste en un sistema autónomo diseñado para recibir, procesar y almacenar mensajes provenientes de una red de nodos Meshtastic a través del protocolo MQTT. Su objetivo principal es capturar en tiempo real la información generada por los dispositivos de la red —como posiciones geográficas, telemetría ambiental y del dispositivo, metadatos de los nodos y mensajes de texto— y registrarla de forma estructurada en una base de datos relacional para su posterior remisión al servidor web para servir de alimentación de datos al mapa de nodos.

El sistema se conecta a un broker MQTT público (en este caso, mesharg.innova.ar) y se suscribe a los canales de la red Meshtastic. Los mensajes recibidos están cifrados mediante AES-CTR, por lo que el sistema utiliza claves preconfiguradas por canal para desencriptarlos antes de su procesamiento.

Una vez descifrados, los mensajes se clasifican según su tipo de contenido:

El procesamiento respeta la lógica de la red: solo se almacenan datos válidos (por ejemplo, valores de telemetría distintos de cero o nulos se ignoran), y se evitan duplicados innecesarios. En el caso de las posiciones, se actualiza únicamente la marca de tiempo si la ubicación no ha cambiado; en telemetría, cada nodo mantiene un único registro que se actualiza con los últimos valores válidos recibidos.

Todos los datos se persisten en una base de datos MySQL con una estructura optimizada para consultas eficientes, permitiendo el desarrollo de interfaces web, mapas en tiempo real o sistemas de monitoreo. Además, el sistema muestra en consola un registro legible y bien formateado de cada evento procesado, facilitando el diagnóstico y la supervisión operativa.

Finalmente con los datos registrados en la base de datos se construye un archivo .json que será enviado al servidor.

El diseño del sistema es modular, lo que permite su uso tanto en entornos de desarrollo (con salida detallada y modo de prueba) como en producción (con enfoque en estabilidad, seguridad y eficiencia). Está pensado para funcionar de forma continua en servidores de bajo consumo, como un ESP32 o una Raspberry Pi, y es especialmente útil ya que brinda un historial confiable de actividad de los nodos participantes.

En resumen, este sistema actúa como un puente entre la red Meshtastic y el mundo de los datos estructurados, transformando mensajes efímeros y cifrados en información persistente, organizada y lista para ser utilizada en aplicaciones de monitoreo, mapeo o comunicación resiliente.

Archivos del Sistema de Recepción, Registro y envío al Servidor Web de Mensajes Meshtastic

Esta parte se encuentra desarrollada en tres archivos: mesh_mensaje_clase.py, mesh_registra_clase.py y mesh_subir_datos.py que se complementa con los archivos mensa-config.json y base-config.json y servid-config.json contienen las claves de desencriptación, los parámetros de conexión MQTT, las credenciales de acceso a la base de datos MySQL, las credenciales de conexión al servidor y todos los datos de configuración necesarios.

Archivo: mesh_mensaje_clase.py

Propósito: Actúa como el orquestador central de recepción, desencriptación y clasificación de mensajes provenientes de la red Meshtastic a través del protocolo MQTT.

Funcionamiento:

Inicialización y configuración: Al instanciarse, la clase carga automáticamente el archivo mensa-config.json, que contiene:
– Credenciales de autenticación MQTT (usuario, clave, broker, puerto, topico_base).
– Un diccionario de claves de cifrado por canal (CLAVES_CANAL), codificadas en Base64, necesarias para desencriptar los mensajes.
– Mapeos de modelos de hardware y roles de nodo (NOMBRE_MODELOS, NOMBRE_ROLES), utilizados para traducir códigos numéricos a nombres legibles.

Conexión segura a MQTT: Establece una conexión TLS con el broker (puerto 8883) y se suscribe al patrón de tópico msh/AR/2/e/#, que cubre todos los canales de la red argentina. Utiliza callbacks estándar de Paho-MQTT para gestionar la conexión y la recepción de mensajes.

Procesamiento de cada mensaje entrante:
Parseo inicial: Convierte el payload del mensaje MQTT en un objeto ServiceEnvelope de Meshtastic.
Extracción de metadatos: Obtiene el ID del nodo emisor (from), el destinatario (to), el canal (channel_id) y el estado de encriptación.
Desencriptación (si aplica): Si el mensaje está cifrado, busca la clave correspondiente al channel_id en CLAVES_CANAL. Usa AES-CTR con un nonce derivado del ID del paquete y del nodo emisor para descifrar el contenido y reemplazar el campo encrypted por decoded.
Clasificación por tipo: Examina el campo portnum del mensaje decodificado para determinar su tipo:
    • POSITION_APP → delega a Proceso_Posicion
    • TELEMETRY_APP → delega a Proceso_Telemetria
    • NODEINFO_APP → delega a Proceso_Usuario
    • TEXT_MESSAGE_APP → delega a Proceso_Texto
Invocación de callbacks: Cada subprocesador ejecuta su lógica de parseo y, si se ha registrado un callback, lo invoca con un diccionario estandarizado de datos listo para consumo externo.

Gestión de seguridad: Las claves de cifrado nunca se almacenan en el código fuente. Residen exclusivamente en mensa-config.json, un archivo que debe mantenerse fuera del control de versiones y con permisos restringidos en el sistema de archivos.

Modularidad y extensibilidad: La clase no contiene lógica de persistencia ni de visualización. Su única responsabilidad es enrutar mensajes ya procesados a funciones externas, lo que permite reutilizarla en distintos contextos (pruebas, producción, análisis en tiempo real).

# mesh_mensaje_clase.py

import paho.mqtt.client as mqtt
from meshtastic import mqtt_pb2, mesh_pb2, portnums_pb2, telemetry_pb2
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import base64
from datetime import datetime, timezone
import ssl
import time
import json
import os

# ------------------------------------------------------------
# 🔧 Cargar configuración desde config.json
# ------------------------------------------------------------
_ruta_config = os.path.join(os.path.dirname(__file__), 'mensa-config.json')
with open(_ruta_config, 'r', encoding='utf-8') as f:
    _config_datos = json.load(f)

CLAVES_CANAL = _config_datos['CLAVES_CANAL']
NOMBRE_MODELOS = _config_datos['NOMBRE_MODELOS']
NOMBRE_ROLES = _config_datos['NOMBRE_ROLES']


# ------------------------------------------------------------
# 📦 Proceso_Crudo: genera diccionario de mensaje crudo
# ------------------------------------------------------------
class Proceso_Crudo:
    def __init__(self, callback_crudo=None):
        self.callback_crudo = callback_crudo

    def procesar(self, paquete, desde_nodo_str, id_recep_str, canal_nombre, canal_indice):
        """Devuelve un diccionario con los metadatos crudos del mensaje."""
        try:
            # Determinar si está encriptado
            encriptado = paquete.HasField("encrypted")
            if encriptado:
                portnum = -1
                portnum_name = "ENCRYPTED_UNKNOWN"
                payload_size = len(paquete.encrypted)
                payload_hex = paquete.encrypted.hex()
            else:
                portnum = getattr(paquete.decoded, 'portnum', -1)
                try:
                    portnum_name = portnums_pb2.PortNum.Name(portnum)
                except ValueError:
                    portnum_name = f"UNKNOWN_{portnum}"
                payload_size = len(paquete.decoded.payload) if paquete.HasField("decoded") else 0
                payload_hex = paquete.decoded.payload.hex() if paquete.HasField("decoded") else ""

            crudo = {
                "portnum": portnum,
                "portnum_name": portnum_name,
                "id_nodo": desde_nodo_str,
                "id_recep": id_recep_str,
                "canal_nombre": canal_nombre,
                "canal_indice": canal_indice,
                "encriptado": 1 if encriptado else 0,
                "payload_size": payload_size,
                "payload_hex": payload_hex,
                "fecha_recepcion": datetime.now(timezone.utc).isoformat()
            }

            if self.callback_crudo:
                self.callback_crudo(crudo)

            return crudo

        except Exception:
            return None


# ------------------------------------------------------------
# 📍 Proceso_Posicion
# ------------------------------------------------------------
class Proceso_Posicion:
    def __init__(self, callback_procesado=None):
        self.callback_procesado = callback_procesado

    def _safe_float(self, val, default=0.0):
        if val is None:
            return float(default)
        try:
            return float(val)
        except (ValueError, TypeError):
            return float(default)

    def proceso(self, paquete, desde_nodo_str, canal_nombre):
        try:
            posicion = mesh_pb2.Position()
            posicion.ParseFromString(paquete.decoded.payload)

            lat = getattr(posicion, 'latitude_i', 0) / 1e7
            lon = getattr(posicion, 'longitude_i', 0) / 1e7
            alt = getattr(posicion, 'altitude', 0)
            time_rx = getattr(posicion, 'time', 0)
            satelites = getattr(posicion, 'sats_in_view', 0)
            velocidad = getattr(posicion, 'ground_speed', 0)

            if time_rx > 0:
                fecha_gps = datetime.fromtimestamp(time_rx, tz=timezone.utc).isoformat()
            else:
                fecha_gps = None

            datos = {
                "id_nodo": desde_nodo_str,
                "canal_nombre": canal_nombre,
                "latitud": self._safe_float(lat),
                "longitud": self._safe_float(lon),
                "altitud": self._safe_float(alt, 0),
                "fecha_gps": fecha_gps,
                "satelites": self._safe_float(satelites, 0),
                "velocidad": self._safe_float(velocidad, 0)
            }

            if self.callback_procesado:
                self.callback_procesado(datos)

            return datos

        except Exception:
            return None


# ------------------------------------------------------------
# 📊 Proceso_Telemetria
# ------------------------------------------------------------
class Proceso_Telemetria:
    def __init__(self, callback_procesado=None):
        self.callback_procesado = callback_procesado

    def _safe_float(self, val, default=0.0):
        if val is None:
            return float(default)
        try:
            return float(val)
        except (ValueError, TypeError):
            return float(default)

    def proceso(self, paquete, desde_nodo_str, canal_nombre):
        try:
            telemetria = telemetry_pb2.Telemetry()
            telemetria.ParseFromString(paquete.decoded.payload)

            bateria = getattr(telemetria.device_metrics, 'battery_level', None)
            voltaje = getattr(telemetria.device_metrics, 'voltage', None)
            canal_util = getattr(telemetria.device_metrics, 'channel_utilization', None)
            aire_util = getattr(telemetria.device_metrics, 'air_util_tx', None)

            temperatura = None
            humedad = None
            presion = None
            if telemetria.HasField('environment_metrics'):
                env = telemetria.environment_metrics
                temperatura = getattr(env, 'temperature', None)
                humedad = getattr(env, 'relative_humidity', None)
                presion = getattr(env, 'barometric_pressure', None)

            datos = {
                "id_nodo": desde_nodo_str,
                "canal_nombre": canal_nombre,
                "bateria": self._safe_float(bateria, None),
                "voltaje": self._safe_float(voltaje, None),
                "canal_util": self._safe_float(canal_util, None),
                "aire_util": self._safe_float(aire_util, None),
                "temperatura": self._safe_float(temperatura, None),
                "humedad": self._safe_float(humedad, None),
                "presion": self._safe_float(presion, None)
            }

            if self.callback_procesado:
                self.callback_procesado(datos)

            return datos

        except Exception:
            return None


# ------------------------------------------------------------
# 👤 Proceso_Usuario
# ------------------------------------------------------------
class Proceso_Usuario:
    def __init__(self, callback_procesado=None):
        self.callback_procesado = callback_procesado

    def proceso(self, paquete, desde_nodo_str, canal_nombre):
        try:
            usuario = mesh_pb2.User()
            usuario.ParseFromString(paquete.decoded.payload)

            nombre_largo = getattr(usuario, 'long_name', '').strip() or "Sin nombre"
            nombre_corto = getattr(usuario, 'short_name', '').strip() or desde_nodo_str[-4:].upper()
            mac_bytes = getattr(usuario, 'macaddr', b'')
            mac = ':'.join(f"{b:02x}" for b in mac_bytes) if mac_bytes else "00:00:00:00:00:00"
            hw_model = getattr(usuario, 'hw_model', 0)
            rol = getattr(usuario, 'role', 0)

            modelo = NOMBRE_MODELOS.get(str(hw_model), f"Modelo_{hw_model}")
            rol_str = NOMBRE_ROLES.get(str(rol), "Desconocido")

            datos = {
                "id_nodo": desde_nodo_str,
                "canal_nombre": canal_nombre,
                "nombre_largo": nombre_largo,
                "nombre_corto": nombre_corto,
                "mac": mac,
                "modelo": modelo,
                "rol": rol_str,
                "firmware": None  # normalmente no viene en NODEINFO
            }

            if self.callback_procesado:
                self.callback_procesado(datos)

            return datos

        except Exception:
            return None


# ------------------------------------------------------------
# 💬 Proceso_Texto
# ------------------------------------------------------------
class Proceso_Texto:
    def __init__(self, callback_procesado=None):
        self.callback_procesado = callback_procesado

    def proceso(self, paquete, desde_nodo_str, id_recep_str, canal_nombre):
        try:
            texto_bytes = paquete.decoded.payload
            try:
                texto = texto_bytes.decode('utf-8').strip()
            except UnicodeDecodeError:
                texto = texto_bytes.decode('utf-8', errors='replace').strip()

            datos = {
                "texto": texto,
                "id_nodo": desde_nodo_str,
                "id_recep": id_recep_str,
                "canal_nombre": canal_nombre
            }

            if self.callback_procesado:
                self.callback_procesado(datos)

            return datos

        except Exception:
            return None


# ------------------------------------------------------------
# 📡 Ruta_Mensajes: orquestador principal
# ------------------------------------------------------------
class Ruta_Mensajes:
    def __init__(self, topico_base, callback_crudo=None, callback_posicion=None,
                 callback_telemetria=None, callback_usuario=None, callback_texto=None):
        self.topico_base = topico_base
        self.proceso_crudo = Proceso_Crudo(callback_crudo)
        self.proceso_posicion = Proceso_Posicion(callback_posicion)
        self.proceso_telemetria = Proceso_Telemetria(callback_telemetria)
        self.proceso_usuario = Proceso_Usuario(callback_usuario)
        self.proceso_texto = Proceso_Texto(callback_texto)

        self.cliente = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
        self.cliente.on_connect = self.nueva_conexion
        self.cliente.on_message = self.nuevo_mensaje

    def _desencripta_mensaje(self, paquete, desde_nodo_str, canal_nombre):
        clave_b64 = CLAVES_CANAL.get(canal_nombre)
        if not clave_b64:
            return False
        try:
            relleno = (4 - (len(clave_b64) % 4)) % 4
            clave_ajustada = clave_b64 + ('=' * relleno)
            clave_normalizada = clave_ajustada.replace('-', '+').replace('_', '/')
            clave_bytes = base64.b64decode(clave_normalizada)

            vector_inicial = paquete.id.to_bytes(8, "little") + getattr(paquete, "from").to_bytes(8, "little")
            cifrador = Cipher(algorithms.AES(clave_bytes), modes.CTR(vector_inicial), backend=default_backend())
            descifrador = cifrador.decryptor()
            descifrado = descifrador.update(paquete.encrypted) + descifrador.finalize()

            datos = mesh_pb2.Data()
            datos.ParseFromString(descifrado)
            paquete.decoded.CopyFrom(datos)
            return True
        except Exception:
            return False

    def nueva_conexion(self, cliente, userdata, flags, reason_code, properties):
        if reason_code == 0:
            topico = f"{self.topico_base}#"
            cliente.subscribe(topico)
            print(f"✅ Conectado y suscrito a: {topico}")
        else:
            print(f"❌ Error al conectar: {reason_code}")
            
            
    def nuevo_mensaje(self, cliente, userdata, msg):
        try:
            envoltura = mqtt_pb2.ServiceEnvelope()
            envoltura.ParseFromString(msg.payload)
            paquete = envoltura.packet

            desde_nodo_int = getattr(paquete, 'from', 0)
            desde_nodo_str = f"!{desde_nodo_int:08x}"
            id_recep_int = getattr(paquete, 'to', 0xffffffff)
            id_recep_str = f"!{id_recep_int:08x}"
            canal_nombre = getattr(envoltura, 'channel_id', 'unknown')
            canal_indice = getattr(paquete, 'channel', 0)

            # ✅ Registrar mensaje crudo (siempre)
            self.proceso_crudo.procesar(paquete, desde_nodo_str, id_recep_str, canal_nombre, canal_indice)

            # Desencriptar si es necesario
            if paquete.HasField("encrypted"):
                if not self._desencripta_mensaje(paquete, desde_nodo_str, canal_nombre):
                    return

            if not paquete.HasField("decoded"):
                return

            tipo_mensaje = paquete.decoded.portnum

            if tipo_mensaje == portnums_pb2.POSITION_APP:
                self.proceso_posicion.proceso(paquete, desde_nodo_str, canal_nombre)
            elif tipo_mensaje == portnums_pb2.TELEMETRY_APP:
                self.proceso_telemetria.proceso(paquete, desde_nodo_str, canal_nombre)
            elif tipo_mensaje == portnums_pb2.NODEINFO_APP:
                self.proceso_usuario.proceso(paquete, desde_nodo_str, canal_nombre)
            elif tipo_mensaje == portnums_pb2.TEXT_MESSAGE_APP:
                self.proceso_texto.proceso(paquete, desde_nodo_str, id_recep_str, canal_nombre)

        except Exception:
            pass  # silencioso, como en original

    def conectar(self, config_mqtt):
        self.cliente.username_pw_set(config_mqtt['usuario'], config_mqtt['clave'])
        if config_mqtt['puerto'] == 8883:
            self.cliente.tls_set(ca_certs=None, cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLS)
            self.cliente.tls_insecure_set(True)
        self.cliente.connect(config_mqtt['broker'], config_mqtt['puerto'], 60)
        self.cliente.loop_forever()
📥 Descargar mesh_mensaje_clase.py

Este programa para su funcionamiento debe cargar la configuración desde el archivo mensa-config.json

  {
  "CLAVES_CANAL": {
    "RosarioMesh": "ksv+4MMhc9unauU8i6bixjLt/pkjWMv1PIFr0fH8g58=",
    "BairesMesh": "aB3K7ZIciBKq49nxn5gVmPQEtbTUVZOHKxuCaCKaHtA=",
    "NQNmesh": "7jYDJLWy9TSnavWI/yBEywXIzdkTV1FOI0uHkciaGgc=",
    "PATAGONIA": "Ae6IxXali8k08O27Gl04A/2yzvTj9XVnb+r0ZnpLRpk=",
    "MendozaMesh": "yVyN1359YQb0S1LW2cslgMrXHbTkHnR1TSHYDa7VCCs=",
    "MapaNQN": "1PG7OiApB1nwvP+rz05pAQ==",
    "LongFast": "1PG7OiApB1nwvP+rz05pAQ=="
  },
  "CONFIG": {
    "broker": "mesharg.innova.ar",
    "puerto": 8883,
    "usuario": "usuario obtenido del bot",
    "clave": "clave obtenida del bot",
    "topico_base": "msh/AR/2/e/"
  },
  "NOMBRE_MODELOS": {
    "0": "UNSET",
    "1": "T_LORA_V1",
    "2": "T_LORA_V2",
    "3": "T_BEAM",
    "4": "HELTEC_V1",
    "5": "HELTEC_V2_1",
    "6": "RAK4631",
    "7": "NRF52840DK",
    "8": "T_ECHO",
    "9": "T_LORA_V2_1_1P6",
    "10": "T_LORA_V1_1P3",
    "11": "RAK11200",
    "12": "NANO_G1",
    "13": "TLORA_T3S3_V1",
    "14": "TLORA_T3S3_EPAPER",
    "15": "NANO_G1_EXPLORER",
    "16": "NANO_G2_ULTRA",
    "17": "TLORA_T3S3_V2",
    "18": "HELTEC_HT62",
    "19": "HELTEC_WSL_V3",
    "20": "HELTEC_WIRELESS_TRACKER",
    "21": "HELTEC_WIRELESS_PAPER",
    "22": "T_DECK",
    "23": "T_WATCH_S3",
    "24": "PICOMPUTER_S3",
    "25": "HELTEC_HT62_V2",
    "26": "RAK11310",
    "27": "RAK12600",
    "28": "HELTEC_V3",
    "29": "RAK11200_V2",
    "30": "HELTEC_HT62_V3",
    "31": "STATION_G1",
    "32": "STATION_G2",
    "33": "STATION_G3",
    "34": "STATION_G4",
    "35": "NANO_G3",
    "43": "STATION_G1",
    "44": "BETAFPV_2400_TX",
    "45": "BETAFPV_900_TX",
    "46": "LILYGO_T3S3_1P6",
    "47": "LILYGO_T3S3_1P7",
    "48": "LILYGO_T3S3_1P8",
    "49": "LILYGO_T3S3_1P9",
    "50": "LILYGO_T3S3_1P10",
    "51": "LILYGO_T3S3_1P11",
    "52": "LILYGO_T3S3_1P12",
    "53": "LILYGO_T3S3_1P13",
    "54": "LILYGO_T3S3_1P14",
    "55": "LILYGO_T3S3_1P15",
    "56": "LILYGO_T3S3_1P16",
    "57": "LILYGO_T3S3_1P17",
    "58": "LILYGO_T3S3_1P18",
    "59": "LILYGO_T3S3_1P19",
    "60": "LILYGO_T3S3_1P20",
    "61": "LILYGO_T3S3_1P21",
    "62": "LILYGO_T3S3_1P22",
    "63": "LILYGO_T3S3_1P23",
    "64": "LILYGO_T3S3_1P24",
    "65": "LILYGO_T3S3_1P25",
    "66": "LILYGO_T3S3_1P26",
    "67": "LILYGO_T3S3_1P27",
    "68": "LILYGO_T3S3_1P28",
    "69": "CDEBYTE_EORA_S3",
    "70": "ALUMI_MOTE_V1",
    "71": "ALUMI_MOTE_V2",
    "72": "ALUMI_MOTE_V3",
    "73": "ALUMI_MOTE_V4",
    "74": "ALUMI_MOTE_V5",
    "75": "ALUMI_MOTE_V6",
    "76": "ALUMI_MOTE_V7",
    "77": "ALUMI_MOTE_V8",
    "78": "ALUMI_MOTE_V9",
    "79": "ALUMI_MOTE_V10",
    "80": "SEEED_STUDIO_T1000_E",
    "81": "SEEED_STUDIO_T1000_E_V2",
    "82": "T_DECK_PLUS",
    "83": "T_WATCH_S3_PRO",
    "84": "T_ECHO_V2",
    "85": "T_ECHO_V3",
    "86": "T_ECHO_V4",
    "87": "T_ECHO_V5",
    "88": "T_ECHO_V6",
    "89": "T_ECHO_V7",
    "90": "T_ECHO_V8",
    "91": "T_ECHO_V9",
    "92": "T_ECHO_V10"
  },
  "NOMBRE_ROLES": {
    "0": "UNSET",
    "1": "ROUTER",
    "2": "ROUTER_CLIENT",
    "3": "CLIENT_MUTE",
    "4": "CLIENT",
    "5": "TRACKER",
    "6": "SENSOR",
    "7": "TAK",
    "8": "LOST_AND_FOUND",
    "9": "TAK_TRACKER",
    "10": "REPEATER"
  }
}
📥 Descargar mensa-config.json

Sobre el contenido del archivo de configuración cabe destacar que se pueden agregar más canales y periódicamente se deben verificar que las claves de los mismos sigan vigentes. También se pueden ir agregando modelos de placas en la medida que se vayan registrando.

Archivo: mesh_registra_clase.py

Propósito: Es el gestor de persistencia robusto y eficiente que almacena los datos procesados en una base de datos MySQL, aplicando reglas de negocio específicas para garantizar la integridad y utilidad de la información histórica.

Funcionamiento:

Conexión a la base de datos: Al instanciarse, carga la configuración de conexión desde base-config.json, que incluye host, usuario, contraseña, nombre de la base de datos y parámetros de timeout. Establece una conexión reutilizable y la reconecta automáticamente si se pierde.

Métodos especializados por tipo de mensaje:
registrar_posicion(datos): Verifica si ya existe un registro reciente para el mismo nodo (excluyendo el canal “Manual”). Compara las coordenadas, altitud, satélites y velocidad con el último registro. Si no hay cambios, solo actualiza fecha_gps si el nuevo timestamp es más reciente. Si hay cambios, inserta un nuevo registro, evitando duplicados innecesarios y manteniendo un historial de desplazamientos significativos.
registrar_telemetria(datos): Aplica una política estricta de datos válidos: ignora valores nulos o iguales a cero (considerados inválidos por el firmware de los nodos). Utiliza INSERT ... ON DUPLICATE KEY UPDATE para mantener un solo registro por nodo, actualizando únicamente los campos con datos nuevos y válidos. Preserva los valores anteriores cuando los nuevos son inválidos, evitando la pérdida de información útil.
registrar_usuario(datos): Inserta o actualiza la información del nodo (nombre largo, nombre corto, MAC, modelo, rol) usando ON DUPLICATE KEY UPDATE. Deriva el nombre_corto a partir del ID del nodo si no se recibe, asegurando consistencia en la visualización.
registrar_texto(datos): Guarda cada mensaje de texto con su contexto completo: emisor, destinatario, canal y fecha de recepción. Trunca el texto a 500 caracteres para respetar la definición de la tabla y evitar errores de inserción.

Manejo de errores y robustez: Cada operación de base de datos está envuelta en bloques try/except que capturan errores de conexión o sintaxis, registran el fallo en consola y evitan que el programa se detenga. Los cursores y conexiones se cierran explícitamente en bloques finally.

Optimización para entornos de bajo recurso: La clase está diseñada para funcionar en servidores con recursos limitados (como un ESP32 o una Raspberry Pi), minimizando el uso de memoria y evitando operaciones costosas. La conexión persistente reduce la sobrecarga de establecer una nueva conexión por cada mensaje.

Separación de responsabilidades: No depende de archivos temporales ni de estado global. Toda la lógica de proceso está encapsulada en métodos claros y con un único propósito, facilitando pruebas unitarias y mantenimiento.

# mesh_registra_clase.py

import mysql.connector
from mysql.connector import Error
import json
import os
from datetime import datetime


# ------------------------------------------------------------
# 🔧 Cargar configuración de base de datos
# ------------------------------------------------------------
_ruta_config = os.path.join(os.path.dirname(__file__), 'base-config.json')
with open(_ruta_config, 'r', encoding='utf-8') as f:
    _config_datos = json.load(f)

DB_CONFIG = _config_datos['DB_CONFIG']


# ------------------------------------------------------------
# 🗃️ Clase principal: RegistradorMensajes
# ------------------------------------------------------------
class RegistradorMensajes:
    def __init__(self):
        self.conn = None
        self._conectar()

    def _conectar(self):
        """Establece una conexión reutilizable a la base de datos."""
        try:
            self.conn = mysql.connector.connect(**DB_CONFIG)
        except Error as e:
            print(f"❌ Error al conectar a la base de datos: {e}")
            self.conn = None

    def _asegurar_conexion(self):
        """Reconecta si la conexión está caída."""
        if self.conn is None or not self.conn.is_connected():
            self._conectar()
        return self.conn and self.conn.is_connected()

    def _safe_float(self, val, default=0.0):
        if val is None:
            return float(default)
        try:
            return float(val)
        except (ValueError, TypeError):
            return float(default)

    # --------------------------------------------------------
    # 📝 Métodos de registro por tipo de mensaje
    # --------------------------------------------------------

    def registrar_posicion(self, datos):
        """
        Registra una posición.
        Espera un dict con: id_nodo, canal_nombre, latitud, longitud,
        altitud, fecha_gps, satelites, velocidad.
        """
        if not self._asegurar_conexion():
            return False

        try:
            cursor = self.conn.cursor(dictionary=True)

            # Convertir fecha_gps a naive datetime si es string
            fecha_gps = datos.get('fecha_gps')
            if isinstance(fecha_gps, str):
                fecha_gps = datetime.fromisoformat(fecha_gps.replace('Z', '+00:00')).replace(tzinfo=None)

            # Buscar último registro (excluyendo 'Manual')
            query_ultimo = """
                SELECT id, latitud, longitud, altitud, satelites, velocidad, fecha_gps
                FROM posiciones
                WHERE id_nodo = %s AND canal != 'Manual'
                ORDER BY fecha_gps DESC
                LIMIT 1
            """
            cursor.execute(query_ultimo, (datos['id_nodo'],))
            ultimo = cursor.fetchone()

            # Valores actuales
            lat_actual = round(self._safe_float(datos.get('latitud')), 6)
            lon_actual = round(self._safe_float(datos.get('longitud')), 6)
            alt_actual = self._safe_float(datos.get('altitud'), 0)
            sats_actual = self._safe_float(datos.get('satelites'), 0)
            speed_actual = self._safe_float(datos.get('velocidad'), 0)

            if ultimo:
                lat_ultimo = round(self._safe_float(ultimo['latitud'], 0), 6)
                lon_ultimo = round(self._safe_float(ultimo['longitud'], 0), 6)
                alt_ultimo = self._safe_float(ultimo['altitud'], 0)
                sats_ultimo = self._safe_float(ultimo['satelites'], 0)
                speed_ultimo = self._safe_float(ultimo['velocidad'], 0)

                sin_cambios = (
                    lat_actual == lat_ultimo and
                    lon_actual == lon_ultimo and
                    alt_actual == alt_ultimo and
                    sats_actual == sats_ultimo and
                    speed_actual == speed_ultimo
                )

                if sin_cambios:
                    ult_fecha = ultimo['fecha_gps']
                    if isinstance(ult_fecha, str):
                        ult_fecha = datetime.strptime(ult_fecha, '%Y-%m-%d %H:%M:%S')
                    elif hasattr(ult_fecha, 'tzinfo') and ult_fecha.tzinfo is not None:
                        ult_fecha = ult_fecha.replace(tzinfo=None)
                    if fecha_gps and fecha_gps > ult_fecha:
                        cursor.execute("UPDATE posiciones SET fecha_gps = %s WHERE id = %s", (fecha_gps, ultimo['id']))
                        self.conn.commit()
                        return True
                    else:
                        return False  # No se actualiza

            # Insertar nuevo registro
            cursor.execute("""
                INSERT INTO posiciones 
                (id_nodo, nombre_corto, canal, latitud, longitud, altitud, fecha_gps, satelites, velocidad)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
            """, (
                datos['id_nodo'],
                datos.get('nombre_corto') or datos['id_nodo'][-4:].upper(),
                datos['canal_nombre'],
                lat_actual,
                lon_actual,
                alt_actual,
                fecha_gps or datetime.utcnow(),
                sats_actual,
                speed_actual
            ))
            self.conn.commit()
            return True

        except Exception as e:
            print(f"❌ Error al registrar posición: {e}")
            return False
        finally:
            if 'cursor' in locals():
                cursor.close()

    def registrar_telemetria(self, datos):
        """
        Registra telemetría (un solo registro por nodo).
        Espera un dict con: id_nodo, bateria, voltaje, uso_canal,
        transmision, temperatura, humedad, presion.
        """
        if not self._asegurar_conexion():
            return False

        try:
            cursor = self.conn.cursor()

            def es_valido(val):
                return val is not None and val != 0

            # Preparar valores
            valores = [
                datos['id_nodo'],
                datos.get('bateria') if es_valido(datos.get('bateria')) else None,
                datos.get('voltaje') if es_valido(datos.get('voltaje')) else None,
                datos.get('uso_canal') if es_valido(datos.get('uso_canal')) else None,
                datos.get('transmision') if es_valido(datos.get('transmision')) else None,
                datos.get('temperatura') if es_valido(datos.get('temperatura')) else None,
                datos.get('humedad') if es_valido(datos.get('humedad')) else None,
                datos.get('presion') if es_valido(datos.get('presion')) else None
            ]

            # Construir cláusula de actualización
            update_parts = []
            if es_valido(datos.get('bateria')):
                update_parts.append("bateria = VALUES(bateria)")
            if es_valido(datos.get('voltaje')):
                update_parts.append("voltaje = VALUES(voltaje)")
            if es_valido(datos.get('uso_canal')):
                update_parts.append("uso_canal = VALUES(uso_canal)")
            if es_valido(datos.get('transmision')):
                update_parts.append("transmision = VALUES(transmision)")
            if es_valido(datos.get('temperatura')):
                update_parts.append("temperatura = VALUES(temperatura)")
            if es_valido(datos.get('humedad')):
                update_parts.append("humedad = VALUES(humedad)")
            if es_valido(datos.get('presion')):
                update_parts.append("presion = VALUES(presion)")

            if not update_parts:
                return False

            query = f"""
                INSERT INTO telemetria 
                (id_nodo, bateria, voltaje, uso_canal, transmision, temperatura, humedad, presion)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
                ON DUPLICATE KEY UPDATE {', '.join(update_parts)}
            """
            cursor.execute(query, valores)
            self.conn.commit()
            return True

        except Exception as e:
            print(f"❌ Error al registrar telemetría: {e}")
            return False
        finally:
            if 'cursor' in locals():
                cursor.close()

    def registrar_usuario(self, datos):
        """
        Registra o actualiza información de nodo.
        Espera un dict con: id_nodo, nombre_largo, nombre_corto,
        mac, modelo, rol, firmware.
        """
        if not self._asegurar_conexion():
            return False

        try:
            cursor = self.conn.cursor()
            cursor.execute("""
                INSERT INTO usuarios 
                (id_nodo, nombre, shortName, mac, modelo, rol, firmware)
                VALUES (%s, %s, %s, %s, %s, %s, %s)
                ON DUPLICATE KEY UPDATE
                    nombre = VALUES(nombre),
                    shortName = VALUES(shortName),
                    mac = VALUES(mac),
                    modelo = VALUES(modelo),
                    rol = VALUES(rol),
                    firmware = VALUES(firmware)
            """, (
                datos['id_nodo'],
                datos.get('nombre_largo', 'Sin nombre'),
                datos.get('nombre_corto', datos['id_nodo'][-4:].upper()),
                datos.get('mac', '00:00:00:00:00:00'),
                datos.get('modelo', 'Desconocido'),
                datos.get('rol', 'Desconocido'),
                datos.get('firmware')
            ))
            self.conn.commit()
            return True

        except Exception as e:
            print(f"❌ Error al registrar usuario: {e}")
            return False
        finally:
            if 'cursor' in locals():
                cursor.close()

    def registrar_texto(self, datos):
        """
        Registra un mensaje de texto.
        Espera un dict con: texto, id_nodo, id_recep, canal_nombre.
        """
        if not self._asegurar_conexion():
            return False

        try:
            cursor = self.conn.cursor()
            texto = datos['texto'][:500]  # límite de la tabla
            cursor.execute("""
                INSERT INTO mensajes 
                (texto, id_nodo, id_recep, canal, fecha)
                VALUES (%s, %s, %s, %s, NOW())
            """, (
                texto,
                datos['id_nodo'],
                datos['id_recep'],
                datos['canal_nombre']
            ))
            self.conn.commit()
            return True

        except Exception as e:
            print(f"❌ Error al registrar texto: {e}")
            return False
        finally:
            if 'cursor' in locals():
                cursor.close()

    def cerrar(self):
        """Cierra la conexión a la base de datos."""
        if self.conn and self.conn.is_connected():
            self.conn.close()
📥 Descargar mesh_registra_clase.py

Este archivo para su funcionamiento debe cargar la configuración en el archivo base-config.json

{
  "DB_CONFIG": {
    "host": "localhost",
    "database": "nombre de la base de datos",     
    "user": "usuario de la base de datos",
    "password": "clave de la base de datos",
    "charset": "utf8mb4"
  }
}
📥 Descargar base-config.json

Base de Datos

El funcionamiento del archivo mesh_registra_clase.py requiere la existencia de una base de datos.

Aunque fue desarrollada originalmente para MySQL, puede adaptarse fácilmente a cualquier otro sistema gestor de bases de datos compatible que el usuario tenga disponible en su equipo.

Para el caso de MySQL o MariaDB, el siguiente script, al ejecutarse directamente en cualquier servidor compatible, creará automáticamente la base de datos y las tablas necesarias:

CREATE DATABASE IF NOT EXISTS `mapa` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `mapa`;

-- Tabla: mensajes
DROP TABLE IF EXISTS `mensajes`;
CREATE TABLE `mensajes` (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `texto` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `id_nodo` varchar(9) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
  `id_recep` varchar(9) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
  `canal` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `fecha` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_id_nodo` (`id_nodo`),
  KEY `idx_id_recep` (`id_recep`),
  KEY `idx_canal` (`canal`),
  KEY `idx_fecha` (`fecha`),
  KEY `idx_id_nodo_fecha` (`id_nodo`, `fecha`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Tabla: posiciones
DROP TABLE IF EXISTS `posiciones`;
CREATE TABLE `posiciones` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `id_nodo` varchar(9) COLLATE utf8mb4_unicode_ci NOT NULL,
  `nombre_corto` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `canal` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
  `latitud` decimal(10,7) NOT NULL,
  `longitud` decimal(10,7) NOT NULL,
  `altitud` smallint DEFAULT '0',
  `fecha_gps` timestamp NULL DEFAULT NULL,
  `fecha_recepcion` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `satelites` tinyint UNSIGNED DEFAULT '0',
  `velocidad` smallint DEFAULT '0',
  `estado` tinyint UNSIGNED NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  KEY `idx_id_nodo` (`id_nodo`),
  KEY `idx_fecha_gps` (`fecha_gps`),
  KEY `idx_fecha_recepcion` (`fecha_recepcion`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Tabla: telemetria
DROP TABLE IF EXISTS `telemetria`;
CREATE TABLE `telemetria` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `id_nodo` varchar(9) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `bateria` decimal(5,2) DEFAULT NULL,
  `voltaje` decimal(5,2) DEFAULT NULL,
  `uso_canal` decimal(5,2) DEFAULT NULL,
  `transmision` decimal(5,2) DEFAULT NULL,
  `temperatura` decimal(5,2) DEFAULT NULL,
  `humedad` decimal(5,2) DEFAULT NULL,
  `presion` decimal(6,2) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_id_nodo` (`id_nodo`),
  KEY `idx_id_nodo` (`id_nodo`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- Tabla: usuarios
DROP TABLE IF EXISTS `usuarios`;
CREATE TABLE `usuarios` (
  `id_nodo` varchar(9) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `nombre` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `shortName` varchar(4) COLLATE utf8mb4_unicode_ci NOT NULL,
  `mac` varchar(17) COLLATE utf8mb4_unicode_ci NOT NULL,
  `modelo` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
  `rol` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `firmware` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  PRIMARY KEY (`id_nodo`),
  UNIQUE KEY `id_nodo_UNIQUE` (`id_nodo`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
📥 Descargar base-datos.sql

Archivo: mesh_subir_datos.py

Para subir la información al servidor web se desarrolló un programa que a partir de los datos registrados en la base de datos genere un archivo .json que luego es subido al servidor para que aporte los datos al mapa.

Este programa automatiza la extracción, procesamiento y publicación de información actualizada de los nodos de una red Meshtastic. A partir de una base de datos MySQL, recopila la última posición geográfica, metadatos del dispositivo (como modelo, rol, MAC y firmware) y los datos más recientes de telemetría (batería, voltaje, temperatura, humedad, etc.) de cada nodo activo.

Características principales:

Filtrado inteligente: excluye nodos con coordenadas inválidas (0,0) o aquellos marcados con estado especial (estado = 9), para el control de su visibilización en el mapa.
Formato estandarizado: genera un archivo nodos.json limpio, legible y optimizado para su consumo del mapa en la página web cuyo desarrollo se explica más adelante.
Publicación automática: sube el archivo resultante a un servidor remoto mediante SFTP, permitiendo actualizaciones en tiempo casi real de la red pública.
Robustez y manejo de errores: notifica claramente cualquier fallo en la conexión a la base de datos, generación del JSON o transferencia SFTP, sin interrumpir abruptamente la ejecución.
Configuración externa: toda la información sensible (credenciales de base de datos y SFTP) se gestiona desde un archivo separado (servid-config.json), facilitando la portabilidad y seguridad.

Con ello se asegura que la información pública refleje siempre el estado más reciente de los nodos reales y activos en el mapa.

#!/usr/bin/env python3
import json
import os
import sys
from datetime import datetime
import mysql.connector
from mysql.connector import Error
import paramiko

# === CONFIGURACIÓN ===
CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'servid-config.json')
OUTPUT_FILE = "nodos.json"

def cargar_configuracion():
    with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
        config = json.load(f)
    return config['DB_CONFIG'], config['SFTP_CONFIG']

# === FUNCIÓN: OBTENER DATOS DE LA BASE DE DATOS ===
def conectar_y_obtener_datos(db_config):
    connection = None
    cursor = None
    try:
        connection = mysql.connector.connect(**db_config)
        if connection.is_connected():
            cursor = connection.cursor(dictionary=True)
            query = """
            SELECT 
                u.id_nodo,
                u.nombre,
                u.shortName,
                u.mac,
                u.modelo,
                u.rol,
                u.firmware,
                p.canal,
                p.latitud,
                p.longitud,
                p.altitud,
                p.estado AS estado_ultima_posicion,
                UNIX_TIMESTAMP(p.fecha_recepcion) AS hora,
                t.bateria,
                t.voltaje,
                t.uso_canal,
                t.transmision,
                t.temperatura,
                t.humedad,
                t.presion,
                CASE 
                    WHEN EXISTS (
                        SELECT 1 FROM posiciones px 
                        WHERE px.id_nodo = u.id_nodo AND px.estado = 9
                    ) THEN 1 
                    ELSE 0 
                END AS tiene_estado_9
            FROM usuarios u
            INNER JOIN (
                SELECT 
                    id_nodo,
                    canal,
                    latitud,
                    longitud,
                    altitud,
                    estado,
                    fecha_recepcion,
                    ROW_NUMBER() OVER (PARTITION BY id_nodo ORDER BY fecha_recepcion DESC) AS rn
                FROM posiciones
            ) p ON u.id_nodo = p.id_nodo AND p.rn = 1
            LEFT JOIN (
                SELECT 
                    id_nodo,
                    bateria,
                    voltaje,
                    uso_canal,
                    transmision,
                    temperatura,
                    humedad,
                    presion,
                    ROW_NUMBER() OVER (PARTITION BY id_nodo ORDER BY id DESC) AS rn
                FROM telemetria
                WHERE 
                    bateria IS NOT NULL OR
                    voltaje IS NOT NULL OR
                    uso_canal IS NOT NULL OR
                    transmision IS NOT NULL OR
                    temperatura IS NOT NULL OR
                    humedad IS NOT NULL OR
                    presion IS NOT NULL
            ) t ON u.id_nodo = t.id_nodo AND t.rn = 1
            """
            cursor.execute(query)
            return cursor.fetchall()
    except Error as e:
        print(f"❌ Error MySQL: {e}")
        return None  # ← Cambiado: no sys.exit()
    except Exception as e:
        print(f"❌ Error inesperado: {e}")
        return None  # ← Cambiado
    finally:
        if cursor:
            cursor.close()
        if connection and connection.is_connected():
            connection.close()

def coordenada_es_valida(valor):
    if valor is None:
        return False
    s = str(valor).strip()
    if not s:
        return False
    try:
        f = float(s)
        return f != 0.0
    except (ValueError, TypeError):
        return False

def procesar_nodo(nodo_db):
    resultado = {}
    resultado["id"] = str(nodo_db["id_nodo"])
    nombre = nodo_db.get("nombre") or "sin nombre"
    resultado["nombre"] = str(nombre).strip() or "sin nombre"
    shortName = nodo_db.get("shortName") or "s/n"
    resultado["shortName"] = str(shortName).strip() or "s/n"
    resultado["latitud"] = str(nodo_db["latitud"]).strip()
    resultado["longitud"] = str(nodo_db["longitud"]).strip()
    hora = nodo_db.get("hora")
    if hora is None or hora == 0:
        resultado["ultimo_contacto"] = "0"
    else:
        resultado["ultimo_contacto"] = str(int(hora)) 
    campos_telemetria = {
        "bateria": "bateria",
        "voltaje": "voltaje",
        "uso_canal": "usoCanal",
        "transmision": "transmision",
        "temperatura": "temperatura",
        "humedad": "humedad",
        "presion": "presion",
    }
    otros_campos = ["modelo", "rol", "mac", "canal", "altitud", "firmware"]
    for db_key, json_key in campos_telemetria.items():
        val = nodo_db.get(db_key)
        if val is not None:
            s = str(val).strip()
            if s and s.lower() not in {"null", "none", "sin datos", ""}:
                resultado[json_key] = s
    for campo in otros_campos:
        val = nodo_db.get(campo)
        if val is None:
            continue
        s = str(val).strip()
        if s and s.lower() not in {"null", "none", "sin datos", ""}:
            resultado[campo] = s
    return resultado

def generar_json(db_config):
    print("🔄 Obteniendo la ÚLTIMA posición y telemetría de cada nodo...")
    todos = conectar_y_obtener_datos(db_config)
    if todos is None:
        return False  # ← Falló la conexión
    sin_estado_9 = [n for n in todos if not n.get("tiene_estado_9")]
    validos = [
        n for n in sin_estado_9
        if coordenada_es_valida(n.get("latitud")) and coordenada_es_valida(n.get("longitud"))
    ]
    print(f"📊 Total de nodos únicos: {len(todos)}")
    print(f"🚫 Excluidos por tener estado == 9 en alguna posición: {len(todos) - len(sin_estado_9)}")
    print(f"🚫 Excluidos por coordenadas inválidas: {len(sin_estado_9) - len(validos)}")
    print(f"✅ Nodos exportables: {len(validos)}")
    nodos_json = [procesar_nodo(n) for n in validos]
    fecha_actual = datetime.now().strftime("%d/%m/%Y - %H:%M")
    nodo_fecha = {"id": "fecha", "nombre": fecha_actual}
    nodos_json.insert(0, nodo_fecha)
    try:
        with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
            json.dump(nodos_json, f, indent=2, ensure_ascii=False)
        print(f"✅ Archivo '{OUTPUT_FILE}' generado con éxito.")
        return True
    except Exception as e:
        print(f"❌ Error al escribir el archivo JSON: {e}")
        return False

def subir_archivo_sftp(sftp_config):
    if not os.path.exists(OUTPUT_FILE):
        print(f"❌ Archivo local '{OUTPUT_FILE}' no encontrado.")
        return False
    cliente = paramiko.SSHClient()
    cliente.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        print("🔐 Conectando al servidor SFTP...")
        cliente.connect(
            hostname=sftp_config["host"],
            port=sftp_config["port"],
            username=sftp_config["username"],
            password=sftp_config["password"]
        )
        sftp = cliente.open_sftp()
        print(f"📤 Subiendo {OUTPUT_FILE} a {sftp_config['remote_path']}...")
        sftp.put(OUTPUT_FILE, sftp_config["remote_path"])
        stat = sftp.stat(sftp_config["remote_path"])
        print(f"✅ Archivo subido exitosamente. Tamaño: {stat.st_size} bytes")
        sftp.close()
        return True
    except paramiko.AuthenticationException:
        print("❌ Error: Autenticación fallida. Verifica tu usuario o contraseña.")
        return False
    except paramiko.SSHException as e:
        print(f"❌ Error de conexión SSH: {e}")
        return False
    except Exception as e:
        print(f"❌ Error inesperado: {e}")
        return False
    finally:
        cliente.close()

# === FUNCIÓN PRINCIPAL AJUSTADA ===
def main():
    """Devuelve True si todo salió bien, False en caso de error."""
    print("🚀 Iniciando generación y subida de nodos.json")
    try:
        db_config, sftp_config = cargar_configuracion()
    except Exception as e:
        print(f"❌ Error al cargar la configuración: {e}")
        return False

    if not generar_json(db_config):
        print("❌ Falló la generación del JSON.")
        return False

    if not subir_archivo_sftp(sftp_config):
        print("❌ La subida falló.")
        return False

    print("🎉 Proceso completado con éxito.")
    return True

# Solo ejecuta si se llama directamente
if __name__ == "__main__":
    exit_code = 0 if main() else 1
    sys.exit(exit_code)
📥 Descargar mesh_subir_datos.py

Este archivo para su funcionamiento debe cargar la configuración en el archivo servid-config.json

{
  "DB_CONFIG": {
    "host": "localhost",
    "database": "nombre de la base de datos",
    "user": "usuario de de la base de datos",
    "password": "clave de la de la base de datos",
    "charset": "utf8mb4"
  },
  "SFTP_CONFIG": {
    "host": "ip del servidor",
    "port": puerto  del servidor ,
    "username": "usuario del  del servidor",
    "password": "clave del servidor",
    "remote_path": "/home/{path de la carpeta de la pagina web en el servidor}/nodos.json"
  }
}
📥 Descargar servid-config.json

Archivo: procesador_mesh.py

Este programa actúa como el núcleo coordinador del sistema de recepción, procesamiento y sincronización de datos de la red Meshtastic. Su función principal es integrar tres componentes clave del ecosistema desarrollado:

mesh_mensaje_clase.py: contiene la clase Ruta_Mensajes, responsable de conectarse al broker MQTT, suscribirse a los tópicos de la red Meshtastic y enrutar los mensajes entrantes según su tipo (posición, telemetría, usuario o texto).
mesh_registra_clase.py: define la clase RegistradorMensajes, que persiste los datos recibidos en una base de datos MySQL, estructurando la información de nodos, posiciones, telemetría y mensajes de texto de forma normalizada y consistente.
mesh_subir_datos.py: script independiente encargado de generar un archivo nodos.json con la información actualizada de los nodos activos y subirlo a un servidor remoto (por ejemplo, mediante HTTP o FTP).

Funcionalidades principales:

Conexión MQTT: se conecta al broker configurado en mensa-config.json y escucha los mensajes de la red Meshtastic.
Enrutamiento inteligente: utiliza callbacks específicos (callback_posicion, callback_telemetria, etc.) para procesar cada tipo de mensaje, mostrando información en consola y delegando el almacenamiento a RegistradorMensajes.
Registro persistente: todo dato relevante (ubicación, sensores, metadatos de nodos, mensajes de texto) se guarda automáticamente en la base de datos.
Sincronización periódica: lanza un hilo en segundo plano que **ejecuta mesh_subir_datos.py cada 30 minutos**, asegurando que el archivo nodos.json en el servidor remoto se mantenga actualizado sin interrumpir la recepción de mensajes.
Diseño modular y robusto: al separar la lógica de recepción, almacenamiento y exportación en módulos distintos, el sistema es fácil de mantener, probar y extender.

En conclusión procesador_mesh.py es el corazón del sistema: organiza la recepción en tiempo real desde Meshtastic, el almacenamiento local en base de datos y la publicación periódica de datos hacia un servidor externo, integrando de forma coherente los módulos mesh_mensaje_clase.py, mesh_registra_clase.py y mesh_subir_datos.py.

#!/usr/bin/env python3
import json
import os
import sys
import threading
import time
import subprocess
from datetime import datetime
from mesh_mensaje_clase import Ruta_Mensajes
from mesh_registra_clase import RegistradorMensajes

# === CONFIGURACIÓN MQTT ===
with open('mensa-config.json', 'r', encoding='utf-8') as f:
    config_data = json.load(f)
CONFIG_MQTT = config_data['CONFIG']

# Instancia del registrador de base de datos
registrador = RegistradorMensajes()

# === CALLBACKS ===
def callback_posicion(datos):
    nombre_corto = datos.get('nombre_corto', datos['id_nodo'][-4:].upper())
    print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 📍 NUEVA POSICIÓN")
    print(f"    🆔 ID Nodo: {datos['id_nodo']} ({nombre_corto})")
    print(f"    📡 Canal: {datos['canal_nombre']}")
    print(f"    🌍 Coordenadas: Lat {datos['latitud']:.6f}, Lon {datos['longitud']:.6f}")
    print(f"    ⛰️  Altitud: {datos.get('altitud', 0)} metros")
    if 'fecha_gps' in datos and datos['fecha_gps']:
        print(f"    🕒 Hora GPS: {datos['fecha_gps']}")
    print(f"    📶 Satélites: {datos.get('satelites', 0)} | Velocidad: {datos.get('velocidad', 0)} m/s")
    print("-" * 60)
    registrador.registrar_posicion(datos)

def callback_telemetria(datos):
    nombre_corto = datos.get('nombre_corto', datos['id_nodo'][-4:].upper())
    bateria = datos.get('bateria', 'N/A')
    voltaje = datos.get('voltaje', 'N/A')
    uso_canal = datos.get('uso_canal', 'N/A')
    transmision = datos.get('transmision', 'N/A')
    temp = datos.get('temperatura', None)
    hum = datos.get('humedad', None)
    pres = datos.get('presion', None)

    print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 📊 NUEVA TELEMETRÍA")
    print(f"    🆔 Nodo: {datos['id_nodo']} ({nombre_corto}) | Canal: {datos['canal_nombre']}")
    print(f"    🔋 Batería: {bateria}% | ⚡ Voltaje: {voltaje}V")
    print(f"    📡 Utilización canal: {uso_canal}% | TX: {transmision}%")
    if temp is not None or hum is not None or pres is not None:
        print(f"    🌡️  Temperatura: {temp}°C | Humedad: {hum}% | Presión: {pres} hPa")
    print("-" * 60)
    registrador.registrar_telemetria(datos)

def callback_usuario(datos):
    nombre_corto = datos.get('nombre_corto', datos['id_nodo'][-4:].upper())
    print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 👤 NUEVO USUARIO/NODO")
    print(f"    🆔 ID: {datos['id_nodo']}")
    print(f"    📛 Nombre: {datos.get('nombre_largo', 'Sin nombre')} ({nombre_corto})")
    print(f"    🖥️  HW: {datos.get('modelo', 'Desconocido')} | 📄 Rol: {datos.get('rol', 'Desconocido')}")
    print("-" * 60)
    registrador.registrar_usuario(datos)

def callback_texto(datos):
    nombre_corto = datos.get('nombre_corto', datos['id_nodo'][-4:].upper())
    destino = "Broadcast" if datos['id_recep'] == "!ffffffff" else datos['id_recep']
    print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 💬 NUEVO MENSAJE DE TEXTO")
    print(f"    🆔 De: {datos['id_nodo']} ({nombre_corto})")
    print(f"    📥 Para: {destino}")
    print(f"    📡 Canal: {datos['canal_nombre']}")
    print(f"    📝 Texto: \"{datos['texto']}\"")
    print("-" * 60)
    registrador.registrar_texto(datos)

# === FUNCIÓN: EJECUTAR subir_datos.py CADA 30 MINUTOS ===
def tarea_periodica_subir_datos():
    ruta_subir_datos = os.path.join(os.path.dirname(__file__), "mesh_subir_datos.py")
    while True:
        try:
            print("\n🕒 Ejecutando subir_datos.py como proceso externo...")
            result = subprocess.run(
                [sys.executable, ruta_subir_datos],
                capture_output=True,
                text=True,
                cwd=os.path.dirname(__file__)
            )
            if result.returncode == 0:
                print("✅ subir_datos.py finalizó correctamente.")
            else:
                print(f"❌ mesh_subir_datos.py falló. Código: {result.returncode}")
                if result.stdout.strip():
                    print("   STDOUT:", result.stdout.strip())
                if result.stderr.strip():
                    print("   STDERR:", result.stderr.strip())
        except Exception as e:
            print(f"⚠️ Error al ejecutar mesh_subir_datos.py: {e}")
        print("⏳ Esperando 30 minutos para la próxima ejecución...")
        time.sleep(30 * 60)  # 1800 segundos

# === FUNCIÓN PRINCIPAL ===
def main():
    print("🚀 Iniciando receptor de mensajes Meshtastic...")
    print(f"📡 Broker: {CONFIG_MQTT['broker']}:{CONFIG_MQTT['puerto']}")
    print("💾 Guardando en base de datos + subiendo nodos.json cada 30 min...")
    print("-" * 60)

    # Iniciar hilo para ejecutar subir_datos.py periódicamente
    hilo_subida = threading.Thread(target=tarea_periodica_subir_datos, daemon=True)
    hilo_subida.start()

    # Configurar y conectar el router de mensajes
    router = Ruta_Mensajes(
        topico_base=CONFIG_MQTT['topico_base'],
        callback_crudo=None,
        callback_posicion=callback_posicion,
        callback_telemetria=callback_telemetria,
        callback_usuario=callback_usuario,
        callback_texto=callback_texto
    )

    try:
        router.conectar(CONFIG_MQTT)
    except KeyboardInterrupt:
        print("\n🛑 Detenido por el usuario.")
    except Exception as e:
        print(f"❌ Error crítico: {e}")
    finally:
        registrador.cerrar()

if __name__ == "__main__":
    main()
📥 Descargar procesador_mesh.py

Página Web: Mapa de nodos Red Meshtastic

La página permite visualizar en tiempo casi real (cada ~30 minutos) el estado y ubicación geográfica de los nodos Meshtastic que están conectados a una red descentralizada basada en radio LoRa. Es una herramienta útil tanto para operadores de la red como para entusiastas, radioaficionados o curiosos que quieren entender la cobertura, densidad y actividad de la red en distintas zonas del país.

Archivos de la página web

El archivo index.html

El archivo index.html es la página principal de la aplicación web que visualiza la red Meshtastic en Argentina. Su propósito es ofrecer una vista geográfica interactiva de todos los nodos registrados, permitiendo al usuario explorar rápidamente el estado y ubicación de la red.

En su estructura podemos destacar los siguientes elementos:

Encabezado informativo
– Título principal: “Mapa de nodos Red Meshtastic”.
– Subtítulo aclaratorio: “Datos extraídos del Servidor MQTT de Innova”, lo que indica la fuente de los datos.

Botones de navegación
– “📋 Ver listado de nodos”: redirige al usuario a listado.html, donde puede ver todos los nodos en una tabla ordenada por fecha de último contacto.

Mapa interactivo
– Utiliza la biblioteca Leaflet.js para mostrar un mapa de OpenStreetMap.
– El contenedor del mapa (#map) ocupa un espacio central y destacado en la página, con dimensiones fijas (600 px de alto) y centrado visualmente.
– Al cargar la página, se intenta centrar el mapa en la ubicación aproximada del visitante (usando su IP vía geojs.io); si esto falla, se usa Buenos Aires como ubicación predeterminada.

Leyenda visual
Ubicada justo debajo del mapa, explica el significado del color de los marcadores:
– 🟢 Verde: nodo activo (último contacto en las últimas 24 horas).
– 🔵 Azul: nodo inactivo reciente (entre 1 y 30 días sin contacto).
– 🔴 Rojo: nodo inactivo prolongado (más de 30 días).
– 🟠 Naranja: nodo sin información de último contacto.
También incluye una instrucción clara: “Haz clic en un marcador para ver detalles”.

Modal de detalles
Al hacer clic en cualquier marcador, se abre un modal superpuesto (#modal-detalle) que muestra información técnica del nodo seleccionado:
– ID, nombre, nombre corto, coordenadas.
– Fecha y hora del último contacto.
– Metadatos como modelo de hardware, rol, batería, voltaje, canal, MAC, etc.
– Un botón adicional para abrir una vista individual del nodo en mapa_nodo.html, centrada en su ubicación.

Scripts y funcionalidad
– Carga Leaflet desde un CDN para el mapa.
– Incluye tu archivo funciones.js, que contiene toda la lógica de inicialización del mapa, carga de datos desde nodos.json, procesamiento de nodos, manejo de eventos y renderizado de marcadores.
– Ejecuta initMap() al cargar la página, asegurando que el mapa se construya solo una vez y de forma segura.

Entre sus características de diseño se destaca:

– Diseño responsivo: aunque está optimizado para escritorio, incluye estilos CSS que permiten una visualización aceptable en dispositivos móviles.
– Interactividad fluida: no hay recargas de página; todo se gestiona mediante JavaScript y modales.
– Accesibilidad contextual: la leyenda y los tooltips (títulos en botones) guían al usuario sin necesidad de instrucciones externas.
– Integración coherente: comparte estilos (estilo.css) y lógica (funciones.js) con las otras páginas (listado.html, mapa_nodo.html), manteniendo una experiencia unificada.

En resumen el archivo index.html actúa como el punto de entrada visual y funcional de la plataforma que permite:
– Tener una visión general geográfica de la red.
– Identificar rápidamente zonas con alta o baja densidad de nodos.
– Verificar el estado de conectividad de la red en tiempo casi real.

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>mapa</title>
    <link rel="stylesheet" href="estilo.css">
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>

    <h1 class="titulo">Mapa de nodos Red Meshtastic</h1>
    <p class="obs">Datos extraídos del Servidor MQTT de Innova</p>
        


    <!-- Botones centrados: Ver listado de nodos + Ver instructivo -->
<div class="button-container" style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; padding: 10px 0;">
  <!-- Botón: Ver listado de nodos -->
  <button id="btn-ver-tabla"
          title="Ver listado de nodos"
          style="cursor: pointer; padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 5px; font-weight: bold; display: flex; align-items: center; gap: 8px; font-family: inherit; font-size: 1rem;">
    📋 Ver listado de nodos
  </button>

</div>


    <!-- Sección del mapa -->
    <div class="map-section">
        <div id="map-container">
            <div id="map"></div>
        </div>
    </div>

    <!-- Leyenda debajo del mapa -->
    <div class="leyenda">
        🔍 Haz clic en un marcador para ver detalles<br>
        🟢 Marcadores verdes → Nodos conectados (último contacto hace menos de 24 horas)<br>
        🔵 Marcadores azules → Nodos desconectados (entre 24 horas y 30 días)<br>
        🔴 Marcadores rojos → Nodos sin conexión hace más de 30 días<br>
        🟠 Marcadores naranjas → Sin datos de conexión (campo no disponible)
    </div>

    <!-- Modal para detalles del nodo -->
    <div id="modal-detalle" class="modal">
        <div class="modal-content">
            <span class="close-detalle">&times;</span>
            <h2>Detalles del Nodo</h2>
            <div id="detalle-contenido"></div>
        </div>
    </div>

    <!-- Enlace inferior -->
    <br><br>

    <!-- Leaflet JS -->
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

    <!-- Tu script de funciones -->
    <script src="funciones.js"></script>

    <script>
        window.onload = function () {
            if (typeof initMap === 'function') {
                initMap();
            } else {
                console.error("❌ initMap no está definida");
            }
        };
    </script>

</body>
</html>
📥 Descargar index.html

El archivo listado.html

El archivo listado.html es la vista tabular complementaria de la página principal (index.html). Su propósito es ofrecer una lista ordenada, completa y legible de todos los nodos registrados en la red Meshtastic, permitiendo al usuario revisar rápidamente el estado de conectividad, identificar dispositivos por nombre o ID, y acceder a sus detalles técnicos sin depender del mapa.

En su estructura podemos destacar los siguientes elementos:

Encabezado y navegación
– Título principal: “Listado de Nodos”.
– Botón con imagen “Volver al mapa” que redirige a index.html, manteniendo la coherencia de navegación entre vistas.
– Dos párrafos informativos:
    • Indican que los datos se actualizan aproximadamente cada 30 minutos.
    • Muestran la fecha y hora exacta de la última actualización, extraída del primer elemento de nodos.json (con id: "fecha").

Tabla principal
– Contenida en un

con scroll vertical para evitar desbordamientos en pantallas pequeñas.
– Columnas definidas en el :
    • Acción: botón “Ver Detalle” por fila.
    • Último Contacto: fecha y hora formateada (orden descendente por defecto).
    • ID Hex: identificador único del nodo en formato hexadecimal (ej. !025711b3).
    • Nombre: nombre completo del nodo (ej. “LU8AJA”).
    • Nombre Corto: alias o short name (ej. “AJA”).
– El se genera dinámicamente mediante JavaScript al cargar los datos.

Modal de detalles
Al hacer clic en “Ver Detalle”, se abre un modal reutilizable (#modal-detalle) con la misma apariencia y contenido que en index.html.
Muestra todos los metadatos del nodo: ubicación, hardware, batería, canal, MAC, etc., y un botón para verlo en su mapa individual (mapa_nodo.html).

Lógica de carga y procesamiento
– Define una variable global nodosGlobales y una función callback onNodosCargados que espera ser invocada desde funciones.js tras cargar nodos.json.
– Al recibir los datos:
    • Extrae y muestra la fecha de última actualización.
    • Ordena los nodos por fecha de último contacto (más reciente primero).
    • Cuenta y muestra el número total de nodos.
    • Renderiza cada nodo como una fila en la tabla.
– Incluye una función verDetalleNodo(id) que busca el nodo correspondiente en nodosGlobales y lo pasa a mostrarDetalle() (definida en funciones.js).

Integración con funciones.js
– Carga funciones.js después de definir las variables y callbacks necesarios, asegurando que cargarNodos() pueda invocar onNodosCargados correctamente.
– Llama explícitamente a cargarNodos() tras la carga del script, iniciando el flujo de datos.

Entre sus características de diseño se destaca:

– Legibilidad: la tabla usa un diseño limpio, con filas alternadas y efecto hover para facilitar la lectura.
– Orden lógico: los nodos más activos aparecen primero, lo que ayuda a identificar rápidamente la actividad reciente de la red.
– Accesibilidad: cada nodo es accesible directamente desde la tabla, sin necesidad de interactuar con un mapa.
– Consistencia: comparte estilos (estilo.css), modal y lógica de detalle con las otras páginas, ofreciendo una experiencia unificada.
– Responsividad: en móviles, la tabla se ajusta con fuentes más pequeñas y scroll horizontal si es necesario.

El archivo listado.html actúa como una vista de auditoría o inventario de la red que es ideal para:
– Usuarios que prefieren una interfaz textual sobre una visual.
– Operadores que necesitan buscar nodos específicos por nombre o ID.
– Situaciones donde el mapa no es útil (ej. muchos nodos superpuestos en una misma ubicación).
– Verificación rápida del estado general de conectividad de toda la red.
Es, en resumen, la contraparte funcional del mapa: mientras index.html muestra dónde están los nodos, listado.html muestra quiénes son y cuándo se vieron por última vez.

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Red Meshtastic - Listado de Nodos</title>
    <link rel="stylesheet" href="estilo.css">
</head>
<body>
    <div class="container">
        <h1 class="titulo">Listado de Nodos</h1>
        <div class="volver-container">
            <a href="index.html"><img width="15%" src="volver-mapa.jpg" alt="Volver al mapa"></a>
        </div>
            <p>Los datos se actualizan aproximadamente cada 30 minutos <span id="contador-nodos">(cargando...)</span>  </p>
            <p>Última actualización: <span id="ultima-actualizacion">(cargando...)</span>  </p>
        <div class="table-wrapper">
            <table class="main-table">
                <thead>
                    <tr>
                        <th class="accion-col">Acción</th>
                        <th class="fecha-col">Último Contacto</th>
                        <th>ID Hex</th>
                        <th>Nombre</th>
                        <th>Nombre Corto</th>
                    </tr>
                </thead>
                <tbody id="tabla-registros">
                </tbody>
            </table>
        </div>
    </div>

    <!-- Modal -->
    <div id="modal-detalle" class="modal">
        <div class="modal-content">
            <span class="close-detalle">&times;</span>
            <div id="detalle-contenido"></div>
        </div>
    </div>

    <!-- Paso 1: Definir variables y callback GLOBALES -->

    <script>
        let nodosGlobales = [];

        window.onNodosCargados = function(data) {
            console.log("✅ onNodosCargados llamado con", data.length, "nodos");
            nodosGlobales = data;

            const tbody = document.getElementById('tabla-registros');
            const contador = document.getElementById('contador-nodos');
            const ultimaActualizacionEl = document.getElementById('ultima-actualizacion');

            if (!tbody || !contador) {
                console.error("❌ No se encontró tbody o contador");
                return;
            }

            // Procesar y ordenar nodos
            const nodosOrdenados = data
                .map(nodo => {
                    const fechaOrden = nodo.ultimo_contacto ? new Date(nodo.ultimo_contacto * 1000) : new Date(0);
                    return {
                        ...nodo,
                        fechaTexto: nodo.ultimo_contacto ? fechaOrden.toLocaleString() : 'Nunca',
                        fechaOrden
                    };
                })
                .sort((a, b) => b.fechaOrden - a.fechaOrden);

            // Actualizar contador de nodos
            contador.textContent = `(${nodosOrdenados.length} nodos)`;


            // Renderizar tabla
            tbody.innerHTML = '';
            nodosOrdenados.forEach(nodo => {
                const tr = document.createElement('tr');
                tr.innerHTML = `
                    <td class="accion-col">
                        <button class="btn-ver" onclick="verDetalleNodo('${nodo.id}')">Ver Detalle</button>
                    </td>
                    <td class="fecha-col">${nodo.fechaTexto}</td>
                    <td>${nodo.id || 'N/D'}</td>
                    <td>${nodo.nombre || 'N/D'}</td>
                    <td>${nodo.shortName || 'N/D'}</td>
                `;
                tbody.appendChild(tr);
            });
        };

        function verDetalleNodo(idNodo) {
            console.log("🔍 Buscando nodo con ID:", idNodo);
            const nodo = nodosGlobales.find(n => n.id === idNodo);
            if (nodo) {
                console.log("✅ Nodo encontrado:", nodo);
                mostrarDetalle(nodo);
            } else {
                console.error("❌ Nodo con ID", idNodo, "no encontrado en nodosGlobales");
            }
        }
    </script>


    <!-- Paso 2: Cargar funciones.js -->
    <script src="funciones.js"></script>

    <!-- Paso 3: Llamar manualmente a cargarNodos() SOLO DESPUÉS de que funciones.js cargue -->
    <script>
        // Aseguramos que funciones.js haya cargado
        if (typeof cargarNodos === 'function') {
            console.log("📞 Llamando manualmente a cargarNodos()");
            cargarNodos();
        } else {
            console.error("❌ cargarNodos no está definido en funciones.js");
        }
    </script>
</body>
</html>
📥 Descargar listado.html

El archivo mapa_nodo.html

El archivo mapa_nodo.html es una vista especializada y contextual que muestra un mapa centrado en un nodo específico de la red Meshtastic, permitiendo al usuario visualizar su ubicación exacta en relación con los demás nodos cercanos. Esta página se abre desde el detalle de cualquier nodo (tanto en index.html como en listado.html) y está diseñada para ofrecer una perspectiva geográfica local de la topología de la red alrededor de ese dispositivo.

– Destaca el nodo seleccionado con un marcador distintivo (📡).
– Muestra visualmente los nodos vecinos como círculos naranjas.
– Facilita el análisis de cobertura local o la verificación de proximidad entre dispositivos.
– Complementa la información técnica del nodo con un contexto espacial preciso.

En su estructura podemos destacar los siguientes elementos:

Encabezado
– Título genérico: “Mapa del Nodo”.
– Subtítulo dinámico (#subtitulo) que se actualiza con el nombre y nombre corto del nodo seleccionado (ej. “LU8AJA (AJA)”).
– Botón “Volver al mapa” que redirige a index.html, manteniendo la navegación intuitiva.

Contenedor del mapa
– Usa Leaflet.js con capas de OpenStreetMap.
– El mapa ocupa casi toda la altura de la ventana (80vh en CSS), optimizado para visualización geográfica.

Leyenda contextual
Ubicada debajo del mapa, explica claramente:
– 🟠 → Nodos de la red (círculos naranjas).
– 📡 → Nodo seleccionado (marcador central con emoji de antena).

Modal de detalles
Al hacer clic en cualquier marcador (nodo vecino o el nodo central), se abre un modal reutilizable (#modal-detalle) con los mismos datos técnicos que en las otras páginas (ID, batería, modelo, etc.).
Este modal también incluye un botón para volver a esta misma vista (aunque rara vez se use desde aquí).

Su funcionamiento consiste en:

Recepción de parámetros por URL
La página no carga nodos directamente desde una base de datos interna, sino que recibe los datos del nodo seleccionado a través de parámetros en la URL, como:
nombre: nombre completo del nodo.
corto: nombre corto (short name).
latitud y longitud: coordenadas decimales.
zoom: nivel de zoom inicial (por defecto 15).
Ejemplo de URL:
mapa_nodo.html?nombre=LU8AJA&corto=AJA&latitud=-34.5243648&longitud=-58.4843264&zoom=15

Inicialización del mapa
– Al cargar, crea un nuevo mapa centrado en las coordenadas recibidas.
– Intenta enriquecer los datos del nodo seleccionado cargando nodos.json y buscando un nodo con coordenadas coincidentes (tolerancia de 0.00001 grados). Si lo encuentra, usa sus metadatos reales; si no, usa los datos mínimos de la URL.

Renderizado de nodos
– Nodo seleccionado: se muestra con un marcador personalizado (📡) usando L.divIcon.
– Nodos vecinos: se dibujan como círculos naranjas (#FF8C00) con radio dinámico basado en el nivel de zoom (usando la función metersPerPixel de funciones.js).
– Exclusión del nodo central: al iterar sobre nodos.json, se omite el nodo cuyas coordenadas coinciden con las del nodo seleccionado para evitar duplicados.

Interactividad
– Cada círculo y el marcador central son clickeables y abren el modal con los detalles del nodo correspondiente.
– El tamaño de los círculos se ajusta automáticamente al hacer zoom, gracias al evento zoomend.

Reutiliza lógica: depende de funciones.js para mostrarDetalle, metersPerPixel y adjustMarkersSize manteniendo un estilo unificado ya que comparte el archivo estilo.css con las otras páginas, manteniendo coherencia visual (colores, tipografía, modales).

En resumen el archivo mapa_nodo.html es una herramienta de análisis geoespacial focalizado, que transforma un nodo abstracto en un punto de referencia geográfico rodeado por su entorno inmediato en la red Meshtastic. Es una extensión natural del mapa general y del listado tabular, ofreciendo una tercera dimensión de exploración: la proximidad relativa.

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mapa del Nodo - Red Meshtastic</title>
    <link rel="stylesheet" href="estilo.css">

    <!-- Leaflet CSS -->
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    
 
</head>
<body>
    <div class="container">
        <h1 class="titulo">Mapa del Nodo</h1>
        <div class="subtitulo" id="subtitulo">Cargando...</div>
        <div class="volver-container">
            <a href="index.html">
                <img width="20%" src="volver-mapa.jpg" alt="Volver al mapa">
            </a>
        </div>
        <div id="map-container">
            <div id="map"></div>
        </div>
        <div class="leyenda">
            🔍 Haz clic en un marcador para ver detalles<br>
            🟠 Nodos de la red<br>
            📡 Nodo seleccionado
        </div>
    </div>

    <!-- Modal para detalles -->
    <div id="modal-detalle" class="modal">
        <div class="modal-content">
            <span class="close-detalle">&times;</span>
            <div id="detalle-contenido"></div>
        </div>
    </div>

    <!-- Leaflet JS -->
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

    <!-- funciones.js (proporciona L, mostrarDetalle, metersPerPixel, adjustMarkersSize, etc.) -->
    <script src="funciones.js"></script>

    <!-- Código específico para esta página -->
    <script>
    // Leer parámetros de URL
    function getURLParams() {
        const urlParams = new URLSearchParams(window.location.search);
        return {
            nombre: urlParams.get('nombre') || 'Sin nombre',
            corto: urlParams.get('corto') || 'N/D',
            latitud: parseFloat(urlParams.get('latitud')),
            longitud: parseFloat(urlParams.get('longitud')),
            zoom: parseInt(urlParams.get('zoom')) || 15
        };
    }

    // Inicializar el mapa
    async function initMapFromURL() {
        const params = getURLParams();

        if (!params.latitud || !params.longitud || isNaN(params.latitud) || isNaN(params.longitud)) {
            document.getElementById('subtitulo').textContent = 'Coordenadas inválidas';
            return;
        }

        const nombre = decodeURIComponent(params.nombre);
        const corto = decodeURIComponent(params.corto);
        document.getElementById('subtitulo').textContent = `${nombre} (${corto})`;

        // ✅ Crear el mapa SOLO AQUÍ
        map = L.map('map').setView([params.latitud, params.longitud], params.zoom);

        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        }).addTo(map);

        // Limpiar marcadores
        window.markers = [];

        // Nodo destacado (puede venir de nodos.json o ser genérico)
        let nodoDestacado = {
            id: "N/D",
            nombre: nombre,
            shortName: corto,
            latitud: params.latitud,
            longitud: params.longitud,
            altitud: "sin datos",
            bateria: "sin datos",
            voltaje: "sin datos",
            ultimo_contacto: Math.floor(Date.now() / 1000),
            modelo: "sin datos",
            rol: "CLIENT",
            firmware: "sin datos",
            mac: "sin datos",
            canal: "sin datos",
            viaMqtt: "sin datos",
            saltos: "sin datos",
            ubicacion: "sin datos"
        };

        try {
            const response = await fetch('nodos.json');
            const data = await response.json();

            if (Array.isArray(data)) {
                // Buscar nodo exacto
                const nodoExacto = data.find(nodo => {
                    const lat = parseFloat(nodo.latitud) || 0;
                    const lng = parseFloat(nodo.longitud) || 0;
                    return Math.abs(lat - params.latitud) < 0.00001 && Math.abs(lng - params.longitud) < 0.00001;
                });

                if (nodoExacto) {
                    nodoDestacado = { ...nodoExacto };
                    document.getElementById('subtitulo').textContent = `${nodoExacto.nombre} (${nodoExacto.shortName})`;
                }

                // Dibujar otros nodos
                data.forEach(nodo => {
                    try {
                        let lat = parseFloat(nodo.latitud);
                        let lng = parseFloat(nodo.longitud);
                        if (Math.abs(lat) > 180) lat /= 1e7;
                        if (Math.abs(lng) > 180) lng /= 1e7;
                        if (isNaN(lat) || isNaN(lng)) return;

                        // Saltar el nodo actual
                        const diffLat = Math.abs(lat - params.latitud);
                        const diffLng = Math.abs(lng - params.longitud);
                        if (diffLat < 0.00001 && diffLng < 0.00001) return;

                        const zoom = map.getZoom();
                        const mpp = metersPerPixel(lat, zoom);
                        const radiusMeters = (15 / 2) * mpp;

                        const circle = L.circle([lat, lng], {
                            color: "#FF8C00",
                            fillColor: "#FFA500",
                            fillOpacity: 0.5,
                            weight: 2,
                            radius: radiusMeters
                        }).addTo(map);

                        circle.on('click', () => mostrarDetalle(nodo));
                        markers.push(circle);
                    } catch (e) {
                        console.error("Error:", e);
                    }
                });
            }
        } catch (e) {
            console.warn("No se pudo cargar nodos.json", e);
        }

        // === Marcador destacado 📡 ===
        const icon = L.divIcon({
            html: '<div class="icon-marker-label">📡</div>',
            className: 'custom-node-icon',
            iconSize: [32, 32],
            iconAnchor: [16, 32]
        });

        const marker = L.marker([params.latitud, params.longitud], { icon }).addTo(map);
        marker.on('click', () => mostrarDetalle(nodoDestacado));
        markers.push(marker);

        // Ajustar tamaño de círculos
        map.on('zoomend moveend', adjustMarkersSize);
        setTimeout(adjustMarkersSize, 500);
    }

    // Esperar a que Leaflet y funciones.js estén listos
    window.addEventListener('load', () => {
        if (typeof L === 'undefined') {
            console.error("❌ Leaflet no cargado");
            return;
        }
        if (typeof mostrarDetalle === 'undefined') {
            console.error("❌ mostrarDetalle no está definido");
            return;
        }
        // Todo listo → iniciar
        initMapFromURL();
    });
</script>
    <!-- Cerrar modal -->
    <script>
        document.addEventListener("DOMContentLoaded", () => {
            const close = document.querySelector(".close-detalle");
            if (close) {
                close.onclick = () => {
                    document.getElementById("modal-detalle").style.display = "none";
                };
            }

            window.onclick = e => {
                const modal = document.getElementById("modal-detalle");
                if (e.target === modal) {
                    modal.style.display = "none";
                }
            };
        });
    </script>
</body>
</html>
📥 Descargar mapa_nodo.html

El archivo funciones.js

El archivo funciones.js es un módulo central de JavaScript diseñado para ser reutilizable en múltiples páginas (index.html, listado.html y mapa_nodo.html). Su propósito principal es gestionar la carga de datos, la visualización en el mapa, la interacción con el usuario y la presentación de detalles técnicos de los nodos, todo ello de forma eficiente, robusta y coherente.

Es el encargado de la implementación de las funciones clave de la página:

Inicialización del mapa (initMap)
– Crea un mapa interactivo con Leaflet centrado en la ubicación del visitante (detectada vía geojs.io) o, por defecto, en Buenos Aires.
– Incluye protección contra inicializaciones duplicadas (isMapInitializing).
– Destruye mapas previos si ya existen, evitando errores al recargar o navegar.
– Asocia el evento zoomend/moveend para ajustar dinámicamente el tamaño de los marcadores.
– Llama automáticamente a cargarNodos() tras inicializar el mapa (solo en index.html).

Cálculo geoespacial (metersPerPixel)
– Convierte píxeles en el mapa a metros reales, teniendo en cuenta la latitud y el nivel de zoom.
– Es esencial para que los círculos que representan nodos mantengan un radio físico constante (ej. 15 metros) independientemente del zoom.

Ajuste dinámico de marcadores (adjustMarkersSize)
– Recalcula el radio de todos los círculos en el mapa cada vez que el usuario hace zoom.
– Garantiza una representación geográficamente precisa en todos los niveles de acercamiento.

Carga y procesamiento de datos (cargarNodos)
– Obtiene los datos desde nodos.json.
– Detecta y extrae la fecha de última actualización (primer elemento con id: "fecha").
– Filtra y normaliza coordenadas (incluyendo corrección de valores en microgrados).
– Determina el color del marcador según el estado de conexión:
    • 🟢 Verde: activo (< 24 h)
    • 🔵 Azul: inactivo reciente (1–30 días)
    • 🔴 Rojo: inactivo prolongado (> 30 días)
    • 🟠 Naranja: sin datos de contacto
– Renderiza los nodos como círculos + etiquetas de texto en el mapa (index.html) o los pasa al callback onNodosCargados (listado.html).
– Asocia eventos de clic para mostrar detalles.

Presentación de detalles (mostrarDetalle)
– Abre un modal unificado con información técnica del nodo seleccionado.
– Formatea y traduce campos técnicos (modelo, rol, batería, etc.) a nombres amigables.
– Muestra solo los campos relevantes (omite valores nulos o “sin datos”).
– Incluye un botón para ver el nodo en mapa_nodo.html, con URL construida dinámicamente usando coordenadas y metadatos.

Detección de contexto y autoinicialización
– Al final del archivo, usa DOMContentLoaded para detectar si está en index.html (buscando #btn-ver-tabla).
– Solo en ese caso, llama automáticamente a initMap(), evitando errores en otras páginas.
– En listado.html, espera que el HTML defina onNodosCargados antes de pasarle los datos.

Sus características técnicas más destacadas son:

Modularidad: no asume que se ejecuta en una sola página; se adapta al contexto.
Robustez: maneja errores en la carga de datos, coordenadas inválidas y ausencia de elementos HTML.
Eficiencia: usa una sola solicitud HTTP (nodos.json) y reutiliza los datos en todas las vistas.
Consistencia: el mismo modal, los mismos colores y la misma lógica de estado se aplican en todas las páginas.
Extensibilidad: fácil de ampliar con nuevos campos, mapeos de hardware o lógica de visualización.

En resumen funciones.js es el motor coordinador de la página:
– Conecta datos y visualización.
– Unifica la experiencia de usuario en todas las vistas.
– Aísla la lógica compleja (geoespacial, parsing, UI) del HTML.
– Facilita el mantenimiento y la evolución del proyecto.

// funciones.js - Versión segura y compatible con múltiples páginas

// === Variables globales ===
let map;
let markers = [];
let isMapInitializing = false;

// Configuración
const DEFAULT_SIZE_PX = 10;
const UNCONNECED_THRESHOLD_30_DAYS = 30 * 24 * 3600; // 30 días

// === Inicializar el mapa con Leaflet ===
async function initMap() {
    // 🔒 Evitar ejecución múltiple concurrente
    if (isMapInitializing) {
        console.warn("⚠️ initMap() ya está en progreso. Ignorando llamada duplicada.");
        return;
    }
    isMapInitializing = true;

    const mapDiv = document.getElementById("map");
    if (!mapDiv) {
        console.error("❌ Elemento #map no encontrado.");
        isMapInitializing = false;
        return;
    }

    // Si ya hay un mapa, destruirlo
    if (mapDiv._leaflet_id) {
        console.warn("⚠️ Mapa existente detectado. Eliminando...");
        if (map && typeof map.remove === 'function') {
            map.remove();
        }
        mapDiv.innerHTML = '';
    }

    const defaultCenter = [-34.6037, -58.3816];
    const defaultZoom = 12;
    let centerToUse = defaultCenter;

    try {
        const response = await fetch('https://get.geojs.io/v1/ip/geo.json');
        if (response.ok) {
            const data = await response.json();
            const lat = parseFloat(data.latitude);
            const lon = parseFloat(data.longitude);
            if (!isNaN(lat) && !isNaN(lon)) {
                centerToUse = [lat, lon];
                console.log('📍 Ubicación por IP (geojs.io):', centerToUse, data.city || '', data.country || '');
            }
        }
    } catch (error) {
        console.warn('⚠️ No se pudo obtener ubicación por IP. Usando Buenos Aires.', error.message);
    }

    // Crear nuevo mapa
    map = L.map('map').setView(centerToUse, defaultZoom);

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
    }).addTo(map);

    map.on('zoomend moveend', adjustMarkersSize);

    cargarNodos();

    // ✅ Liberar el lock al final
    isMapInitializing = false;
}




// === Calcular metros por píxel (ajustado por latitud y zoom) ===
function metersPerPixel(lat, zoom) {
    const earthCircumference = 40075016.686;
    const scale = Math.pow(2, zoom);
    const metersPerPixel = earthCircumference / (256 * scale);
    const latRad = (lat * Math.PI) / 180;
    return metersPerPixel * Math.cos(latRad);
}

// === Ajustar tamaño de los círculos ===
function adjustMarkersSize() {
    if (!map || markers.length === 0) return;

    const zoom = map.getZoom();
    const center = map.getCenter();
    const lat = center.lat;
    const mpp = metersPerPixel(lat, zoom);
    const radiusMeters = (DEFAULT_SIZE_PX / 2) * mpp;

    markers.forEach(circle => {
        if (circle && circle.setRadius) {
            circle.setRadius(radiusMeters);
        }
    });
}

// === Cargar nodos desde nodos.json ===
function cargarNodos() {
    console.log("📥 Cargando nodos.json...");
    fetch('nodos.json')
        .then(response => {
            if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            return response.json();
        })
        .then(data => {
            if (!Array.isArray(data)) {
                console.error("❌ nodos.json no es un array:", typeof data);
                return;
            }

            // ✅ EXTRAER Y ELIMINAR el primer elemento si es el de fecha
            let ultimaActualizacion = null;
            if (data.length > 0 && data[0].id === "fecha") {
                ultimaActualizacion = data[0].nombre;
                data.shift(); // Elimina el primer elemento del array
            }

            // ✅ Mostrar la fecha en el HTML
            const ultimaActualizacionEl = document.getElementById('ultima-actualizacion');
            if (ultimaActualizacionEl) {
                ultimaActualizacionEl.textContent = ultimaActualizacion || '—';
            }

            console.log(`✅ Cargados ${data.length} nodos reales.`);

            // Si estamos en listado.html, llamamos al callback
            if (typeof window.onNodosCargados === 'function') {
                window.onNodosCargados(data);
                return;
            }

            // Si estamos en index.html, procesamos los nodos
            data.forEach((nodo, index) => {
                try {
                    // Validar y convertir coordenadas
                    let lat = parseFloat(nodo.latitud);
                    let lng = parseFloat(nodo.longitud);

                    if (isNaN(lat) || isNaN(lng)) {
                        console.log(`📍 Nodo ${nodo.id} omitido: coordenadas inválidas`);
                        return;
                    }

                    if (Math.abs(lat) > 180) lat /= 1e7;
                    if (Math.abs(lng) > 180) lng /= 1e7;

                    nodo.latitud = lat;
                    nodo.longitud = lng;

                    // Determinar color según último contacto
                    const ahora = Math.floor(Date.now() / 1000);
                    const ultimo = parseInt(nodo.ultimo_contacto);
                    let strokeColor, fillColor;

                    if (!nodo.ultimo_contacto || isNaN(ultimo)) {
                        strokeColor = "#FF8C00";
                        fillColor = "#FFD580";
                    } else {
                        const diferencia = ahora - ultimo;
                        if (diferencia > UNCONNECED_THRESHOLD_30_DAYS) {
                            strokeColor = "#8B0000";
                            fillColor = "#FF6347";
                        } else if (diferencia < 24 * 3600) {
                            strokeColor = "#006400";
                            fillColor = "#32CD32";
                        } else {
                            strokeColor = "#00008B";
                            fillColor = "#4682B4";
                        }
                    }

                    // Crear círculo
                    const circle = L.circle([lat, lng], {
                        color: strokeColor,
                        opacity: 0.8,
                        weight: 2,
                        fillColor: fillColor,
                        fillOpacity: 0.5,
                        radius: 1,
                    }).addTo(map);

                    // Etiqueta de texto
                    const nombreCorto = nodo.shortName || `Nodo ${nodo.id}`;
                    const label = L.divIcon({
                        className: 'nodo-label',
                        html: `<span>${nombreCorto}</span>`,
                        iconSize: [25, 10],
                        iconAnchor: [12, -10]
                    });

                    const textMarker = L.marker([lat, lng], {
                        icon: label,
                        interactive: false,
                        bubblingMouseEvents: false
                    }).addTo(map);

                    // Click para mostrar detalle
                    circle.on('click', () => {
                        mostrarDetalle(nodo);
                    });

                    markers.push(circle);
                    markers.push(textMarker);

                } catch (error) {
                    console.error(`💥 Error procesando nodo ${nodo.id || index}:`, error);
                }
            });

            adjustMarkersSize();

            const btn = document.getElementById("btn-ver-tabla");
            if (btn) {
                btn.addEventListener("click", () => {
                    window.location.href = "listado.html";
                });
            }

        })
        .catch(error => {
            console.error("💥 Error cargando nodos.json:", error);
        });
}






function mostrarDetalle(nodo) {
    
    const modal = document.getElementById("modal-detalle");
    const contenido = document.getElementById("detalle-contenido");

    if (!modal || !contenido) {
        console.error("❌ Modal o contenedor no encontrado.");
        return;
    }

    // Normalizar coordenadas
    const lat = parseFloat(nodo.latitud) || 0;
    const lng = parseFloat(nodo.longitud) || 0;

    // Formatear fecha de último contacto
    const ultimo = parseInt(nodo.ultimo_contacto);
    const fechaUltimo = !nodo.ultimo_contacto || isNaN(ultimo)
        ? "Sin datos"
        : new Date(ultimo * 1000).toLocaleString();

    // Mapeos de campos
    const roleMap = {
        "CLIENT": "Cliente (retransmite)",
        "CLIENT_MUTE": "Cliente silencioso (no retransmite)",
        "ROUTER": "Router (alta prioridad)",
        "": "Desconocido"
    };

    const hardwareMap = {
        "HELTEC_VISION_MASTER_E213": "Heltec Vision Master",
        "HELTEC_V3": "Heltec V3",
        "HELTEC_MESH_NODE_T114": "Heltec T114",
        "SEEED_SENSECAP_T1000_E": "Seeed T1000",
        "CHATTER": "Chatter",
        "": "Desconocido"
    };

    // Función para formatear nombres de campo
    function formatearNombre(key) {
        const nombres = {
            modelo: "Modelo",
            rol: "Rol",
            bateria: "Batería",
            voltaje: "Voltaje",
            altitud: "Altitud",
            ubicacion: "Ubicación",
            firmware: "Firmware",
            mac: "MAC",
            canal: "Canal",
            viaMqtt: "Conectado por MQTT",
            saltos: "Saltos"
        };
        return nombres[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
    }

    // Campos fijos obligatorios
    let camposFijos = `
        <tr><td><b>ID:</b></td><td>${nodo.id}</td></tr>
        <tr><td><b>Nombre:</b></td><td>${nodo.nombre}</td></tr>
        <tr><td><b>Nombre corto:</b></td><td>${nodo.shortName}</td></tr>
        <tr><td><b>Latitud:</b></td><td>${lat.toFixed(6)}</td></tr>
        <tr><td><b>Longitud:</b></td><td>${lng.toFixed(6)}</td></tr>
        <tr><td><b>Último contacto:</b></td><td>${fechaUltimo}</td></tr>
    `;

    // Campos adicionales (solo si existen y no son nulos)
    let camposAdicionales = "";
    const excluidos = new Set(['id', 'nombre', 'shortName', 'latitud', 'longitud', 'ultimo_contacto']);

    for (const [key, value] of Object.entries(nodo)) {
        if (excluidos.has(key)) continue;
        if (value === undefined || value === null || value === "sin datos") continue;

        let displayValue = value;
        if (key === 'rol') displayValue = roleMap[value] || value;
        else if (key === 'modelo') displayValue = hardwareMap[value] || value;
        else if (key === 'bateria') displayValue = `${value}%`;
        else if (key === 'voltaje') displayValue = `${value} V`;
        else if (key === 'altitud') displayValue = `${value} m`;
        else if (typeof displayValue === 'boolean') displayValue = displayValue ? 'Sí' : 'No';

        camposAdicionales += `<tr><td><b>${formatearNombre(key)}:</b></td><td>${displayValue}</td></tr>`;
    }

    if (!camposAdicionales) {
        camposAdicionales = `<tr><td colspan="2" style="color: #999; font-style: italic;">No hay más datos disponibles.</td></tr>`;
    }

    // === Generar enlace a mapa_nodo.html ===
    const nombreUrl = encodeURIComponent(nodo.nombre || "Nodo sin nombre");
    const cortoUrl = encodeURIComponent(nodo.shortName || nodo.id || "Nodo");

    const urlMapa = lat && lng && isFinite(lat) && isFinite(lng)
        ? `https://{path ubicacion del archivo} mapa_nodo.html?nombre=${nombreUrl}&corto=${cortoUrl}&longitud=${lng.toFixed(6)}&latitud=${lat.toFixed(6)}&zoom=15`
        : null;

    const botonMapa = urlMapa
    ? `<div style="text-align: center; margin: 20px 0;">
        <a href="${urlMapa}">
            <button class="btn-ver-mapa-individual">
                🌍 Ver en mapa individual
            </button>
        </a>
      </div>`
    : '';
    
    // Insertar todo en el modal
    contenido.innerHTML = `
        <div style="max-height: 400px; overflow-y: auto; padding-right: 10px;">
            <table style="width:100%; font-size:14px; border-collapse: collapse;">
                <tr><th colspan="2" style="text-align:center; background:#444; color:white; padding:8px;">${nodo.nombre || "Nodo desconocido"}</th></tr>
                ${camposFijos}
                ${camposAdicionales}
            </table>
            ${botonMapa}
        </div>
    `;

    // Mostrar el modal
    modal.style.display = "block";
}



// === Cerrar modal ===
document.addEventListener("DOMContentLoaded", function () {
    const modal = document.getElementById("modal-detalle");
    const closeBtn = document.querySelector(".close-detalle");

    if (closeBtn && modal) {
        closeBtn.addEventListener("click", () => {
            modal.style.display = "none";
        });

        window.addEventListener("click", (e) => {
            if (e.target === modal) {
                modal.style.display = "none";
            }
        });
    }

    // ✅ Inicializar el mapa en index.html
    if (document.getElementById("btn-ver-tabla")) {
        console.log("📍 Inicializando mapa para index.html");
        if (typeof initMap === 'function') {
            initMap();
        } else {
            console.error("❌ initMap no está definida");
        }
    } else {
        console.log("📍 Detectado mapa_nodo.html u otra página. initMap no se ejecutará automáticamente.");
    }
});
📥 Descargar funciones.js

El archivo estilo.css

El archivo estilo.css es la hoja de estilos central de la aplicación web, encargada de definir la apariencia visual coherente y atractiva en todas las páginas (index.html, listado.html y mapa_nodo.html). Establece un diseño unificado con una paleta cálida (fondo Peach Puff), tipografía legible, colores y sombras para botones, tablas y modales, además de estilos específicos para el mapa, los marcadores, la leyenda y los elementos interactivos. Incluye también reglas responsivas para adaptar la interfaz a dispositivos móviles y personalizaciones para scroll, animaciones y contenedores, asegurando una experiencia de usuario fluida, accesible y visualmente consistente en todo el sitio.

/* =============================================
   ESTILO.CSS - UNIFICADO PARA TODA LA APLICACIÓN
   ============================================= */

/* ====================
   RESET Y BASE
   ==================== */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: Arial, sans-serif; /* Fuente base para toda la app */
    margin: 0;
    padding: 0;
    font-size: 24px;
    background-color: #FFDAB9; /* Peach Puff */
}

/* ====================
   TIPOGRAFÍA Y TÍTULOS
   ==================== */
h1 {
    text-align: center;
    color: #2c3e50;
    margin-bottom: 20px;
    font-size: 2.5em;
}

h1.titulo {
    /* Estilo específico para listado.html (rojo y más pequeño) */
    text-align: center;
    color: red;
    margin-bottom: 20px;
    font-size: 2em;
}

.titulo {
    text-align: center;
}

.subtitulo {
    text-align: center;
    color: #555;
    margin-bottom: 20px;
    font-size: 1.1em;
}

.obs{
    text-align: center;
    color: #555;
    font-size: 0.5em;

}

/* ====================
   CONTENEDORES PRINCIPALES
   ==================== */
.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
    height: 100vh;
    display: flex;
    flex-direction: column;
}

/* Estilo específico para mapa_nodo.html */
.container.mapa-nodo {
    width: 95%;
    max-width: 1200px;
    margin: 20px auto;
    padding: 20px;
    background: white;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0,0,0,0.1);
    height: auto;
}

/* ====================
   BOTONES Y ENLACES
   ==================== */

/* Botón "Ver listado de nodos" en index.html */
.button-container {
    text-align: center;
    margin: 20px 0;
}

#btn-ver-tabla {
    background-color: #007BFF;
    color: white;
    padding: 12px 20px;
    cursor: pointer;
    border-radius: 6px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.2);
    font-size: 18px;
    transition: background-color 0.3s ease;
    display: inline-block;
}

#btn-ver-tabla:hover {
    background-color: #0056b3;
}

/* Botón "Ver Detalle" en la tabla de listado.html */
.btn-ver {
    background-color: #3498db;
    color: white;
    border: none;
    padding: 8px 12px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
    transition: background-color 0.3s ease;
    width: 100px;
}

.btn-ver:hover {
    background-color: #2980b9;
}

/* Botón "Ver en mapa individual" (extraído de funciones.js) */
.btn-ver-mapa-individual {
    background-color: #007BFF;
    color: white;
    border: none;
    padding: 12px 24px;
    border-radius: 6px;
    cursor: pointer;
    font-size: 15px;
    font-weight: bold;
    box-shadow: 0 2px 6px rgba(0,0,0,0.2);
    display: inline-flex;
    align-items: center;
    gap: 8px;
}

/* ====================
   MAPA Y MARCADORES
   ==================== */

/* Contenedor del mapa (index.html y mapa_nodo.html) */
.map-section,
#map-container {
    display: flex;
    justify-content: center;
    
}

#map-container {
    width: 100%;
    max-width: 1000px;
    height: 600px;
}

#map {
    width: 100%;
    height: 600px;
    border-radius: 10px;
    box-shadow: 0 0 10px rgba(0,0,0,0.1);
}

/* Estilo específico para mapa_nodo.html */
#map-container.mapa-nodo {
    height: 80vh;
    width: 100%;
    border-radius: 6px;
    overflow: hidden;
    box-shadow: 0 0 8px rgba(0,0,0,0.15);
}

#map.mapa-nodo {
    height: 100%;
    width: 100%;
    border-radius: 0;
}

/* Etiqueta de nodo en el mapa */
.nodo-label {
    font-size: 10px;
    font-weight: bold;
    color: #000;
    background-color: #FFD700;
    padding: 1px 3px;
    border-radius: 2px;
    white-space: nowrap;
    text-align: center;
    font-family: 'Courier New', monospace, sans-serif;
    pointer-events: none;
    box-shadow: 0 0 2px rgba(0,0,0,0.2);
    border: 1px solid #CCAA00;
    line-height: 1;
}

/* Icono personalizado para mapa_nodo.html */
.icon-marker-label {
    color: white;
    text-shadow: 1px 1px 2px black, 0 0 5px #000;
    font-size: 24px;
    font-weight: bold;
    line-height: 1;
    text-align: center;
}

/* ====================
   LEYENDA
   ==================== */
.leyenda {
    text-align: center;
    font-size: 16px;
    color: #333;
    margin: 10px 0 30px;
}

/* Estilo específico para leyenda en mapa_nodo.html */
.leyenda.mapa-nodo {
    width: 95%;
    margin: 15px auto;
    padding: 10px;
    background-color: #f8f9fa;
    border-radius: 5px;
    box-shadow: 0 0 5px rgba(0,0,0,0.1);
    font-size: 0.9em;
    color: #555;
    text-align: center;
}

/* ====================
   TABLAS
   ==================== */

/* Tabla principal en listado.html */
.table-wrapper {
    flex: 1;
    overflow: auto;
    background-color: white;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    position: relative;
    max-height: calc(100vh - 200px);
}

.main-table {
    width: 100%;
    border-collapse: separate;
    border-spacing: 0;
    min-width: 800px;
}

.main-table thead {
    background-color: #34495e;
    color: white;
    position: sticky;
    top: 0;
    z-index: 10;
}

.main-table thead th {
    padding: 15px 12px;
    text-align: left;
    font-weight: 600;
    border-bottom: 2px solid #2c3e50;
    white-space: nowrap;
}

.main-table tbody td {
    padding: 6px;
    border-bottom: 1px solid #ecf0f1;
    white-space: nowrap;
    font-size: 20px;
}

.main-table tbody tr:hover {
    background-color: #f8f9fa;
    transition: background-color 0.3s ease;
}

.main-table tbody tr:nth-child(even) {
    background-color: #fdfdfd;
}

/* Anchos específicos para columnas */
.fecha-col {
    width: 200px;
}

.accion-col {
    width: 120px;
    text-align: center;
}

/* Tabla de detalles en modal */
.detail-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 14px;
}

.detail-table thead {
    background-color: #ecf0f1;
    position: sticky;
    top: 0;
    z-index: 10;
}

.detail-table th {
    padding: 12px;
    text-align: left;
    border-bottom: 2px solid #bdc3c7;
}

.detail-table td {
    padding: 10px 12px;
    border-bottom: 1px solid #ecf0f1;
}

.detail-table tr:hover {
    background-color: #f8f9fa;
}

.detail-table tr:nth-child(even) {
    background-color: #fdfdfd;
}

/* Estilo para tabla embebida en modal (index.html y mapa_nodo.html) */
#detalle-contenido table {
    width: 100%;
    font-size: 14px;
    border-collapse: collapse;
}

#detalle-contenido th {
    text-align: center;
    background: #444;
    color: white;
    padding: 8px;
}

#detalle-contenido td {
    padding: 6px;
    border-bottom: 1px solid #ddd;
}



/* ====================
   MODAL 
   ==================== */
.modal {
    display: none;
    position: fixed;
    z-index: 1000;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: auto;
    background-color: rgba(0,0,0,0.5);
    animation: fadeIn 0.3s ease;
}

/* Estilo para listado.html  */
.modal-content {
    background-color: white;
    margin: 5% auto; /* Centrado vertical más cercano al top */
    padding: 0;
    border-radius: 8px;
    width: 90%;
    max-width: 800px;
    position: relative;
    display: flex;
    flex-direction: column;
    animation: slideIn 0.3s ease;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    max-height: 90vh; /* Límite máximo para evitar que salga de la pantalla */
}

.modal-content h2 {
    background-color: #34495e;
    color: white;
    padding: 20px;
    margin: 0;
    border-radius: 8px 8px 0 0;
    font-size: 1.5em;
}

.close-detalle {
    color: white;
    float: right;
    font-size: 28px;
    font-weight: bold;
    cursor: pointer;
    line-height: 1;
    padding: 0 10px;
}

.close-detalle:hover {
    color: #f1c40f;
}

/* Contenedor de la tabla: ocupa el espacio disponible */
.modal-table-container {
    flex: 1;
    padding: 20px;
    overflow-y: auto;
    
}

/* Tabla de detalles en modal */
.detail-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 14px;
}

.detail-table thead {
    background-color: #ecf0f1;
    position: sticky;
    top: 0;
    z-index: 10;
}

.detail-table th {
    padding: 12px;
    text-align: left;
    border-bottom: 2px solid #bdc3c7;
}

.detail-table td {
    padding: 10px 12px;
    border-bottom: 1px solid #ecf0f1;
}

.detail-table tr:hover {
    background-color: #f8f9fa;
}

.detail-table tr:nth-child(even) {
    background-color: #fdfdfd;
}




/* ====================
   ENLACES 
   ==================== */
.volver-container {
    text-align: center;
    margin-top: 10px;
}

.volver-container img {
    transition: opacity 0.3s ease;
}

.volver-container img:hover {
    opacity: 0.8;
}

/* Estilo específico para mapa_nodo.html */
.volver-container.mapa-nodo img {
    transition: transform 0.2s;
}

.volver-container.mapa-nodo img:hover {
    transform: scale(1.1);
}

/* ====================
   ANIMACIONES
   ==================== */
@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

@keyframes slideIn {
    from {
        transform: translateY(-50px);
        opacity: 0;
    }
    to {
        transform: translateY(0);
        opacity: 1;
    }
}

/* ====================
   SCROLL PERSONALIZADO
   ==================== */
.table-wrapper::-webkit-scrollbar,
.modal-table-container::-webkit-scrollbar,
#detalle-contenido::-webkit-scrollbar {
    width: 8px;
    height: 8px;
}

.table-wrapper::-webkit-scrollbar-track,
.modal-table-container::-webkit-scrollbar-track,
#detalle-contenido::-webkit-scrollbar-track {
    background: #f1f1f1;
}

.table-wrapper::-webkit-scrollbar-thumb,
.modal-table-container::-webkit-scrollbar-thumb,
#detalle-contenido::-webkit-scrollbar-thumb {
    background: #888;
    border-radius: 4px;
}

.table-wrapper::-webkit-scrollbar-thumb:hover,
.modal-table-container::-webkit-scrollbar-thumb:hover,
#detalle-contenido::-webkit-scrollbar-thumb:hover {
    background: #555;
}

/* ====================
   RESPONSIVE
   ==================== */
@media (max-width: 768px) {
    body {
        font-size: 16px; /* Reduce el tamaño base para móviles */
    }

    h1 {
        font-size: 2em;
    }

    h1.titulo {
        font-size: 1.5em;
        margin-bottom: 15px;
    }

    .container > p {
        font-size: 14px;
        margin-bottom: 15px;
    }

    .table-wrapper {
        max-height: calc(100vh - 120px);
    }

    .main-table thead th,
    .main-table tbody td {
        padding: 4px 4px;
        font-size: 14px;
    }

    .main-table tbody td {
        font-size: 12px;
    }

    .main-table thead th {
        font-size: 13px;
    }

    .fecha-col {
        width: 150px;
    }

    .accion-col {
        width: 100px;
    }

    .btn-ver {
        padding: 6px 8px;
        font-size: 12px;
        width: 80px;
    }

    .modal-content {
        width: 95%;
        margin: 10% auto;
    }

    .volver-container {
        margin-top: 8px;
    }

    .volver-container img {
        width: 20%;
        max-width: 80px;
    }

    /* Estilo para mapa_nodo.html en móviles */
    .container.mapa-nodo {
        padding: 10px;
        margin: 10px auto;
    }

    .subtitulo {
        font-size: 1em;
    }

    .leyenda {
        font-size: 14px;
    }

    .leyenda.mapa-nodo {
        font-size: 0.8em;
        padding: 8px;
    }
}
📥 Descargar estilo.css

El archivo nodos.json

Finalmente acá dejo un ejemplo del archivo nodos.json el cual es elaborado en la computadora que procesa los datos cargados a la base de datos durante la ejecución de los programas alojados en los archivos procesador_mesh.py y mesh_subir_datos.py, encargándose este último también de subirlo al servidor. ATENCIÓN: Este archivo no debe ni descargarse ni crearse manualmente; es creado automáticamente la primera vez que se ejecuta el programa y subido al servidor.

[
  {
    "id": "fecha",
    "nombre": "28/10/2025 - 11:16"
  },
  {
    "id": "!025711b3",
    "nombre": "LU8AJA",
    "shortName": "AJA",
    "latitud": "-34.5243648",
    "longitud": "-58.4843264",
    "ultimo_contacto": "1760212993",
    "modelo": "HELTEC_MESH_NODE_T114",
    "rol": "CLIENT",
    "mac": "ee:34:02:57:11:b3",
    "canal": "LongFast",
    "altitud": "0"
  },
  {
    "id": "!06ca019c",
    "nombre": "Carlos NQN Base",
    "shortName": "CN-B",
    "latitud": "-38.9635000",
    "longitud": "-68.0607000",
    "ultimo_contacto": "1761229362",
    "bateria": "101.00",
    "voltaje": "4.27",
    "usoCanal": "5.60",
    "transmision": "2.45",
    "modelo": "T_BEAM",
    "rol": "UNSET",
    "mac": "90:15:06:ca:01:9c",
    "canal": "Manual",
    "altitud": "0"
  },
  {
    "id": "!06d84b34",
    "nombre": "Carlos Nqn Antena",
    "shortName": "CN-A",
    "latitud": "-38.9635000",
    "longitud": "-68.0606000",
    "ultimo_contacto": "1761229430",
    "bateria": "101.00",
    "voltaje": "4.30",
    "usoCanal": "2.93",
    "transmision": "4.11",
    "modelo": "T_BEAM",
    "rol": "UNSET",
    "mac": "90:15:06:d8:4b:34",
    "canal": "Manual",
    "altitud": "0"
  },
  {
    "id": "!06d9e28c",
    "nombre": "JJC3",
    "shortName": "JJC3",
    "latitud": "-34.6292224",
    "longitud": "-58.3794688",
    "ultimo_contacto": "1761377823",
    "bateria": "96.00",
    "voltaje": "4.14",
    "usoCanal": "10.08",
    "transmision": "0.29",
    "modelo": "T_BEAM",
    "rol": "UNSET",
    "mac": "90:15:06:d9:e2:8c",
    "canal": "LongFast",
    "altitud": "24"
  },
  {
    "id": "!06f57ee0",
    "nombre": "Mikrousuario",
    "shortName": "mik",
    "latitud": "-26.8042240",
    "longitud": "-65.2083200",
    "ultimo_contacto": "1760976032",
    "bateria": "98.00",
    "voltaje": "4.17",
    "usoCanal": "2.53",
    "modelo": "T_BEAM",
    "rol": "UNSET",
    "mac": "90:15:06:F5:7E:E0",
    "canal": "LongFast",
    "altitud": "460"
  },
  {
    "id": "!1203ad70",
    "nombre": "mandrake_02",
    "shortName": "mdk2",
    "latitud": "-38.0829696",
    "longitud": "-57.5471616",
    "ultimo_contacto": "1759368600",
    "modelo": "HELTEC_V2_0",
    "rol": "CLIENT",
    "mac": "a4:cf:12:03:ad:70",
    "canal": "LongFast",
    "altitud": "29"
  },
  {
    "id": "!1203bd04",
    "nombre": "mandrake_01 (MQTT)",
    "shortName": "mdk1",
    "latitud": "-38.0138805",
    "longitud": "-57.5717051",
    "ultimo_contacto": "1759359272",
    "modelo": "HELTEC_V2_1",
    "rol": "UNSET",
    "mac": "A4:CF:12:03:BD:04",
    "canal": "LongFast",
    "altitud": "23"
  },
  {
    "id": "!122bc3a0",
    "nombre": "TENCO 👉Instagram bahiamesh👈",
    "shortName": "TNCO",
    "latitud": "-38.7640320",
    "longitud": "-61.6965970",
    "ultimo_contacto": "1761597312",
    "bateria": "80.00",
    "voltaje": "4.02",
    "usoCanal": "10.62",
    "transmision": "0.37",
    "temperatura": "37.80",
    "humedad": "0.00",
    "presion": "0.00",
    "modelo": "ALUMI_MOTE_V2",
    "rol": "ROUTER",
    "mac": "c7:6e:12:2b:c3:a0",
    "canal": "LongFast",
    "altitud": "85"
  },
  {
    "id": "!16d7f5d0",
    "nombre": "2KLAB",
    "shortName": "2KL",
    "latitud": "-34.6112000",
    "longitud": "-58.3811070",
    "ultimo_contacto": "1761501429",
    "modelo": "LILYGO_T3S3_1P10",
    "rol": "UNSET",
    "mac": "98:a3:16:d7:f5:d0",
    "canal": "LongFast",
    "altitud": "-253"
  },
  {
    "id": "!1b3ec782",
    "nombre": "Maxi_Movil",
    "shortName": "SMA3",
    "latitud": "-40.1543168",
    "longitud": "-71.3084928",
    "ultimo_contacto": "1758391169",
    "modelo": "HELTEC_MESH_NODE_T114",
    "rol": "CLIENT_MUTE",
    "mac": "cd:7d:1b:3e:c7:82",
    "canal": "LongFast",
    "altitud": "884"
  },
  {
    "id": "!1ba96a46",
    "nombre": "Meshtastic 6a46",
    "shortName": "6a46",
    "latitud": "-34.6456064",
    "longitud": "-58.5728000",
    "ultimo_contacto": "1761425760",
    "modelo": "HELTEC_MESH_NODE_T114",
    "rol": "CLIENT",
    "mac": "c5:d8:1b:a9:6a:46",
    "canal": "LongFast",
    "altitud": "47"
  },
  {
    "id": "!1bc6112e",
    "nombre": "CAD xiao Solar",
    "shortName": "Cxs1",
    "latitud": "-32.9408512",
    "longitud": "-60.6494720",
    "ultimo_contacto": "1760641722",
    "modelo": "XIAO_NRF52_KIT",
    "rol": "CLIENT",
    "mac": "da:da:1b:c6:11:2e",
    "canal": "LongFast",
    "altitud": "0"
  }
]

Cerrar