From aabbaf7eb7f1d28ae761e1a67375547dd70abe62 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 17:37:54 -0700 Subject: [PATCH] Control Wio Tracker L2 GPS power and reserve display runtime RAM --- scripts/check_firmware_ram.py | 40 +++ test/fixtures/wio_tracker_l2_gps_power.cpp | 128 ++++++++++ test/test_firmware_ram.py | 52 ++++ test/test_wio_tracker_l2_gps_power.py | 235 ++++++++++++++++++ variants/wio-tracker-l2/README.md | 3 + variants/wio-tracker-l2/WioTrackerL2Board.cpp | 7 +- .../WioTrackerL2LocationProvider.h | 51 ++++ variants/wio-tracker-l2/target.cpp | 2 +- variants/wio-tracker-l2/target.h | 2 +- 9 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/wio_tracker_l2_gps_power.cpp create mode 100644 test/test_wio_tracker_l2_gps_power.py create mode 100644 variants/wio-tracker-l2/WioTrackerL2LocationProvider.h diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 91a97e49d..5ec50d26c 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -14,6 +14,7 @@ from __future__ import annotations import argparse import hashlib import json +import math from pathlib import Path import re import struct @@ -97,6 +98,34 @@ def mqtt_requirements(defines): } +def wio_tracker_l2_display_heap(defines): + # Both LGFXDisplay::begin()'s internal canvas and Wio's concrete begin() + # must fit. The latter requests PSRAM, but LovyanGFX 1.2.30 explicitly + # falls back to internal DMA memory when that allocation fails. + zoom_text = str(defines.get("UI_ZOOM", 1)).strip().strip("()") + zoom_text = re.sub(r"[fFlL]$", "", zoom_text) + try: + zoom = float(zoom_text) + except ValueError as error: + raise ValueError(f"memory budget needs a numeric UI_ZOOM, got {zoom_text!r}") from error + if not math.isfinite(zoom) or zoom <= 0: + raise ValueError("Wio Tracker L2 UI_ZOOM must be finite and positive") + if integer(defines, "UI_COORD_SCALE", 1) == 0: + raise ValueError("Wio Tracker L2 UI_COORD_SCALE must be positive") + depth = integer(defines, "UI_BUFFER_COLOR_DEPTH", 8) + if depth not in (1, 2, 4, 8, 16, 24, 32): + raise ValueError("unsupported Wio Tracker L2 UI_BUFFER_COLOR_DEPTH") + # The base constructor divides physical dimensions by zoom*coordinate + # scale; its render path multiplies by coordinate scale again. Rounding + # upward bounds both that canvas and Wio's fixed-8bpp logical sprite. + width = math.ceil(320 / zoom) + height = math.ceil(240 / zoom) + row_bytes = (width * max(8, depth) + 7) // 8 + # Include the sprite's extra trailing pixel, up to a 256-entry RGB888 + # palette, alignment and allocation metadata, even with working PSRAM. + return row_bytes * height + 1024 + + def requirements(platform, defines, target): if platform not in SUPPORTED: raise ValueError(f"no memory policy for {platform}") @@ -111,11 +140,22 @@ def requirements(platform, defines, target): depth = integer(defines, "UI_BUFFER_COLOR_DEPTH", 8) pixels = 480 * 480 if "INDICATOR_TRANSPORT_RENDER_PROFILE" in defines else 320 * 320 display_heap = (pixels * depth + 7) // 8 + 1024 + elif display == "WioTrackerL2Display": + if platform != "ESP32_PLATFORM": + raise ValueError("Wio Tracker L2 display budget requires ESP32") + display_heap = wio_tracker_l2_display_heap(defines) elif display in DISPLAY_HEAP: display_heap = DISPLAY_HEAP[display] else: raise ValueError(f"add a runtime allocation budget for display {display!r}") parts = {} + if display == "WioTrackerL2Display": + # The pinned LGFX QSPI bus has two DMA flip buffers. RGB565 conversion + # requests at most 2048 bytes each (2304 for RGB888); shrinking can + # briefly retain a third. Reserve 16 KiB for those, queued descriptors + # (up to 320 rows, including overlap while growing) and SDK bus/touch + # driver metadata. These allocations coexist with the UI sprite. + parts["display_dma_and_bus"] = 16384 mqtt = None combined_rak_ethernet = "RAK4631_COMBINED_ETHERNET" in defines if combined_rak_ethernet and (platform != "NRF52_PLATFORM" diff --git a/test/fixtures/wio_tracker_l2_gps_power.cpp b/test/fixtures/wio_tracker_l2_gps_power.cpp new file mode 100644 index 000000000..aee7c4528 --- /dev/null +++ b/test/fixtures/wio_tracker_l2_gps_power.cpp @@ -0,0 +1,128 @@ +#include +#include +#include +#include "GpsTap.h" +#include + +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define POWERSAVING_DEBUG_PRINTLN(...) ((void)0) + +void LocationProvider::sendSentence(const char*) {} + +@EXPANDER@ + +// Keep only ESP32 startup hardware out of this host harness. The expander +// initialization, output shadows and GNSS power/reset methods are production. +void WioTrackerL2Board::begin() { expander_ok = initExpander(); } +uint16_t WioTrackerL2Board::getBattMilliVolts() { return 0; } +bool WioTrackerL2Board::isExternalPowered() { return false; } +void WioTrackerL2Board::setLed(bool) {} + +class EnvironmentSensorManager : public SensorManager { +public: + LocationProvider* _location; + bool gps_detected=false, gps_active=false, gps_serial_transport=false; + bool gps_serial_transport_blocked=false, gps_discovery_pending=false; + bool gps_discovery_preference_known=false; + uint32_t gps_discovery_started_at=0; + explicit EnvironmentSensorManager(LocationProvider& location) : _location(&location) {} + LocationProvider* getLocationProvider() override { return _location; } + bool telemetryGpsDetected() const override { return gps_detected; } + bool telemetryGpsActive() const override { return gps_active; } + void telemetryGpsStart() override { start_gps(); } + void telemetryGpsStop() override { stop_gps(); } + bool setSettingValue(const char*,const char*) override; + void setPowerSavingEnabled(bool) override; + void finishBasicGpsDiscovery(bool found); + void serviceBasicGpsDiscovery(); + void initBasicGPS(); + void armGpsPowerSavingCycle(); + void start_gps(); + void stop_gps(); + void loop() override; +}; + +@LIFECYCLE@ + +WioTrackerL2Board board; +GpsTapStream gps_tap(Serial1); +mesh::RTCClock clock_source; +WioTrackerL2LocationProvider gps(board, gps_tap, &clock_source); +EnvironmentSensorManager sensors(gps); +struct RadioMock { void powerOff() { event("radio-off"); } } radio_driver; + +@SHUTDOWN@ + +size_t first(const char* name) { + for (size_t i=0;i +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define HIGH 1 +#define LOW 0 +#define OUTPUT 1 +#define INPUT 0 +struct Event { std::string name; int a; int b; }; +inline std::vector events; +inline int modes[64] = {}; +inline uint32_t now_ms = 1; +inline void event(const char* name, int a=0, int b=0) { events.push_back({name,a,b}); } +inline uint32_t millis() { return now_ms; } +inline void delay(uint32_t ms) { now_ms += ms; } +inline void yield() { ++now_ms; } +inline void pinMode(int pin,int mode) { assert(pin>=0&&pin<64);modes[pin]=mode;event("pin",pin,mode); } +inline void digitalWrite(int,int) {} +inline int digitalRead(int) { return HIGH; } +class Stream { +public: + virtual ~Stream() = default; + virtual int available() { return 0; } + virtual int peek() { return -1; } + virtual int read() { return -1; } + virtual void flush() {} + virtual size_t write(uint8_t) { return 1; } + virtual size_t write(const uint8_t*,size_t size) { return size; } +}; +class HardwareSerial : public Stream { +public: + bool installed=false; + unsigned begins=0, ends=0; + int rx=-1,tx=-1; + std::deque incoming; + void setPins(int r,int t) { rx=r;tx=t;event("uart-pins",r,t); } + void begin(unsigned baud) { + assert(rx>=0&&tx>=0);installed=true;++begins; + pinMode(rx,INPUT);pinMode(tx,OUTPUT);event("uart-begin",baud); + } + void end() { installed=false;++ends;incoming.clear();event("uart-end"); } + int available() override { return installed ? int(incoming.size()) : 0; } + int peek() override { return available() ? incoming.front() : -1; } + int read() override { if(!available())return -1;int c=incoming.front();incoming.pop_front();return c; } + void feed(const char* text) { while(*text)incoming.push_back(*text++); } +}; +inline HardwareSerial Serial1, Serial; +using std::isnan; +''' + +WIRE = r''' +#pragma once +#include +class TwoWire { + std::vector sent; + uint8_t address=0; +public: + bool present=true; + unsigned transactions=0; + uint8_t regs[256]; + TwoWire() { memset(regs,0xff,sizeof(regs)); } + bool gnssOn() const { return !(regs[7]&(1<<5)) && (regs[3]&(1<<5)); } + void beginTransmission(uint8_t a) { address=a;sent.clear();++transactions; } + size_t write(uint8_t byte) { sent.push_back(byte);return 1; } + uint8_t endTransmission() { + if(!present)return 4; + if(address==0x21&&sent.size()==2) { + const bool before=gnssOn();regs[sent[0]]=sent[1]; + if(before!=gnssOn()) { + // Both enabling and disabling the receiver happen with UART pads + // released, never an idle-high MCU TX into an unpowered GNSS RX. + assert(!Serial1.installed&&modes[17]==INPUT&&modes[18]==INPUT); + event(gnssOn()?"gnss-on":"gnss-off"); + } + event("i2c-write",sent[0],sent[1]); + } + return 0; + } + int requestFrom(int,int size) { return size; } + int read() { return 0; } +}; +inline TwoWire Wire; +''' + +ESP_BOARD = r''' +#pragma once +#include +class ESP32Board { +public: + virtual void begin() {} + virtual uint16_t getBattMilliVolts() { return 0; } + virtual const char* getManufacturerName() const { return "host"; } + virtual void onBeforeTransmit() {} + virtual void onAfterTransmit() {} + virtual bool isExternalPowered() { return false; } + virtual void shutdownPeripherals(); +}; +''' + + +def harness(mutation=None): + board = (VARIANT / "WioTrackerL2Board.cpp").read_text() + if mutation == "boot-rail-on": + board = board.replace("expSetOutput(EXP_PIN_GNSS_EN, LOW);", + "expSetOutput(EXP_PIN_GNSS_EN, HIGH);", 1) + constants = "\n".join(line for line in board.splitlines() + if re.match(r"static const uint8_t TCA_REG_", line)) + expander = "\n".join(extract_braced(board, signature) for signature in ( + "bool WioTrackerL2Board::expWriteReg(", + "void WioTrackerL2Board::expSetOutput(", + "void WioTrackerL2Board::expSetInput(", + "void WioTrackerL2Board::expWritePin(", + "bool WioTrackerL2Board::initExpander()", + "int WioTrackerL2Board::expReadInputs()", + )) + manager = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() + lifecycle = "\n".join(extract_braced(manager, signature) for signature in ( + "bool EnvironmentSensorManager::setSettingValue(", + "void EnvironmentSensorManager::setPowerSavingEnabled(", + "void EnvironmentSensorManager::finishBasicGpsDiscovery(", + "void EnvironmentSensorManager::serviceBasicGpsDiscovery()", + "void EnvironmentSensorManager::initBasicGPS()", + "void EnvironmentSensorManager::armGpsPowerSavingCycle()", + "void EnvironmentSensorManager::start_gps()", + "void EnvironmentSensorManager::stop_gps()", + "void EnvironmentSensorManager::loop()", + )) + shutdown = extract_braced((ROOT / "src/helpers/ESP32Board.cpp").read_text(), + "void ESP32Board::shutdownPeripherals()") + fixture = (ROOT / "test/fixtures/wio_tracker_l2_gps_power.cpp").read_text() + return (fixture.replace("@EXPANDER@", constants + "\n" + expander) + .replace("@LIFECYCLE@", lifecycle).replace("@SHUTDOWN@", shutdown)) + + +class WioTrackerL2GpsPowerTests(unittest.TestCase): + def compile_run(self, mode, mutation=None, fail=False): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="wio-l2-gps-power-") as directory: + work = Path(directory) + files = { + "Arduino.h": ARDUINO, "Wire.h": WIRE, "Mesh.h": MESH, + "MicroNMEA.h": NMEA, "RTClib.h": RTC, "CayenneLPP.h": CAYENNE, + "helpers/ESP32Board.h": ESP_BOARD, + } + provider = (VARIANT / "WioTrackerL2LocationProvider.h").read_text() + if mutation == "never-power-off": + provider = provider.replace("_board.setGnssPower(false);", "// negative control") + # Copy the production adaptation unchanged except explicit negative + # controls; quoted includes still resolve the real board header. + files["WioTrackerL2LocationProvider.h"] = provider + for name, contents in files.items(): + path = work / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + source = work / "case.cpp" + source.write_text(harness(mutation)) + binary = work / "case" + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-DENV_INCLUDE_GPS=1", + "-DESP32_PLATFORM=1", "-DENV_SKIP_GPS_DETECT=1", + "-DPIN_GPS_TX=18", "-DPIN_GPS_RX=17", "-DGPS_BAUD_RATE=9600", + "-I", str(work), "-I", str(VARIANT), + "-I", str(ROOT / "src"), str(source), + str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(binary)] + if sys.platform.startswith("linux"): + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(command, text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary), mode], text=True, capture_output=True) + if fail: + self.assertNotEqual(ran.returncode, 0, "negative control did not fail") + else: + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_boot_gnss_is_off_without_global_constructor_io(self): + self.compile_run("boot") + + def test_begin_stop_are_idempotent_and_order_uart_before_rail(self): + self.compile_run("cycles") + + def test_failed_expander_leaves_uart_released_and_provider_disabled(self): + self.compile_run("failed") + + def test_actual_manager_saved_gps_off_and_scheduled_sleep_wake(self): + self.compile_run("manager") + + def test_gps_tap_parser_and_actual_esp32_shutdown_are_preserved(self): + self.compile_run("tapshutdown") + + def test_negative_controls_detect_boot_on_and_missing_rail_stop(self): + self.compile_run("boot", "boot-rail-on", fail=True) + self.compile_run("cycles", "never-power-off", fail=True) + + def test_target_uses_board_provider_without_fake_enable_gpio(self): + target = (VARIANT / "target.cpp").read_text() + self.assertIn("GpsTapStream gps_tap(Serial1);", target) + self.assertIn("WioTrackerL2LocationProvider gps(board, gps_tap, &rtc_clock);", target) + self.assertIn("EnvironmentSensorManager sensors(gps);", target) + provider = (VARIANT / "WioTrackerL2LocationProvider.h").read_text() + self.assertIn("MicroNMEALocationProvider(stream, clock, -1, -1)", provider) + self.assertNotIn("getPinEn()", provider) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/wio-tracker-l2/README.md b/variants/wio-tracker-l2/README.md index 26d1e4a9c..657dabf4b 100644 --- a/variants/wio-tracker-l2/README.md +++ b/variants/wio-tracker-l2/README.md @@ -55,6 +55,9 @@ PIN shown on the device screen. - **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. + The GNSS rail starts off. The board-local provider powers it only while + GPS is enabled, including scheduled wake periods, and releases the UART + pads before cutting power so they cannot back-power the receiver. - **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. diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.cpp b/variants/wio-tracker-l2/WioTrackerL2Board.cpp index edeaacce5..7dc2cda03 100644 --- a/variants/wio-tracker-l2/WioTrackerL2Board.cpp +++ b/variants/wio-tracker-l2/WioTrackerL2Board.cpp @@ -66,9 +66,12 @@ bool WioTrackerL2Board::initExpander() { delay(10); expSetOutput(EXP_PIN_BAT_ADC_EN, HIGH); delay(10); - expSetOutput(EXP_PIN_GNSS_EN, HIGH); + // The location provider owns this rail, including saved GPS-off settings. + // Keep it off until its explicit power/reset sequence starts acquisition. + expSetOutput(EXP_PIN_GNSS_EN, LOW); delay(10); - // GNSS reset is active HIGH; hold 10ms then release LOW so the L76K runs + // Configure active-HIGH reset while unpowered. The provider pulses it again + // after enabling the GNSS rail, before it starts receiving NMEA. expSetOutput(EXP_PIN_GNSS_RST, HIGH); delay(10); expWritePin(EXP_PIN_GNSS_RST, LOW); diff --git a/variants/wio-tracker-l2/WioTrackerL2LocationProvider.h b/variants/wio-tracker-l2/WioTrackerL2LocationProvider.h new file mode 100644 index 000000000..e4244842e --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2LocationProvider.h @@ -0,0 +1,51 @@ +#pragma once + +#include "WioTrackerL2Board.h" +#include + +// The GNSS enable/reset pins are TCA9535 channels, not ESP32 GPIOs. Keep this +// adaptation local to the board while retaining the normal NMEA/GpsTap stream. +class WioTrackerL2LocationProvider : public MicroNMEALocationProvider { + WioTrackerL2Board& _board; + bool _powered = false; + + static void releaseUart() { + Serial1.end(); + // The MCU's TX idle level must not feed an unpowered GNSS through its RX. + pinMode(PIN_GPS_TX, INPUT); + pinMode(PIN_GPS_RX, INPUT); + } + +public: + WioTrackerL2LocationProvider(WioTrackerL2Board& board, Stream& stream, + mesh::RTCClock* clock) + : MicroNMEALocationProvider(stream, clock, -1, -1), _board(board) {} + + void begin() override { + if (_powered) return; + // The manager starts Serial1 before provider begin(). Release its pads + // until the board-specific power/reset sequence has made GNSS ready. + releaseUart(); + if (!_board.expanderOK()) return; + _board.setGnssPower(true); + Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); +#ifdef GPS_BAUD_RATE + Serial1.begin(GPS_BAUD_RATE); +#else + Serial1.begin(9600); +#endif + MicroNMEALocationProvider::begin(); + clearPositionFix(); + _powered = true; + } + + void stop() override { + if (!_powered) return; + MicroNMEALocationProvider::stop(); + releaseUart(); + _board.setGnssPower(false); + _powered = false; + } + + bool isEnabled() override { return _powered; } +}; diff --git a/variants/wio-tracker-l2/target.cpp b/variants/wio-tracker-l2/target.cpp index a87da3924..a20480628 100644 --- a/variants/wio-tracker-l2/target.cpp +++ b/variants/wio-tracker-l2/target.cpp @@ -11,7 +11,7 @@ WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); GpsTapStream gps_tap(Serial1); // GSV sats-in-view / SNR watcher -MicroNMEALocationProvider gps(gps_tap, &rtc_clock); +WioTrackerL2LocationProvider gps(board, gps_tap, &rtc_clock); EnvironmentSensorManager sensors(gps); #ifdef DISPLAY_CLASS diff --git a/variants/wio-tracker-l2/target.h b/variants/wio-tracker-l2/target.h index 11a66e4ec..adbe16d04 100644 --- a/variants/wio-tracker-l2/target.h +++ b/variants/wio-tracker-l2/target.h @@ -8,7 +8,7 @@ #include #include #include -#include +#include "WioTrackerL2LocationProvider.h" #include "GpsTap.h" #ifdef DISPLAY_CLASS #include "WioTrackerL2Display.h"