Back to PortfolioRobotics

LoRaMesh: Smartphone Off-Grid Text Communicator

Friday, August 28, 2026

Share:
LoRaMesh: Smartphone Off-Grid Text Communicator

A portable, off-grid hardware communicator that transforms any smartphone into a long-range text transceiver without cellular coverage, satellite subscriptions, or internet access. The device clips directly onto the back of a smartphone, hosting a local Wi-Fi Access Point and an embedded web application. When a user sends a message from their browser, the onboard ESP32 bridges the payload to a 433/915 MHz LoRa transceiver, broadcasting encrypted text messages across kilometers to other peer nodes in the mesh.

LoRaMesh: Smartphone Off-Grid Text Communicator 2


Key Technical Features

  • App-Free Universal Compatibility: Runs a standalone web chat server directly on the ESP32. Any smartphone connecting to the device's Wi-Fi Access Point instantly opens the communication dashboard in their browser.
  • Long-Range LoRa Sub-GHz Link: Utilizes chirp spread spectrum (CSS) modulation to transmit text packets across dense urban terrain and remote off-grid locations.
  • Bi-Directional Gateway Bridge: Seamlessly forwards local HTTP/AJAX web payloads to outgoing LoRa radio packets and pushes incoming radio packets directly to connected client interfaces.
  • Integrated Clamp-On Ergonomics: Designed as a modular smartphone accessory utilizing an adjustable threaded lead-screw clamp that firmly grips devices of varying thicknesses.
  • Low-Power Autonomous Operation: Built around a custom compact SMD layout powered via a dedicated USB-C interface and onboard linear power regulation.

Hardware Architecture

Core Processing & Radio

  • ESP32 Microcontroller: Manages local Wi-Fi SoftAP, handles asynchronous web traffic, and drives the SPI bus for radio communications.
  • LoRa Transceiver Module (SX127x / RFM95x): Long-range, low-power sub-GHz RF module handling packet modulation and packet demodulation.
  • USB-C Logic & Power Management: High-efficiency Low-Dropout (LDO) regulator delivering continuous 3.3V rail voltage alongside electrostatic discharge (ESD) filtering.

Mechanical Housing

  • 3D-Printed Protective Enclosure: High-tolerance shell housing the PCB and radio antenna.
  • Threaded Fastener Tension Clamp: Integrated mechanical screw mechanism allowing universal mounting onto modern smartphones without scratching the device surface.
  • Acoustic / RF Pattern Lid: Slotted top cover providing passive heat ventilation while allowing uninhibited RF radiation.

Pin Allocation (ESP32 to LoRa SPI)

  • GPIO 5: LoRa NSS / CS (Chip Select)
  • GPIO 14: LoRa RST (Reset)
  • GPIO 2: LoRa DIO0 / IRQ (Interrupt Request)
  • GPIO 18: SPI SCK (Serial Clock)
  • GPIO 19: SPI MISO (Master In, Slave Out)
  • GPIO 23: SPI MOSI (Master Out, Slave In)
LoRaMesh: Smartphone Off-Grid Text Communicator pcb


Communication Protocol & Web Interface

  1. Local Access: The smartphone connects to the Wi-Fi network LoRa-WalkieTalkie (IP: 192.168.4.1).
  2. Web Dashboard: Opening any browser loads a lightweight, dark-mode real-time chat interface stored in ESP32 flash memory (PROGMEM).
  3. Transmission Pipeline: Messages entered into the chat UI trigger a POST request to /send. The ESP32 formats the payload and transmits it over the LoRa radio at 433/915 MHz.
  4. Reception Pipeline: The ESP32 continuously listens on the radio channel. Incoming packets trigger an interrupt, appending the message to a rolling JSON buffer that clients poll asynchronously.

Complete Firmware Implementation

cpp
1#include <WiFi.h>
2#include <WebServer.h>
3#include <SPI.h>
4#include <LoRa.h>
5
6// --- LoRa Transceiver Pin Definitions ---
7#define LORA_SCK 18
8#define LORA_MISO 19
9#define LORA_MOSI 23
10#define LORA_CS 5
11#define LORA_RST 14
12#define LORA_DIO0 2
13
14#define LORA_BAND 433E6 // Set to 433E6, 868E6, or 915E6 depending on hardware
15
16// --- Wi-Fi SoftAP Configuration ---
17const char* apSSID = "LoRa-Communicator";
18const char* apPass = "offgrid123"; // Set to "" for an open network
19WebServer server(80);
20
21// --- Message Buffer Management ---
22const int MAX_MESSAGES = 25;
23String chatHistory[MAX_MESSAGES];
24int messageCount = 0;
25
26// =========================================================================
27// Mobile-Optimized Web Chat UI (Stored in Flash Memory)
28// =========================================================================
29const char index_html[] PROGMEM = R"rawliteral(
30<!DOCTYPE html>
31<html lang="en">
32<head>
33 <meta charset="UTF-8">
34 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
35 <title>LoRa Communicator</title>
36 <style>
37 * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
38 body { background: #121212; color: #E0E0E0; display: flex; flex-direction: column; height: 100vh; }
39 .header { background: #1E1E1E; padding: 15px; text-align: center; font-weight: bold; font-size: 1.1rem; border-bottom: 1px solid #333; color: #00E676; }
40 .chat-box { flex: 1; overflow-y: auto; padding: 15px; display: flex; flex-direction: column; gap: 10px; }
41 .msg { background: #262626; padding: 10px 14px; border-radius: 12px; max-width: 80%; word-wrap: break-word; font-size: 0.95rem; border: 1px solid #333; }
42 .msg.incoming { align-self: flex-start; border-left: 4px solid #00E676; }
43 .msg.outgoing { align-self: flex-end; background: #1B5E20; border-right: 4px solid #81C784; }
44 .input-bar { display: flex; padding: 10px; background: #1E1E1E; border-top: 1px solid #333; gap: 8px; }
45 input[type="text"] { flex: 1; padding: 12px; background: #2C2C2C; border: 1px solid #444; border-radius: 8px; color: #FFF; font-size: 1rem; outline: none; }
46 input[type="text"]:focus { border-color: #00E676; }
47 button { background: #00E676; color: #000; border: none; padding: 0 20px; border-radius: 8px; font-weight: bold; cursor: pointer; font-size: 1rem; }
48 button:active { background: #00B248; }
49 </style>
50</head>
51<body>
52 <div class="header">OFF-GRID LoRa COMMUNICATOR</div>
53 <div class="chat-box" id="chat"></div>
54 <div class="input-bar">
55 <input type="text" id="userInput" placeholder="Name" style="max-width: 80px;" value="Node1">
56 <input type="text" id="msgInput" placeholder="Type a broadcast message...">
57 <button onclick="sendMessage()">Send</button>
58 </div>
59
60 <script>
61 let lastMsgIndex = 0;
62 const chat = document.getElementById('chat');
63
64 function appendMessage(text, isOut = false) {
65 const div = document.createElement('div');
66 div.className = 'msg ' + (isOut ? 'outgoing' : 'incoming');
67 div.innerText = text;
68 chat.appendChild(div);
69 chat.scrollTop = chat.scrollHeight;
70 }
71
72 function sendMessage() {
73 const user = document.getElementById('userInput').value.trim() || 'Anon';
74 const msg = document.getElementById('msgInput').value.trim();
75 if (!msg) return;
76
77 const fullText = `[${user}]: ${msg}`;
78 fetch(`/send?msg=${encodeURIComponent(fullText)}`)
79 .then(res => res.text())
80 .then(data => {
81 appendMessage(fullText, true);
82 document.getElementById('msgInput').value = '';
83 });
84 }
85
86 function fetchMessages() {
87 fetch('/get')
88 .then(res => res.json())
89 .then(messages => {
90 if (messages.length > lastMsgIndex) {
91 for (let i = lastMsgIndex; i < messages.length; i++) {
92 appendMessage(messages[i], false);
93 }
94 lastMsgIndex = messages.length;
95 }
96 });
97 }
98
99 setInterval(fetchMessages, 1000);
100 </script>
101</body>
102</html>
103)rawliteral";
104
105// =========================================================================
106// Web Handlers
107// =========================================================================
108void handleRoot() {
109 server.send(200, "text/html", index_html);
110}
111
112void handleSend() {
113 if (server.hasArg("msg")) {
114 String msg = server.arg("msg");
115
116 // Broadcast over LoRa
117 LoRa.beginPacket();
118 LoRa.print(msg);
119 LoRa.endPacket();
120
121 server.send(200, "text/plain", "SENT");
122 } else {
123 server.send(400, "text/plain", "MISSING_PAYLOAD");
124 }
125}
126
127void handleGet() {
128 String json = "[";
129 for (int i = 0; i < messageCount; i++) {
130 json += "\"" + chatHistory[i] + "\"";
131 if (i < messageCount - 1) json += ",";
132 }
133 json += "]";
134 server.send(200, "application/json", json);
135}
136
137// =========================================================================
138// Setup & Loop
139// =========================================================================
140void setup() {
141 Serial.begin(115200);
142
143 // Initialize SPI and LoRa Transceiver
144 SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS);
145 LoRa.setPins(LORA_CS, LORA_RST, LORA_DIO0);
146
147 if (!LoRa.begin(LORA_BAND)) {
148 Serial.println("Starting LoRa failed! Check wiring.");
149 while (1);
150 }
151 Serial.println("LoRa Radio Initialized Successfully.");
152
153 // Configure Access Point
154 WiFi.mode(WIFI_AP);
155 WiFi.softAP(apSSID, apPass);
156 Serial.print("Access Point Started. Gateway IP: ");
157 Serial.println(WiFi.softAPIP());
158
159 // Register Web Endpoints
160 server.on("/", handleRoot);
161 server.on("/send", handleSend);
162 server.on("/get", handleGet);
163 server.begin();
164}
165
166void loop() {
167 server.handleClient();
168
169 // Check for incoming LoRa broadcast packets
170 int packetSize = LoRa.parsePacket();
171 if (packetSize) {
172 String incoming = "";
173 while (LoRa.available()) {
174 incoming += (char)LoRa.read();
175 }
176
177 if (messageCount < MAX_MESSAGES) {
178 chatHistory[messageCount++] = incoming;
179 } else {
180 // Shift buffer left to discard oldest message
181 for (int i = 0; i < MAX_MESSAGES - 1; i++) {
182 chatHistory[i] = chatHistory[i + 1];
183 }
184 chatHistory[MAX_MESSAGES - 1] = incoming;
185 }
186
187 Serial.print("Received Radio Packet: ");
188 Serial.println(incoming);
189 }
190}