Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pr-build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ jobs:
- wio_wm1110_repeater
# SX1276 (ESP32)
- Tbeam_SX1276_repeater
# Seeed Wio Tracker L2 Pro (ESP32-S3, LovyanGFX display)
- Wio_Tracker_L2_companion_radio_ble

steps:
- name: Clone Repo
Expand Down
43 changes: 43 additions & 0 deletions boards/seeed_wio_tracker_l2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"memory_type": "qio_opi",
"partitions": "default_16MB.csv"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "opi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino"],
"name": "Seeed Wio Tracker L2 (16 MB flash, 8 MB OPI PSRAM)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"require_upload_port": true,
"speed": 921600
},
"url": "https://www.seeedstudio.com/",
"vendor": "Seeed Studio"
}
139 changes: 139 additions & 0 deletions variants/wio-tracker-l2/GpsTap.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#pragma once

#include <Arduino.h>

// Transparent wrapper around the GNSS UART. MicroNMEA ignores GSV sentences,
// so this watches the byte stream for them and keeps a running count of
// satellites in view, strong satellites and best SNR, which separates RF
// faults from a receiver that only lacks sky view. It also records the
// fix flags straight from GGA/RMC so a parser problem upstream can't hide a
// real fix, and can hand every complete line to a logger (SD card).
class GpsTapStream : public Stream {
Stream& _src;
char _line[120];
uint8_t _len = 0;
struct Talker { char id[3]; uint8_t in_view; uint8_t strong; uint8_t strong_acc; unsigned long at; };
Talker _talkers[6] = {};
int _best_snr = 0;
unsigned long _best_at = 0;
unsigned long _last_gsv = 0;
int _gga_fix = -1; // GGA fix quality (0 = no fix)
int _gga_used = 0; // GGA satellites used
char _rmc_status = '?'; // RMC status: A = valid, V = void
unsigned long _last_fix_sentence = 0;

static const unsigned long FRESH_MS = 6000;
static const int STRONG_DB = 30;

// pointer to comma-separated field n (0 = sentence id), NULL if absent
static const char* field(const char* s, int n) {
const char* p = s;
for (int i = 0; i < n; i++) {
p = strchr(p, ',');
if (p == NULL) return NULL;
p++;
}
return p;
}

void parseGsv(const char* s) {
// $GPGSV,total,msg,in_view,prn,elev,az,snr,...*cs
char talker[3] = {s[1], s[2], 0};
const char* f;
int total = (f = field(s, 1)) ? atoi(f) : 0;
int msg = (f = field(s, 2)) ? atoi(f) : 0;
int in_view = (f = field(s, 3)) ? atoi(f) : -1;
if (in_view < 0) return;

int strong_here = 0;
for (int k = 0; k < 4; k++) {
const char* snr = field(s, 7 + 4 * k);
if (snr == NULL || *snr == ',' || *snr == '*' || *snr == 0) continue;
int v = atoi(snr);
if (v >= STRONG_DB) strong_here++;
if (v > _best_snr || millis() - _best_at > FRESH_MS) { _best_snr = v; _best_at = millis(); }
}

int slot = -1;
for (int i = 0; i < 6; i++) {
if (strcmp(_talkers[i].id, talker) == 0) { slot = i; break; }
if (slot < 0 && _talkers[i].id[0] == 0) slot = i;
}
if (slot < 0) return;
Talker& t = _talkers[slot];
memcpy(t.id, talker, 3);
t.in_view = (uint8_t) in_view;
if (msg <= 1) t.strong_acc = 0;
t.strong_acc += strong_here;
if (msg >= total) t.strong = t.strong_acc;
t.at = millis();
_last_gsv = millis();
}

void parseFixFlags(const char* s) {
const char* type = s + 3;
if (strncmp(type, "GGA", 3) == 0) {
const char* f;
_gga_fix = (f = field(s, 6)) && *f != ',' ? atoi(f) : 0;
_gga_used = (f = field(s, 7)) && *f != ',' ? atoi(f) : 0;
_last_fix_sentence = millis();
} else if (strncmp(type, "RMC", 3) == 0) {
const char* f = field(s, 2);
_rmc_status = (f != NULL && *f != ',' && *f != 0) ? *f : '?';
_last_fix_sentence = millis();
}
}

void feed(int c) {
if (echo) Serial.write((uint8_t) c);
if (c == '\n' || c == '\r') {
if (_len > 6 && _line[0] == '$') {
_line[_len] = 0;
if (on_line != NULL) on_line(_line);
if (strncmp(_line + 3, "GSV", 3) == 0) parseGsv(_line);
else parseFixFlags(_line);
}
_len = 0;
} else if (_len < sizeof(_line) - 1) {
_line[_len++] = (char) c;
}
}

public:
bool echo = false; // mirror raw NMEA to USB Serial
void (*on_line)(const char* line) = NULL; // complete-sentence hook (e.g. SD log)

explicit GpsTapStream(Stream& src) : _src(src) {}

int satsInView() const {
int n = 0;
for (int i = 0; i < 6; i++) {
if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].in_view;
}
return n;
}
int strongSats() const {
int n = 0;
for (int i = 0; i < 6; i++) {
if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].strong;
}
return n;
}
int bestSnr() const { return millis() - _best_at < FRESH_MS ? _best_snr : 0; }
bool streaming() const { return millis() - _last_gsv < FRESH_MS; }
int ggaFix() const { return _gga_fix; }
int ggaUsed() const { return _gga_used; }
char rmcStatus() const { return _rmc_status; }

// Stream interface: pass-through with a tap on read()
int available() override { return _src.available(); }
int peek() override { return _src.peek(); }
int read() override {
int c = _src.read();
if (c >= 0) feed(c);
return c;
}
void flush() override { _src.flush(); }
size_t write(uint8_t b) override { return _src.write(b); }
size_t write(const uint8_t* buf, size_t n) override { return _src.write(buf, n); }
};
64 changes: 64 additions & 0 deletions variants/wio-tracker-l2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# MeshCore for Seeed Wio Tracker L2 Pro

MeshCore port for the **Seeed Studio Wio Tracker L2 Pro** (pre-release hardware;
pin map may change on production units).

## Hardware

| Component | Detail |
|-----------|--------|
| MCU module | Wio-S3: ESP32-S3, 16 MB flash (QIO), 8 MB PSRAM (OPI) |
| LoRa | SX1262, 862-930 MHz (SCK 4, MISO 5, MOSI 6, CS 21, RST 7, BUSY 8, DIO1 9, DIO2 = RF switch, TCXO 1.8 V) |
| GNSS | Quectel L76K (GPS/BeiDou/GLONASS/QZSS), UART: module TX to GPIO 18, module RX from GPIO 17, 9600 baud |
| Display | 3.2" 320x240 NV3031B on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46), 75 MHz |
| Touch | GT911 capacitive, I2C 0x5D |
| Backlight | LP5814 LED driver, I2C 0x2C |
| I/O expander | TCA9535, I2C 0x21 - gates power/reset for GNSS, LCD, touch, SD, speaker amp, battery ADC, Grove |
| Battery ADC | ADS1115, I2C 0x48, AIN0 through x2 divider |
| USB-C detect | AW35615 CC controller, I2C 0x22 (charge detection) |
| Audio | ES8311 codec (speaker, alert tones) + ES7243E mic ADC (unused) on I2S |
| I2C bus | SDA 47, SCL 48 |
| Buttons | User/Boot = GPIO 0; Wake/lock button on expander P00 |
| SD card | SDIO 1-bit: CLK 2, CMD 3, D0 1 - offline map tiles, logs |

## Firmware flavors

| Env | What it does |
|-----|--------------|
| `Wio_Tracker_L2_companion_radio_ble` | **Phone companion.** Standard display UI, MeshCore app over BLE (pairing PIN `123456`), on-screen Bluetooth toggle page. |
| `Wio_Tracker_L2_companion_radio_usb` | Standard display companion over USB-C serial. |
| `Wio_Tracker_L2_repeater` | Standalone mesh repeater. |
| `Wio_Tracker_L2_room_server` | Standalone room/BBS server. |

## Build & flash

```bash
# from the MeshCore repo root
pio run -e Wio_Tracker_L2_companion_radio_ble

# bootloader mode if needed: hold User/Boot, tap RST, release - then:
pio run -e Wio_Tracker_L2_companion_radio_ble -t upload
```

The board enumerates as a native USB-CDC port (auto-reset via 1200-bps touch
is enabled). For the BLE companion build, pair from the MeshCore app with the
PIN shown on the device screen.

## Port notes

- **Everything hangs off the TCA9535 expander.** `WioTrackerL2Board::begin()`
replays Seeed's power-up sequence; order and delays matter (especially the
500 ms LCD reset settle). If the expander probe fails, the firmware still
runs but GPS/display/battery reads are dead.
- **Battery** is read via the ADS1115 at +/-4.096 V FSR and mapped through a
LiPo discharge curve.
- **GNSS**: GPS+BeiDou+GLONASS are enabled at start (`$PCAS04,7`). A watchdog
resets the module if it tracks 4+ strong satellites for minutes without
producing a fix. Raw NMEA can be logged to SD for diagnosis.
- **Power**: CPU drops to 80 MHz while the screen sleeps; the speaker amp and
I2S clocks run only while a tone plays; the GNSS and Grove rails are gated;
idle Bluetooth switches to slow advertising.
- **Display orientation**: `offset_rotation=1` (landscape). If a unit shows
the UI upside-down, use 3 in `WioTrackerL2Display.h`.
- Pin map source: `meshtastic/firmware` PR #10909 and `meshtastic/device-ui`
(`wio-l2` branches).
Loading