WendyOS Docs
TutorialsPython

Connect three boards with no internet

Deploy one Python app with Offline Protocol to three WendyOS boards that discover each other, encrypt every message, hold messages for a board that goes offline, and relay through a middle board.

Build one small Python app with Offline Protocol and deploy it to three WendyOS boards on a network with no internet connection. The boards find each other, encrypt everything they send, hold messages for a board that goes dark, and carry messages for a board they cannot reach directly.

This is the walkthrough from Three computers, no internet, tested on a Qualcomm Dragonwing IQ9, a Raspberry Pi 5, and an NVIDIA Jetson Orin Nano. Watch it in the video.

Prerequisites

  • The Wendy CLI on your development machine, with a working container build setup for wendy run. The machine builds the app, so it keeps its own internet connection.
  • Three boards running WendyOS on one local network with no internet connection, such as a small router with no uplink. Boards can use Ethernet or Wi-Fi. See the Raspberry Pi, Jetson Orin Nano, and Qualcomm Dragonwing guides.

The boards in this tutorial are all 64-bit Arm, so one Dockerfile builds for every board. Check that the CLI sees your boards:

wendy discover

The commands use wendyos-pi.local, wendyos-iq9.local, and wendyos-jetson.local. Replace them with your boards' names.

Set the clocks

With no internet, the boards have no time server, and encryption depends on the current time. If a board's clock is wrong, its keys look invalid and sessions won't form:

wendy device sync-time

Your development machine fetches a signed timestamp and multicasts it to every WendyOS board on the local network. Each board checks the signature itself, so none of them has to trust your machine.

Create the project

Create a project directory (video chapter):

mkdir table-talk
cd table-talk

Save this as wendy.json:

{
  "appId": "sh.wendy.examples.table-talk",
  "version": "0.1.0",
  "platform": "linux",
  "entitlements": [
    { "type": "network", "mode": "host" },
    { "type": "persist", "name": "table-talk-data", "path": "/data" }
  ]
}

Offline Protocol finds neighbors using multicast DNS, which does not leave a container's private network, so the app uses the board's host network. The persistent volume keeps the board's identity and its undelivered messages across deploys; without it, the board gets a new address on every deploy. See entitlements.

Save this as Dockerfile:

# Debian on purpose: the SDK's Linux wheels need glibc 2.34 or newer, so Alpine won't work.
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1
WORKDIR /app

RUN pip install --no-cache-dir 'offline-protocol-sdk[lan]==0.32.0'

COPY app.py .

CMD ["python", "app.py"]

python:3.12-slim is Debian-based. The SDK's prebuilt 64-bit Arm Linux wheels need glibc 2.34 or newer, so Alpine does not work. The lan extra adds local network discovery.

Save this as app.py:

"""Table Talk: three boards, Offline Protocol, no internet.

The same program runs on every board. Each board finds the others, opens an
encrypted session with each one, and sends a numbered message every few seconds.
"""

import asyncio
import json
import os
import secrets
import socket
from collections import Counter
from pathlib import Path

from offline_protocol_sdk import ProtocolManager
from offline_protocol_sdk.offline_protocol import OverflowPolicy, ProtocolConfig

NAME = os.environ.get("BOARD_NAME") or socket.gethostname()
DATA = Path(os.environ.get("DATA_DIR", "/data"))
PORT = int(os.environ.get("LISTEN_PORT", "7878"))
EVERY = float(os.environ.get("SEND_EVERY", "5"))

# Comma-separated host:port list. Set it to turn off discovery and choose links by hand.
FIXED_LINKS = [link for link in os.environ.get("TALK_ONLY_TO", "").split(",") if link]

me = None  # this board's address, set once the identity is ready
peers = set()  # every board we have heard of
names = {}  # address -> board name, learned as boards introduce themselves
sent = {}  # message id -> (address, sequence number)


def say(text):
    print(f"[{NAME}] {text}", flush=True)


def label(address):
    return names.get(address) or address[:12] + "..."


def links():
    """How this board is connected, read from its network interfaces."""
    up = [n.name for n in Path("/sys/class/net").glob("*")
          if (n / "operstate").read_text().strip() == "up"]
    kinds = {"Wi-Fi" if n.startswith("wl") else "Ethernet"
             for n in up if n.startswith(("wl", "en", "eth"))}
    return " and ".join(sorted(kinds)) or "an unknown link"


def on_event(event):
    kind = event.get("type")

    if kind == "neighbor_discovered":
        peer = event["peer_id"]
        if peer not in peers:
            peers.add(peer)
            say(f"found {label(peer)} over {event.get('transport')}")

    elif kind == "secure_session_established":
        say(f"encrypted session with {label(event['peer_id'])}")

    elif kind == "message_received":
        body = json.loads(event["content"])
        names[event["sender"]] = body["from"]
        peers.add(event["sender"])
        for address, name in body["knows"].items():
            if address != me:
                peers.add(address)
                names[address] = name
        say(f"<- {body['from']} #{body['seq']}  hops: {event.get('hop_count', 0)}")

    elif kind == "message_delivered":
        if event["message_id"] in sent:
            address, seq = sent.pop(event["message_id"])
            say(f"ok {label(address)} has #{seq}  via {event.get('transport')}")

    elif kind == "message_relayed":
        say(f"carrying a message from {label(event['sender'])} to {label(event['recipient'])}")


config = ProtocolConfig(
    app_id="table-talk",
    profile="gateway",
    ble_enabled=False,
    wifi_direct_enabled=True,  # the slot for peer streams over local IP
    internet_enabled=False,
    reticulum_enabled=False,
    nostr_enabled=False,
    prefer_online=False,
    initial_ttl=8,
    encryption_enabled=True,
    require_encryption=True,
    auto_key_exchange=True,
    store_pending=True,
    max_pending_per_peer=64,
    max_pending_global=4096,
    pending_ttl_ms=86_400_000,  # keep undelivered messages for a day
    overflow_policy=OverflowPolicy.DROP_OLDEST,
)


def store_key():
    """One key per board, made on first boot. Use a real secret store in production."""
    path = DATA / "store.key"
    if not path.exists():
        DATA.mkdir(parents=True, exist_ok=True)
        path.write_text(secrets.token_hex(32))
        path.chmod(0o600)
    return bytes.fromhex(path.read_text())


async def main():
    global me
    async with ProtocolManager(
        config,
        event_handler=on_event,
        store_key=store_key(),
        mls_root=DATA / "keys",
        state_root=DATA / "state",
    ) as pm:
        me = pm.local_address
        names[me] = NAME
        say(f"I am {me}")
        say(f"on {links()}")

        pm.peer_stream.configure(
            listen_host="0.0.0.0",
            listen_port=PORT,
            advertise=not FIXED_LINKS,
            discover=not FIXED_LINKS,
            peers=FIXED_LINKS or None,
        )
        await pm.peer_stream.start()

        seq = 0
        while True:
            await asyncio.sleep(EVERY)
            seq += 1
            roster = {a: n for a, n in names.items() if n}
            body = json.dumps({"from": NAME, "seq": seq, "knows": roster})
            for peer in list(peers):
                sent[pm.send_message(peer, body)] = (peer, seq)

            # Anything still in `sent` hasn't been acknowledged yet.
            waiting = Counter(label(address) for address, _ in list(sent.values()))
            for name, count in waiting.items():
                if count > 2:
                    say(f"holding {count} messages for {name}")


asyncio.run(main())

The SDK reports everything as an event to on_event:

EventMeaning
neighbor_discoveredAnother board was heard on the local network.
secure_session_establishedThe two boards finished exchanging keys.
message_receivedA message arrived for this board, with its hop count.
message_deliveredThe receipt for a message this board sent.
message_relayedThis board passed along a message for another board.

The config turns off the Bluetooth, internet, Reticulum, and Nostr transports, so only the local network carries traffic. On Linux, wifi_direct_enabled is the SDK's slot for peer streams, plain TCP on the local network here, and the logs call it WiFiDirect. require_encryption=True makes a send fail rather than go out unencrypted. store_pending=True keeps messages for unreachable boards on disk: 64 per board for one day, after which the oldest are dropped.

There is no account or API key. store_key() generates the key that seals the SDK's files on first boot and keeps it on the volume. In production, supply it from a secret store.

Deploy to the first board

Run this from the project directory (video chapter):

wendy run --device wendyos-pi.local --env BOARD_NAME=pi

wendy run builds the container, pushes it to the board, starts it, and streams the logs. The first build takes a minute. The board prints its address and how it is connected:

[pi] I am off1qx7ey8znppjclm2sc99728r4rn9xlfyvgs4fljah
[pi] on Wi-Fi

The address comes from a key the board generated itself; yours will differ. Stop the app and run the same command again: the address stays the same, because it lives on the persistent volume.

Add two more boards

Run each command in its own terminal:

wendy run --device wendyos-iq9.local --env BOARD_NAME=iq9
wendy run --device wendyos-jetson.local --env BOARD_NAME=jetson

No board is told where the others are. The Pi's log shows them arrive:

[pi] found off1qym97ysv... over WiFiDirect
[pi] encrypted session with off1qym97ysv...
[pi] <- iq9 #1  hops: 0
[pi] ok iq9 has #1  via wifiDirect

found means the Pi heard the IQ9 on the local network. encrypted session means the two boards exchanged keys using MLS. The arrow is message 1 from the IQ9; zero hops means it came directly. ok is the receipt for the Pi's message.

A receipt means the SDK on the other board received the message. For work orders or robot commands, have your app store the message first, then send its own acknowledgment back.

Unplug a board

Unplug the Jetson's network cable (video chapter). The other boards keep sending to it, and its messages wait on disk on each board:

[pi] holding 3 messages for jetson
[pi] holding 4 messages for jetson
[pi] holding 5 messages for jetson

Plug it back in (video chapter). Everything it missed is delivered, with no retry, queue, or reconnect code in the app:

[jetson] <- iq9 #4  hops: 0
[jetson] <- iq9 #5  hops: 0
[jetson] <- iq9 #6  hops: 0
[jetson] <- pi #4  hops: 0
[jetson] <- pi #5  hops: 0

Messages can arrive out of order, so number them when order matters, as this app does.

Relay through a middle board

Make the Pi and the Jetson unable to reach each other (video chapter). With TALK_ONLY_TO set, a board turns off discovery and connects only to the listed host:port addresses. Give both only the IQ9's address:

wendy run --device wendyos-pi.local --env BOARD_NAME=pi --env TALK_ONLY_TO=<iq9-ip>:7878
wendy run --device wendyos-jetson.local --env BOARD_NAME=jetson --env TALK_ONLY_TO=<iq9-ip>:7878

The IQ9 now carries messages between them:

[iq9] carrying a message from pi to jetson
[jetson] <- pi #4  hops: 1

The message left the Pi over Wi-Fi and reached the Jetson over Ethernet. It is encrypted for the Jetson, so the IQ9 carries it but cannot read it.

Clean up

Remove the app from each board:

wendy device apps remove sh.wendy.examples.table-talk --force --cleanup --device wendyos-pi.local

Repeat with wendyos-iq9.local and wendyos-jetson.local.

Next steps

On this page