Untangling the Mesh: An Exploration of Offline Network Engineering

How you can use cheap components and creative engineering to build your own private network and take your communications with others offline.

Josh Weiner
CodeX
11 min readDec 6, 2023

--

As a software & machine learning engineer, I often deal with programming for hardware in the abstract sense: software is built on top of an understanding of how computer components partition resources when it comes to runtime and memory. Rarely, however, have I built directly on top of the hardware layer– save a handful of classes in operating systems and computer architecture. It’s always good practice to expand one’s comfort zone– and I decided that learning how to program Arduino and basic electrical engineering components would be a good first step.

The motivation behind this project to create an offline mesh network was twofold:

  1. Humanitarian / Open-access Principles
    A well-constructed offline mesh network would work in disaster relief zones, areas of the developing world without internet coverage, be end-to-end encrypted (for safe use in situations where say a government is limiting access to the internet / disrupting communications), and resilient. Ideally if a node were to be removed from the network, so long as it’s not a cut vertex, the network should remain intact and usable.
    With the minimal requisite components, this project should provide a cost-efficient platform for people in these situations to be able to communicate.
  2. Unfinished Business
    As an overly-ambitious high school student, I participated in a student developer program at Google where our team sought to build WiFi-enabled mesh networks between Android devices for the above purposes. A lack of experience and knowledge in this domain, and in Android programming, seriously hindered our ability to succeed– so why not give it another shot, albeit five years later!

Before we continue, I would like to credit the good people at IffyBooks in Philadelphia for renewing my interest in this project. Check out their guide to setting up a pocket wifi library portal for a very cool, less involved project along similar principles!

But without further ado, let’s jump right into the project. Feel free to follow along on my abbreviated Twitter/X thread or view the GitHub repository.

Photo by Victor Aznabaev on Unsplash

Getting Started

Because this is a hardware and software-enabled project, we will need a few components to get started.

  • Multiple ESP8266 WiFi Microchips with 16MB of Flash Memory
    (Between $2-$6 each)
  • USB/USB-C to Micro-USB Cables
    (Between $2–$6 each)
  • Multiple devices and/or multiple low-voltage power sources

You should also be sure to download the Arduino IDE or PlatformIO extension for VSCode, this will allow us to write our programs to flash memory, monitor the activity on the chips, and upload any files we need to serve over the network to users.

To get familiar with our IDE, and how to work with these chips, let’s first focus on writing a simple program to create a WiFi portal and have the chips blink an LED. I will skip over the details of how to compile and upload files to the ESP8266 chips, as these can be found in the IffyBooks guide.

#include <ESP8266WiFi.h>
// Setup is only run once
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
WiFi.mode(WIFI_AP);
WiFi.softAP("Free Reading Wifi");
}

// Loop is going to run continuously
void loop() {
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
delay(1000);
}

Success! Now we are confident that we can write to the chips and have them do something. The floodgates are open, and there is little better than this feeling.

Blink if you’re excited about network programming!

Establishing and Testing a Mesh Network

This is the portion of the project in which we must carefully think around our system and network design. Let’s review some of the constraints, and what we hope to build:

  • We want to build a mesh network: a group of ESP8266 nodes that share a network and send information among themselves. This means that some of the chips will have to be Access Points, and others will be station nodes. This leads us to a “star” model as follows:
Clients connect to access points, which themselves connect to the rest of the mesh network. This leads to the “star” diagram model we see here. We can extend the range of the network by adding more station nodes that connect access points, which would otherwise be out of range for some devices.
  • We also want to build a chat interface: some way for users connected to the mesh network to communicate with each other. However, because the chips comprising the network do not have a lot of memory, and to reduce the amount of total information being streamed at one time over the network, we will store chat histories on the client devices. This will also allow us to build client-side encryption: which offers a much higher level of privacy for our chat app.

With these considerations in mind, let’s start to build the mesh network. First, using the PainlessMesh library, we build the connection, send message, and receiving callbacks. This allows nodes to connect to the mesh and communicate with each other.

Now, we write some functions that we can start the network on each node, join and say hello to the network, and list the other nodes making up our mesh. These last two aren’t necessary, but allow us to see if the correct behavior occurs in the Serial Monitor.

#define LED_BUILTIN 2
#define MESH_PREFIX "YOUR NETWORK NAME"
#define MESH_PASSWORD "CHANGE ME AND KEEP ME A SECRET"
#define MESH_PORT 5555

const int HTTP_PORT = 80;
const int WS_PORT = 1337;

painlessMesh mesh;

void sendMessage();

void sendMessageClient(String msg) {
Serial.printf("Sending to node %u msg=%s\n", mesh.getNodeId(), msg.c_str());
mesh.sendBroadcast(msg);
}

void receivedCallback( uint32_t from, String &msg ) {
Serial.printf("Received from %u msg=%s\n", from, msg.c_str());
webSocket.broadcastTXT(msg.c_str());
}

void newConnectionCallback(uint32_t nodeId) {
Serial.printf("New Connection, nodeId = %u\n", nodeId);
}

void changedConnectionCallback() {
Serial.printf("Changed connections\n");
}

void nodeTimeAdjustedCallback(int32_t offset) {
Serial.printf("Adjusted time %u. Offset = %d\n", mesh.getNodeTime(),offset);
webSocket.loop();
}

void broadcastTestMessage() {
String msg = "Hello from node " + String(mesh.getNodeId());
mesh.sendBroadcast(msg);
Serial.println("Test message sent: " + msg);
}

unsigned long previousMillis = 0;
const long interval = 5000;

void listNodes() {
auto nodes = mesh.getNodeList();
Serial.printf("The mesh has %u nodes:\n", nodes.size());
for (auto &id : nodes) {
Serial.printf("Node ID: %u\n", id);
}
}

void StartMesh() {
mesh.setDebugMsgTypes( ERROR | STARTUP | CONNECTION );
mesh.init( MESH_PREFIX, MESH_PASSWORD, MESH_PORT );
mesh.onReceive(&receivedCallback);
mesh.onNewConnection(&newConnectionCallback);
mesh.onChangedConnections(&changedConnectionCallback);
mesh.onNodeTimeAdjusted(&nodeTimeAdjustedCallback);
broadcastTestMessage();
}

Make sure to set your own SSID and password to keep the network secured. Especially if you plan to use open-source code to build this (see this article about sensitive information leakage from LLMs).

In addition to establishing a mesh network, some nodes will need to serve as WiFi Access Points (APs) as we established earlier. Not sure if this was due to the properties of the ESP8266 chips, the properties of APs or painlessMesh nodes, or issues within my code, but I quickly learned that the mesh won’t work if all nodes are APs with the same SSID information. This means the dispersal of APs on the mesh needs to be purposeful. This is why we will build the network with the star model design from earlier– with all APs out of range of eachother.

Once we flash some of the chips as APs, and all as nodes on the mesh network, let’s actually test if the network is established and the nodes communicate!

When chips talk…

Setting up a File System & Serving Static Files

With the network set up and operational, we need to shift our focus to making this full-stack enabled. We need to open a Web Socket that can communicate with a frontend, access the chips’ filesystems, and establish a DNS server.

// Functions for WebSocket
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) {
switch(type) {
case WStype_DISCONNECTED:
Serial.printf("[%u] Disconnected!\n", num);
break;
case WStype_CONNECTED:
{
IPAddress ip = webSocket.remoteIP(num);
// Serial.printf("[%u] Connected from %d.%d.%d.%d url: %s\n", num, ip[0], ip[1], ip[2], ip[3], payload);
}
break;
case WStype_TEXT:
Serial.printf("[%u] get Text: %s\n", num, payload);
webSocket.broadcastTXT(payload, length);
sendMessageClient((char*)payload);
break;

case WStype_BIN:
case WStype_ERROR:
case WStype_FRAGMENT_TEXT_START:
case WStype_FRAGMENT_BIN_START:
case WStype_FRAGMENT:
case WStype_FRAGMENT_FIN:
default:
break;
}
}

The following allow us to serve static files stored on the chip to the client once the file system has been mounted:

void onIndexRequest(AsyncWebServerRequest *request) {
IPAddress ip = request->client()->remoteIP();
request->send(LittleFS, "/index.html", "text/html");
}

void onCSSRequest(AsyncWebServerRequest *request) {
IPAddress ip = request->client()->remoteIP();
request->send(LittleFS, "/styles.css", "text/css");
}

void onJSRequest(AsyncWebServerRequest *request) {
IPAddress ip = request->client()->remoteIP();
request->send(LittleFS, "/scripts.js", "text/js");
}

Let’s put it all together: mounting the file system, creating routes on HTTP requests for the server, connecting to the Web Socket, and starting our DNS server.

void StartFilesystem() {
if(!LittleFS.begin()){
Serial.println("An Error has occurred while mounting LittleFS");
return;
}
}

void ConnectToServer() {
server.on("/", HTTP_GET, onIndexRequest);
server.on("/styles.css", HTTP_GET, onCSSRequest);
server.on("/scripts.js", HTTP_GET, onJSRequest);
server.onNotFound([](AsyncWebServerRequest *request) {
request->send(LittleFS, "/index.html", String(), false);
});
server.begin();
}

void ConnectToWebSocket() {
webSocket.begin();
webSocket.onEvent(onWebSocketEvent);
}

void StartMDNS() {
if (!MDNS.begin("esp8266")) {
Serial.println("Error setting up MDNS responder!");
while(1) {
delay(1000);
}
}
Serial.println("mDNS responder started");
MDNS.addService("http", "tcp", HTTP_PORT);
}

We’re now essentially done with the server portion of things (skipping a few minor details), so long as we update the mesh network and web sockets constantly, and process requests through our DNS server! Let’s shift focus towards building the client-side application.

Building the Chat Application

The way we’ve set up our network is to bring up a captive portal whenever the user connects to the WiFi. Because our network is served on an IP address of our choosing, it would be difficult for the average user to navigate to that address in a web-browser– so we just make it easier for them by automatically directing them there.

Going over the code of the frontend doesn’t accomplish much for the purposes of this article: anyone reading should be able to create any application or interface they wish to. Instead, let’s discuss the important parts, namely accessing our browser memory (local storage), setting up & accessing the IndexedDB, and communicating with the network.

First, let’s store something simple in our local storage: a username. When we connect to the Chat Application, we should have users enter a username so others can see who is messaging them!

We define the following function and event listener in our “scripts.js” file mounted onto the chip (this assumes we have a form with a “nameField” and an “enter chat room” button):

function enterChatRoom() {
const name = document.getElementById('nameField').value;
if (name) {
localStorage.setItem('username', name);
window.location.href = 'chat.html';
} else {
console.log("Please enter your name.");
}
}


enterButton.addEventListener("click", async function (e) {
e.preventDefault();
enterChatRoom();

});

Now, let’s go over how connect to and setup tables in IndexedDB in the client browser. IndexedDB is a feature of client-side browsers that allows us to store sizable amounts of structured data. This will be great for our chat server as we can store encrypted or decrypted messages in a table: maintaining a table of usernames, timestamps, and message content.

let db;
const request = indexedDB.open("ChatAppDB", 1);
var socket = new WebSocket('ws://192.168.4.1:1337');

request.onerror = function(event) {
console.error("An error occurred with IndexedDB:", event.target.errorCode);
};

request.onsuccess = function(event) {
db = event.target.result;
};

request.onupgradeneeded = function(event) {
let db = event.target.result;
let userStore = db.createObjectStore("User", { keyPath: "ip" });
userStore.createIndex("name", "name", { unique: false });
let chatStore = db.createObjectStore("Chat", { autoIncrement: true });
chatStore.createIndex("message", "message", { unique: false });
};

Note above how we define multiple functions that handle errors accessing the database, make a request to the database, and finally, create the tables in IndexedDB if they don’t exist yet. Also, note how the WebSocket connects to the IP of the network (192.168.4.1) at the established port (1337) we defined in our backend.

Let’s now integrate the above with our flow of sending and receiving messages.

  1. When we load the chat application, we should fetch any existing messages from the database. If the database doesn’t exist, we should create it.
  2. Any existing messages should be “decrypted” and rendered in our chat window. Again, this assumes that we have written the requisite HTML elements to interact with.
  3. When we send a message on the frontend, we should send them to the mesh network through the WebSocket. We should also “encrypt” them before doing so.
  4. Finally, when we receive a message from the server, we should add it to our database, decrypt it, and render it.
function fetchAndRenderMessages() {
openDatabase().then(db => {
const transaction = db.transaction(["Chat"], "readonly");
const store = transaction.objectStore("Chat");
const request = store.getAll();

request.onerror = (event) => {
console.error("Error fetching messages:", event.target.errorCode);
};

request.onsuccess = (event) => {
let messages = event.target.result;

//console.log("messages: ", messages);
let decryptedMessages = messages.map(message => {
const decryptedMessage = JSON.parse(decryptData(message));
return {
...decryptedMessage,
message: decryptedMessage,
timestamp: new Date(decryptedMessage.timestamp)
};
});

console.log("decrypted: ", decryptedMessages);

decryptedMessages.sort((a, b) => a.timestamp - b.timestamp);

decryptedMessages.forEach(msg => {
renderMessage(msg);
});

updateScroll();
};
});
}

socket.onmessage = function(event) {
console.log("Message!");
let decryptedData = JSON.parse(decryptData(event.data));
renderMessage({
message: decryptedData,
timestamp: new Date(decryptedData.timestamp)
});
openDatabase().then(db => {
const transaction = db.transaction(["Chat"], "readwrite");
const store = transaction.objectStore("Chat");
store.add(event.data);
});
updateScroll();
}

window.onload = () => {
fetchAndRenderMessages();
updateScroll();
};

The rest of the code for the frontend can be found in the GitHub repository.

Voilà! Putting this all together, we should have a functioning chat application that only works when connected to our own private, offline mesh network. If you followed the steps in this overview, or simply cloned the linked repository, you should get an app that looks like this! Happy chatting!

You should be able to quickly build and deploy something that looks like this!

As discussed in the opening remarks, the use cases for an application like this are wide-ranging. This is furthered by the facts that the individual components of the network are very low cost, allowing us to establish large networks with wide coverage.

Further Research & Next Steps

While we’ve certainly built a lot today, there is still so much more that can be done to improve the safety, resiliency, and abilities of our chat application. Some examples include:

  1. Better encryption. We currently use base64 encoding and decoding of messages client side, which is not secure at all– just unreadable to humans.
  2. Private chat rooms. Right now, when users connect to the application they all enter one chat room. This has its uses, but most messaging is done in one-on-one settings and smaller groups. Implementing this would require more complex database design and such as: storing private chat encryption keys, different tables for different chats, and a table of available users on the network. We should also be more stringent with which messages are sent to the intended users on the server, as opposed to sending each message to every connected user.
  3. Route Optimization. Instead of sending packets around the network across every node, we should ideally route messages more efficiently towards their intended destinations based on things such as traffic demands and network topology. This is quite complex, but could be a great area to explore using machine learning alongside graph-traversal algorithms on top of our network.

I hope this was as entertaining and informative of a read as this project was for me to build.

If you have any questions, feedback, or comments please feel free to reach out to me via Twitter, LinkedIn, or by opening an issue on the GitHub repository!

--

--

Josh Weiner
CodeX
Writer for

Rising senior majoring in Computer Science & Mathematical Economics at the University of Pennsylvania. Always eager to learn, and currently open to work!