4 Commits

Author SHA1 Message Date
scottp f13b858370 Cleanup docs 2026-06-17 22:54:25 +10:00
scottp 411864dab8 multiple buttons 2026-06-16 21:00:39 +10:00
scottp f0cd430eb9 Cleanup pins 2026-06-16 19:04:50 +10:00
scottp 1706186727 meshncore version 2026-06-16 17:42:45 +10:00
8 changed files with 986 additions and 262 deletions
-69
View File
@@ -1,69 +0,0 @@
# esp32-debug-dongle → LilyGo T3-S3 debug bridge
## Why
USB serial to the trough/solartrack sensors drops on every deep sleep, so we
can't watch boots/crashes/wakes. This dongle becomes an always-connected box on
the LAN that bridges a sensor's UART to telnet + a web terminal, controls GPIO
to reset/wake the target, logs to SD, and (Phase 2) sniffs the MeshCore LoRa
mesh — so we can debug remotely and stop flying blind.
## Decisions (confirmed with user)
1. **Board: LilyGo T3-S3** (ESP32-S3 + SX127x LoRa + SSD1306 128×64 OLED + SD).
Chosen over TTGO for onboard SD and LoRa (mesh monitoring).
2. **Extend `esp32-debug-dongle` in place** (not a new Sh3dNb app). Keep the
existing `esp32dev` env working; add a `t3s3` env.
3. **Both telnet (:23) and the WebSocket terminal.** Telnet is the primary
remote control path (nc/minicom + an agent driving it); GPIO reset/wake and
port/baud are exposed as **both** telnet `~commands` and REST endpoints.
4. **SD logging in Phase 1, ON by default** once NTP (built-in `configTzTime`)
gives today's date — log file named/stamped from real time.
5. **Power monitoring: out of scope.**
6. **Phase 2: MeshCore monitor** over the onboard LoRa (passive RX of mesh
packets, surfaced on telnet/web/SD).
## Board facts that shape the build
- ESP32-S3 has **no Bluetooth Classic**`BluetoothSerial`/SerialBT is guarded
behind `HAS_BT_CLASSIC` (esp32dev only). On T3-S3, telnet + WebSocket replace it.
- USB CDC on boot (`-DARDUINO_USB_CDC_ON_BOOT=1`) → UART0 (GPIO43/44) is free for
the target bridge.
- Pins (from `Sh3dNb/apps_oglas/lilygot3s3_basic`): OLED SDA18/SCL17/RST21@0x3C;
LoRa SCK5/MISO3/MOSI6/CS7/RST8/DIO0:9; BTN0.
## Phase 1 (this build) — extend src/main.cpp + platformio.ini
- **platformio.ini**: keep `[env:esp32dev]` (+ `-DHAS_BT_CLASSIC=1`); add
`[env:t3s3]` (esp32-s3-devkitc-1, USB CDC on boot, huge_app, littlefs,
Adafruit SSD1306+GFX). All pins via build flags (target UART, GPIO reset/wake,
SD, OLED) so they're config, not hardcoded.
- **Target UART bridge**: Serial1 on `TARGET_RX/TX` pins ↔ fan-out to WebSocket
(existing), **telnet clients**, and **SD log**. RX/TX byte+line counters.
- **Telnet server :23** with line protocol: non-`~` lines forward to the target
UART; `~` lines are commands:
`~help ~status ~reset [ms] ~wake [ms] ~baud <n> ~port <int|usb|ext> ~log on|off
~gpio <pin> <0|1>`. Lets an operator (or agent via `nc`) drive a remote board.
- **REST** (mirror of the commands): `/api/status /api/reset /api/wake
/api/baud?baud= /api/log?on= /api/gpio?pin=&val= /api/send?data=`.
- **GPIO control**: configurable `reset`/`wake` pins (active-low option), pulse.
- **NTP + SD logging**: `configTzTime(<TZ>)`; once time syncs, open
`/logs/YYYYMMDD-HHMMSS.log` and append timestamped serial. Logging ON by
default; `~log off` / REST toggles. Graceful no-op if SD absent/pins wrong.
- **OLED**: IP/SSID/RSSI, target pins+baud, RX/TX counts, telnet client count,
logging on/off, NTP synced y/n.
## Phase 2 — MeshCore monitor (later)
- Bring up the T3-S3 LoRa radio (confirm chip: SX1276 vs SX1262 variant) with
the mesh's PHY (freq/SF/BW/CR matching the sensors), RX-only.
- Decode/forward MeshCore channel frames to telnet/web/SD so we see beacons
(e.g. count trough beacons to confirm the sleep-crash fix) without a sensor.
## Pins to CONFIRM against the actual board (set as build flags)
- **SD**: SCK/MISO/MOSI/CS for the T3-S3 SD slot (may share the LoRa SPI bus +
own CS — verify). Logging silently disables if wrong.
- **Target UART bridge** RX/TX (default UART0 43/44, free under USB CDC).
- **GPIO reset/wake** pins to whatever lines you wire to the sensor's RST/control.
- **LoRa chip variant** (Phase 2).
## Verify
- `pio run -e t3s3` compiles; `pio run -e esp32dev` still compiles (BT intact).
- Flash T3-S3: OLED shows IP; `nc <ip> 23` streams a wired sensor's UART;
`~reset` reboots it (seen on the same stream); a dated file appears under
`/logs` on the SD and grows.
+190 -177
View File
@@ -1,241 +1,254 @@
# ESP32 Debug Dongle # ESP32 Debug Dongle
A WiFi/Bluetooth serial debugging tool for ESP32. Access serial ports via web browser or Bluetooth terminal. A remote serial-debug bridge for ESP32 targets. It bridges a target device's UART to a
**web terminal**, **telnet**, and (on the original ESP32) **Bluetooth SPP** — and adds GPIO
reset/button control, NTP-dated SD logging, an OLED status page, and a **MeshCore LoRa** comms
panel for sniffing/sending channel traffic while you debug.
![ESP32 Debug Dongle web interface](debug_dongle.png)
*Web UI: serial terminal (left), SD log files (top), and the MeshCore comms panel (right).*
## Features ## Features
- **Web Terminal**: Browser-based serial terminal using xterm.js - **Web terminal** — browser serial terminal (xterm.js) over WebSocket
- **Bluetooth SPP**: Classic Bluetooth serial port for desktop/mobile apps - **Telnet** (port 23) — primary remote path for `nc`/minicom or an agent
- **Multi-Port**: Switch between internal debug, USB serial, and external serial - **Bluetooth SPP** — Classic Bluetooth serial, original ESP32 only
- **Virtual Serial**: Internal loopback for ESP32's own debug output - **Multi-port** — switch between internal-debug loopback, USB serial, and the external target UART
- **Configurable**: Change baud rates on the fly - **Target control** — pulse reset, pulse/latch a button (wake / force-on) from web, telnet, and REST
- **SD logging** (T3-S3) — NTP-dated logs of UART *and* MeshCore traffic, with a self-describing header
- **MeshCore panel** (T3-S3 + LoRa) — program a channel PSK at runtime, watch received messages, send messages
- **OLED status** (T3-S3) — IP, WiFi, UART, byte counts, log/SD/NTP state
## Build variants
The board and optional features are selected by PlatformIO environment (`platformio.ini`):
| Env | Board | SD | OLED | MeshCore LoRa | Bluetooth |
|-----|-------|----|------|---------------|-----------|
| `esp32dev` | generic ESP32 | | | | ✅ Classic SPP |
| `t3s3` | LilyGo T3-S3 (ESP32-S3) | ✅ | ✅ | | |
| `t3s3_mesh` | LilyGo T3-S3 (ESP32-S3 + SX1276) | ✅ | ✅ | ✅ | |
ESP32-S3 has no Classic Bluetooth — telnet + the web terminal replace SerialBT there.
`t3s3_mesh` extends `t3s3` and just adds the radio (`-DUSE_MESHCORE=1`); everything mesh-related
compiles to no-ops in the other builds, so the firmware and web UI are identical across all three.
## Hardware ## Hardware
### Requirements ### Pin connections — LilyGo T3-S3 (`t3s3` / `t3s3_mesh`)
- ESP32 DevKit v1 (or compatible ESP32 board with Classic Bluetooth)
- **Note**: ESP32-S2, S3, C3 do NOT support Classic Bluetooth SPP
### Pin Connections for External Serial (Serial1) Wire the target to the **right-hand header** — reset, TX, RX, and button are the top pins, with
GND a few pins down:
| ESP32 Pin | Function | Connect To | | T3-S3 GPIO | Function | Connect to target |
|-----------|----------|------------| |------------|----------|-------------------|
| GPIO16 | RX1 | External device TX | | GPIO38 | Reset out (active-low pulse) | target RST |
| GPIO17 | TX1 | External device RX | | GPIO43 | TX | target RX |
| GND | Ground | External device GND | | GPIO44 | RX | target TX |
| GPIO39 | Button out (active-low pulse/latch) | target button / wake |
| GND | Ground | target GND |
Onboard peripherals (already wired on the board, listed for reference): SD card on HSPI
(SCK 14, MISO 2, MOSI 11, CS 13); OLED on I2C (SDA 18, SCL 17); and in `t3s3_mesh` the SX1276
on FSPI (NSS 7, RST 8, DIO0 9, DIO1 33, SCLK 5, MISO 3, MOSI 6, RXEN 21, TXEN 10). The reset/
button polarity is active-low (`GPIO_CTRL_ACTIVE_LOW=1`); change it in `platformio.ini` if your
target is active-high.
### Pin connections — generic ESP32 (`esp32dev`)
External serial defaults to RX=GPIO16, TX=GPIO17, plus a common GND. No SD/OLED/LoRa.
## Quick Start ## Quick Start
### 1. Install PlatformIO ### 1. Install PlatformIO
```bash ```bash
# Install PlatformIO CLI (if not already installed) pip install platformio # or use the VS Code PlatformIO IDE extension
pip install platformio
# Or use VS Code with PlatformIO IDE extension
``` ```
### 2. Build and Upload ### 2. Build & upload
Pick your environment with `-e`:
```bash ```bash
# Clone/copy this project
cd esp32-debug-dongle cd esp32-debug-dongle
# Build the firmware # Firmware
pio run pio run -e t3s3_mesh -t upload # or: -e t3s3 / -e esp32dev
# Upload firmware to ESP32 # Web files (LittleFS) -- needed on first flash and after any data/ change
pio run -t upload pio run -e t3s3_mesh -t uploadfs
# Upload web files to LittleFS # Serial monitor
pio run -t uploadfs pio device monitor
``` ```
> **MeshCore note:** `t3s3_mesh` pulls the MeshCore library from a local checkout
> (`symlink:///home/scottp/github/MeshCore`) plus RadioLib / Crypto / RTClib / base64 / ed25519.
> Adjust that path in `platformio.ini` to wherever your MeshCore checkout lives.
### 3. Connect ### 3. Connect
#### Via WiFi (Web Terminal) The dongle joins your WiFi in station mode (SSID/password are build flags in `platformio.ini`).
If it can't join, it falls back to an access point:
1. Connect to WiFi network: `ESP32-DebugDongle` - **AP SSID:** `ESP32-DebugDongle` **Password:** `debug1234` → open `http://192.168.4.1`
2. Password: `debug1234`
3. Open browser: `http://192.168.4.1`
#### Via Bluetooth On a successful station join, the device prints its IP on the USB serial monitor and on the OLED:
1. Pair with device: `ESP32-Debug` ```
2. Use any Bluetooth serial terminal app: [Ready] http://10.0.1.241 telnet 10.0.1.241 23
- **Android**: "Serial Bluetooth Terminal" by Kai Morich ```
- **Windows**: PuTTY (use assigned COM port after pairing)
- **Linux**: `rfcomm connect 0 XX:XX:XX:XX:XX:XX` then use `/dev/rfcomm0` #### Bluetooth (`esp32dev` only)
- **macOS**: Pair in System Preferences, use `/dev/tty.ESP32-Debug`
Pair with `ESP32-Debug`, then use any BT serial terminal (Android "Serial Bluetooth Terminal",
Windows PuTTY on the COM port, Linux `rfcomm`, macOS `/dev/tty.ESP32-Debug`).
## Usage ## Usage
### Web Interface ### Web interface
The web terminal provides: - **Port** — Internal (debug loopback) / USB Serial / External (target UART)
- **Port Selection**: Choose between Internal, USB Serial, or External - **Baud** — 9600…921600
- **Baud Rate**: Configure serial speed (9600 - 921600) - **Reset / Button** — momentary pulse of the target reset / button lines
- **Clear**: Clear terminal screen - **Hold** — latch the button line held active (force-on) until released
- **Reconnect**: Re-establish WebSocket connection - **Clear / Reconnect** — terminal + WebSocket
- **Log / Files** — toggle SD logging and browse/download/delete log files (T3-S3)
- **MeshCore panel** — program a channel + PSK, view RX/TX messages, send a message
### Serial Ports ### Serial ports
| Port | Description | Use Case | | Port | Description | Use case |
|------|-------------|----------| |------|-------------|----------|
| Internal | Virtual loopback buffer | ESP32's own debug output | | Internal | Virtual loopback buffer | the dongle's own debug output |
| USB Serial | UART0 (USB connection) | Shared with programming | | USB Serial | UART0 (shared with USB) | console |
| External | Serial1 (GPIO16/17) | External device debugging | | External | Serial1 (target UART pins) | the device under test |
### Using Internal Debug Output ### Telnet (port 23)
In your ESP32 code, use the provided helper functions: Any line is forwarded verbatim to the target UART **unless** it starts with `~`, in which case
it's a dongle command:
```cpp ```
// Write to internal virtual serial ~help list commands
debugPrint("Sensor value: %d", sensorValue); ~status port/baud/counters/log/ntp/heap
debugPrintln("Status: OK"); ~reset [ms] pulse the reset line (default 200 ms)
~button [ms|on|off] pulse the button line, or latch it on/off (force-on)
// Or write directly to the loopback stream ~baud <n> set target baud
internalSerial.println("Debug message"); ~port <int|usb|ext> select the active port
~log on|off SD logging (T3-S3)
~gpio <pin> <0|1> drive an arbitrary GPIO
~mesh [on|off] mesh status / toggle echo of mesh msgs to telnet
~psk <base64key> reprogram the user channel's PSK (16- or 32-byte key)
~chan <name> <base64key> set channel name + PSK together
~msg <text> send a message on the user channel
``` ```
These messages appear when "Internal" port is selected. ### REST API
Mirrors the telnet commands:
```
/api/status /api/reset?ms= /api/button?ms= | ?latch=on|off
/api/baud?baud= /api/port?port=int|usb|ext
/api/log?on=0|1 /api/gpio?pin=&val= /api/send?data=
/api/logs /api/logfile?name= /api/logdelete?name= (T3-S3)
```
## MeshCore comms panel (`t3s3_mesh`)
The right-hand panel talks to a MeshCore `BaseChatMesh` node on the SX1276 radio. It listens on
the well-known `Public` channel plus one **user channel** (default `SensorsTest`). You can:
- **Program** a new channel name + PSK at runtime (base64-encoded 16- or 32-byte key). The PSK is
persisted to NVS, so it survives reboots, and the change is recorded in the SD log.
- **Watch** received channel messages (sender, text, RSSI/SNR).
- **Send** a message on the user channel.
The same actions are available over telnet (`~psk`, `~chan`, `~msg`, `~mesh`). LoRa PHY defaults
are Australia-narrow (916.575 MHz, BW 62.5, SF7, CR8, 20 dBm) — change them in `platformio.ini`.
## SD logging (T3-S3)
Logs are written to NTP-dated files under `/logs/` on the SD card. Each file opens with a
self-describing header and captures both the target UART stream and MeshCore RX/TX, timestamped
per line:
```
# debug-dongle log opened /logs/20260616-142348.log
# uart: port=external baud=115200 rx=44 tx=43
# mesh: up node=dongle channel=SensorsPH psk=<base64> freq=916.575 bw=62.5 sf=7 cr=8 tx=20
[14:24:15] [mesh rx batcave] device=ScottTrailer batt=13.11 ...
[14:26:36] sensor boot #1 ...
```
This is a low-level debugger, so the channel PSK is logged in plaintext on purpose — the card
carries the key. Browse/download/delete logs from the **Files** panel or the `/api/logs*`
endpoints. Toggle with the **Log** button or `~log on|off`.
## WebSocket protocol
Endpoint: `ws://<device-ip>/ws`
- **Binary frames** — raw target serial data (both directions).
- **Text frames** — control/event JSON, prefixed with a `0x00` byte. Events carry a `type`:
`status`, `mesh` (a received/sent message), or `meshcfg` (channel/PSK state).
Browser → device commands (sent as `0x00` + JSON):
```javascript
{ "cmd": "setPort", "port": 2 } // 0=Internal, 1=USB, 2=External
{ "cmd": "setBaud", "baud": 115200 }
{ "cmd": "getStatus" }
{ "cmd": "meshSend", "text": "hello" }
{ "cmd": "meshPsk", "name": "SensorsTest", "psk": "PNtgMxiq9R7eQ3IleHoL3g==" }
{ "cmd": "meshGet" }
```
## Configuration ## Configuration
Edit `src/main.cpp` to change defaults: Most settings are **build flags** in `platformio.ini` (per environment): WiFi `STA_SSID` /
`STA_PASSWORD`, target UART pins (`TARGET_RX_PIN` / `TARGET_TX_PIN`), control pins
(`GPIO_RESET_PIN` / `GPIO_WAKE_PIN` / `GPIO_CTRL_ACTIVE_LOW`), SD/OLED/LoRa pins, the LoRa PHY,
and the default mesh channel (`SENSORS_CHANNEL_NAME` / `SENSORS_CHANNEL_PSK_B64`). The AP SSID,
Bluetooth name, and default bauds live near the top of `src/main.cpp`.
```cpp ## Project structure
// WiFi Access Point
const char* AP_SSID = "ESP32-DebugDongle";
const char* AP_PASSWORD = "debug1234";
// Bluetooth name
const char* BT_NAME = "ESP32-Debug";
// Serial1 pins
#define SERIAL1_RX_PIN 16
#define SERIAL1_TX_PIN 17
// Default baud rates
#define DEFAULT_BAUD_SERIAL 115200
#define DEFAULT_BAUD_SERIAL1 115200
```
## Project Structure
``` ```
esp32-debug-dongle/ esp32-debug-dongle/
├── platformio.ini # PlatformIO configuration ├── platformio.ini # build envs: esp32dev / t3s3 / t3s3_mesh
├── src/ ├── src/
── main.cpp # Main ESP32 firmware ── main.cpp # bridge: UART <-> web/telnet/BT, control, SD log, OLED
│ ├── meshcore_link.{h,cpp} # MeshCore node facade (no-op unless USE_MESHCORE)
│ └── LoopbackStream.{h,cpp} # internal virtual serial
├── data/ ├── data/
│ └── index.html # Web interface (uploaded to LittleFS) │ └── index.html # web UI (uploaded to LittleFS)
├── scripts/ ├── scripts/
│ └── download_xterm.py # Optional: download xterm.js locally │ └── download_xterm.py # optional: host xterm.js locally instead of CDN
└── README.md └── README.md
``` ```
## xterm.js Setup
The web interface uses xterm.js loaded from CDN. If you need offline operation:
```bash
# Download files locally
python scripts/download_xterm.py --local
# Then edit data/index.html to use local paths:
# <link rel="stylesheet" href="/css/xterm.min.css">
# <script src="/js/xterm.min.js"></script>
# etc.
```
## WebSocket Protocol
The WebSocket endpoint is `ws://192.168.4.1/ws`
### Data Format
- **Regular serial data**: Raw bytes sent/received directly
- **Commands**: JSON prefixed with `0x00` byte
### Commands
```javascript
// Switch serial port
{ "cmd": "setPort", "port": 0 } // 0=Internal, 1=USB, 2=External
// Set baud rate
{ "cmd": "setBaud", "port": 2, "baud": 115200 }
// Get status
{ "cmd": "getStatus" }
```
### JavaScript Example
```javascript
const ws = new WebSocket('ws://192.168.4.1/ws');
ws.binaryType = 'arraybuffer';
// Send serial data
ws.send(new TextEncoder().encode('Hello\r\n'));
// Send command
function sendCommand(cmd) {
const json = JSON.stringify(cmd);
const data = new Uint8Array(json.length + 1);
data[0] = 0x00;
new TextEncoder().encodeInto(json, data.subarray(1));
ws.send(data);
}
// Receive data
ws.onmessage = (e) => {
const data = new Uint8Array(e.data);
if (data[0] === 0x00) {
// Command response
const json = JSON.parse(new TextDecoder().decode(data.slice(1)));
console.log('Response:', json);
} else {
// Serial data
console.log('Serial:', new TextDecoder().decode(data));
}
};
```
## Troubleshooting ## Troubleshooting
### Can't connect to WiFi - **Web page won't load** — upload the filesystem (`pio run -e <env> -t uploadfs`); use `http://`, not `https`.
- Ensure you're connecting to `ESP32-DebugDongle` network - **No serial data** — check baud, swap TX/RX, ensure a common ground.
- Password is `debug1234` (case-sensitive) - **SD "not found" / CRC errors** — confirm the SD pins, and on `t3s3_mesh` that the radio is on
- Try resetting the ESP32 FSPI and SD on HSPI (they must be different SPI peripherals).
- **MeshCore: nothing received** — confirm the channel PSK matches the sender's, and the LoRa PHY
### Web page won't load (freq/BW/SF/CR) matches the fleet.
- Make sure you uploaded the filesystem: `pio run -t uploadfs` - **Terminal lines "staircase"** — already handled (`convertEol`); the raw SD log keeps bare `\n`.
- Check serial monitor for errors - **Bluetooth won't pair** — original ESP32 only (`esp32dev`); not on S3.
- Try `http://192.168.4.1` (not https)
### Bluetooth won't pair
- Only works on original ESP32 (not S2, S3, C3)
- Delete existing pairing and try again
- Check that Bluetooth is enabled in build flags
### No serial data
- Verify baud rate matches your device
- Check TX/RX connections (try swapping them)
- Ensure common ground connection
### Build errors
- Ensure you have the ESP32 board package installed in PlatformIO
- Library dependencies should auto-install on first build
## License ## License
MIT License - Feel free to use and modify. MIT License — feel free to use and modify.
## Credits ## Credits
- [xterm.js](https://xtermjs.org/) - Terminal emulator - [xterm.js](https://xtermjs.org/) — terminal emulator
- [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer) - Async web server - [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) — async web server
- [ArduinoJson](https://arduinojson.org/) - JSON library by Benoît Blanchon - [ArduinoJson](https://arduinojson.org/) JSON library by Benoît Blanchon
- [MeshCore](https://github.com/meshcore-dev/MeshCore) — LoRa mesh + RadioLib radio drivers
+208 -2
View File
@@ -109,16 +109,85 @@
background: #ef4444; background: #ef4444;
} }
.main-row {
flex: 1;
display: flex;
min-height: 0;
}
.terminal-container { .terminal-container {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
#terminal { #terminal {
height: 100%; height: 100%;
} }
/* Right-side comms panel (MeshCore today, more later) */
.comms-panel {
flex: 0 0 340px;
display: flex;
flex-direction: column;
background: #16213e;
border-left: 1px solid #0f3460;
padding: 10px 12px;
gap: 10px;
min-height: 0;
}
.comms-panel h2 {
margin: 0;
font-size: 1em;
color: #e94560;
display: flex;
align-items: center;
justify-content: space-between;
}
.comms-cfg {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85em;
}
.comms-cfg input {
background: #0f3460;
color: #fff;
border: 1px solid #1a4a7a;
padding: 6px 8px;
border-radius: 4px;
font-size: 0.9em;
width: 100%;
}
.comms-cfg .row { display: flex; gap: 6px; }
.comms-cfg .row input { flex: 1; }
#meshMessages {
flex: 1;
overflow-y: auto;
background: #0f1a30;
border: 1px solid #0f3460;
border-radius: 4px;
padding: 6px 8px;
font-family: Menlo, Monaco, "Courier New", monospace;
font-size: 0.8em;
line-height: 1.4;
min-height: 60px;
}
.mc-msg { margin-bottom: 4px; word-break: break-word; }
.mc-msg .who { color: #60a5fa; }
.mc-msg.tx .who { color: #4ade80; }
.mc-msg .meta { color: #666; font-size: 0.85em; }
.mc-empty { color: #666; }
.comms-send { display: flex; gap: 6px; }
.comms-send input { flex: 1; }
.mesh-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #666; display: inline-block;
}
.mesh-dot.up { background: #4ade80; }
.mesh-dot.down { background: #ef4444; }
.footer { .footer {
background: #16213e; background: #16213e;
padding: 8px 20px; padding: 8px 20px;
@@ -138,6 +207,14 @@
display: none; display: none;
} }
} }
@media (max-width: 900px) {
.main-row { flex-direction: column; }
.comms-panel {
flex: 0 0 45vh;
border-left: none;
border-top: 1px solid #0f3460;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -165,6 +242,9 @@
<option value="921600">921600</option> <option value="921600">921600</option>
</select> </select>
</div> </div>
<button class="danger" onclick="doReset()" title="Pulse the target reset line">Reset</button>
<button onclick="doButton()" title="Momentary press of the target button / wake line">Button</button>
<button id="holdBtn" onclick="toggleHold()" title="Latch the button line held down (force-on) / release">Hold: off</button>
<button onclick="clearTerminal()">Clear</button> <button onclick="clearTerminal()">Clear</button>
<button onclick="reconnect()">Reconnect</button> <button onclick="reconnect()">Reconnect</button>
<button id="logBtn" onclick="toggleLog()">Log: --</button> <button id="logBtn" onclick="toggleLog()">Log: --</button>
@@ -194,8 +274,29 @@
<table id="filesTable" style="width:100%; border-collapse:collapse; font-size:0.85em;"></table> <table id="filesTable" style="width:100%; border-collapse:collapse; font-size:0.85em;"></table>
</div> </div>
<div class="terminal-container"> <div class="main-row">
<div id="terminal"></div> <div class="terminal-container">
<div id="terminal"></div>
</div>
<div class="comms-panel" id="commsPanel">
<h2>
<span>📡 MeshCore</span>
<span><span class="mesh-dot" id="meshDot"></span> <span id="meshState" style="font-size:0.75em;color:#aaa;"></span></span>
</h2>
<div class="comms-cfg">
<input type="text" id="meshChan" placeholder="Channel name (e.g. SensorsTest)">
<div class="row">
<input type="text" id="meshPsk" placeholder="PSK (base64, 16 or 32-byte key)">
<button onclick="programChannel()">Program</button>
</div>
</div>
<div id="meshMessages"><div class="mc-empty">No messages yet.</div></div>
<div class="comms-send">
<input type="text" id="meshInput" placeholder="Message…" onkeydown="if(event.key==='Enter')sendMesh()">
<button onclick="sendMesh()">Send</button>
</div>
</div>
</div> </div>
<div class="footer"> <div class="footer">
@@ -208,6 +309,7 @@
// Terminal setup // Terminal setup
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
convertEol: true, // target sends bare '\n'; treat it as CRLF so lines don't staircase
fontSize: 14, fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace', fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: { theme: {
@@ -272,6 +374,7 @@
// Request initial status // Request initial status
sendCommand({ cmd: 'getStatus' }); sendCommand({ cmd: 'getStatus' });
sendCommand({ cmd: 'meshGet' });
}; };
ws.onclose = () => { ws.onclose = () => {
@@ -293,6 +396,13 @@
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
// Control/mesh events arrive as text frames (0x00-prefixed JSON).
if (typeof event.data === 'string') {
let s = event.data;
if (s.charCodeAt(0) === 0) s = s.slice(1);
try { handleResponse(JSON.parse(s)); } catch (e) {}
return;
}
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data); const data = new Uint8Array(event.data);
@@ -326,6 +436,7 @@
updateStatus('btStatus', msg.btConnected); updateStatus('btStatus', msg.btConnected);
document.getElementById('heap').textContent = `Heap: ${msg.freeHeap}`; document.getElementById('heap').textContent = `Heap: ${msg.freeHeap}`;
updateLogUi(msg); updateLogUi(msg);
updateButtonUi(msg);
// Show WiFi info // Show WiFi info
const wifiInfo = document.getElementById('wifiInfo'); const wifiInfo = document.getElementById('wifiInfo');
@@ -344,9 +455,76 @@
const portNames = ['Internal', 'USB Serial', 'External']; const portNames = ['Internal', 'USB Serial', 'External'];
term.writeln(`\r\n\x1b[33m[Switched to ${portNames[msg.port]}]\x1b[0m\r\n`); term.writeln(`\r\n\x1b[33m[Switched to ${portNames[msg.port]}]\x1b[0m\r\n`);
break; break;
case 'mesh':
appendMeshMsg(msg);
break;
case 'meshcfg':
updateMeshCfg(msg);
break;
} }
} }
// ---- MeshCore comms panel ----
let meshEnabled = false;
function updateMeshCfg(c) {
meshEnabled = !!c.enabled;
const dot = document.getElementById('meshDot');
const state = document.getElementById('meshState');
const chan = document.getElementById('meshChan');
const psk = document.getElementById('meshPsk');
if (!meshEnabled) {
dot.className = 'mesh-dot';
state.textContent = 'not in build';
} else if (c.up) {
dot.className = 'mesh-dot up';
state.textContent = `up · rx ${c.rx} tx ${c.tx}`;
} else {
dot.className = 'mesh-dot down';
state.textContent = 'radio down';
}
// Don't clobber a field the user is editing.
if (document.activeElement !== chan && typeof c.channel !== 'undefined') chan.value = c.channel;
if (document.activeElement !== psk && typeof c.psk !== 'undefined') psk.value = c.psk;
const disabled = !meshEnabled;
document.getElementById('meshInput').disabled = disabled;
}
function appendMeshMsg(m) {
const box = document.getElementById('meshMessages');
const empty = box.querySelector('.mc-empty');
if (empty) empty.remove();
const div = document.createElement('div');
div.className = 'mc-msg' + (m.dir === 'tx' ? ' tx' : '');
const t = new Date().toLocaleTimeString();
let meta = m.dir === 'tx' ? '↑' : '↓';
if (m.channel) meta += ' ' + m.channel;
if (typeof m.rssi !== 'undefined' && m.rssi) meta += ` ${m.rssi}dBm`;
div.innerHTML = `<span class="meta">[${t}] ${escapeHtml(meta)}</span> ` +
`<span class="who">${escapeHtml(m.sender || '?')}:</span> ` +
escapeHtml(m.text || '');
box.appendChild(div);
box.scrollTop = box.scrollHeight;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function programChannel() {
const name = document.getElementById('meshChan').value.trim();
const psk = document.getElementById('meshPsk').value.trim();
if (!psk) { alert('Enter a PSK (base64 16- or 32-byte key)'); return; }
sendCommand({ cmd: 'meshPsk', name: name, psk: psk });
setTimeout(() => sendCommand({ cmd: 'meshGet' }), 300);
}
function sendMesh() {
const inp = document.getElementById('meshInput');
const text = inp.value;
if (!text) return;
sendCommand({ cmd: 'meshSend', text: text });
inp.value = '';
}
function sendCommand(cmd) { function sendCommand(cmd) {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
const json = JSON.stringify(cmd); const json = JSON.stringify(cmd);
@@ -418,6 +596,34 @@
return (bytes / 1024 / 1024).toFixed(1) + ' MB'; return (bytes / 1024 / 1024).toFixed(1) + ' MB';
} }
// ---- Target control lines (reset / button) ----
function pulseLine(path, label) {
fetch(path + '?ms=200').then(r => r.text())
.then(t => term.writeln(`\r\n\x1b[33m[${label}] ${t.trim()}\x1b[0m`))
.catch(() => term.writeln(`\r\n\x1b[31m[${label} failed]\x1b[0m`));
}
function doReset() { pulseLine('/api/reset', 'reset'); }
function doButton() {
fetch('/api/button?ms=200').then(r => r.json()).then(s => {
updateButtonUi(s);
term.writeln('\r\n\x1b[33m[button] pulsed\x1b[0m');
}).catch(() => term.writeln('\r\n\x1b[31m[button failed]\x1b[0m'));
}
let holdOn = false;
function toggleHold() {
fetch('/api/button?latch=' + (holdOn ? 'off' : 'on')).then(r => r.json()).then(s => {
updateButtonUi(s);
term.writeln(`\r\n\x1b[33m[button] hold ${holdOn ? 'ON' : 'off'}\x1b[0m`);
}).catch(() => term.writeln('\r\n\x1b[31m[hold failed]\x1b[0m'));
}
function updateButtonUi(s) {
if (typeof s.buttonLatch === 'undefined') return;
holdOn = !!s.buttonLatch;
const b = document.getElementById('holdBtn');
b.textContent = 'Hold: ' + (holdOn ? 'ON' : 'off');
b.classList.toggle('danger', holdOn);
}
// ---- SD logging controls ---- // ---- SD logging controls ----
let logOn = false; let logOn = false;
function toggleLog() { function toggleLog() {
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+83 -3
View File
@@ -83,9 +83,12 @@ build_flags =
; --- target UART bridge (wire to the sensor's debug UART) --- ; --- target UART bridge (wire to the sensor's debug UART) ---
-DTARGET_RX_PIN=44 ; CONFIRM: dongle RX <- sensor TX -DTARGET_RX_PIN=44 ; CONFIRM: dongle RX <- sensor TX
-DTARGET_TX_PIN=43 ; CONFIRM: dongle TX -> sensor RX -DTARGET_TX_PIN=43 ; CONFIRM: dongle TX -> sensor RX
; --- GPIO control lines to the target (reset / wake) --- ; --- GPIO control lines to the target (reset out / button out) ---
-DGPIO_RESET_PIN=2 ; CONFIRM: -> sensor RST ; GPIO38 + GPIO39 are free pins on the T3-S3 RIGHT header, right next to the
-DGPIO_WAKE_PIN=1 ; CONFIRM: -> sensor control/wake pin ; UART pins TXD=43/RXD=44 -- so reset/button/TX/RX/GND all come off one row.
; (GPIO2=SD MISO and GPIO1=battery ADC; GPIO4/12 are not broken out at all.)
-DGPIO_RESET_PIN=38 ; -> sensor RST (active-low pulse)
-DGPIO_WAKE_PIN=39 ; -> sensor button/wake (active-low pulse)
-DGPIO_CTRL_ACTIVE_LOW=1 -DGPIO_CTRL_ACTIVE_LOW=1
; --- microSD on FSPI/SPI2 (separate bus from LoRa, which uses HSPI/SPI3) --- ; --- microSD on FSPI/SPI2 (separate bus from LoRa, which uses HSPI/SPI3) ---
-DT3S3_SD_SCK=14 -DT3S3_SD_SCK=14
@@ -103,3 +106,80 @@ lib_deps =
adafruit/Adafruit GFX Library adafruit/Adafruit GFX Library
extra_scripts = extra_scripts =
pre:scripts/download_xterm.py pre:scripts/download_xterm.py
; ============================================================================
; LilyGo T3-S3 + MeshCore radio. Extends env:t3s3 (OLED + SD logging) and
; adds the SX1276 LoRa link so the web "MeshCore" panel and telnet ~psk/~msg
; commands work. Builds on the same hardware; pins match MeshCore's
; experiment/lilygot3s3logger (the proven T3-S3 SX1276 reference).
;
; pio run -e t3s3_mesh -t upload && pio run -e t3s3_mesh -t uploadfs
; ============================================================================
[env:t3s3_mesh]
extends = env:t3s3
; ed25519 (MeshCore's bundled identity crypto) lives in MeshCore/lib.
lib_extra_dirs =
/home/scottp/github/MeshCore/lib
lib_ldf_mode = deep+
build_unflags =
-DBOARD_HAS_PSRAM
lib_deps =
${env:t3s3.lib_deps}
symlink:///home/scottp/github/MeshCore
jgromes/RadioLib @ ^7.6.0
rweather/Crypto @ ^0.4.0
adafruit/RTClib @ ^2.1.3
densaugeo/base64 @ ~1.4.0
Preferences
ed25519
build_flags =
${env:t3s3.build_flags}
-w
-DUSE_MESHCORE=1
-D ESP32_PLATFORM
; --- pin 21 is the SX1276 RX-enable on the T3-S3, so the OLED can't use it
; as a reset line here; SSD1306 over I2C runs fine with no reset pin. ---
-DT3S3_OLED_RST=-1
; --- RadioLib: lock to the SX127x family ---
-D RADIOLIB_STATIC_ONLY=1
-D RADIOLIB_GODMODE=1
-D RADIOLIB_EXCLUDE_CC1101=1
-D RADIOLIB_EXCLUDE_RF69=1
-D RADIOLIB_EXCLUDE_SX1231=1
-D RADIOLIB_EXCLUDE_SI443X=1
-D RADIOLIB_EXCLUDE_RFM2X=1
-D RADIOLIB_EXCLUDE_SX128X=1
-D RADIOLIB_EXCLUDE_AFSK=1
-D RADIOLIB_EXCLUDE_AX25=1
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
-D RADIOLIB_EXCLUDE_MORSE=1
-D RADIOLIB_EXCLUDE_APRS=1
-D RADIOLIB_EXCLUDE_BELL=1
-D RADIOLIB_EXCLUDE_RTTY=1
-D RADIOLIB_EXCLUDE_SSTV=1
; --- T3-S3 v1.2 SX1276 pin map (MeshCore variants/lilygo_t3s3_sx1276) ---
-D USE_SX1276
-D P_LORA_NSS=7
-D P_LORA_RESET=8
-D P_LORA_DIO_0=9
-D P_LORA_DIO_1=33
-D P_LORA_SCLK=5
-D P_LORA_MISO=3
-D P_LORA_MOSI=6
-D P_LORA_TX_LED=37
-D SX127X_CURRENT_LIMIT=120
-D SX176X_RXEN=21
-D SX176X_TXEN=10
; --- LoRa PHY: Australia Narrow (match the sensor fleet) ---
-D LORA_FREQ=916.575
-D LORA_BW=62.5
-D LORA_SF=7
-D LORA_CR=8
-D LORA_TX_POWER=20
-D MAX_GROUP_CHANNELS=4
-D MAX_CONTACTS=8
; --- channel defaults (runtime-overridable + persisted in NVS) ---
-D NODE_NAME='"dongle"'
-D SENSORS_CHANNEL_NAME='"SensorsTest"'
-D SENSORS_CHANNEL_PSK_B64='"PNtgMxiq9R7eQ3IleHoL3g=="'
-D PUBLIC_CHANNEL_PSK_B64='"izOH6cXN6mrJ5e26oRXNcg=="'
+163 -8
View File
@@ -27,6 +27,7 @@
#include <LittleFS.h> #include <LittleFS.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include "LoopbackStream.h" #include "LoopbackStream.h"
#include "meshcore_link.h"
#if HAS_BT_CLASSIC #if HAS_BT_CLASSIC
#include "BluetoothSerial.h" #include "BluetoothSerial.h"
@@ -124,12 +125,17 @@ size_t telnetLineLen[MAX_TELNET] = {0};
volatile uint32_t rxBytes = 0; // from target volatile uint32_t rxBytes = 0; // from target
volatile uint32_t txBytes = 0; // to target volatile uint32_t txBytes = 0; // to target
// Button line held active (latched) rather than momentary-pulsed.
bool buttonLatched = false;
// SD logging (T3-S3) // SD logging (T3-S3)
bool sdAvailable = false; bool sdAvailable = false;
bool logEnabled = true; // ON by default once we have a date bool logEnabled = true; // ON by default once we have a date
bool ntpSynced = false; bool ntpSynced = false;
#if BOARD_T3S3 #if BOARD_T3S3
SPIClass sdSPI(FSPI); // SD on HSPI (SPI3). The MeshCore LoRa radio (env:t3s3_mesh) is pinned to FSPI
// (SPI2) in meshcore_link.cpp, so the two SPI peripherals never collide.
SPIClass sdSPI(HSPI);
File logFile; File logFile;
bool logAtLineStart = true; bool logAtLineStart = true;
char logPath[40] = {0}; char logPath[40] = {0};
@@ -143,6 +149,7 @@ bool oledOk = false;
void broadcastFromTarget(const uint8_t* data, size_t len); void broadcastFromTarget(const uint8_t* data, size_t len);
void forwardToTarget(const uint8_t* data, size_t len); void forwardToTarget(const uint8_t* data, size_t len);
void handleCommand(const char* line, Stream* reply); void handleCommand(const char* line, Stream* reply);
static const char* portName(SerialPortId p);
// ============================================================================ // ============================================================================
// Serial Port Management // Serial Port Management
@@ -186,6 +193,17 @@ static void gpioPulse(int pin, uint32_t ms, Stream* reply, const char* name) {
if (reply) reply->printf("[ctrl] %s pulsed pin %d for %lu ms\r\n", name, pin, (unsigned long)ms); if (reply) reply->printf("[ctrl] %s pulsed pin %d for %lu ms\r\n", name, pin, (unsigned long)ms);
} }
// Hold a control line active (on) or release it (off) -- a latched "press".
static void gpioLatch(int pin, bool on, Stream* reply, const char* name) {
if (pin < 0) { if (reply) reply->printf("[ctrl] %s pin not configured\r\n", name); return; }
int active = GPIO_CTRL_ACTIVE_LOW ? LOW : HIGH;
int inactive = GPIO_CTRL_ACTIVE_LOW ? HIGH : LOW;
pinMode(pin, OUTPUT);
digitalWrite(pin, on ? active : inactive);
if (reply) reply->printf("[ctrl] %s latch=%s (pin %d %s)\r\n",
name, on ? "on" : "off", pin, on ? "held" : "released");
}
// ============================================================================ // ============================================================================
// SD logging (T3-S3) // SD logging (T3-S3)
// ============================================================================ // ============================================================================
@@ -201,6 +219,14 @@ static void openLogIfReady() {
logFile = SD.open(logPath, FILE_WRITE); logFile = SD.open(logPath, FILE_WRITE);
if (logFile) { if (logFile) {
logFile.printf("# debug-dongle log opened %s\n", logPath); logFile.printf("# debug-dongle log opened %s\n", logPath);
logFile.printf("# uart: port=%s baud=%lu rx=%d tx=%d\n",
portName(currentPort), (unsigned long)baudSerial1,
TARGET_RX_PIN, TARGET_TX_PIN);
if (mc::enabled()) {
logFile.printf("# mesh: %s node=%s channel=%s psk=%s %s\n",
mc::up() ? "up" : "down", mc::nodeName(),
mc::channelName(), mc::pskB64(), mc::radioConfig());
}
logFile.flush(); logFile.flush();
logAtLineStart = true; logAtLineStart = true;
} }
@@ -250,6 +276,82 @@ void forwardToTarget(const uint8_t* data, size_t len) {
activePort->write(data, len); activePort->write(data, len);
} }
// ============================================================================
// MeshCore comms panel plumbing (mc:: is no-ops when USE_MESHCORE is unset, so
// none of this needs #if guards -- the UI/telnet just report "not in build").
// ============================================================================
bool meshEchoTelnet = true; // mirror received/sent mesh lines to telnet+USB
static void wsBroadcastJson(JsonDocument& doc) {
String s; serializeJson(doc, s);
ws.textAll(String((char)0x00) + s); // 0x00-prefixed JSON text frame
}
// Build one mesh line and fan it out: SD log (always, timestamped per line via
// logBytes) + telnet/USB mirror (only when ~mesh echo is on).
static void meshEchoLine(const char* dir, const char* channel, const char* who,
const char* text, int rssi, float snr) {
char line[300];
int n;
if (rssi != 0)
n = snprintf(line, sizeof(line), "[mesh %s %s] %s: %s (rssi %d snr %.1f)\r\n",
dir, channel, who, text, rssi, snr);
else
n = snprintf(line, sizeof(line), "[mesh %s %s] %s: %s\r\n", dir, channel, who, text);
if (n < 0) return;
if (n >= (int)sizeof(line)) n = sizeof(line) - 1;
logBytes((const uint8_t*)line, n); // -> SD (no-op when off / not T3-S3)
if (meshEchoTelnet) {
for (int i = 0; i < MAX_TELNET; i++)
if (telnetClients[i] && telnetClients[i].connected()) telnetClients[i].print(line);
Serial.print(line);
}
}
// Received channel message -- called from mc::loop() in the main task.
void onMeshRx(const char* channel, const char* sender, const char* text, int rssi, float snr) {
JsonDocument d;
d["type"] = "mesh"; d["dir"] = "rx";
d["channel"] = channel; d["sender"] = sender; d["text"] = text;
d["rssi"] = rssi; d["snr"] = snr;
wsBroadcastJson(d);
meshEchoLine("rx", channel, sender, text, rssi, snr);
}
// Optimistically echo a locally-sent message to the web panel + telnet.
static void meshEchoTx(const char* text) {
JsonDocument d;
d["type"] = "mesh"; d["dir"] = "tx";
d["channel"] = mc::channelName(); d["sender"] = mc::nodeName(); d["text"] = text;
wsBroadcastJson(d);
meshEchoLine("tx", mc::channelName(), mc::nodeName(), text, 0, 0.0f);
}
static void meshBuildCfg(JsonDocument& d) {
d["type"] = "meshcfg";
d["enabled"] = mc::enabled();
d["up"] = mc::up();
d["channel"] = mc::channelName();
d["psk"] = mc::pskB64();
d["rx"] = mc::rxCount();
d["tx"] = mc::txCount();
}
static void meshBroadcastCfg() { JsonDocument d; meshBuildCfg(d); wsBroadcastJson(d); }
// Note a (re)programmed channel/PSK in the SD log so the capture is self-describing.
static void meshLogCfg() {
char cfg[200];
int n = snprintf(cfg, sizeof(cfg), "[mesh cfg] channel=%s psk=%s %s\r\n",
mc::channelName(), mc::pskB64(), mc::radioConfig());
if (n < 0) return;
if (n >= (int)sizeof(cfg)) n = sizeof(cfg) - 1;
logBytes((const uint8_t*)cfg, n);
}
// ============================================================================ // ============================================================================
// Command handling (telnet ~cmds and REST share this) // Command handling (telnet ~cmds and REST share this)
// ============================================================================ // ============================================================================
@@ -276,15 +378,18 @@ void handleCommand(const char* line, Stream* reply) {
const char* args = line[n] ? line + n + 1 : line + n; const char* args = line[n] ? line + n + 1 : line + n;
if (!strcmp(verb, "help")) { if (!strcmp(verb, "help")) {
reply->print("[help] ~status ~reset [ms] ~wake [ms] ~baud <n> " reply->print("[help] ~status ~reset [ms] ~button [ms|on|off] ~baud <n> "
"~port <int|usb|ext> ~log on|off ~gpio <pin> <0|1>\r\n" "~port <int|usb|ext> ~log on|off ~gpio <pin> <0|1>\r\n"
" ~mesh [on|off] ~psk <base64key> ~chan <name> <base64key> ~msg <text>\r\n"
" (any non-~ line is sent to the target UART)\r\n"); " (any non-~ line is sent to the target UART)\r\n");
} else if (!strcmp(verb, "status")) { } else if (!strcmp(verb, "status")) {
printStatus(reply); printStatus(reply);
} else if (!strcmp(verb, "reset")) { } else if (!strcmp(verb, "reset")) {
gpioPulse(GPIO_RESET_PIN, (uint32_t)strtoul(args, nullptr, 10), reply, "reset"); gpioPulse(GPIO_RESET_PIN, (uint32_t)strtoul(args, nullptr, 10), reply, "reset");
} else if (!strcmp(verb, "wake")) { } else if (!strcmp(verb, "wake") || !strcmp(verb, "button")) {
gpioPulse(GPIO_WAKE_PIN, (uint32_t)strtoul(args, nullptr, 10), reply, "wake"); if (!strncmp(args, "on", 2)) { gpioLatch(GPIO_WAKE_PIN, true, reply, "button"); buttonLatched = true; }
else if (!strncmp(args, "off", 3)) { gpioLatch(GPIO_WAKE_PIN, false, reply, "button"); buttonLatched = false; }
else { gpioPulse(GPIO_WAKE_PIN, (uint32_t)strtoul(args, nullptr, 10), reply, "button"); buttonLatched = false; }
} else if (!strcmp(verb, "baud")) { } else if (!strcmp(verb, "baud")) {
uint32_t b = strtoul(args, nullptr, 10); uint32_t b = strtoul(args, nullptr, 10);
if (b) { setBaudRate(b); reply->printf("[ctrl] baud=%lu\r\n", (unsigned long)b); } if (b) { setBaudRate(b); reply->printf("[ctrl] baud=%lu\r\n", (unsigned long)b); }
@@ -311,6 +416,26 @@ void handleCommand(const char* line, Stream* reply) {
digitalWrite(pin, val ? HIGH : LOW); digitalWrite(pin, val ? HIGH : LOW);
reply->printf("[ctrl] gpio %d = %d\r\n", pin, val ? 1 : 0); reply->printf("[ctrl] gpio %d = %d\r\n", pin, val ? 1 : 0);
} else reply->print("[ctrl] usage: ~gpio <pin> <0|1>\r\n"); } else reply->print("[ctrl] usage: ~gpio <pin> <0|1>\r\n");
} else if (!strcmp(verb, "mesh")) {
if (!strncmp(args, "on", 2)) { meshEchoTelnet = true; reply->print("[mesh] echo on\r\n"); }
else if (!strncmp(args, "off", 3)) { meshEchoTelnet = false; reply->print("[mesh] echo off\r\n"); }
else reply->printf("[mesh] %s up=%s channel=%s psk=%s rx=%lu tx=%lu echo=%s\r\n",
mc::enabled() ? "enabled" : "(not in build)", mc::up() ? "yes" : "no",
mc::channelName(), mc::pskB64(),
(unsigned long)mc::rxCount(), (unsigned long)mc::txCount(),
meshEchoTelnet ? "on" : "off");
} else if (!strcmp(verb, "psk")) {
if (*args) { mc::requestPsk("", args); reply->printf("[mesh] psk queued: %s\r\n", args); }
else reply->print("[mesh] usage: ~psk <base64 16- or 32-byte key>\r\n");
} else if (!strcmp(verb, "chan")) {
char nm[32] = {0}, pk[48] = {0};
if (sscanf(args, "%31s %47s", nm, pk) == 2) {
mc::requestPsk(nm, pk);
reply->printf("[mesh] channel '%s' queued\r\n", nm);
} else reply->print("[mesh] usage: ~chan <name> <base64key>\r\n");
} else if (!strcmp(verb, "msg")) {
if (*args) { mc::requestSend(args); meshEchoTx(args); }
else reply->print("[mesh] usage: ~msg <text>\r\n");
} else { } else {
reply->printf("[ctrl] unknown '~%s' (try ~help)\r\n", verb); reply->printf("[ctrl] unknown '~%s' (try ~help)\r\n", verb);
} }
@@ -386,6 +511,7 @@ void handleWebSocketMessage(AsyncWebSocketClient* client, uint8_t* data, size_t
r["baudSerial1"] = baudSerial1; r["baudSerial1"] = baudSerial1;
r["rx"] = rxBytes; r["tx"] = txBytes; r["rx"] = rxBytes; r["tx"] = txBytes;
r["log"] = logEnabled; r["ntp"] = ntpSynced; r["sd"] = sdAvailable; r["log"] = logEnabled; r["ntp"] = ntpSynced; r["sd"] = sdAvailable;
r["buttonLatch"] = buttonLatched;
#if BOARD_T3S3 #if BOARD_T3S3
r["logfile"] = logFile ? logPath : ""; r["logfile"] = logFile ? logPath : "";
#endif #endif
@@ -394,6 +520,17 @@ void handleWebSocketMessage(AsyncWebSocketClient* client, uint8_t* data, size_t
r["ip"] = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); r["ip"] = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString();
serializeJson(r, response); serializeJson(r, response);
client->text(String((char)0x00) + response); client->text(String((char)0x00) + response);
} else if (!strcmp(cmd, "meshSend")) {
const char* t = doc["text"];
if (t && *t) { mc::requestSend(t); meshEchoTx(t); }
} else if (!strcmp(cmd, "meshPsk")) {
const char* nm = doc["name"];
const char* pk = doc["psk"];
if (pk && *pk) mc::requestPsk(nm ? nm : "", pk);
} else if (!strcmp(cmd, "meshGet")) {
JsonDocument r; meshBuildCfg(r);
String resp; serializeJson(r, resp);
client->text(String((char)0x00) + resp);
} }
} else { } else {
forwardToTarget(data, len); forwardToTarget(data, len);
@@ -419,6 +556,7 @@ static String statusJson() {
doc["baudSerial1"] = baudSerial1; doc["baudSerial1"] = baudSerial1;
doc["rx"] = rxBytes; doc["tx"] = txBytes; doc["rx"] = rxBytes; doc["tx"] = txBytes;
doc["log"] = logEnabled; doc["ntp"] = ntpSynced; doc["sd"] = sdAvailable; doc["log"] = logEnabled; doc["ntp"] = ntpSynced; doc["sd"] = sdAvailable;
doc["buttonLatch"] = buttonLatched;
#if BOARD_T3S3 #if BOARD_T3S3
doc["logfile"] = logFile ? logPath : ""; doc["logfile"] = logFile ? logPath : "";
#endif #endif
@@ -443,11 +581,23 @@ void setupWebServer() {
gpioPulse(GPIO_RESET_PIN, ms, nullptr, "reset"); gpioPulse(GPIO_RESET_PIN, ms, nullptr, "reset");
req->send(200, "text/plain", "reset pulsed\n"); req->send(200, "text/plain", "reset pulsed\n");
}); });
server.on("/api/wake", HTTP_GET, [](AsyncWebServerRequest* req) { // Momentary pulse (?ms=), or latch the line held (?latch=on|off|1|0).
auto doButton = [](AsyncWebServerRequest* req) {
if (req->hasParam("latch")) {
String v = req->getParam("latch")->value();
bool on = (v == "1" || v.startsWith("on") || v == "true");
gpioLatch(GPIO_WAKE_PIN, on, nullptr, "button");
buttonLatched = on;
req->send(200, "application/json", statusJson());
return;
}
uint32_t ms = req->hasParam("ms") ? req->getParam("ms")->value().toInt() : 0; uint32_t ms = req->hasParam("ms") ? req->getParam("ms")->value().toInt() : 0;
gpioPulse(GPIO_WAKE_PIN, ms, nullptr, "wake"); gpioPulse(GPIO_WAKE_PIN, ms, nullptr, "button");
req->send(200, "text/plain", "wake pulsed\n"); buttonLatched = false;
}); req->send(200, "application/json", statusJson());
};
server.on("/api/button", HTTP_GET, doButton);
server.on("/api/wake", HTTP_GET, doButton); // back-compat alias
server.on("/api/baud", HTTP_GET, [](AsyncWebServerRequest* req) { server.on("/api/baud", HTTP_GET, [](AsyncWebServerRequest* req) {
if (req->hasParam("baud")) setBaudRate(req->getParam("baud")->value().toInt()); if (req->hasParam("baud")) setBaudRate(req->getParam("baud")->value().toInt());
req->send(200, "application/json", statusJson()); req->send(200, "application/json", statusJson());
@@ -633,6 +783,8 @@ void setup() {
setupTelnet(); setupTelnet();
setupWebServer(); setupWebServer();
mc::begin(onMeshRx); // MeshCore radio (no-op unless USE_MESHCORE)
String ip = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); String ip = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString();
Serial.printf("[Ready] http://%s telnet %s 23\n", ip.c_str(), ip.c_str()); Serial.printf("[Ready] http://%s telnet %s 23\n", ip.c_str(), ip.c_str());
} }
@@ -656,6 +808,9 @@ void loop() {
serviceTelnet(); serviceTelnet();
ws.cleanupClients(); ws.cleanupClients();
mc::loop(); // pump radio + drain mesh queue
if (mc::consumeCfgChanged()) { meshBroadcastCfg(); meshLogCfg(); }
#if BOARD_T3S3 #if BOARD_T3S3
// Once NTP lands, mark synced + open today's log. // Once NTP lands, mark synced + open today's log.
if (!ntpSynced && time(nullptr) > 1700000000) { ntpSynced = true; if (logEnabled) openLogIfReady(); } if (!ntpSynced && time(nullptr) > 1700000000) { ntpSynced = true; if (logEnabled) openLogIfReady(); }
+296
View File
@@ -0,0 +1,296 @@
/**
* meshcore_link.cpp -- see meshcore_link.h.
*
* Pattern mirrors MeshCore/experiment/lilygot3s3logger (same board: LilyGo
* T3-S3, ESP32-S3 + SX1276), trimmed to a single reprogrammable channel.
*/
#include "meshcore_link.h"
#if USE_MESHCORE
#include <Arduino.h>
#include <SPI.h>
#include <LittleFS.h>
#include <Preferences.h>
#include <mbedtls/base64.h> // ESP-IDF builtin; avoids clashing with MeshCore's base64.hpp
#include <Mesh.h>
#include <helpers/BaseChatMesh.h>
#include <helpers/ArduinoHelpers.h>
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/SimpleMeshTables.h>
#include <helpers/IdentityStore.h>
#include <helpers/ESP32Board.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/radiolib/CustomSX1276.h>
#include <helpers/radiolib/CustomSX1276Wrapper.h>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
// ---- compile-time defaults (override via build flags) ----------------------
#ifndef NODE_NAME
#define NODE_NAME "dongle"
#endif
#ifndef SENSORS_CHANNEL_NAME
#define SENSORS_CHANNEL_NAME "SensorsTest"
#endif
#ifndef SENSORS_CHANNEL_PSK_B64
#define SENSORS_CHANNEL_PSK_B64 "PNtgMxiq9R7eQ3IleHoL3g=="
#endif
// Standard MeshCore "Public" channel (same key the companion examples use).
#ifndef PUBLIC_CHANNEL_PSK_B64
#define PUBLIC_CHANNEL_PSK_B64 "izOH6cXN6mrJ5e26oRXNcg=="
#endif
namespace {
// ---- radio + mesh support objects (file-scope, mirrors the logger) ---------
ESP32Board board;
// Radio on FSPI (SPI2). The SD card is on HSPI (SPI3, see main.cpp). They MUST
// be different peripherals -- SPIClass() defaults to HSPI, which would collide
// with the SD bus and corrupt it the moment the radio re-inits the pins.
SPIClass lora_spi(FSPI);
CustomSX1276 lora(new Module(P_LORA_NSS, P_LORA_DIO_0, P_LORA_RESET, P_LORA_DIO_1, lora_spi));
CustomSX1276Wrapper radio_driver(lora, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
StdRNG fast_rng;
SimpleMeshTables tables;
mc::RxHandler s_on_rx = nullptr;
bool s_up = false;
volatile bool s_cfg_dirty = false;
QueueHandle_t s_queue = nullptr;
// User-channel state (index 1; "Public" sits at index 0).
const int USER_IDX = 1;
char s_channel[32] = SENSORS_CHANNEL_NAME;
char s_psk[48] = SENSORS_CHANNEL_PSK_B64;
// ---- queued action (producer = any task, consumer = loop()) ----------------
struct McAction {
uint8_t kind; // 0 = send text, 1 = reprogram channel
char name[32];
char text[176];
};
// Split "<sender>: <payload>" in place; returns sender (or "?") + payload.
void split_sender(char* buf, const char** sender, const char** payload) {
*sender = "?";
*payload = buf;
char* sep = strstr(buf, ": ");
if (sep) { *sep = 0; *sender = buf; *payload = sep + 2; }
}
// ---- the mesh node ---------------------------------------------------------
class DongleMesh : public BaseChatMesh {
ChannelDetails* _public = nullptr;
ChannelDetails* _user = nullptr;
ChannelDetails _user_view; // kept in sync for sending
uint32_t _rx = 0, _tx = 0;
protected:
// Unused contact-protocol hooks -- this node only does channel chat.
void onDiscoveredContact(ContactInfo&, bool, uint8_t, const uint8_t*) override {}
void onContactPathUpdated(const ContactInfo&) override {}
ContactInfo* processAck(const uint8_t*) override { return nullptr; }
void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}
void onCommandDataRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}
void onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const uint8_t*, const char*) override {}
uint8_t onContactRequest(const ContactInfo&, uint32_t, const uint8_t*, uint8_t, uint8_t*) override { return 0; }
void onContactResponse(const ContactInfo&, const uint8_t*, uint8_t) override {}
void onSendTimeout() override {}
uint32_t calcFloodTimeoutMillisFor(uint32_t air) const override { return 500 + 16 * air; }
uint32_t calcDirectTimeoutMillisFor(uint32_t air, uint8_t) const override { return 500 + 16 * air; }
// Listener only -- do not re-flood other nodes' traffic.
bool allowPacketForward(const mesh::Packet*) override { return false; }
const char* channelName(const mesh::GroupChannel& ch) {
int idx = findChannelIdx(ch);
if (idx < 0) return "unknown";
ChannelDetails cd;
if (!getChannel(idx, cd)) return "unknown";
return cd.name[0] ? cd.name : "unnamed";
}
void onChannelMessageRecv(const mesh::GroupChannel& channel, mesh::Packet* pkt,
uint32_t, const char* text) override {
_rx++;
int rssi = (int)lroundf(radio_driver.getLastRSSI());
float snr = radio_driver.getLastSNR();
const char* chan = channelName(channel);
char buf[200];
strlcpy(buf, text, sizeof(buf));
const char *sender, *payload;
split_sender(buf, &sender, &payload);
if (s_on_rx) s_on_rx(chan, sender, payload, rssi, snr);
}
public:
DongleMesh()
: BaseChatMesh(radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock,
*new StaticPoolPacketManager(16), tables) {}
void begin() {
BaseChatMesh::begin();
IdentityStore store(LittleFS, "/identity");
if (!store.load("_main", self_id)) {
self_id = mesh::LocalIdentity(getRNG());
store.save("_main", self_id);
}
_public = addChannel("Public", PUBLIC_CHANNEL_PSK_B64);
_user = addChannel(s_channel, s_psk); // -> index USER_IDX
if (_user) getChannel(USER_IDX, _user_view);
}
// Overwrite the user channel's key (and name) at runtime. Recomputes the
// channel hash from the decoded secret via setChannel().
bool reprogram(const char* name, const char* psk_b64) {
ChannelDetails cd;
memset(&cd, 0, sizeof(cd));
unsigned char key[32];
size_t len = 0;
if (mbedtls_base64_decode(key, sizeof(key), &len,
(const unsigned char*)psk_b64, strlen(psk_b64)) != 0)
return false;
if (len != 16 && len != 32) return false; // PSK must be a 16- or 32-byte key
memcpy(cd.channel.secret, key, len);
strlcpy(cd.name, name, sizeof(cd.name));
if (!setChannel(USER_IDX, cd)) return false;
getChannel(USER_IDX, _user_view);
_user = &_user_view;
strlcpy(s_channel, name, sizeof(s_channel));
strlcpy(s_psk, psk_b64, sizeof(s_psk));
return true;
}
bool send(const char* text) {
if (!_user) return false;
bool ok = sendGroupMessage(getRTCClock()->getCurrentTime(),
_user_view.channel, NODE_NAME, text, strlen(text));
if (ok) _tx++;
return ok;
}
uint32_t rxCount() const { return _rx; }
uint32_t txCount() const { return _tx; }
};
DongleMesh the_mesh;
void persist_cfg() {
Preferences p;
if (p.begin("mcfg", false)) {
p.putString("name", s_channel);
p.putString("psk", s_psk);
p.end();
}
}
void load_cfg() {
Preferences p;
if (p.begin("mcfg", true)) {
String n = p.getString("name", "");
String k = p.getString("psk", "");
if (n.length()) strlcpy(s_channel, n.c_str(), sizeof(s_channel));
if (k.length()) strlcpy(s_psk, k.c_str(), sizeof(s_psk));
p.end();
}
}
} // namespace
namespace mc {
bool enabled() { return true; }
void begin(RxHandler on_rx) {
s_on_rx = on_rx;
s_queue = xQueueCreate(8, sizeof(McAction));
load_cfg(); // NVS overrides of channel/psk, if any
board.begin();
fallback_clock.begin();
if (!lora.std_init(&lora_spi)) {
Serial.println("[mesh] radio init FAILED -- mesh disabled");
return;
}
fast_rng.begin(lora.random(0x7FFFFFFF));
the_mesh.begin();
lora.startReceive();
s_up = true;
Serial.printf("[mesh] up: freq=%.3f bw=%.1f sf=%d cr=%d channel='%s'\n",
(float)LORA_FREQ, (float)LORA_BW, (int)LORA_SF, (int)LORA_CR, s_channel);
}
void loop() {
if (s_queue) {
McAction a;
while (xQueueReceive(s_queue, &a, 0) == pdTRUE) {
if (a.kind == 0) {
the_mesh.send(a.text);
} else {
if (the_mesh.reprogram(a.name, a.text)) persist_cfg();
s_cfg_dirty = true;
}
}
}
if (s_up) the_mesh.loop();
}
void requestSend(const char* text) {
if (!s_queue || !text) return;
McAction a; a.kind = 0; a.name[0] = 0;
strlcpy(a.text, text, sizeof(a.text));
xQueueSend(s_queue, &a, 0);
}
void requestPsk(const char* name, const char* psk_b64) {
if (!s_queue || !psk_b64) return;
McAction a; a.kind = 1;
strlcpy(a.name, (name && *name) ? name : s_channel, sizeof(a.name));
strlcpy(a.text, psk_b64, sizeof(a.text));
xQueueSend(s_queue, &a, 0);
}
bool up() { return s_up; }
bool consumeCfgChanged() { bool d = s_cfg_dirty; s_cfg_dirty = false; return d; }
const char* nodeName() { return NODE_NAME; }
const char* channelName() { return s_channel; }
const char* pskB64() { return s_psk; }
const char* radioConfig() {
static char buf[80];
snprintf(buf, sizeof(buf), "freq=%.3f bw=%.1f sf=%d cr=%d tx=%d",
(float)LORA_FREQ, (float)LORA_BW, (int)LORA_SF, (int)LORA_CR, (int)LORA_TX_POWER);
return buf;
}
uint32_t rxCount() { return the_mesh.rxCount(); }
uint32_t txCount() { return the_mesh.txCount(); }
} // namespace mc
#else // ---- USE_MESHCORE not set: cheap no-op build ----------------------
namespace mc {
bool enabled() { return false; }
void begin(RxHandler) {}
void loop() {}
void requestSend(const char*) {}
void requestPsk(const char*, const char*) {}
bool up() { return false; }
bool consumeCfgChanged() { return false; }
const char* nodeName() { return "me"; }
const char* channelName() { return ""; }
const char* pskB64() { return ""; }
const char* radioConfig() { return ""; }
uint32_t rxCount() { return 0; }
uint32_t txCount() { return 0; }
} // namespace mc
#endif
+43
View File
@@ -0,0 +1,43 @@
/**
* meshcore_link -- thin facade over a MeshCore BaseChatMesh node.
*
* Lets the debug dongle send/receive text on a single PSK-protected group
* channel (plus the well-known "Public" channel), and reprogram that channel's
* PSK at runtime. All MeshCore + RadioLib headers stay inside meshcore_link.cpp
* so the rest of the firmware compiles identically with or without the radio.
*
* Enabled by the USE_MESHCORE build flag (see env:t3s3_mesh in platformio.ini).
* When the flag is absent every function below is a cheap no-op, so callers
* need no #if guards.
*
* Threading: requestSend()/requestPsk() are safe to call from any task (e.g.
* the async web-server callback). They enqueue work that is applied inside
* loop(), which must be called from the same task that owns the radio.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
namespace mc {
// Fired from loop() (radio task context) for each received channel message.
typedef void (*RxHandler)(const char* channel, const char* sender,
const char* text, int rssi, float snr);
bool enabled(); // compile-time: USE_MESHCORE set?
void begin(RxHandler on_rx); // bring up radio + mesh
void loop(); // pump radio, drain action queue
void requestSend(const char* text); // queue a TX on the user channel
void requestPsk(const char* name, const char* psk_b64); // queue a channel reprogram
bool up(); // radio initialised OK
bool consumeCfgChanged(); // true once after a reprogram
const char* nodeName(); // our sender name
const char* channelName(); // current user-channel name
const char* pskB64(); // current user-channel PSK (base64)
const char* radioConfig(); // "freq=.. bw=.. sf=.. cr=.. tx=.." ("" if disabled)
uint32_t rxCount();
uint32_t txCount();
} // namespace mc