Complete technical documentation — hardware, protocol, menus, code architecture & API
If you just want to get two devices talking, start here. You do not need to understand the protocol, gateway, or code structure first.
Fastest path: flash two nodes, give them different node IDs, make sure they share the same AES key, connect from the Android app or T-Deck, and send a test message.
| Item | Minimum | Recommended |
|---|---|---|
| LoRa nodes | 2 | 2 nodes plus 1 repeater for extra range |
| Phone | 1 Android phone | Android phone with the app installed |
| USB cables | Enough to flash each board | Known good data cables, not charge-only |
| Browser | Chrome or Edge | Latest Chrome or Edge for web flashing |
| Goal | Best Option | Why |
|---|---|---|
| Cheapest repeater | XIAO ESP32S3 + Wio-SX1262 | Very low cost, compact, easy to deploy |
| Best value repeater | Heltec V3 | OLED status, SX1262 radio, good general-purpose choice |
| Best handheld communicator | T-Deck Plus | Keyboard, screen, GPS, and direct field use without a phone |
Important: matching AES key, matching radio settings, and unique node IDs are the three things that must be correct before anything else will work.
If you want more range, flash a third node as a repeater and place it between the two endpoints. The mesh will forward traffic automatically when it hears packets that are not for itself.
Once basic messaging works, connect a relay or sensor to a repeater node. From the Android app or T-Deck you can then read inputs, trigger outputs, and optionally forward group alerts to Telegram when a phone has internet access.
FFFF for broadcast| Symptom | Most likely cause |
|---|---|
| Nodes do not see each other | Different AES key, mismatched radio settings, bad antenna, or same node ID |
| BLE app cannot connect | Wrong firmware type, BLE disabled, or board is in solar mode |
| Message sends but never delivers | Target unreachable, wrong target ID, weak link, or ACK lost |
| Gateway does not appear on map | No location configured or hub connection not active |
Then keep reading: the rest of this page is the full reference manual covering boards, UI, protocol, gateway setup, GPIO control, power modes, and troubleshooting in more depth.
TiggyOpenMesh is an off-grid messaging, alerting, and remote-control system built on cheap ESP32 LoRa boards. It is designed for small private networks where you want messages, SOS alerts, sensor inputs, and relay outputs to keep working even when there is no internet or mobile coverage.
Best fit: farms, estates, expeditions, cabins, remote sites, event control, and hobby emergency networks.
Core strengths: directed routing, message delivery receipts, relay and sensor control, Android + BLE control, T-Deck handheld UI, WiFi and PC gateway options, SOS workflows, solar repeater mode, and a shared MeshCore library so all radio protocol logic is maintained in one place.
What it does in practice:
Send short messages between field nodes, trigger relays remotely, read sensor inputs, repeat traffic across solar-powered nodes, and forward alerts to Telegram when any connected phone regains internet access.
| Role | Hardware | Firmware | Description |
|---|---|---|---|
| Full Communicator | T-Deck Plus | main.cpp |
Keyboard, 2.8" colour TFT, GPS, trackball, chat UI, SOS mode, mesh map, phrase picker, remote control panel |
| Repeater / Relay Node | LoRa32, Heltec V3, or Heltec V4 | repeater.cpp |
Headless mesh repeater with 0.96" OLED stats display, BLE for phone control, GPIO relay/sensor control, serial config, gateway bridge mode |
| WiFi Gateway | Heltec V2/V3/V4 or XIAO | gateway_wifi.cpp |
Standalone LoRa-to-WiFi bridge — no PC needed. Connects to hub, supports BLE config, location for map |
| Phone Controller | Android phone | Android app (Kotlin) | BLE connection to any repeater node — chat, relay control, settings, node discovery, delivery tracking |
| Parameter | Value |
|---|---|
| Frequency | 868.0 MHz (EU ISM band) |
| Modulation | LoRa — SF9, BW 125 kHz, CR 4/5 |
| TX Power | 20 dBm (28 dBm with Heltec V4 PA) |
| Encryption | AES-128-GCM (authenticated encryption) |
| Max Hops (TTL) | 5 |
| Max Nodes Tracked | 50 |
| Max Message Size | 200 bytes (plaintext) |
| Heartbeat Interval | 30 seconds (±2s jitter) |
| Route Stale Timeout | 120 seconds |
| Duty Cycle Limit | 10% per hour (EU legal) |
MCU: ESP32-S3 • Radio: SX1262 • Display: ST7789 320×240 IPS
Extras: QWERTY keyboard, trackball, GPS (MIA-M10Q), SD card slot
| Function | Pin |
|---|---|
| SPI MOSI / MISO / SCK | 41 / 38 / 40 |
| Radio CS / RST / DIO1 / BUSY | 9 / 17 / 45 / 13 |
| TFT CS / DC / BL | 12 / 11 / 42 |
| GPS TX / RX | 43 / 44 |
| I2C SDA / SCL | 18 / 8 |
| Keyboard INT (I2C 0x55) | 46 |
| Trackball U/D/L/R/Click | 3 / 15 / 1 / 2 / 0 |
| SD Card CS | 39 |
| Battery ADC | 4 |
| Power ON | 10 |
MCU: ESP32 • Radio: SX1276 • Display: SSD1306 128×64 OLED
The cheap repeater. Lots of free GPIO for relays & sensors.
| Function | Pin |
|---|---|
| SPI MOSI / MISO / SCK | 27 / 19 / 5 |
| Radio CS / RST / DIO0 | 18 / 23 / 26 |
| I2C SDA / SCL / OLED RST | 21 / 22 / 16 |
| Battery ADC | 35 |
| LED | 25 |
| Button | 0 |
| Default Relay Pins | 2, 4, 12, 15, 17, 33 |
| Default Sensor Pins | 34, 36, 39 (input only) |
MCU: ESP32-S3 • Radio: SX1262 • Display: SSD1306 128×64 OLED
Modern repeater. Better range than SX1276, more GPIO, USB-C.
| Function | Pin |
|---|---|
| SPI MOSI / MISO / SCK | 10 / 11 / 9 |
| Radio CS / RST / DIO1 / BUSY | 8 / 12 / 14 / 13 |
| I2C SDA / SCL / OLED RST | 17 / 18 / 21 |
| Vext Power Control | 36 (LOW = OLED on) |
| Battery ADC | 1 |
| LED | 35 |
| Default Relay Pins | 2, 3, 4, 5, 6, 7 |
| Default Sensor Pins | 19, 20, 33 |
MCU: ESP32-S3 • Radio: SX1262 • Display: None (optional I2C SSD1306)
Cheapest repeater. Plug-together kit, no soldering. Tiny 10×21mm. Available from The Pi Hut UK.
Pins verified from Seeed schematic: Wio-SX1262 for XIAO V1.0.kicad_sch
| Function | GPIO | Header Pin |
|---|---|---|
| SPI SCK | 8 | D8 |
| SPI MISO | 9 | D9 |
| SPI MOSI | 10 | D10 |
| Radio CS (NSS) | 44 | D7 |
| Radio RST | 3 | D2 |
| Radio DIO1 | 33 | internal |
| Radio BUSY | 34 | internal |
| RF Switch (TX/RX) | 1 | D0 — HIGH=RX, LOW=TX |
| Optional OLED SDA / SCL | 5 / 6 | D4 / D5 |
| Default Relay Pins | 2, 4, 5, 6 | D1, D3, D4, D5 |
[USB-C]
D0/GPIO1 ● ● 5V RF_SW1 (TX/RX control)
D1/GPIO2 ● ● GND Free relay/GPIO
D2/GPIO3 ● ● 3V3 RST
D3/GPIO4 ● ● D10/GPIO10 MOSI
D4/GPIO5 ● ● D9 /GPIO9 MISO (optional OLED SDA)
D5/GPIO6 ● ● D8 /GPIO8 SCK (optional OLED SCL)
D6/GPIO43 ● ● D7 /GPIO44 CS/NSS
MCU: ESP32-S3 • Radio: SX1262 + GC1109 PA (28 dBm!) • Display: SSD1306 128×64 OLED
Best repeater. Highest TX power, 16MB flash, native USB-C, solar input, GNSS connector.
FEM Note: The GC1109 front-end module requires GPIO 2 (FEM enable) and GPIO 46 (PA TX enable) permanently HIGH. These pins are reserved by the firmware and cannot be used as relay pins. GPIO 7 is the PA power pin (set to ANALOG mode). Misconfiguring these pins disables the power amplifier and reduces TX range to ~1/10th.
| Function | Pin |
|---|---|
| SPI MOSI / MISO / SCK | 10 / 11 / 9 |
| Radio CS / RST / DIO1 / BUSY | 8 / 12 / 14 / 13 |
| I2C SDA / SCL / OLED RST | 17 / 18 / 21 |
| FEM Enable / PA TX Enable / PA Power | 2 / 46 / 7 (reserved, do not touch) |
| Vext Power Control | 36 (LOW = OLED on) |
| ADC Control | 37 (HIGH = enable battery divider) |
| Battery ADC | 1 |
| LED | 35 |
| Default Relay Pins | 3, 4, 5, 6 |
| Default Sensor Pins | 33, 34, 15 |
All mesh protocol logic lives in lib/MeshCore/. Both firmwares link against it — no duplicate code.
Each firmware registers hardware-specific callbacks with MeshCore at startup:
// In setup(): mesh.onTransmitRaw = radioTransmit; // Send raw bytes via SX1262/SX1276 mesh.onChannelFree = radioChannelFree; // Check RSSI for listen-before-talk mesh.onMessage = handleMessage; // Decrypted message arrived for us mesh.onCmd = handleCmd; // CMD (GPIO control) arrived mesh.onAck = handleAck; // ACK received mesh.onNodeDiscovered = handleNodeDiscovered;// New node heard for first time mesh.onIdConflict = handleIdConflict; // Another node using our ID detected mesh.onCfg = handleCfg; // Config change request (SF change) mesh.onCfgAck = handleCfgAck; // Config change acknowledged by a node mesh.onCfgGo = handleCfgGo; // Config change committed — apply now
loop() {
receiveCheck(); // Check radio ISR flag, read packet, call mesh.processPacket()
handleAckRetry(); // Retry unacknowledged messages
mesh.processPendingForwards(); // Send jitter-delayed forwards
// Heartbeat (every ~30s with ±2s jitter)
if (millis() > nextHeartbeatTime)
mesh.sendHeartbeat();
// Route cleanup (every 10s)
mesh.pruneStale();
// ... GPS, UI, BLE handling ...
}
| File | Purpose | Lines |
|---|---|---|
lib/MeshCore/MeshCore.h | Shared library header — constants, structs, class definition, callbacks | ~234 |
lib/MeshCore/MeshCore.cpp | All shared mesh logic — CRC, crypto, routing, dedup, forwarding, CFG auth | ~575 |
src/main.cpp | T-Deck Plus full communicator — TFT UI, keyboard, GPS, menus, display sleep, delivery tracking | ~2300 |
src/repeater.cpp | Headless repeater — OLED stats, BLE, GPIO, serial config, solar mode, pulse counters | ~1800 |
src/gateway_wifi.cpp | WiFi ESP gateway — LoRa + WiFi bridge, BLE config, GWHEIGHT, hub WebSocket | ~900 |
include/Pins.h | Board-specific pin definitions (5 boards + custom template) | ~550 |
include/phrases.h | Quick phrase library (40 phrases, 4 categories) | ~40 |
android-app/ | Kotlin + Jetpack Compose BLE controller app | ~3700 |
gateway/ | Python gateway client, hub server (headless + GUI), web map | ~3300 |
<from>,<to>,<mid>,<ttl>,<route>,<encrypted_hex>
Example: "0001,0002,abcxyz,5,0001,48656C6C6F..."
Fields:
from 4-char hex sender ID
to 4-char hex destination (FFFF = broadcast)
mid 6-char message ID (A-Z random)
ttl Remaining hop count
route Comma-separated path (e.g. "0001,0003,0005")
encrypted_hex AES-128-GCM encrypted payload (hex encoded, nonce || ciphertext || tag)
| Prefix | Format | Description |
|---|---|---|
MSG, | MSG,Hello World | Text message |
MSG,# | MSG,#1/3|Part one text | Chunked message (part 1 of 3) |
POS, | POS,51.507222,-0.127500 | GPS position broadcast |
SOS, | SOS,51.507222,-0.127500 or SOS,NOFIX | Emergency distress signal |
CMD, | CMD,SET,2,1 | Remote GPIO command |
| Type | Format | Description |
|---|---|---|
| Heartbeat | HB,0001 | Periodic presence announcement (every ~30s) |
| ACK | ACK,0001,XYZW | Delivery acknowledgement (to node 0001, for message XYZW) |
| CFG | CFG,SF,7,A1B2,C3D4 | Config change proposal (type, value, changeId, authTag) |
| CFGACK | CFGACK,SF,7,A1B2,0010 | Config change accepted by node 0010 |
| CFGGO | CFGGO,SF,7,A1B2,C3D4 | Config change committed — all nodes apply now |
CFG Auth Tags: CFG and CFGGO packets include an 8-hex-char auth tag derived from the current AES key and the change ID. Nodes verify this before accepting a spreading-factor change, which stops casual or accidental reconfiguration from nodes outside the key group.
MSG,Helloencrypt(key, nonce)nonce || ciphertext || tag450Each message uses a 12-byte random nonce generated via esp_random(). GCM provides both confidentiality and authentication — no separate CRC needed.
// Encrypted blob format (hex-encoded): nonce(12 bytes) || ciphertext(N bytes) || tag(16 bytes) // Decryption: extract nonce, decrypt, verify GCM tag // If tag check fails → message rejected (tampered or wrong key)
6-character random (A-Z), used for deduplication and ACK matching. It is not used as the encryption nonce.
This system maintains a routing table and prefers directed forwarding when a route is known. If no route exists, it falls back to a controlled broadcast with random jitter to reduce collision storms.
// Route cost = (hop_count × 10) - RSSI // Lower is better. Closer nodes with fewer hops win. Example: Route via 0003: cost = (2 hops × 10) - (-85 RSSI) = 105 Route via 0005: cost = (1 hop × 10) - (-60 RSSI) = 70 ← winner
| Mechanism | Detail |
|---|---|
| Jitter Delay | Random 50–500ms wait before forwarding. Prevents multiple nodes forwarding simultaneously. |
| Listen-Before-Talk | Scans channel RSSI before transmitting. If > -120 dBm, waits up to 200ms for channel to clear. |
| Duty Cycle | Tracks total airtime. The 10% budget (EU 868 MHz legal limit) is split: local traffic (messages, heartbeats, setpoint actions) is capped at 7%, reserving 3% exclusively for forwarding. This guarantees that a node acting as a repeater will never refuse to forward other nodes' packets because its own automation commands used up the budget. |
| Heartbeat Jitter | Heartbeats sent every 30s ±2s random offset to prevent periodic collisions. |
// FNV-1a hash of the 6-char Message ID hash = 2166136261; // FNV offset basis for each char c in mid: hash ^= c; hash *= 16777619; // FNV prime hash |= 1; // Never zero (zero = empty slot) // Stored in 128-slot ring buffer. O(1) lookup, O(1) insert.
The T-Deck Plus has a full 320×240 colour TFT display with a hardware QWERTY keyboard and trackball. All screens share a consistent layout: header bar (28px), content area, and status bar (18px).
| Item | Action | Key |
|---|---|---|
| ■ New Message | Opens text input — type a message with the keyboard, press Enter to send | Trackball click / Enter |
| ■ Quick Phrases | Pick up to 5 pre-defined phrases from 4 categories. Phrases are compressed to hex IDs for compact transmission. | |
| ■ View Messages | Chat bubble view showing message history with scroll | |
| ■ Remote Control | Send GPIO commands (SET/GET/PULSE) to the target node | |
| ■ SOS Emergency | Flashing red screen, broadcasts SOS + GPS every 2 minutes. Hold Esc 3s to cancel. | |
| ■ Settings | Opens settings submenu | |
| ■ Known Nodes | List of discovered nodes with RSSI, signal bars, age, and GPS indicator | |
| ■ Mesh Map | Live view of mesh topology — nodes, signal strength, and neighbour links. Auto-refreshes every 5s. | |
| ■ Broadcast Pos | Sends your GPS coordinates to all nodes | |
| ■ Find Node | Compass arrow and distance to the target node (requires both nodes to have GPS) |
| Setting | Input | Notes |
|---|---|---|
| Set Local ID | 4 hex characters (0-9, A-F) | Your node's unique address on the mesh |
| Set Target ID | 4 hex characters | Default recipient. FFFF = broadcast to all |
| Edit AES Key | Exactly 16 ASCII characters | All nodes must share the same key! |
| Brightness | Cycles: 100% → 50% → 25% | Display backlight PWM |
| Display Timeout | Cycles: Off → 30s → 60s → 120s → 300s | Auto-sleep after idle. See Display Sleep. |
| Clear Nodes | Immediate | Wipes all known node IDs from memory & EEPROM |
On first power-up (EEPROM magic byte 0xA5 not found), a 3-step wizard guides setup:
3FA7) and a 10-second conflict scan runs in the background. A progress bar and countdown are shown on screen. If no conflict is detected, the suggested ID is accepted. If a collision is found, the ID auto-regenerates and re-scans. You can override the ID at any time by typing a custom one.Repeater first-boot: Repeater nodes also auto-generate a random ID on first boot and run a 10-second conflict scan with an OLED countdown. If a collision is found, the ID is regenerated and the scan restarts automatically. Config is only saved to EEPROM after a clear scan completes.
| Context | Key | Action |
|---|---|---|
| Menu | Trackball Up/Down | Navigate items |
| Menu | Trackball Click / Enter | Select item |
| Menu | Esc / Trackball Left | Back to parent menu |
| Chat | n | New message |
| Chat | r | Quick reply to last sender |
| Chat | Trackball Up/Down | Scroll messages |
| Chat | Esc | Back to menu |
| Text Input | Enter | Send / confirm |
| Text Input | Backspace | Delete last char |
| Text Input | Esc | Cancel |
| Tracking/Map | q / Esc | Back to menu |
| SOS | Hold Esc 3s | Cancel SOS mode |
The repeater boards (LoRa32 & Heltec V3) show a 128×64 OLED stats screen that refreshes every 5 seconds.
| Field | Meaning |
|---|---|
MESH RPT 0010 | Device role + node ID |
RX:1247 | Total packets received since boot |
FWD:583 | Total packets forwarded (relayed) |
N:5 | Number of known nodes |
R:12 | Active routes in routing table |
BLE | Shown when a phone is connected via BLE |
RSSI:-78dBm | Signal strength of last received packet |
Relay:101000 | State of each relay pin (1=ON, 0=OFF) |
Up: 14h23m | Uptime since last reset |
The companion Android app connects to any repeater node via BLE (Nordic UART Service). It turns any headless board into a full communicator with chat, relay control, sensor monitoring, and remote node management. A foreground BLE service keeps the connection alive when the app is in the background.
| Component | File | Responsibility |
|---|---|---|
| BLE Manager | BleManager.kt | Scan, connect, disconnect, send/receive via Nordic UART. Remote relay/sensor control functions. |
| BLE Service | BleConnectionService.kt | Android foreground service — keeps BLE connection alive when app is in background. |
| App Singleton | LoRaMeshApp.kt | Application-level BleManager instance shared across all components. |
| ViewModel | MeshViewModel.kt | Parse incoming data, manage UI state. Auto-saves key/ID to EEPROM after changes. |
| Scan Screen | ScanScreen.kt | BLE device discovery with RSSI colour coding |
| Chat Screen | ChatScreen.kt | Send/receive messages, broadcast/direct toggle |
| Control Screen | ControlScreen.kt | Relay toggles, pulse buttons, sensor sparklines. Node picker for remote node control via mesh. |
| Nodes Screen | NodesScreen.kt | Discovered mesh nodes list with online/offline status and signal strength |
| Settings Screen | SettingsScreen.kt | Node ID, AES key (auto-saved), GPS toggle, solar mode toggle, Telegram config |
| Theme | Theme.kt | Dark theme matching firmware cyan/green palette |
The Control screen includes a node picker dropdown at the top. Select any discovered mesh node to control its relays and read its sensors remotely. Commands are encrypted and sent via MSG,<nodeId>,CMD,... over the mesh. The local node processes commands directly via BLE.
The app uses an Android foreground service (BleConnectionService) to maintain the BLE connection when the app is backgrounded or the screen is off. A persistent notification shows the connection status. This prevents Android from killing the BLE connection during battery optimization.
BLE Service: Nordic UART Service (NUS)
Service UUID: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E
TX (phone → node): 6E400002-... RX (node → phone): 6E400003-...
Data is chunked to 20-byte BLE MTU packets with 30ms delays between chunks.
| Command | Format | Example | Response |
|---|---|---|---|
| Send Message | MSG,<target>,<text> | MSG,0001,Hello | OK,SENT,0001 |
| Set Relay | CMD,SET,<pin>,<0|1> | CMD,SET,2,1 | CMD,RSP,2,1 |
| Read Pin | CMD,GET,<pin> | CMD,GET,34 | CMD,RSP,34,2847 |
| Pulse | CMD,PULSE,<pin>,<ms> | CMD,PULSE,2,1000 | CMD,RSP,2,0 |
| List Pins | CMD,LIST | PINS,R:2,4,12|S:34,36 | |
| Set Node ID | ID <4hex> | ID 0002 | OK,ID,0002 |
| Set AES Key | KEY <16chars> | KEY MySecretKey123 | OK,KEY |
| Get Status | STATUS | STATUS,ID:0010,BOARD:... | |
| Save Config | SAVE | OK,SAVED | |
| Set SF | SF,<7-12> | SF,7 | OK,SF,7 |
| Commit SF | SFGO,<7-12> | SFGO,7 | OK,SFGO,7 |
| Solar Mode On | POWER,SOLAR | OK,POWER,SOLAR (BLE deinits, connection drops) | |
| Solar Mode Off | POWER,NORMAL | OK,POWER,NORMAL | |
| Poll Local Sensors | POLL | SDATA,0010,34:2048,36:1200 | |
| Poll Remote | POLL,<nodeId> | POLL,0002 | Remote node replies with SDATA over mesh |
| Set Timer | TIMER,<pin>,ON|OFF,<sec> | TIMER,4,ON,300 | OK,TIMER,4,ON,300 |
| Pulse Timer | TIMER,<pin>,PULSE,<on>,<off>,<rpt> | TIMER,4,PULSE,60,120,5 | OK,TIMER,4,PULSE |
| List Timers | TIMER,LIST | TIMERS,<count>,... | |
| Clear Timers | TIMER,CLEAR | OK,TIMER,CLEAR | |
| Set Setpoint | SETPOINT,<sPin>,<op>,<thr>,<node>,<rPin>,<act> | SETPOINT,34,GT,2000,0010,4,1 | OK,SETPOINT |
| List Setpoints | SETPOINT,LIST | SETPOINTS,<count>,... | |
| Clear Setpoints | SETPOINT,CLEAR | OK,SETPOINT,CLEAR | |
| Start Auto-Poll | AUTOPOLL,<nodeId>,<sec> | AUTOPOLL,FFFF,300 | OK,AUTOPOLL,FFFF,300 |
| Stop Auto-Poll | AUTOPOLL,OFF | OK,AUTOPOLL,OFF | |
| Set Pin Mode | PINMODE,<pin>,PULSE|AUTO | PINMODE,34,PULSE | OK,PINMODE,34,PULSE |
POWER,SOLAR warning: Enabling solar mode via BLE deinitialises BLE after sending the response — the connection drops. The app should warn the user before sending. To restore: connect via USB serial and send SOLAR OFF, or press the PRG button.
| Event | Format | Example |
|---|---|---|
| Incoming Message | RX,<from>,<text>,<rssi> | RX,0001,Hello,-85 |
| Message Sent | SENT,<mid> | SENT,abcxyz |
| Delivery ACK | ACK,<from>,<mid> | ACK,0002,abcxyz |
| Command Response | CMD,RSP,<pin>,<value> | CMD,RSP,2,1 |
| Pin List | PINS,R:<pins>|S:<pins> | PINS,R:2,4,12|S:34,36 |
| Status | STATUS,<key:val,...> | STATUS,ID:0010,BOARD:LoRa32...,POWER:NORMAL |
| Node Found | NODE,<id>,<rssi>,<hops> | NODE,0002,-95,1 |
| ID Conflict | CONFLICT,<id>,<rssi> | CONFLICT,0010,-65 |
| CFG Proposed | CFGSTART,<type>,<value>,<changeId> | CFGSTART,SF,7,A1B2 |
| CFG Node ACK | CFGACK,<nodeId>,<type>,<value>,<changeId> | CFGACK,0010,SF,7,A1B2 |
| CFG Applied | CFGGO,<type>,<value>,<changeId> | CFGGO,SF,7,A1B2 |
| Sensor Data | SDATA,<nodeId>,<pin>:<val>,... | SDATA,0010,34:2048,36:1200 |
| Timer Fired | TIMER,FIRED,<pin>,ON|OFF | TIMER,FIRED,4,ON |
| Timer Done | TIMER,DONE,<pin> | TIMER,DONE,4 |
| Setpoint Fired | SETPOINT,FIRED,<pin>,<value> | SETPOINT,FIRED,34,2100 |
Connect to the repeater via USB serial at 115200 baud. Same commands work via BLE.
ID <4hex> Set node ID e.g. ID 0002 KEY <16chars> Set AES key e.g. KEY MySecretKey12345 RELAY <pins> Set relay GPIO pins e.g. RELAY 2,4,12,15 SENSOR <pins> Set sensor GPIO pins e.g. SENSOR 34,36 STATUS Show device info SAVE Write config to EEPROM RESET Restore factory defaults GATEWAY ON Enable gateway mode (raw packet forwarding) GATEWAY OFF Disable gateway mode PKT,<hex> Inject raw packet into mesh (gateway mode only) AUTOPOLL <id> <sec> Start auto-polling e.g. AUTOPOLL FFFF 300 AUTOPOLL OFF Stop auto-polling PINMODE <pin> <mode> Set sensor mode e.g. PINMODE 34 PULSE (or AUTO) PINMODE <pin> PULSE <ms> Pulse mode + sample window e.g. PINMODE 5 PULSE 3000 EXPAND ON Enable IO expansion board on UART2 EXPAND OFF Disable IO expansion board SOLAR ON Enable solar/low-power mode (SX1262 boards only) SOLAR OFF Disable solar mode, restore normal operation BEACON,SCAN Scan for nearby BLE beacons (2 seconds) BEACON,ADD,... Add a beacon detection rule (see iBeacon Scanner) BEACON,LIST List all active beacon rules BEACON,CLEAR Delete all beacon rules SETPOINT,... Add a cross-node setpoint rule (see Setpoints) SETPOINT,LIST List all active setpoint rules SETPOINT,CLEAR Delete all setpoint rules REBOOT Restart the node (settings preserved) EEPROM,RESET Factory reset — wipe all EEPROM and restart GPSPOS Query current GPS position GPS,<tx>,<rx> Set GPS UART pins e.g. GPS,38,39 # STATUS response includes: # ID, BOARD, POWER:NORMAL|SOLAR, SF:9, RELAY pins, SENSOR pins
Pin safety: The firmware blocks writes to SPI, radio, I2C, and boot pins. Only user-configurable GPIO pins are allowed for relay/sensor control. This prevents accidental damage to the radio or display.
The T-Deck's phrase picker lets you compose messages from pre-defined phrases without typing. Phrases are sent as hex IDs (e.g. $0A|$1E) and expanded on receipt — extremely bandwidth-efficient.
| STATUS (0-9) | REQUEST (10-19) | LOCATION (20-29) | INTERACTION (30-39) |
|---|---|---|---|
| I AM OK | SEND HELP | AT BASE | WHERE ARE YOU |
| I AM LOST | NEED WATER | NEAR RIVER | I SEE YOU |
| I AM INJURED | NEED FOOD | AT CAMP | COME TO ME |
| I AM RESTING | NEED BATTERY | NEAR ROAD | GO TO BASE |
| I AM MOVING | NEED WARMTH | NEAR TREE | FOLLOW ME |
| I AM SAFE | BRING SUPPLIES | NEAR BUILDING | STAY THERE |
| I AM WAITING | RADIO CHECK | ON TRAIL | TURN BACK |
| I AM COLD | GPS HELP | INSIDE SHELTER | JOIN ME |
| I AM SCARED | MEDIC NEEDED | AT CHECKPOINT | MEET AT CAMP |
| I AM WET | SEND TRANSPORT | LOST POSITION | CONFIRM SIGNAL |
Wire format example: Selecting "I AM OK" + "AT CAMP" + "NEED WATER" sends: $00|$16|$0B (6 bytes instead of 30)
GPIO commands are encrypted and routed through the mesh like regular messages. A T-Deck or phone app can control relays on any reachable node.
| Command | Format | Description |
|---|---|---|
| SET | CMD,SET,<pin>,<0|1> | Set pin HIGH (1) or LOW (0). Pin is configured as OUTPUT. |
| GET | CMD,GET,<pin> | Read pin value. Analog sensor pins return 0-4095. Digital pins return 0/1. (ESP32: ADC on GPIO 32-39. ESP32-S3: ADC on GPIO 1-20.) |
| PULSE | CMD,PULSE,<pin>,<ms> | Set pin HIGH for N milliseconds, then LOW. Max 30 seconds. |
| LIST | CMD,LIST | Returns configured relay and sensor pins. |
| RSP | CMD,RSP,<pin>,<value> | Response containing pin value (sent back to requester). |
Forbidden Pins: The firmware blocks access to these pins regardless of configuration:
SPI bus (MOSI, MISO, SCK) • Radio (CS, RST, DIO, BUSY) • Display (CS, DC) • Boot (GPIO 0)
Up to 4 concurrent relay timers can be set via BLE, serial, or mesh command. Timers are ephemeral (not saved to EEPROM) and lost on reboot.
| Command | Format | Description |
|---|---|---|
| Delay ON/OFF | TIMER,<pin>,ON|OFF,<seconds> | Set pin HIGH or LOW after a delay (max 86400s / 24 hours). |
| Pulse | TIMER,<pin>,PULSE,<onSec>,<offSec>,<repeats> | Alternating ON/OFF cycle. repeats=0 = forever. Max 3600s per phase. |
| List | TIMER,LIST | Returns active timers and remaining time. |
| Clear | TIMER,CLEAR | Cancels all active timers. |
TIMER,FIRED,<pin>,ON|OFF over BLE.TIMER,DONE,<pin>.CMD,TIMER,...).# Turn on pump (GPIO 4) for 30 minutes, then off TIMER,4,ON,0 # ON immediately TIMER,4,OFF,1800 # OFF after 30 min # Pulse: 5 min on, 10 min off, repeat 3 times TIMER,4,PULSE,300,600,3
Setpoints are conditional automation rules that run on the node itself — no gateway needed. The node reads its own sensor, evaluates the condition locally, and only uses the radio when the condition triggers. This is far more efficient than gateway-polled rules.
Why on-node matters: A gateway rule polls the sensor every 5 seconds (3 radio transmissions per cycle even when nothing changes). An on-node setpoint uses zero radio until the condition triggers — then sends one packet. For battery/solar nodes this is critical.
| Type | Format | Description |
|---|---|---|
| Relay | SETPOINT,<pin>,<op>,<threshold>,<target>,<relayPin>,<action> |
When condition triggers, send CMD,SET to target node. If target is self, the relay is set directly via digitalWrite() with zero radio usage. |
| Message | SETPOINT,<pin>,<op>,<threshold>,MSG,<msgTrue>[,<msgFalse>] |
Broadcasts a text message when condition becomes true (e.g., “Gate open”) and optionally a different message when it clears (e.g., “Gate closed”). |
| List | SETPOINT,LIST | Returns all active setpoint rules. |
| Clear | SETPOINT,CLEAR | Removes all setpoint rules. |
| Op | Meaning | Example |
|---|---|---|
GT | Greater than | Temperature above threshold → turn on fan |
LT | Less than | Soil moisture below threshold → turn on pump |
EQ | Equal to | Digital input = 1 → broadcast “Gate open” |
GE | Greater or equal | Wind speed ≥ 15 m/s → sound alarm |
LE | Less or equal | Battery ≤ 3.3V → send low-battery alert |
NE | Not equal | Value changed from expected → alert |
Append these to any setpoint command to add signal processing on the node:
| Suffix | Format | What It Does |
|---|---|---|
| SCALE | ,SCALE,<factor>,<offset> |
Converts raw sensor reading to engineering units before comparison: scaled = raw × factor + offset. This means the threshold is in real units (m/s, °C, etc.) not raw ADC counts. |
| DEBOUNCE | ,DEBOUNCE,<ms> |
Condition must stay true for this many milliseconds before the action fires. Filters out noise and brief spikes. If the condition goes false before the timer expires, it resets. |
digitalWrite() is called directly — no radio packet is generated at all.SETPOINT,FIRED,<sensorPin>,<value> over BLE for monitoring.# Gate monitor: broadcast "Gate open" / "Gate closed" on digital input change SETPOINT,5,EQ,1,MSG,Gate open,Gate closed # Wind alarm: Scale raw pulse rate to m/s, alarm if >= 15 m/s, debounce 3s SETPOINT,5,GE,15.0,0010,2,1,SCALE,0.4524,0,DEBOUNCE,3000 # Frost protection: Scale ADC to °C, trigger heater when < 3°C SETPOINT,34,LT,3.0,0010,4,1,SCALE,0.0244,-10.0 # Local relay: target is self (same node), no radio needed SETPOINT,34,GT,2000,0001,4,1 # (where 0001 is this node's own ID)
Nodes can read local analog/digital sensors and broadcast bundled sensor data (SDATA) across the mesh for remote monitoring.
SDATA,<nodeID>,<pin1>:<value1>,<pin2>:<value2>,...
# Example: Node 0010 reporting 3 sensors
SDATA,0010,34:2048,36:1200,39:4095
| Command | Format | Description |
|---|---|---|
| Local POLL | POLL (BLE/serial) | Read all local sensor pins, return SDATA over BLE/serial. |
| Remote POLL | POLL,<targetNodeId> (BLE) | Send CMD,POLL to a remote node; it replies with SDATA over the mesh. |
| Single Pin | CMD,GET,<pin> | Read one specific pin on a remote node. |
Auto-Poll sends SDATA to a target node at a regular interval. Ideal for unattended sensor stations that periodically report readings.
| Command | Format | Description |
|---|---|---|
| Start | AUTOPOLL,<targetNodeId>,<intervalSec> | Begin periodic SDATA broadcasts. Minimum interval: 30 seconds. |
| Stop | AUTOPOLL,OFF | Stop auto-polling. |
Persistence: Auto-poll configuration is saved to EEPROM (address 433). It resumes automatically 10 seconds after reboot.
| Pin Range | Read Method | Value Range | Use Case |
|---|---|---|---|
| ESP32: GPIO 32-39 | analogRead() | 0-4095 (12-bit ADC) | Temperature, soil moisture, light, wind speed |
| ESP32-S3: GPIO 1-20 | analogRead() | 0-4095 (12-bit ADC) | Same — S3 has ADC on most GPIOs |
| Any GPIO (digital) | digitalRead() | 0 or 1 | Door switches, reed sensors, rain gauges |
| Board | Default Sensor Pins | Notes |
|---|---|---|
| LoRa32 T3 | 34, 36, 39 | Input-only, ADC1. Best for analog sensors. |
| Heltec V3 | 19, 20, 33 | All ADC-capable on ESP32-S3. |
| Heltec V4 | 33, 34, 15 | GPIO 19/20 are USB D-/D+, GPIO 2/46/7 are GC1109 FEM, GPIO 38 is GPS header — do not use any of these as GPIO. |
| XIAO ESP32S3 | 43, 44 | Digital-only (UART pins). Use SENSOR 2,4,5 for analog. |
| Heltec V2 | 36, 39 | Input-only, ADC1. Used as WiFi gateway — sensors optional. |
| T-Deck Plus | None | No free GPIO — all pins used by display/keyboard/GPS. |
Reconfigure pins: Use SENSOR <pin1>,<pin2>,... via serial, then SAVE. This overrides the defaults.
The sensor system is designed for unattended outdoor monitoring. Typical setup:
Connect sensors to a solar-powered repeater node (see Section 24: Solar Mode), configure AUTOPOLL to send readings every 5 minutes to a gateway node, and monitor via the PC gateway GUI, hub GUI, or web map — all with sparkline charts.
gateway/exports/).Sensor pins can be switched from analog/digital read mode to pulse counter mode, where hardware interrupts count rising-edge pulses. The firmware calculates pulses per second over a configurable sample window, so SDATA and setpoint rules work with rate values, not raw counts.
| Interface | Command | Response |
|---|---|---|
| BLE | PINMODE,<pin>,PULSE | OK,PINMODE,<pin>,PULSE |
| BLE | PINMODE,<pin>,AUTO | OK,PINMODE,<pin>,AUTO |
| Serial | PINMODE <pin> PULSE | OK: Pin <pin> set to PULSE mode (window=5000ms) |
| Serial | PINMODE <pin> PULSE <windowMs> | OK: Pin <pin> set to PULSE mode (window=<windowMs>ms) |
| Serial | PINMODE <pin> AUTO | OK: Pin <pin> set to AUTO mode |
Every sample window (default 5 seconds), the firmware reads the pulse count, calculates the delta since last read, and divides by elapsed time to get pulses per second. The SDATA value for pulse pins is rate × 100 — so a reading of 470 means 4.70 pulses per second.
# Configure sensor pin 5 for pulse counting (3-second window) PINMODE 5 PULSE 3000 # SDATA response shows rate × 100 (centipulses/sec): # SDATA,0010,5:470,36:2048 # pin 5: 470 = 4.70 pulses/sec # pin 36: 2048 (analog read, temperature sensor) # On-node setpoint: scale pulse rate to m/s, alarm at 15 m/s # For an anemometer with 7.2cm arm, 1 PPR: # speed_ms = pulses_per_sec × 2π × 0.072 = rate × 0.4524 # But SDATA gives rate×100, so scaleFactor = 0.4524 / 100 = 0.004524 SETPOINT,5,GE,15.0,0010,2,1,SCALE,0.004524,0
Pin requirements: Pulse counter pins must support hardware interrupts. On ESP32, most GPIO pins support interrupts. The pin is configured as INPUT_PULLUP when pulse mode is enabled. Use PINMODE <pin> AUTO to revert to normal analog/digital read. Minimum sample window is 100ms.
The mesh network includes a built-in SOS emergency system. When triggered, an SOS alert is broadcast to all nodes on the mesh with the sender's GPS location (if available), and forwarded to Telegram.
SOS,lat,lon# With GPS fix: SOS,51.481583,-3.178500 # Without GPS: SOS
Emergency use only: SOS alerts are broadcast to the entire mesh and cannot be recalled. The confirmation dialog is there for a reason — only send when you genuinely need help.
512 bytes of EEPROM store persistent configuration. Layout is identical across all boards.
| Address | Size | Content | Default |
|---|---|---|---|
0 | 5 bytes | Local Node ID (4 chars + null) | "0001" |
10 | 5 bytes | Target Node ID (4 chars + null) | "FFFF" |
20 | ~100 bytes | Known nodes array (up to 20 × 5 bytes) | Empty |
400 | 1 byte | Known node count | 0 |
430 | 1 byte | Solar mode flag (0=off, 1=on) — repeater only | 0 |
431 | 1 byte | Runtime spreading factor (7-12) — repeater only | 9 (LORA_SF) |
432 | 1 byte | Display sleep timeout in seconds (0=off) — T-Deck only | 60 |
433 | 1 byte | Auto-poll enabled flag (0=off, 1=on) | 0 |
434 | 5 bytes | Auto-poll target node ID (4 chars + null) | Empty |
439 | 2 bytes | Auto-poll interval in seconds (uint16) | 300 |
450 | 17 bytes | AES-128 key (16 chars + null) | Randomly generated on first boot |
508 | 1 byte | First-boot magic byte | 0xA5 after first setup |
Tip: The SAVE command (via serial or BLE) calls EEPROM.commit() which writes the buffer to flash. Without SAVE, changes are lost on reboot.
# Build all 4 targets pio run # Build specific target pio run -e tdeck-plus pio run -e lora32 pio run -e heltec-v3 pio run -e heltec-v4 # Flash specific target (with board connected) pio run -e heltec-v4 --target upload # Open serial monitor pio device monitor -b 115200 # Clean build pio run -e lora32 --target clean
PlatformIO uses build_src_filter to ensure each target only compiles its firmware:
| Target | Compiles | Excludes |
|---|---|---|
| tdeck-plus | main.cpp + MeshCore | repeater.cpp |
| lora32 | repeater.cpp + MeshCore | main.cpp |
| heltec-v3 | repeater.cpp + MeshCore | main.cpp |
| heltec-v4 | repeater.cpp + MeshCore | main.cpp |
# Open in Android Studio: android-app/ # Build: compileSdk 34, minSdk 26 # Dependencies: Jetpack Compose BOM 2024.01, BLE
The gateway feature connects geographically separated LoRa mesh networks over the internet. Each gateway node is a repeater connected via USB to a PC running the Python bridge server. Packets remain AES-128-GCM encrypted end-to-end — the servers never decrypt them.
The simplest way to connect multiple mesh islands is using a central hub server. Each location runs a gateway that connects to the hub. Gateways only need the hub's address — no complex peer-to-peer configuration.
ISLAND A CENTRAL HUB ISLAND B (cloud server or VPS) [Node 0001] [Node 0005] | | [Repeater 0003] [Repeater 0007] | | USB Serial USB Serial | | ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Gateway A │────>│ Gateway Hub │<────│ Gateway B │ │ "Base Camp" │ │ (relay only) │ │ "Ridge Post" │ └──────────────┘ └──────┬───────┘ └──────────────┘ laptop at camp │ PC at ridge post │ ┌──────────────┐ │ Gateway C │ │ "Town HQ" │ └──────────────┘ office in town
End-to-end encryption: The hub and gateway servers only see opaque hex-encoded ciphertext. They never possess the AES key and cannot read message contents. All encryption/decryption happens on the LoRa nodes themselves.
| Component | File | What It Does | Where It Runs |
|---|---|---|---|
| Repeater Firmware | src/repeater.cpp |
LoRa radio + gateway serial protocol (PKT,<hex> forwarding) |
LoRa32 or Heltec V3 board |
| Gateway Client | gateway/gateway.py |
Reads packets from repeater via USB serial, sends to hub or peers | PC/laptop at each location |
| Gateway Hub | gateway/gateway_hub.py |
Central relay — receives packets from any gateway, relays to all others | Cloud VPS, home server, or any always-on PC |
PKT,<hex>gateway.py) reads this line and sends it to the central hub via WebSocketgateway_hub.py) relays the packet to all other connected gatewaysPKT,<hex> over serial| Layer | Protection |
|---|---|
| Message Content | AES-128-GCM encrypted on-device. Hub and gateways never see plaintext. |
| Hub Access | Optional authentication key (--key) — only gateways with the correct key can join. |
| Transport | WebSocket (ws://) by default. Use wss:// with a TLS reverse proxy (nginx/caddy) for encrypted transport. |
| Network | For maximum security, run the hub on a VPN or use SSH tunnels between sites. |
Important: The hub auth key prevents unauthorized gateways from connecting, but it does not encrypt the WebSocket traffic itself. For sensitive deployments, always use wss:// (TLS) or a VPN to protect the transport layer.
| Direction | Format | Description |
|---|---|---|
| Repeater → Client | PKT,<hex> | Raw radio packet forwarded as hex string (auto when gateway mode ON) |
| Client → Repeater | PKT,<hex> | Inject raw packet into local mesh via radio transmission |
| Config | GATEWAY ON | Enable gateway mode (gateway.py sends this automatically on start) |
| Config | GATEWAY OFF | Disable gateway mode (sent automatically on shutdown) |
All WebSocket messages are JSON. The hub understands three message types:
// Python gateway (PC-based) { "type": "auth", "gateway_id": "3f8a1b2c", // Unique ID (auto-generated) "name": "Base Camp", // Friendly name (--name flag) "key": "MySecretKey" // Hub auth key (--hub-key flag) } // ESP WiFi gateway (includes location for map) { "type": "auth", "key": "MySecretKey", "id": "AA:BB:CC:DD:EE:FF", // WiFi MAC "name": "Ridge Top", "lat": 51.5074, // GPS latitude "lon": -3.1791, // GPS longitude "antenna": 2, // 0=indoor 1=external 2=rooftop "height": 10.0 // Antenna height in metres }
{
"type": "auth_result",
"success": true,
"peers": 2 // Number of other gateways online
}
{
"type": "pkt",
"data": "A50012...", // Hex-encoded raw radio packet
"origin": "3f8a1b2c" // Source gateway ID (prevents echo loops)
}
{
"type": "peer_joined", // or "peer_left"
"name": "Ridge Post",
"peers": 3 // Total gateways now online
}
Both the hub and each gateway maintain time-expiring hash sets to prevent packets from bouncing in loops:
| Component | Buffer Size | TTL |
|---|---|---|
| Gateway Client | 256 entries | 30 seconds |
| Gateway Hub | 512 entries | 30 seconds |
Additionally, the origin field in each packet prevents a gateway from re-processing packets it originally sent.
Each location needs a repeater board (LoRa32 or Heltec V3) connected to a PC via USB.
# Flash a LoRa32 as a repeater pio run -e lora32 --target upload # Configure via serial (115200 baud): ID 0003 # Unique node ID for this repeater KEY MySecretKey12345 # Must match ALL nodes in the mesh SAVE
Key rule: All nodes across all mesh islands must share the same AES key. The key is what makes them one logical mesh network, even if they're physically separated.
The hub runs on any always-on machine with a public IP or port forwarding. It does not need a LoRa radio — it's purely a network relay.
# On your server/VPS: cd gateway/ pip install -r requirements.txt # Start hub (open, no authentication) python gateway_hub.py --listen 9000 # Start hub WITH authentication (recommended) python gateway_hub.py --listen 9000 --key MySecretMeshKey
At each location, run gateway.py pointing to the hub:
# At Location A: python gateway.py --port COM3 --hub ws://your-server.com:9000 \ --hub-key MySecretMeshKey --name "Base Camp" # At Location B: python gateway.py --port COM5 --hub ws://your-server.com:9000 \ --hub-key MySecretMeshKey --name "Ridge Post" # At Location C: python gateway.py --port /dev/ttyUSB0 --hub ws://your-server.com:9000 \ --hub-key MySecretMeshKey --name "Town HQ"
That's it. The gateway automatically:
GATEWAY ON to the repeaterGATEWAY OFF on graceful shutdown (Ctrl+C)Port forwarding: The hub server needs its WebSocket port (default 9000) accessible from the internet. If behind a router, forward port 9000 to the hub PC. For production deployments, use a TLS reverse proxy (nginx/caddy) for wss:// encrypted transport.
For two-site setups or when you don't want a central hub, gateways can connect directly to each other:
# Site A (listens for inbound connections) python gateway.py --port COM3 --listen 9000 # Site B (connects to Site A) python gateway.py --port COM5 --listen 9001 --peers ws://siteA-ip:9000
In this mode, each gateway must know every other gateway's address. For 3+ sites, the hub mode is strongly recommended.
| Topology | Mode | Description | Best For |
|---|---|---|---|
| Star (Hub) | --hub | All gateways connect to one central hub | 3+ sites, simplest setup, recommended |
| Direct Pair | --peers | Two gateways connect directly | Two-site deployments |
| Full Mesh | --peers | Every gateway connects to every other | Maximum resilience (complex config) |
| Flag | Description | Example |
|---|---|---|
--port, -p | Serial port to repeater | --port COM3 |
--baud, -b | Baud rate (default: 115200) | --baud 115200 |
--hub | Hub server WebSocket URL | --hub ws://server:9000 |
--hub-key | Authentication key for hub | --hub-key MySecret |
--name, -n | Friendly name for this gateway | --name "Base Camp" |
--listen, -l | Local WebSocket port (peer mode) | --listen 9000 |
--peers | Direct peer URLs (peer mode) | --peers ws://ip:9000 |
--list-ports | List serial ports and exit | --list-ports |
| Flag | Description | Example |
|---|---|---|
--listen, -l | WebSocket listen port | --listen 9000 |
--key, -k | Authentication key (gateways must match) | --key MySecret |
When gateway mode is active, the repeater OLED displays GW in the top-right status area instead of the BLE indicator. The STATUS serial command and BLE status response also include the gateway state.
gateway/ gateway.py # Gateway client — headless (Linux/Windows CLI) gateway_gui.py ★ # Gateway client — Windows GUI (topology view + packet waterfall) gateway_hub.py # Central hub server — headless (Linux/Windows CLI) gateway_hub_gui.py # Central hub server — Windows GUI (live dashboard) automation_engine.py ★ # Logic Builder evaluation engine — rule model, persistence, anti-flood automation_canvas.py ★ # Logic Builder visual canvas — drag, connect, configure blocks telegram_bot.py ★ # Telegram bot bridge — exposes rules as chat commands meshtastic_bridge.py ★ # TiggyOpenMesh ↔ Meshtastic bidirectional bridge sensor_dashboard.py ★ # Sensor gauges, line chart, CSV export (Sensors tab) rules.json # Auto-generated Logic Builder rules (JSON persistence) requirements.txt # Python dependencies: pyserial, websockets, aiohttp, customtkinter gateways.json # Auto-generated gateway registry (location persistence) web/ map.html # Live gateway coverage map (Leaflet.js, served by hub)
Hub serves the map: The hub serves both WebSocket and HTTP on the same port. Open http://hub-address:9000/ in a browser to see the Live Gateway Map. Gateways connect via ws://hub:9000/ (same port).
The Windows GUI client (gateway_gui.py) provides a comprehensive dashboard with four main tabs:
| Tab / Feature | What It Shows |
|---|---|
| Mesh Topology | Live force-directed graph of all discovered nodes. Edges coloured and sized by RSSI (green=strong, red=weak). Signal quality summary bar, weak link warnings. Node cards with signal bars, hop count, route info, and right-click context menus. |
| Packets tab | Packet Waterfall: scrolling log of all mesh traffic with timestamps, direction, source, destination, TTL, and route. Click a packet to inspect. Packet Inspector: detailed breakdown of the selected packet with AES key entry for manual decryption. |
| Sensors tab | Sensor Dashboard: circular gauges with needles and min/max tracking, matplotlib line charts, pause/resume, clear, and CSV export. See Section 41. |
| Logic tab | Logic Builder: PLC-style visual automation canvas. Drag-and-drop blocks, wire connections, live value display on wires, rule management, deploy/undeploy. See Section 22. |
Additional GUI integrations above the tabs:
wss:// URL, gateway name, hub key, lat/lon, and antenna height. All settings are saved between sessions.The Logic Builder is a PLC-style visual automation system built into the PC gateway GUI. It lets you create cross-node automation rules by dragging and connecting blocks on a canvas — like ladder logic or Scratch, but for your mesh network. Rules evaluate sensor data received from the mesh and fire actions (relay control, messages) back through the mesh.
Two levels of automation: The Logic Builder creates gateway-evaluated rules that run in the Python GUI process. For simple rules (one sensor → one compare → one relay), you can also deploy to the node as a firmware setpoint that runs autonomously even without the gateway — see Section 15: Cross-Node Setpoint Rules.
Click the Logic tab at the bottom of the gateway GUI (alongside Packets and Sensors tabs). The automation canvas appears below the mesh topology map. Rules continue evaluating in the background regardless of which tab is active.
Input blocks read data from the mesh or provide constant values.
| Block | What It Does | Configuration | Output |
|---|---|---|---|
| Sensor Read | Reads the latest sensor value for a specific pin on a specific node. Uses SDATA data received by the gateway. | Node ID: The 4-character hex ID of the node (e.g., A1B2). Pin: GPIO pin number. Label: Descriptive name (e.g., “Wind Sensor”). | A number (the raw sensor reading, 0–4095 for analog). |
| Beacon Detect | Detects an iBeacon tag by UUID or MAC address. Outputs true/false based on proximity (RSSI threshold). See Section 38: iBeacon Scanner. | Beacon ID: UUID or MAC address. Name: Human label. RSSI Threshold: Minimum signal strength (e.g., -70 dBm). | detected (bool), rssi (number). |
| Constant | Outputs a fixed number. Use as a threshold for comparisons. | Value: Any number (e.g., 2000, 15.5). | A number. |
| Schedule | Time-based trigger. Outputs true during configured hours/days. Uses the gateway PC’s clock. Supports overnight schedules (e.g., 22:00–06:00). | On time: HH:MM (24hr). Off time: HH:MM. Days: Mon,Tue,Wed,Thu,Fri,Sat,Sun (comma-separated, default all). | active (bool) — true during scheduled hours on selected days. |
Transform blocks convert raw sensor values into meaningful units.
| Block | What It Does | Configuration | Formula |
|---|---|---|---|
| Scale | Multiplies a value by a factor and adds an offset. Use this to convert raw ADC counts into real-world units. | Factor: Multiply value by this number. Offset: Add this after multiplying. Unit: Label for the result (e.g., “m/s”, “°C”). | output = (input × factor) + offset |
| Moving Average | Smooths a noisy signal by averaging the last N samples. | Window: Number of samples to average (e.g., 5). | output = average of last N inputs |
| Delta Rate | Calculates the rate of change per second. Useful for converting a growing counter into a speed value. | None — automatic. | output = (current − previous) / elapsed_seconds |
Example — Wind speed from pulse counter: A Sensor Read block reads a pulse-counter pin. The Delta Rate block converts the growing count into pulses-per-second. A Scale block then multiplies by the anemometer's calibration factor (e.g., ×0.0278) to get metres per second. The canvas shows live values on each wire so you can verify the maths.
Condition blocks evaluate true/false logic to decide when actions should fire.
| Block | What It Does | Configuration | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Compare | Compares two numbers and outputs true or false. | Operator (choose one):
|
||||||||||||
| AND Gate | Outputs true only when all connected inputs are true. Has up to 4 inputs (in1–in4). | None — connect 2–4 boolean inputs. | ||||||||||||
| OR Gate | Outputs true when any connected input is true. Has up to 4 inputs (in1–in4). | None — connect 2–4 boolean inputs. | ||||||||||||
| NOT Gate | Inverts a boolean: true becomes false, false becomes true. | None — one boolean input. | ||||||||||||
| Debounce | Only outputs true after the input has been true for a sustained period. Filters out brief spikes or noise. | Hold (seconds): How long the input must stay true before the output becomes true (e.g., 5.0). | ||||||||||||
| Monostable | One-shot retriggerable timer. When the trigger input goes true, the output goes true and stays true for the configured hold time. If the trigger fires again before the timer expires, the timer restarts. Useful for beacon-based gate control: “keep the gate open while the beacon is nearby, close 60 seconds after it leaves.” | Hold time (seconds): How long the output stays true after the last trigger (e.g., 60.0). | ||||||||||||
| Latch (SR Flip-Flop) | Once “set” goes true, the output stays true until “reset” goes true. Like a physical toggle switch. | None — connect a set signal and a reset signal. | ||||||||||||
| Geofence | GPS boundary detection. Checks if a node’s GPS position is inside or outside a circle you define. Outputs distance in metres. | Node ID: which GPS node to track. Centre Lat/Lon: fence centre coordinates. Radius: metres (default 500). | inside (bool) — true if node is within the radius. distance (number) — metres from centre. |
Action blocks send commands to the mesh when their trigger input transitions from false to true (rising edge).
| Block | What It Does | Configuration |
|---|---|---|
| Set Relay | Turns a relay (digital output) on or off on a target node. | Node ID: Target node. Pin: GPIO pin number. Action: HIGH (on) or LOW (off). |
| Pulse Relay | Briefly activates a relay for a set duration, then turns it off. Good for solenoids, buzzers, or momentary triggers. | Node ID: Target node. Pin: GPIO pin number. Duration (ms): How long to hold the relay on. |
| Send Broadcast | Sends a text message to all nodes on the mesh (address FFFF). | Message (true): Text sent when trigger becomes true (e.g., “Gate open”). Message (false): Optional text sent when trigger becomes false (e.g., “Gate closed”). |
| Send Direct | Sends a text message to a specific node. | Node ID: Target node. Message (true): Text when true. Message (false): Optional text when false. |
| Telegram Output | Stores the incoming value for Telegram bot queries. When a user sends the rule's #name command in Telegram (e.g., #getwind), the bot reads this block's last value and replies with a formatted message. Does not fire a LoRa action — this is a read-only display endpoint. |
Label: Human name (e.g., “Wind Speed”). Unit: Engineering unit (e.g., “m/s”). Format: Template string (default: {label}: {value} {unit}). |
Blocks have ports — small coloured dots on the left (inputs) and right (outputs) of each block:
To connect: click an output port on one block, then click a compatible input port on another block. A smooth Bezier curve wire appears between them. Live values are displayed on wires during evaluation (e.g., “23.4”, “True”).
| Setting | What It Means | Default |
|---|---|---|
| Check every | How often the rule is evaluated (reads sensor data, runs all blocks, checks conditions). Lower values mean faster response but more processing. | 5 seconds |
| Min action gap | Minimum time between action firings for the same rule. Prevents rapid relay toggling when a sensor value oscillates around a threshold (e.g., temperature bouncing between 19°C and 21°C around a 20°C threshold). | 10 seconds |
| Enabled | When ticked, the rule is actively evaluated. Untick to pause a rule without deleting it. | Enabled |
| Action | How |
|---|---|
| Add a block | Click one of the category dropdown buttons: [+ Input], [+ Transform], [+ Condition], [+ Action] |
| Move a block | Click and drag the block body. Blocks snap to a 10-pixel grid. |
| Configure a block | Double-click the block to open its settings dialog. |
| Connect blocks | Click an output port (right side), then click an input port (left side) on another block. |
| Delete a block | Right-click the block → Delete from the context menu, or select and press the Delete key. All connected wires are also removed. |
| Pan the canvas | Middle-click and drag, or scroll. |
With many rules across many nodes, actions could flood the LoRa channel. The Logic Builder includes multiple layers of protection:
For rules that match the firmware setpoint format, you can push the rule directly to a node so it runs autonomously without the gateway. Click the [Deploy] button to attempt this. Deployed rules use zero radio for monitoring — they only transmit when the condition triggers.
A rule can be deployed as a firmware setpoint if it contains:
The gateway builds an expanded SETPOINT command with optional suffixes:
SETPOINT,<pin>,<op>,<threshold>,<target>,<relayPin>,<action>[,SCALE,<f>,<o>][,DEBOUNCE,<ms>] SETPOINT,<pin>,<op>,<threshold>,MSG,<msgTrue>[,<msgFalse>][,SCALE,<f>,<o>][,DEBOUNCE,<ms>]
The [Deploy] button analyses your rule chain and picks the best deploy strategy automatically:
| Pattern | Deploy Mode | How It Works |
|---|---|---|
| Sensor → Compare → Relay/Broadcast | Firmware setpoint | Sends a SETPOINT command to the node. Runs autonomously with zero radio until the condition triggers. |
| Beacon Detect → Relay | Firmware beacon rule | Sends a BEACON,ADD command. Node scans for BLE beacons independently. |
| Beacon Detect → Monostable → Relay | Firmware beacon + REVERT | The Monostable's hold time becomes a REVERT timer: relay auto-reverts after the beacon leaves range for that duration. |
| Beacon Detect → Broadcast Msg | Firmware beacon + MSG | Node broadcasts a mesh message when the beacon is detected. |
| Anything with Telegram Output or Monostable | GUI engine | Runs in the Python gateway process. No firmware deploy needed — the engine evaluates the rule and the Telegram bot reads the output. |
Right-click any node card in the Discovered Nodes panel to access management commands:
| Action | What It Does |
|---|---|
| Query Rules | Sends BEACON,LIST and SETPOINT,LIST to the node and shows deployed rules in a popup. |
| Set Name | Assign a friendly name to the node (e.g., “Farm1”). Saved between sessions. Used by the Telegram bot for human-readable responses. |
| Clear Rules | Sends BEACON,CLEAR and SETPOINT,CLEAR to remove all deployed rules from the node. |
Limitation: Rules using Moving Average, Delta Rate, AND/OR/NOT gates, Latch, Pulse Relay, or Direct Message blocks cannot deploy to node — they can only run as gateway-evaluated rules. Each node supports a maximum of 4 setpoints (RAM only, lost on reboot).
Sound an alarm relay on a base-station node when wind speed exceeds 15 m/s.
[Sensor Read] ──→ [Scale] ──→ [Compare] ──→ [Set Relay] A1B2:pin5 ×0.0278 > 15.0 C3D4:pin2=HIGH "Anemometer" m/s ↑ [Constant: 15]
Sensor Read gets the raw pulse count from node A1B2 pin 5. Scale converts pulses to m/s. Compare checks if the speed exceeds the constant 15. Set Relay activates pin 2 on node C3D4.
Broadcast “Gate open” or “Gate closed” based on a digital input.
[Sensor Read] ──→ [Compare] ──→ [Send Broadcast] F1E2:pin5 = 1 "Gate open" / "Gate closed" "Gate Sensor" ↑ [Constant: 1]
Sensor Read gets the digital value (0 or 1) from node F1E2 pin 5. Compare checks if it equals 1. Send Broadcast sends “Gate open” when true, “Gate closed” when the trigger goes false.
Only activate a heater if temperature stays below threshold for 30 seconds (avoids reacting to brief cold gusts).
[Sensor Read] ──→ [Scale] ──→ [Compare] ──→ [Debounce] ──→ [Set Relay] 0010:pin34 ×0.1-40 < 3.0 30 sec 0010:pin4=HIGH "Temp Sensor" °C ↑ hold "Heater" [Constant: 3.0]
Scale converts raw ADC (0–4095) to degrees Celsius. Compare checks if temp is below 3°C. Debounce waits 30 seconds of continuous “true” before allowing the Set Relay to fire.
All rules are automatically saved to gateway/rules.json whenever you make changes. Rules are reloaded when you restart the gateway GUI. The file stores block positions, configurations, wire connections, and rule settings.
The bottom of the Logic Builder panel shows a one-line event log with timestamped status messages:
14:23 eval OK — 3 blocks14:23 SET_RELAY C3D4:pin2=114:23 ERROR: node A1B2 data staleThe WiFi gateway firmware turns any ESP32 board into a standalone internet bridge. Unlike the PC-based gateway, it needs no computer — just power. Flash it, configure once via the Android app over BLE, mount it outside with a solar panel, and it runs forever.
Old method: LoRa nodes → ESP repeater → USB cable → PC → WebSocket → Hub New method: LoRa nodes → ESP gateway → WiFi → WebSocket → Hub
Use the web flasher, select your board under WiFi Gateway, and click Flash.
Open the Android app, tap Connect to pair with the gateway over BLE, then send these commands from the terminal/config screen:
WIFI,YourSSID,YourPassword Set WiFi network HUBURL,ws://hub.example.com:9000 Hub WebSocket address HUBKEY,your-secret-key Hub auth key (must match hub) GWNAME,Ridge Top Gateway Friendly name shown on hub GWLOC,51.5074,-3.1791 Lat/lon (optional, for map) GWANTENNA,1 0=indoor 1=external 2=rooftop GWHEIGHT,10 Antenna height in metres (0-500) SAVE Write to flash and reboot
The gateway reboots, connects to WiFi, and begins bridging. The OLED shows the WiFi IP and hub connection status.
GWSTATUS Returns: name, WiFi IP, hub connected/disconnected, RX/TX counts, HEIGHT
Gateway auth includes location: When the gateway connects to the hub, the auth message includes lat, lon, antenna type, and height. This enables the Live Gateway Map on the hub.
Total BOM for a self-powered tree-mounted gateway:
| Part | Cost | Notes |
|---|---|---|
| XIAO ESP32S3 + Wio-SX1262 | ~£10 | From The Pi Hut |
| 6V 2W mini solar panel | ~£5 | Point south, 30° tilt |
| TP4056 LiPo charger + 18650 cell | ~£4 | Powers overnight |
| IP65 weatherproof box (75×58mm) | ~£3 | Cable gland for antenna |
| 868MHz fiberglass antenna (3dBi) | ~£6 | SMA-female pigtail |
| Total | ~£28 | Zero running cost |
Power draw: ESP32S3 active ~160mA @ 3.3V = 0.53W. A 2W panel fully charges a 2000mAh cell in ~4 hours of sun. Runs 15+ hours overnight on the cell alone.
The hub runs on any always-on Linux machine (Debian, Ubuntu, Raspberry Pi, etc). The automated setup script handles everything — Python, systemd service, and Tailscale Funnel:
# Download and run the setup script (Debian/Ubuntu)
curl -fsSL https://raw.githubusercontent.com/MichaelGaylor/Tiggy_Lora_Mesh/main/gateway/setup-hub-linux.sh -o setup.sh
bash setup.shThe script will:
aiohttp (the hub's HTTP/WebSocket server library)tiggyopenmesh-hub.service)wss:// URLIf you prefer to set things up manually or the script doesn't suit your distro:
# 1. Install dependencies sudo apt-get install -y python3 python3-pip pip3 install --break-system-packages aiohttp # 2. Copy gateway files to your home directory # You need: gateway_hub.py, gateway_hub_gui.py, gui_common.py # And: web/map.html (create a web/ folder next to gateway_hub.py) cp gateway_hub.py gateway_hub_gui.py gui_common.py ~/ mkdir -p ~/web && cp web/map.html ~/web/ # 3. Test it works python3 ~/gateway_hub.py --listen 8443 # Open http://localhost:8443/ in a browser — you should see the map # 4. Create systemd service for auto-start on boot sudo tee /etc/systemd/system/tiggyopenmesh-hub.service > /dev/null <<EOF [Unit] Description=TiggyOpenMesh Gateway Hub After=network-online.target Wants=network-online.target [Service] Type=simple User=$USER WorkingDirectory=$HOME ExecStart=/usr/bin/python3 $HOME/gateway_hub.py --listen 8443 Restart=always RestartSec=10 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable tiggyopenmesh-hub sudo systemctl start tiggyopenmesh-hub
Tailscale Funnel port: Funnel only supports ports 443, 8443, and 10000. The hub runs on port 8443 by default. Do not change this to 9000 or Funnel will not work.
To enable Funnel manually after setup:
# Enable Tailscale Funnel on port 8443 (runs persistently in background) sudo tailscale funnel --bg 8443 # Check the public URL assigned to your machine tailscale funnel status # → https://your-machine.tail1234.ts.net
Configure each gateway via BLE with:
HUBURL,wss://your-machine.tail1234.ts.net SAVE
No port forwarding, no dynamic DNS, free SSL certificate. The hub service and Funnel both survive reboots automatically.
The headless hub serves the Live Gateway Map as a web page on the same port as the WebSocket relay. Multiple users can open it simultaneously — it's a read-only page that refreshes every 10 seconds:
| From where | URL |
|---|---|
| On the hub machine | http://localhost:8443/ |
| Same LAN / Tailscale network | http://<hub-ip>:8443/ |
| Anywhere (via Tailscale Funnel) | https://your-machine.tail1234.ts.net/ |
If your Linux box has a desktop environment (XFCE, GNOME, KDE, etc), you can create desktop shortcuts for quick access:
cat > ~/Desktop/TiggyOpenMesh-Map.desktop <<'EOF' [Desktop Entry] Version=1.0 Type=Application Name=TiggyOpenMesh Map Comment=Open the live gateway coverage map in your browser Exec=xdg-open http://localhost:8443/ Icon=applications-internet Terminal=false Categories=Network; EOF chmod +x ~/Desktop/TiggyOpenMesh-Map.desktop
The GUI version (gateway_hub_gui.py) shows a full desktop dashboard with live packet flow visualisation, connected gateway table, and statistics. It requires customtkinter:
pip3 install --break-system-packages customtkinter
Since the GUI and headless hub both use the same port, the launcher script stops the headless service before starting the GUI, then restarts it when the GUI window is closed:
# Create launcher script cat > ~/start_hub_gui.sh <<'EOF' #!/bin/bash sudo systemctl stop tiggyopenmesh-hub 2>/dev/null sleep 1 cd ~/ python3 ~/gateway_hub_gui.py sudo systemctl start tiggyopenmesh-hub 2>/dev/null EOF chmod +x ~/start_hub_gui.sh # Create desktop shortcut cat > ~/Desktop/TiggyOpenMesh-Hub.desktop <<'EOF' [Desktop Entry] Version=1.0 Type=Application Name=TiggyOpenMesh Hub Comment=Gateway Hub Dashboard - stops headless service, opens GUI Exec=bash ~/start_hub_gui.sh Icon=network-workgroup Terminal=false Categories=Network; EOF chmod +x ~/Desktop/TiggyOpenMesh-Hub.desktop
Headless vs GUI: The headless service (gateway_hub.py) runs 24/7 via systemd, relaying packets and serving the browser map. The GUI (gateway_hub_gui.py) is optional — use it when you want a desktop dashboard. Both serve the same map and relay the same packets. You don't need a display to run the hub.
# View live logs sudo journalctl -u tiggyopenmesh-hub -f # Restart hub (e.g. after updating gateway_hub.py) sudo systemctl restart tiggyopenmesh-hub # Check status sudo systemctl status tiggyopenmesh-hub # Update hub files from the repo cd ~/ curl -fsSL https://raw.githubusercontent.com/MichaelGaylor/Tiggy_Lora_Mesh/main/gateway/gateway_hub.py -o gateway_hub.py curl -fsSL https://raw.githubusercontent.com/MichaelGaylor/Tiggy_Lora_Mesh/main/gateway/web/map.html -o web/map.html sudo systemctl restart tiggyopenmesh-hub
Remote repeater nodes on hilltops or rooftops often need to run indefinitely from a small solar panel and an 18650 battery. Solar mode dramatically reduces power consumption by putting the ESP32-S3 into light sleep between radio events, while keeping the radio in continuous RX so no packets are lost.
SX1262 only: Solar mode is available on Heltec V3 and Heltec V4 boards (ESP32-S3 + SX1262 radio). The LoRa32 (SX1276 / classic ESP32) does not support this mode.
| Component | Normal Mode | Solar Mode |
|---|---|---|
| ESP32-S3 (CPU) | ~80 mA (active) | ~0.8 mA (light sleep, wakes on DIO1) |
| SX1262 (Radio) | ~10 mA (continuous RX) | ~10 mA (continuous RX — no data loss) |
| OLED Display | ~15 mA | 0 mA (powered off via Vext) |
| BLE | ~15 mA | 0 mA (deinitialised) |
| Total average | ~120 mA | ~11 mA |
With a 3000 mAh 18650: ~11 days on battery alone.
With a 1W solar panel: indefinite operation.
# Enable solar mode (persists across reboots) SOLAR ON # Disable solar mode and restore normal operation SOLAR OFF # Check status (works in solar mode) STATUS
# Enable solar mode (SX1262 boards only) POWER,SOLAR → OK,POWER,SOLAR (BLE then disconnects!) # Disable solar mode POWER,NORMAL → OK,POWER,NORMAL
BLE disabled in solar mode: Sending POWER,SOLAR via BLE immediately deinitialises Bluetooth after sending the response. The phone app disconnects. To exit solar mode, use serial (SOLAR OFF) or press the PRG button.
The app's Settings tab shows a Power Mode card for SX1262-based repeater boards. It displays the current mode from the STATUS response and provides a toggle with a confirmation dialog.
Solar mode is persisted to EEPROM — the node will boot directly into solar mode after a power cycle. No need to re-send the command.
| Feature | Behaviour |
|---|---|
| OLED display | Off. Press the PRG button to show status for 10 seconds. |
| BLE (phone app) | Disabled — phone app cannot connect. Deinitialised to save ~15 mA. |
| LED | Single brief blink every heartbeat (60s) as sign-of-life. |
| Serial commands | All serial commands work. UART wakeup is enabled — sending any character wakes from light sleep. |
| Mesh routing | Fully functional — packets are received, forwarded, and routed normally. No data loss. |
| Gateway mode | Still works if previously enabled — PKT, forwarding over serial continues. |
| GPIO relays | Fully operational — CMD processing, timers, setpoints, and sensor polling all continue. |
Deployment tip: Flash the node, set the ID and AES key, send SOLAR ON, then disconnect USB. The node is now a fully autonomous solar-powered mesh repeater. Reconnect USB and send SOLAR OFF when you need to reconfigure.
Every message sent through the mesh now has end-to-end delivery confirmation. The sender knows exactly whether the message was transmitted, delivered, or failed — with visual status indicators on both the T-Deck and the Android app.
SENT,<mid> to BLE, state becomes SENTACK,<from>,<mid> packet to the original sender| State | T-Deck Chat Bubble | Android App |
|---|---|---|
| SENDING | Orange clock icon / "..." next to bubble | Orange "Sending..." text |
| SENT | Single checkmark (✓) | Single grey checkmark |
| DELIVERED | Double checkmark (✓✓) | Double green checkmarks |
| FAILED | Red cross (✗) | Red "Failed" with retry option |
| Direction | Format | Meaning |
|---|---|---|
| Node → App | SENT,XYZW | Message XYZW has been transmitted over LoRa |
| Node → App | ACK,0002,XYZW | Node 0002 confirmed receipt of message XYZW |
Broadcast messages: Messages sent to FFFF (broadcast) skip delivery tracking — there's no single recipient to send an ACK. The state goes directly from SENDING → SENT.
FAILED does not mean undelivered: A FAILED state means the sender didn't receive an ACK within 3 seconds. The message may have been delivered but the ACK packet was lost in transit. In lossy environments, consider re-sending important messages.
LoRa Spreading Factor (SF) controls the trade-off between range and speed. Changing SF requires all nodes to switch simultaneously — a node on SF7 cannot hear one on SF12. The two-phase CFG protocol ensures safe, coordinated changes across the entire mesh.
| SF | Range | Speed | Airtime (50 bytes) | Use Case |
|---|---|---|---|---|
SF7 | Shortest | Fastest | ~56 ms | Dense urban, many nodes close together |
SF9 | Medium | Medium | ~205 ms | Default — balanced for most deployments |
SF12 | Longest | Slowest | ~1.3 s | Max range, sparse rural, mountain-to-valley |
SF,7 via BLECFG,SF,7,A1B2,C3D4CFGACK,SF,7,A1B2,0010SFGO,7 via BLECFGGO,SF,7,A1B2,C3D4CFG and CFGGO packets include an 8-hex-char auth tag computed using AES-128-GCM:
authTag = truncate(GCM_tag(key, IV=SHA(changeId), AAD=changeId), 4 bytes) // Deterministic IV derived from changeId for reproducible verification
Receiving nodes verify the tag before accepting any CFG or CFGGO packet. This prevents:
| Packet | Format | Fields |
|---|---|---|
| CFG (propose) | CFG,SF,7,A1B2,C3D4 | type, value, changeId (random 4-hex), authTag |
| CFGACK (accept) | CFGACK,SF,7,A1B2,0010 | type, value, changeId, respondingNodeId |
| CFGGO (commit) | CFGGO,SF,7,A1B2,C3D4 | type, value, changeId, authTag |
| Command | Description | Response |
|---|---|---|
SF,7 | Start Phase 1 — broadcast CFG proposal for SF7 | OK,SF,7 + CFGSTART,SF,7,A1B2 + CFGACK notifications |
SFGO,7 | Start Phase 2 — broadcast CFGGO commit for SF7 | OK,SFGO,7 then radio reconfigures after 2s delay |
mesh.currentSF — may differ from compile-time LORA_SFSF,9 + SFGO,9 to change backCritical: If only some nodes receive the CFGGO, the mesh splits — nodes on different SFs cannot hear each other. Always check that CFGACK responses come back from all expected nodes before sending CFGGO.
Node IDs are 4-hex-char values (0001–FFFE) — 65534 possible addresses. As networks grow, or when default IDs are used, collisions become likely. Two nodes with the same ID cause routing chaos — messages get misdelivered, heartbeats overwrite routes, and the mesh becomes unreliable.
Collision detection piggybacks on the existing heartbeat system (no new packets, no extra airtime):
HB,<nodeID> every ~30 secondsprocessPacket(), if the heartbeat's node ID matches our own localID, a collision is detectedonIdConflict callback fires with the conflicting ID and RSSI// In MeshCore::processPacket() — heartbeat handler if (hbFrom == String(localID)) { idConflictDetected = true; if (onIdConflict) onIdConflict(hbFrom, lastRSSI); return; // Don't add ourselves as a peer }
| Device | Alert |
|---|---|
| Repeater (OLED) | Full-screen warning: !! ID CONFLICT !! with conflicting ID and RSSI. Stays on screen until resolved. |
| Repeater (BLE) | Sends CONFLICT,<id>,<rssi> to connected phone |
| T-Deck | 10-second notification banner: "ID CONFLICT! XXXX in use!" |
| Android App | System chat message: "WARNING: ID collision! Node XXXX is used by another device." |
Both repeaters and T-Deck devices perform an automatic conflict scan on first boot:
| Step | Repeater | T-Deck |
|---|---|---|
| 1. Generate ID | Random 4-hex ID (0001-FFFE) | Random 4-hex ID, pre-filled in wizard |
| 2. Scan | 10s blocking scan with OLED countdown | 10s background scan with progress bar |
| 3. Conflict? | Regenerate random ID, restart scan | Regenerate, restart scan |
| 4. Clear | Save to EEPROM, continue boot | Show "No conflicts!", user can accept or override |
No auto-fix for running nodes: If a collision is detected on a node that's already deployed in the field, the firmware does not auto-change the ID. It alerts the user so they can manually choose a new ID via the app, serial, or keyboard. This avoids surprise routing changes on unmanned repeaters.
The T-Deck's 2.8" TFT backlight draws significant power. Display auto-sleep turns off the backlight after a configurable idle period, extending battery life for portable use.
| Idle Time | Action |
|---|---|
| < 50% of timeout | Screen at full brightness |
| 50% – 100% of timeout | Screen dims to 25% brightness (warning) |
| 100% of timeout | Backlight turns off completely |
| Any key / trackball input | Instant wake to full brightness |
Set via the T-Deck Settings menu → Display Timeout. Cycles through:
432. Survives reboot.| State | Backlight Current |
|---|---|
| Full brightness (100%) | ~40 mA |
| Dimmed (25%) | ~10 mA |
| Off (sleeping) | 0 mA |
With a 60-second timeout and typical usage patterns, display sleep can reduce average power consumption by 30–50%, significantly extending battery life.
Repeater nodes do not have display sleep — their OLED draws only ~15 mA and auto-refreshes every 5s. For repeater power savings, use Solar Mode which disables the OLED entirely.
The hub server hosts a live web map showing registered gateways, their online/offline status, and estimated coverage areas. It is an optional planning and monitoring tool — the radio mesh itself works without it.
Zero internet dependency for the mesh: nodes talk directly over LoRa with no internet, hub, or cloud service required. The map is only an optional add-on that provides visibility when internet is available.
The hub serves both WebSocket and HTTP on the same port — no extra port needed:
# If hub runs on port 9000: Map: http://your-hub-address:9000/ API: http://your-hub-address:9000/api/gateways WebSocket: ws://your-hub-address:9000/ (gateways connect here) # Tailscale Funnel exposes ONE port — everything works through it: https://your-machine.tail1234.ts.net/
| Feature | Description |
|---|---|
| Online markers | Green pulsing dots for connected gateways |
| Offline markers | Red static dots for disconnected gateways (remembered from registry) |
| Coverage rings | Semi-transparent circles showing estimated LoRa coverage per gateway |
| Click popups | Gateway name, antenna type, height, uptime, packet count |
| Auto-fit | Map auto-centers to show all gateways on first load |
| Auto-refresh | Updates every 10 seconds via /api/gateways |
Approximate coverage radius based on antenna type and height (tuned for SF9 / 868 MHz):
| Antenna Type | Code | Base Radius | Formula | Example (10m height) |
|---|---|---|---|---|
| Indoor | 0 | 800 m | 800 × √height | ~2.5 km |
| External | 1 | 2,000 m | 2000 × √height | ~6.3 km |
| Rooftop | 2 | 4,000 m | 4000 × √height | ~12.6 km |
These are rough estimates. Real-world coverage depends on terrain, buildings, and antenna quality.
Each ESP gateway must have location configured via BLE before it appears on the map:
GWLOC,51.5074,-3.1791 Latitude and longitude GWANTENNA,2 0=indoor 1=external 2=rooftop GWHEIGHT,10 Antenna height in metres SAVE
Gateways with lat=0, lon=0 (not configured) are hidden from the map.
| Endpoint | Method | Description |
|---|---|---|
/ | GET | Serves map.html |
/api/gateways | GET | JSON array of all gateways with status |
[{
"id": "AA:BB:CC:DD:EE:FF",
"name": "Base Camp",
"lat": 51.5074,
"lon": -3.1791,
"antenna_type": 2,
"antenna_height":10.0,
"online": true,
"uptime": 3600,
"packets": 150
}]
The hub stores gateway locations in gateway/gateways.json. This means:
The gateway firmware now includes location data in the WebSocket auth message:
{
"type": "auth",
"key": "MySecretKey",
"id": "AA:BB:CC:DD:EE:FF", // WiFi MAC address
"name": "Base Camp",
"lat": 51.5074,
"lon": -3.1791,
"antenna": 2, // 0=indoor, 1=external, 2=rooftop
"height": 10.0 // metres
}
Backward compatible: Old gateways without location fields still connect and relay packets normally. They just don't appear on the map. Old hubs ignore the new fields (Python dict.get() defaults).
The Android app can forward group mesh messages and SOS emergencies to a Telegram chat via a bot. This lets family members, a base-camp coordinator, or anyone with Telegram receive real-time alerts — even if they are miles away with no LoRa hardware.
MyHikingGroup!).| Message type | Forwarded? | Format in Telegram |
|---|---|---|
| Group broadcast | ✅ Yes | 📡 [NODE1] Hello everyone |
| SOS with GPS | ✅ Yes (priority) | 🚨 SOS EMERGENCY from NODE1 — maps link |
| SOS no GPS fix | ✅ Yes (priority) | 🚨 SOS EMERGENCY from NODE1 (no GPS fix) |
| Direct message | ❌ No | Stays private between two nodes |
| Position ping | ❌ No | Internal routing packet |
/newbot and follow the prompts to name your bot.123456789:ABCdefGHIjklMNOpqrSTUvwxYZhttps://api.telegram.org/bot<YOUR_TOKEN>/getUpdates"chat":{"id": in the JSON response. That number is your Chat ID.-1001234567890.Devices running the firmware directly (T-Deck, LoRa32, Heltec) are completely unaffected by the Telegram feature. They send and receive mesh messages as normal. If a group member with an Android phone happens to receive the same message and has mobile signal, the forwarding happens transparently — the T-Deck user never knows and nothing changes on their device.
Consistent colour palette used across the TFT display, OLED, and Android app.
0x0000 Background (Pure Black)0x10A2 Panel0x0926 Header0x07FF Accent (Cyan)0xFFFF Text (White)0x8410 Dim (Mid Grey)0x4208 Faint (Dark Grey)0x07E0 Good (Green)0xFD20 Warn (Orange)0xF800 Bad (Red)0x1126 Bubble Incoming (Teal)0x0320 Bubble Outgoing (Green)0x8000 Bubble SOS (Dark Red)0x2104 Selected Item0xFFE0 Cursor (Yellow)MeshCyan Primary accentMeshGreen Success / ONMeshOrange WarningsMeshRed Errors / SOSMeshBlue Incoming messagesMeshGrey Secondary textMeshDarkBg BackgroundMeshSurface Card surfaces| RSSI Range | Colour | Meaning |
|---|---|---|
| > -80 dBm | Green | Excellent signal |
| -80 to -100 dBm | Orange | Moderate signal |
| < -100 dBm | Red | Weak signal |
You can daisy-chain a second ESP32 to any repeater node as an IO expansion board, adding up to 16 additional sensor inputs and relay outputs. The expansion board connects via UART (serial) and has no LoRa radio — it is a simple IO slave controlled by the main mesh node.
Why use an expansion board? Repeater nodes have limited free GPIO (typically 3–6 pins shared between sensors and relays). An expansion board gives you 6+ extra analog inputs and 6+ relay outputs using a cheap ESP32 devkit (£3–5). The main node handles all mesh communication; the expansion board just reads sensors and sets pins.
Connect 3 wires between the main node and the expansion ESP32:
| Main Node | ↔ | Expansion Board |
|---|---|---|
| UART2 TX | → | RX (GPIO 3 or Serial RX) |
| UART2 RX | ← | TX (GPIO 1 or Serial TX) |
| GND | — | GND |
| Main Node Board | TX Pin | RX Pin |
|---|---|---|
| Heltec V3 / V4 | GPIO 2 | GPIO 3 |
| XIAO ESP32S3 | GPIO 2 | GPIO 4 |
| LoRa32 T3 | GPIO 17 | GPIO 16 |
| Heltec V2 | GPIO 17 | GPIO 13 |
# Enable IO expansion (opens UART2 at 115200 baud) EXPAND ON # Disable IO expansion EXPAND OFF
Once enabled, the expansion board's sensors appear as virtual pins 100–115 on the main node. You can use these in setpoint rules, auto-poll SDATA, and the Logic Builder just like regular GPIO pins.
# Setpoint using expansion board sensor on virtual pin 100 SETPOINT,100,GT,2000,0010,4,1 # The main node polls the expansion board every 2 seconds # and caches the values. readSensorPin(100) returns the cached value.
The main node and expansion board communicate over a simple ASCII line protocol at 115200 baud:
| Main Sends | Expansion Responds | Purpose |
|---|---|---|
?S | S,34:2048,36:1024,... | Read all sensors |
?S,34 | S,34:2048 | Read single sensor |
!R,4,1 | R,OK | Set relay pin 4 HIGH |
?P | P,5:1234,... | Read pulse counters |
?I | I,ESP32,6S,6R | Identify board (6 sensors, 6 relays) |
!P,5 | P,OK,5 | Enable pulse counter on pin 5 |
The expansion board runs a separate, minimal firmware (io-expansion build target). It has no LoRa, no BLE, no mesh — just serial IO. Flash it using the web flasher or PlatformIO:
pio run -e io-expansion -t upload
Edit the pin arrays at the top of src/io_expansion.cpp to match your wiring:
const int SENSOR_PINS[] = {34, 35, 36, 39, 32, 33}; // Analog/digital inputs
const int RELAY_PINS[] = {2, 4, 5, 12, 13, 14}; // Digital outputs
Non-blocking: The main node polls the expansion board every 2 seconds and caches the response. Sensor reads from virtual pins return the cached value instantly — there is no blocking serial wait that could delay LoRa reception.
| Board | Issue | Workaround |
|---|---|---|
| Heltec V2 (gateway) | Only ~80KB free heap after BLE+WiFi. TLS certificate verification (beginSslWithCA) fails due to insufficient memory for cert chain processing. |
VERIFY_TLS_CERT is disabled for this board. Uses insecure TLS (no cert verification). Other gateway boards (V3/V4/XIAO on ESP32-S3) have cert verification enabled. |
| Heltec V2 (gateway) | Flash is 86% full with min_spiffs.csv partition. Adding features may require removing debug output. |
CORE_DEBUG_LEVEL=0 is already set for this target. |
| XIAO ESP32S3 | Default sensor pins (43, 44) are UART TX/RX — digital only (0/1). No analog readings on defaults. | Reconfigure: SENSOR 2,4,5,6 + SAVE. Pins 2-6 support ADC on ESP32-S3. |
| T-Deck Plus | No free GPIO pins for sensors or relays. All pins used by display, keyboard, GPS, and radio. | Use T-Deck as communicator only. Pair with a repeater node for sensor/relay control. |
| LoRa32 T3 (repeater) | ESP32 (not S3) has different light-sleep behaviour. Solar mode uses esp_light_sleep_start() which works on both, but power savings vary by chip. |
For lowest-power solar deployments, prefer Heltec V3/V4/XIAO on ESP32-S3. |
| Heltec V4 | GPIO 19/20 are USB D-/D+, GPIO 2/46/7 are GC1109 FEM. The V4 has no CP2102 — it uses ESP32-S3 native USB (HWCDC). Configuring any of these pins as sensor/relay pins will kill USB or disable the 28 dBm power amplifier. | Default sensor pins are 33, 34, 15. Default relay pins are 3, 4, 5, 6. All forbidden pins are blocked in isPinSafe(). See Section 43 for full details. |
| Heltec V4 | USB serial (HWCDC) takes ~60 seconds to start outputting data after boot. The first minute of serial output may be lost. | Normal behaviour for ESP32-S3 native USB. The gateway GUI will receive data once the HWCDC connection establishes. Solar mode light sleep is disabled on V4 to keep USB alive. |
tailscale status).tiggyopenmesh-hub and lora-mesh-hub), only one can bind the port. Disable the conflicting service: sudo systemctl disable <service-name>.pycryptodome: Install with pip3 install pycryptodome for AES-GCM decryption of sensor data.Nodes with a GPS receiver can broadcast their position over the mesh and share location data with the Android app. GPS is supported on any board with a spare UART — the GPS TX/RX pins are configurable per-board via BLE or serial command.
| Board | Default GPS Pins (TX/RX) | Notes |
|---|---|---|
| Heltec V4 | 38 / 39 | Ribbon connector for GPS module |
| All other boards | None (configurable) | Set via GPS,<tx>,<rx> BLE command |
| Command | Response | Description |
|---|---|---|
GPSPOS | GPS,<lat>,<lng> or GPS,NOFIX or GPS,OFF | Query current GPS position |
GPS,<tx>,<rx> | OK,GPS,<tx>,<rx> | Set GPS UART pins (saved to EEPROM) |
GPS,OFF | OK,GPS,OFF | Disable GPS |
POS,<lat>,<lng> packet over the mesh.GPS pins are saved at EEPROM address 441 (2 bytes: TX pin, RX pin). Value 0xFF means disabled. On boards without a default GPS, the pins remain disabled until explicitly configured via BLE. Pin values are validated against the isPinSafe() forbidden list to prevent conflicts with UART0, I2C, SPI, or USB pins.
Nodes can be rebooted or factory-reset remotely via BLE or LoRa. This is useful when a node is deployed in a hard-to-reach location or when EEPROM corruption prevents serial access.
| Command | Response | Description |
|---|---|---|
REBOOT | OK,REBOOT | Restart the node. All settings preserved. |
EEPROM,RESET | OK,EEPROM,RESET | Wipe all EEPROM settings to factory defaults and restart. Node ID, GPIO config, GPS pins, solar mode, setpoints — all reset. |
| Command | Description |
|---|---|
CMD,REBOOT | Remote reboot via encrypted mesh command. The node sends a CMD,RSP,REBOOTING acknowledgement before restarting. Requires the mesh AES key — cannot be spoofed by anyone outside your mesh. |
The Settings screen has a Device Management card with two buttons:
REBOOT over BLE. The node restarts with all settings intact.EEPROM,RESET over BLE. Wipes all stored settings and restarts with defaults. Use this if a node is stuck due to EEPROM corruption (e.g. bad GPIO pin config killing USB/UART).All platforms show signal strength and routing information for discovered nodes, making it easy to optimise repeater placement and diagnose connectivity issues.
Repeater nodes can scan for iBeacon BLE tags alongside the normal GATT server (dual-role BLE). When a configured beacon enters range (based on RSSI threshold), the node can trigger a relay or broadcast a mesh message — all without any phone or gateway connection.
Use cases: Livestock tracking (cow enters/leaves field), asset proximity (forklift near gate), personnel detection (worker arrives at remote site), vehicle presence (car in driveway).
| Command | Description |
|---|---|
BEACON,SCAN | One-off 2-second scan — returns all visible BLE devices with MAC, RSSI, UUID (if iBeacon), and name. |
BEACON,LIST | List all active beacon rules with their configuration. |
BEACON,ADD,<uuid_or_mac>,<name>,<rssi>,RELAY,<pin>,<state>[,<cooldown>][,REVERT,<ms>] |
Add a relay action rule. Fires digitalWrite(pin, state) when beacon detected. Optional cooldown (ms) and auto-revert (ms after beacon gone). |
BEACON,ADD,<uuid_or_mac>,<name>,<rssi>,MSG,<text>[,<cooldown>] |
Add a broadcast message action. Sends an encrypted mesh broadcast when beacon detected. |
BEACON,DELETE,<index> | Delete a rule by its slot index (0-7). |
BEACON,CLEAR | Delete all beacon rules. |
# Scan for nearby beacons BEACON,SCAN # Response: BEACONSCAN,3,aa:bb:cc:dd:ee:ff:-62dBm,11:22:33:44:55:66:-88dBm:MyTag,... # Gate opener: turn on relay pin 4 when tag is within ~3m (-65 dBm) # Auto-revert relay after tag gone for 30 seconds BEACON,ADD,aa:bb:cc:dd:ee:ff,Gate,-65,RELAY,4,1,10000,REVERT,30000 # Alert: broadcast "COW_OUT" when iBeacon UUID detected BEACON,ADD,FDA50693-A4E2-4FB1-AFCF-C6EB0764...,Daisy,-80,MSG,COW_OUT,60000 # List configured rules BEACON,LIST # Response: BEACONS,0:Gate:aa:bb:cc:dd:ee:ff:-65dBm:RELAY4:REVERT30000ms,1:Daisy:FDA50693...:MSG
When a beacon rule fires, the node sends a notification over BLE/serial:
# Relay triggered BEACON,TRIGGERED,Gate,RELAY,4 # Message broadcast triggered BEACON,TRIGGERED,Daisy,MSG,COW_OUT # Relay auto-reverted (tag gone) BEACON,REVERTED,Gate
Beacon rules are saved to EEPROM (address 512+, 128 bytes per rule, up to 8 rules = 1024 bytes). Rules persist across reboots. The runtime state (lastSeen, triggered) is not persisted — all rules start in the untriggered state after reboot.
The gateway GUI Logic Builder includes a Beacon Detect input block that can trigger automation rules based on beacon proximity events received from the mesh. Beacon events (BEACON,TRIGGERED / BEACON,REVERTED) from any node in the mesh are parsed by the gateway and fed into the block evaluation engine.
Note: Beacon scanning is disabled in solar mode to conserve power. The BLE GATT server (phone app connection) continues to work alongside scanning in normal mode.
The gateway GUI includes a built-in Meshtastic bridge that connects TiggyOpenMesh and Meshtastic networks via two USB serial ports. Messages, position reports, and SOS alerts are translated bidirectionally between the two mesh protocols.
Use case: Your TiggyOpenMesh sensor network can relay alerts and data to hikers carrying Meshtastic handhelds, or vice versa. No firmware changes needed on either side — the bridge runs entirely in the Python gateway GUI.
| Direction | Message Type | Translation |
|---|---|---|
| TOM → Mesh | Text messages | Sent as [TOM <nodeId>] <text> via Meshtastic text channel |
| TOM → Mesh | Position (POS) | Sent as Meshtastic position packet |
| TOM → Mesh | SOS alerts | Sent as Meshtastic text: SOS ALERT from TOM node <id> |
| Mesh → TOM | Text messages | Broadcast as [Mesh <name>] <text> on TOM mesh |
| Mesh → TOM | Position | Injected as POS packet into TOM mesh |
| Mesh → TOM | Node info | Node names discovered and mapped to virtual TOM IDs (F000+) |
The bridge can also run standalone without the GUI:
python meshtastic_bridge.py --tom COM18 --mesh COM4 --key DebdaleLodge2401
A 256-entry hash ring with 30-second TTL prevents packets from bouncing between the two networks. The origin field and [TOM ] / [Mesh ] prefixes provide additional loop detection.
Meshtastic 32-bit node IDs are mapped to TOM 16-bit hex IDs using a persistent mapping file (bridge_nodes.json). Meshtastic nodes appear as Fxxx IDs on the TOM mesh.
The gateway GUI includes a built-in Telegram bot that exposes your automation rules as chat commands. Unlike the Android app's Telegram forwarding (which forwards broadcast messages), this bot lets remote users query sensor values and trigger relay actions from anywhere via Telegram.
How it differs from the Android app: The Android app's Telegram feature forwards incoming mesh broadcasts to a chat. The gateway's Telegram bot lets users send commands back to the mesh. They complement each other.
/newbot, follow prompts, and copy the bot token.https://api.telegram.org/bot<TOKEN>/getUpdates.Any automation rule whose name starts with # becomes a Telegram command. The bot matches the command to the rule and executes it:
| Rule Name | Telegram Command | What Happens |
|---|---|---|
#getwind | #getwind or /getwind | Reads the latest sensor values from the rule's Sensor Read blocks, applies any Scale transforms, and replies with formatted values. |
#opendoor | #opendoor or /opendoor | Fires the rule's action blocks (Set Relay, Pulse Relay, etc.) and confirms. |
Telegram Output block: If a rule contains a Telegram Output action block, the bot reads the formatted value from that block instead of raw sensor data. This lets you chain Sensor → Scale → Telegram Output for precisely formatted responses (e.g., “Wind Speed: 12.3 m/s”).
| Command | Description |
|---|---|
/help or #help | Lists all available commands (built-in + rule commands) |
/status or #status | Shows all nodes with online/offline status, RSSI, and last-seen time |
/nodes or #nodes | Lists all discovered nodes with friendly names |
The bot only responds to messages from the configured Chat ID. Messages from other chats or users are silently ignored. The bot token should be kept private.
The automation engine can also push alerts to Telegram when rules trigger. The send_alert_threadsafe() method lets any rule fire a Telegram notification without blocking the GUI or radio threads.
The gateway GUI's Sensors tab provides a full sensor monitoring dashboard with real-time visualisation of all SDATA values received from the mesh.
| Widget | Description |
|---|---|
| Circular Gauges | One gauge per sensor key (nodeId:pin). Shows the current value with a needle, colour gradient ring (green → orange → red), and min/max tracking labels. Auto-scales to the observed data range. |
| Line Chart | Matplotlib-based time-series chart showing sensor history. Configurable history length (default 120 samples). Multiple sensors can be plotted simultaneously. |
| CSV Export | Click the export button to save all sensor data to a timestamped CSV file in gateway/exports/. Useful for offline analysis in Excel or Python. |
| Button | Action |
|---|---|
| Pause / Resume | Freezes the display for inspection without losing incoming data |
| Clear | Clears all historical data from the dashboard (does not affect the underlying data store) |
| Export CSV | Saves data to gateway/exports/sensors_YYYYMMDD_HHMMSS.csv |
SDATA,0010,34:2048sensor_data dictThe dashboard shares the same sensor_data dictionary used by the Logic Builder engine. Both update in real time from the same data source.
All three firmware targets (repeater, T-Deck, WiFi gateway) include a hardware timer watchdog that automatically reboots the device if the main loop hangs. This is completely independent of the RTOS task watchdog and catches low-level lockups that RTOS watchdogs miss (e.g., radio driver deadlocks, SPI bus hangs).
| Parameter | Value |
|---|---|
| Timeout | 30 seconds |
| Timer type | ESP32 hardware timer (independent of RTOS) |
| Pet location | Main loop() function — timerWrite(swWdt, 0) every iteration |
| Reset action | Hard reboot via timer ISR (cannot be blocked by stuck tasks) |
The RTOS task watchdog (TWDT) monitors whether tasks yield to the scheduler. But if the main loop is stuck inside a blocking radio call or SPI transaction, the RTOS scheduler still runs (it just can't preempt the stuck call on the same core). The hardware timer is independent of all software — if loop() does not pet it within 30 seconds, the ESP32 reboots unconditionally.
delay(), synchronous WiFi, etc.) must be avoided in the main loop.rst:0x3 (SW_RESET) or timer-related resets indicate a watchdog trigger.The Heltec V4 uses the ESP32-S3's native USB (HWCDC) instead of an external UART chip like the V3's CP2102. This has important implications:
| Feature | V3 | V4 |
|---|---|---|
| USB chip | CP2102 (external) | ESP32-S3 native USB (HWCDC) |
| COM port | Always available | Appears after firmware boots (~2-5s) |
| Serial during reset | Works (CP2102 stays powered) | Drops briefly during reboot |
| GPIO 19/20 | Available as GPIO | USB D-/D+ — DO NOT USE |
| Build flag | Default | ARDUINO_USB_CDC_ON_BOOT=1 |
The following pins are hardcoded into the isPinSafe() forbidden list and cannot be configured as sensor or relay pins:
| Pin(s) | Function | Consequence if misconfigured |
|---|---|---|
| GPIO 19, 20 | USB D-/D+ (HWCDC) | Kills USB serial connection immediately |
| GPIO 2 | GC1109 FEM enable (HIGH = PA on) | Disables front-end module, TX power drops from 28 dBm to ~10 dBm |
| GPIO 46 | GC1109 PA TX enable (HIGH) | Disables power amplifier, severely reduces range |
| GPIO 7 | GC1109 PA power (ANALOG mode) | Disrupts PA power supply |
esptool.py erase_flash, then reflash.python.exe processes holding the port, or reboot the PC. Windows Fast Startup can persist stale USB state — use shutdown /s /t 0 for a full shutdown.Share any sensor reading as a live web page. Anyone with the URL sees an auto-refreshing gauge on their phone — no app, no login, no limit on viewers.
https://<hub-address>/gauge/<node-id>/<pin>?label=<name>&unit=<unit>&min=<min>&max=<max>
| Parameter | Required | Default | Example |
|---|---|---|---|
| node-id | Yes | — | 5041 |
| pin | Yes | — | 15 |
| label | No | Sensor | Barn+Temperature |
| unit | No | (none) | °C |
| min | No | 0 | 0 |
| max | No | 500 | 50 |
/gauge/5041/15?label=Barn+Temperature&unit=°C&min=0&max=50 /gauge/5041/19?label=Wind+Speed&unit=km/h&min=0&max=100 /gauge/263A/3?label=Water+Level&unit=%25&min=0&max=100
{"type": "sensor", "node": "5041", "pin": "15", "value": 423}/gauge/<node>/<pin> serves a live HTML gauge page with auto-refresh every 5 seconds./api/sensor/<node>/<pin> returns JSON for programmatic access.
GET /api/sensor/<node>/<pin>
Response: {"node": "5041", "pin": "15", "value": 423, "ts": 1711234567}
gateway_hub.py)./gauge endpoint.<iframe> on your website.TiggyOpenMesh uses a free core + paid tiers model. The firmware, CLI gateway, Android app, and hub map are free and open-source. Premium GUI features are licensed per-seat with an offline activation key.
| Tier | Price | What You Get |
|---|---|---|
| Free | — | Node firmware, CLI gateway (gateway.py), hub server (gateway_hub.py), Android app, live hub map |
| Hobbyist | £30 | Gateway GUI + Sensor Dashboard + manual relay control |
| Pro | £80 | Everything in Hobbyist + Logic Builder (all blocks) + Telegram Bot + Meshtastic Bridge + Web Gauges |
| Business | £300 | Everything in Pro + priority support + remote setup assistance |
gateway_gui.py. On first run a licence dialog appears.Keys are perpetual: Once activated, the licence does not expire. Future updates within the same major version are included.
TiggyOpenMesh v4.1 — MeshCore Architecture
T-Deck Plus • LoRa32 T3 V1.6.1 • Heltec WiFi LoRa 32 V3 • Heltec WiFi LoRa 32 V4 • XIAO ESP32S3
45 sections — Hardware, Protocol, UI, BLE, Gateway, Logic Builder, Timers, Setpoints, Sensors, Pulse Counters, SOS, Map, Solar, Delivery, SF Change, Collision Detection, IO Expansion, GPS, Device Management, Signal Routing, iBeacon Scanner, Meshtastic Bridge, Telegram Bot (Gateway), Sensor Dashboard, HW Watchdog, Heltec V4 USB/FEM, Web Gauge, Licensing, Caveats
Documentation updated March 2026