Puente del canal público de MeshCore entre la radio local y un hub MQTT, para que islas de malla que no se alcanzan por RF compartan igual el mismo canal.
Un mensaje enviado al canal público en Santiago aparece en el canal público de Concepción, con su procedencia marcada, sin extender la malla ni saturar el aire.
Es la implementación de referencia del protocolo meshchan v0.1, cuya
especificación va incluida acá al lado, en
meshchan-spec-v0.1.md, pensada para que se pueda
escribir otra implementación sin leer este código.
Un solo binario de ~2,6 MB, configuración por variables de entorno, ~1 MB de RAM en marcha. Corre en cualquier host Linux (una Raspberry Pi, un mini-PC) con un nodo MeshCore Companion conectado por USB.
¿En Chile? El hub de la comunidad ya está andando en
mqtts://bridge-msc.meshchile.cl:8884 y .env.example viene apuntado ahí: solo
necesitas tus credenciales, que se piden por Telegram a
@n3v1l. Ver Conectarse al hub de
MeshChile.
Nota. meshchan es una propuesta de la comunidad MeshChile. No está afiliada al proyecto MeshCore ni ratificada por él.
- Qué hace y qué no
- Cómo se ve en la práctica
- Requisitos
- Instalación
- Configuración · Hub de MeshChile · Pedir credenciales
- Cómo comprobar que funciona
- Operación diaria
- Cuando algo falla
- Detalles del protocolo que conviene saber
- Observabilidad (opcional)
- Desarrollo
Relaya el texto de un solo canal público acordado, con procedencia, límites de airtime y protección de lazos. Es un puente curado a nivel de canal, no una extensión transparente de la malla.
Un bridge conforme nunca relaya (spec §2):
- mensajes directos (DM) ni nada que no sea el canal público configurado;
- adverts, contactos, telemetría, posición ni metadatos de nodos;
- otros canales;
- tramas RF crudas.
La observabilidad de nodos (posiciones para un mapa) es un plano aparte, con su propio árbol de topics y su propio broker. Este agente la trae como módulo opcional, apagado por defecto, y aun encendido no la mezcla con meshchan.
Alguien en Concepción escribe en el canal público:
alguien copia en la zona sur?
Su bridge local lo publica al hub. En Santiago, tu bridge lo recibe y lo inyecta a tu radio, y en los nodos de tu isla aparece:
tu-nodo: [CCP] juan: alguien copia en la zona sur?
Tres cosas que vale la pena notar en esa línea:
tu-nodo:lo antepone el firmware, no este programa. Todo mensaje de canal en MeshCore lleva el nombre del nodo que lo transmitió.[CCP]es la procedencia, que agrega el bridge. Sin ella, el mensaje se leería como si lo hubiera dicho tu nodo.juanes el nombre que usó el remitente original. No está autenticado: en un canal público de MeshCore cualquiera con la clave del canal puede firmar con el nombre que quiera. Trátalo como informativo, nunca como prueba de origen.
- Un nodo MeshCore con firmware Companion, conectado al host por USB serial (también soporta TCP y BLE). Un T-LoRa, un Heltec, un Xiao: cualquiera que corra Companion sirve.
- El canal público ya configurado en ese nodo: nombre y clave compartida. El agente usa el canal, no lo configura. Los datos del canal público de tu comunidad se piden a la comunidad y se cargan en el nodo antes de levantar el agente.
- Credenciales propias del hub MQTT. Cada bridge debería tener las suyas, para que se puedan revocar por separado (spec §4). En el hub de MeshChile se piden por Telegram a @n3v1l.
- El usuario que corre el agente en el grupo
dialout, si usas serial. - Rust 1.75 o superior para compilar.
Ningún otro proceso puede tener abierto el mismo serial en paralelo: ni un publisher de mapa, ni un bot, ni un clock-sync. Dos procesos peleando el mismo
/dev/ttyACM0terminan en que ninguno de los dos anda.Si ya tienes algo hablándole a ese nodo, dedica otro nodo al bridge. Es la forma recomendada de montarlo.
git clone https://github.com/Mesh-Chile/bridge-msc.git
cd bridge-msc
cargo build --releaseEn un host sin bluetooth, un binario más chico y sin dependencias de D-Bus:
cargo build --release --no-default-features # ~2,6 MB en vez de ~3,1 MBsudo cp target/release/bridge-msc /usr/local/bin/
sudo cp .env.example /etc/bridge-msc.env
sudo $EDITOR /etc/bridge-msc.env
sudo chmod 600 /etc/bridge-msc.env # trae credenciales
sudo useradd -r -s /usr/sbin/nologin bridge-msc # o usa un usuario existente
sudo cp bridge-msc.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bridge-msc
journalctl -u bridge-msc -fLa unit trae Restart=always, MemoryMax=128M y SupplementaryGroups=dialout.
Si usas TCP o BLE en vez de serial, puedes sacar ese SupplementaryGroups.
Un detalle que ahorra dolores de cabeza: apunta MC_ADDRESS a
/dev/serial/by-id/... y no a /dev/ttyACM0. El número cambia al reenchufar o
al reiniciar; el by-id no. Para ver el tuyo:
ls -l /dev/serial/by-id/El TLS es 100 % Rust (rustls + ring), así que no hace falta cmake ni
toolchain C para la parte de red: solo el enlazador cruzado.
# arm64 (Raspberry Pi 64 bits, Oracle Ampere)
rustup target add aarch64-unknown-linux-gnu
sudo apt install gcc-aarch64-linux-gnu
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
cargo build --release --no-default-features --target aarch64-unknown-linux-gnuOjo con la glibc: un binario compilado contra una glibc nueva no corre en un host
con una más vieja. Compila en un host de glibc igual o menor que la del destino,
o usa --target x86_64-unknown-linux-musl para un estático.
Si compilas con la feature ble (viene por defecto), el target necesita
además las cabeceras de D-Bus.
Todo va por variables de entorno. .env.example está comentado
entero; lo mínimo es:
MC_ADDRESS=/dev/serial/by-id/usb-XXXX-if00
CHANNEL_NAME=Public
CHANNEL_IDX=0
ISLAND_IATA=SCL
CHAN_MQTT_TRANSPORT=tcp
CHAN_MQTT_HOST=bridge-msc.meshchile.cl
CHAN_MQTT_PORT=8884
CHAN_MQTT_USER=tu-usuario
CHAN_MQTT_PASS=tu-claveSi estás en Chile, no tienes que levantar ningún broker: usa el hub de la
comunidad. .env.example ya viene apuntado ahí, y estos son los datos:
| Servidor | bridge-msc.meshchile.cl |
| Puerto | 8884 |
| Protocolo | MQTT sobre TLS (mqtts), certificado Let's Encrypt |
| Topics | meshchan/CL/Public y meshchan/CL/bots |
| Canales en el nodo | Public (índice 0) y el hashtag #bots (índice 1) |
Los bots de la malla (por ejemplo el de Santiago) atienden solo en el canal
hashtag #bots, para dejar libre el canal público. Para que la gente de tu isla
pueda usarlos, puentea también ese canal:
-
En el nodo del bridge, agrega el canal hashtag
#botsen el índice1(desde la app, "agregar canal hashtag"; la clave se deriva del nombre). -
En el
.env, reemplazaCHANNEL_NAME/CHANNEL_IDXpor:CHANNELS=Public:0,bots:1,emergencias:2En el topic va
bots, sin#: el#es comodín en MQTT.
Los comandos que escriban en #bots en tu isla llegan al bot por el hub y la
respuesta vuelve por el mismo canal. Cada canal lleva su propio rate-limit, así
que una ráfaga de respuestas en #bots no deja mudo al público.
Cada bridge lleva las suyas, para que se puedan revocar por separado sin afectar a los demás. Para que te creen un usuario:
Escríbele a @n3v1l por Telegram y pide un usuario de bridge.
Cuéntale de qué zona vas a puentear (el ISLAND_IATA que piensas usar) y con qué
nodo. Te devuelve un usuario y una clave que pegas tal cual en tu .env.
Aprovecha de pedirle también los datos del canal público, para cargarlos en el
nodo antes de levantar el agente.
Comprobar que el servidor responde, antes de configurar nada:
openssl s_client -connect bridge-msc.meshchile.cl:8884 \
-servername bridge-msc.meshchile.cl </dev/null 2>/dev/null | \
openssl x509 -noout -subject -datesSi estás en otra comunidad, cambia esos valores por los de tu hub: el agente es genérico y funciona contra cualquier broker MQTT (Mosquitto, por ejemplo). No necesita un broker que sepa de MeshCore.
Las que más se prestan a confusión:
| Variable | Qué es |
|---|---|
CHANNEL_NAME |
El nombre del canal tal como está en el nodo, y también el último nivel del topic. Distingue mayúsculas. |
CHANNEL_IDX |
El índice del canal en el nodo, casi siempre 0. Si no calza, el agente no oye nada. |
CHANNELS |
Para puentear varios canales: pares nombre:índice separados por coma (Public:0,bots:1). Si está, reemplaza a CHANNEL_NAME/CHANNEL_IDX. Un canal hashtag va sin # (#bots del nodo → bots). |
ISLAND_IATA |
La etiqueta de tu isla. Es lo que las otras islas ven como [SCL]. |
CHAN_MQTT_TRANSPORT |
tcp para MQTT nativo (1883 / 8883), ws para websockets (443). |
CHAN_MQTT_TLS |
true por defecto. Ponlo en false solo en una red de confianza. |
El topic queda <CHAN_TOPIC_PREFIX>[/<CHAN_REGION>]/<CHANNEL_NAME>; con los
valores por defecto y CHAN_REGION=CL da meshchan/CL/Public. Con CHANNELS
hay un topic por canal (meshchan/CL/Public, meshchan/CL/bots).
Todos los bridges de un mismo canal tienen que usar exactamente el mismo topic (spec §5). Si el tuyo no calza con el de la comunidad, vas a estar solo en el hub sin darte cuenta.
Para ver qué canales tiene cargados tu nodo, sin adivinar:
MC_ADDRESS=/dev/serial/by-id/usb-XXXX-if00 cargo run --example node_infoImprime nombre y pubkey del nodo, los canales con sus índices y los contactos. No lo corras con el servicio andando: pelearían por el serial.
CHANNEL_NAME=Public ISLAND_IATA=SCL CHAN_REGION=CL \
CHAN_MQTT_TRANSPORT=tcp CHAN_MQTT_HOST=bridge-msc.meshchile.cl CHAN_MQTT_PORT=8884 \
CHAN_MQTT_USER=tu-usuario CHAN_MQTT_PASS=tu-clave \
MC_ADDRESS=/dev/null \
cargo run --example hub_smokePublica un mensaje meshchan válido y espera recibirlo de vuelta por su propia
suscripción. Tiene que terminar en OK: ida y vuelta completa. Si no vuelve
nada, el problema son las credenciales, la ACL del topic o el puerto — todavía no
has tocado la radio.
- Suscríbete al topic desde otra máquina.
- Manda un mensaje al canal público desde cualquier nodo de tu isla.
- Tiene que aparecer el JSON, y en el journal
publicado al hub: <texto>.
Publica un payload al topic con un origin_bridge distinto del de tu nodo
(si pones el tuyo, el agente lo ignora, y hace bien):
{"v":1,"id":"0123456789abcdef","channel":"Public","text":"prueba desde el hub",
"sender":"pepe","sender_id":"pepe","origin_bridge":"0000",
"origin_island":"CCP","ts":1756742400}En la RF tiene que aparecer [CCP] pepe: prueba desde el hub, y en el journal
inyectado a la RF: ....
Después vale la pena probar los límites:
- Repite el mismo payload: la segunda vez tiene que decir
duplicado, no se inyecta. - Manda varios seguidos: a partir del quinto,
DESCARTADO por rate-limit.
Con las dos direcciones andando, un mensaje inyectado se oye de vuelta por RF
cuando el repetidor lo re-emite. No debe republicarse al hub. Con
RUST_LOG=bridge_msc=debug vas a ver eco de nuestra propia inyeccion, no se publica. Si en vez de eso ves el mismo texto rebotando, revisa que el nombre de
tu nodo sea el que efectivamente aparece como prefijo en la RF.
systemctl status bridge-msc
journalctl -u bridge-msc -f
journalctl -u bridge-msc | grep -E 'DESCARTADO|watchdog|rate-limit'Reconecta solo: al nodo con backoff exponencial (2 s → 60 s) y a cada broker MQTT
por su cuenta. Ninguna tarea que falle tumba el proceso. SIGTERM y Ctrl-C
cierran el nodo y las conexiones MQTT ordenadamente.
Para actualizar: recompila, reemplaza el binario y systemctl restart bridge-msc.
el nodo no respondio la identificacion inicial
El puerto abrió pero el nodo no contesta. Suele ser transitorio, justo después de
que otro proceso soltó el serial; el agente reintenta solo con backoff. Si
persiste: revisa que el firmware sea Companion (un repeater no habla ese
protocolo) y que nadie más tenga el puerto tomado.
No aparece ningún mensaje de canal
Revisa CHANNEL_IDX con --example node_info. Si pasan 5 minutos sin ningún
mensaje de canal, el agente deja un WARNING y sigue vivo: en una malla
tranquila eso es normal de madrugada. Pero en algunas versiones del firmware
Companion el evento de mensaje de canal directamente no dispara aunque el nodo sí
esté oyendo (issue #1232 aguas arriba); ese WARNING es la pista.
watchdog: mensajes rescatados de la cola del device
Es el agente haciendo su trabajo, no un error. La librería solo espera cuatro
tipos de respuesta al pedir el siguiente mensaje: si en la cola hay una trama que
no conoce (por ejemplo CHANNEL_DATA_RECV = 27, de firmwares recientes), la
espera expira. Esa trama ya salió de la cola del device, así que cortar el
drenaje ahí es justo lo que deja al bridge sordo hasta el próximo aviso. El
agente no corta: sigue drenando, y avisa cuando rescató algo.
El texto llega cortado
El firmware corta el mensaje de canal en 160 bytes contando el prefijo
"<nombre_del_nodo>: " que agrega solo. El agente calcula el techo real al
arrancar y lo deja en el log. Con un nodo llamado cl-bridge quedan 149 bytes
útiles. Ponle nombre corto a tu nodo: cada carácter se le resta al mensaje.
Nada llega al hub y el log dice conexion MQTT caida
Verifica primero con --example hub_smoke, que no toca la radio. Si ahí falla,
es credenciales, ACL o puerto.
sender_ides el nombre del remitente, no su pubkey. No es una limitación de este programa ni del API Companion: un mensaje de canal viaja comoPAYLOAD_TYPE_GRP_TXT, que el propio firmware documenta como "an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp,"name: msg")". No hay pubkey, ni hash del emisor, ni firma. El único rastro del remitente es el nombre dentro del texto cifrado. Ni siquiera una implementación en firmware, leyendo el paquete crudo, tiene algo mejor.- El texto va byte a byte, sin normalizar. Se saca del texto RF solo el
separador —los dos puntos y un espacio, que es exactamente lo que escribe
sendGroupMessage()con"%s: "— y nada más. Nitrim, ni normalización de espacios. Es deliberado y es contrato de cable: elidcanónico se calcula sobre el texto, así que si una implementación normaliza y otra no, dejan de deduplicar entre sí. Un espacio al final hace un mensaje distinto, y está bien que lo sea. - Anti-loop. Nunca se republica lo que transmitió el propio nodo (spec §8.1.1). Como la trama no trae pubkey, la comparación es contra el nombre del nodo, que es justamente lo que el firmware antepone.
- El límite de los 30 s. Los buckets del
idson una grilla fija, no una ventana móvil. Al publicar se prueba también el id del bucket anterior, como permite la spec §7, para cerrar el hueco del mensaje oído a caballo de un límite. - La cache de dedup es acotada de verdad: TTL más techo duro de entradas, sin crecimiento ilimitado (spec §8.5).
- El rate-limit solo aplica a MQTT → RF. Lo que se oyó por radio ya gastó airtime; publicarlo es gratis. Los límites del §8.4 son un mecanismo de seguridad, no una perilla de tuning: subirlos mucho satura la RF de tu isla.
Con OBS_ENABLED=false (el valor por defecto) el agente es un bridge puro: no se
crea el segundo cliente MQTT y no se toca la lista de contactos del nodo. El
módulo no cuesta nada.
Encendido, cada MAP_INTERVAL segundos publica los contactos con posición a un
broker de observabilidad, en su propio árbol de topics. Enciéndelo solo si
acordaste con el operador de ese mapa publicar lo que ve tu observer, y solo con
las credenciales que él te dé.
cargo test # 47 tests, ninguno necesita radio ni broker
cargo clippy --all-targets
RUST_LOG=bridge_msc=debug cargo runEl crate es biblioteca + binario: los módulos viven en src/lib.rs, y el binario
y los ejemplos los consumen.
| Módulo | Qué hace |
|---|---|
config |
Lee y valida el entorno |
mesh |
Conexión al nodo y drenaje defensivo de la cola |
bridge |
Protocolo meshchan y toda la lógica pura |
mqtt |
Clientes MQTT (tcp/ws, con y sin TLS) y reconexión |
dedup |
Cache acotada por TTL y por tamaño |
ratelimit |
Token bucket de la inyección |
observability |
El módulo opcional del mapa |
Todo lo que se puede testear sin hardware está en funciones puras, y los tests
cubren el vector de referencia del id, los casos raros del parseo de nicks, el
recorte UTF-8, el techo de texto del firmware y el comportamiento del rate-limit.
Ejemplos: hub_smoke valida un hub MQTT de punta a punta sin radio; node_info
muestra qué ve el agente en el nodo.
MIT.