init commit
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# PlatformIO
|
||||||
|
.pio/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Generated output
|
||||||
|
src/generated/web_ui.h
|
||||||
|
|
||||||
|
# Empty LittleFS dir for PROGMEM builds — keep .gitkeep only
|
||||||
|
data_empty/*
|
||||||
|
!data_empty/.gitkeep
|
||||||
|
|
||||||
|
# React
|
||||||
|
web-dashboard/node_modules/
|
||||||
|
web-dashboard/dist/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
data/www/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python virtual environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* @file main.cpp
|
||||||
|
* @brief Marine Navigation Display — application entry point.
|
||||||
|
*
|
||||||
|
* Boot sequence
|
||||||
|
* ─────────────
|
||||||
|
* 1. Initialise Serial, NeoPixel LED, LittleFS config.
|
||||||
|
* 2. Check for AP-mode button combo held at boot.
|
||||||
|
* If combo detected (or first boot): start WiFi AP + web server.
|
||||||
|
* 3. Otherwise start BLE scanning for the Marine Gateway.
|
||||||
|
* 4. main loop:
|
||||||
|
* a. Poll touch buttons.
|
||||||
|
* b. Handle page navigation events.
|
||||||
|
* c. Check BLE status → update LED.
|
||||||
|
* d. Render display (partial refresh where possible).
|
||||||
|
* e. Run WiFi server tasks.
|
||||||
|
*
|
||||||
|
* WiFi AP ↔ BLE co-existence
|
||||||
|
* ──────────────────────────
|
||||||
|
* The ESP32-S3 radio is shared. We run in one of two mutually exclusive modes:
|
||||||
|
* MODE_BLE — BLE client active, WiFi off.
|
||||||
|
* MODE_WIFI — WiFi AP active, BLE off (configuration / OTA mode).
|
||||||
|
*
|
||||||
|
* The user switches modes via the NEXT+PREV button combo (hold 3 s).
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "Config.h"
|
||||||
|
#include "ConfigManager.h"
|
||||||
|
#include "BleManager.h"
|
||||||
|
#include "DisplayManager.h"
|
||||||
|
#include "WifiManager.h"
|
||||||
|
#include "TouchManager.h"
|
||||||
|
#include "StatusLed.h"
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Application state
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum class AppMode { BLE, WIFI_AP };
|
||||||
|
static AppMode _mode = AppMode::BLE;
|
||||||
|
static bool _otaInProgress = false;
|
||||||
|
|
||||||
|
// Cached WiFi status for LED
|
||||||
|
static AppState::WifiStatus _wifiStatus = AppState::WifiStatus::OFF;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Forward declarations
|
||||||
|
// =============================================================================
|
||||||
|
static void enterBleMode();
|
||||||
|
static void enterApMode();
|
||||||
|
static void handleTouchEvents();
|
||||||
|
static void updateDisplay();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// setup()
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(200);
|
||||||
|
Serial.println("\n╔═══════════════════════════════╗");
|
||||||
|
Serial.println("║ Marine Navigation Display ║");
|
||||||
|
Serial.printf( "║ v%-28s║\n", APP_VERSION);
|
||||||
|
Serial.println("╚═══════════════════════════════╝");
|
||||||
|
|
||||||
|
// ── Status LED ────────────────────────────────────────────────────────────
|
||||||
|
statusLed.begin();
|
||||||
|
statusLed.setColor(0xFFFFFF, 20); // brief white flash on boot
|
||||||
|
|
||||||
|
// ── Persistent config ─────────────────────────────────────────────────────
|
||||||
|
if (!configManager.begin()) {
|
||||||
|
Serial.println("[MAIN] FATAL: Config init failed");
|
||||||
|
// Continue with defaults — non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display ───────────────────────────────────────────────────────────────
|
||||||
|
displayManager.begin(); // Shows splash screen
|
||||||
|
|
||||||
|
// ── Touch ─────────────────────────────────────────────────────────────────
|
||||||
|
touchManager.begin();
|
||||||
|
|
||||||
|
// ── Detect boot-time AP combo (hold NEXT+PREV while powering on) ──────────
|
||||||
|
// Sample touch pads for 500 ms at boot
|
||||||
|
uint32_t comboStart = millis();
|
||||||
|
bool comboDetected = false;
|
||||||
|
while (millis() - comboStart < 500) {
|
||||||
|
if (touchRead(Pins::TOUCH_NEXT) < Touch::THRESHOLD &&
|
||||||
|
touchRead(Pins::TOUCH_PREV) < Touch::THRESHOLD) {
|
||||||
|
comboDetected = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
delay(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comboDetected) {
|
||||||
|
Serial.println("[MAIN] Boot combo detected → entering AP mode");
|
||||||
|
enterApMode();
|
||||||
|
} else {
|
||||||
|
enterBleMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// loop()
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// ── Touch input ───────────────────────────────────────────────────────────
|
||||||
|
touchManager.update();
|
||||||
|
handleTouchEvents();
|
||||||
|
|
||||||
|
// ── Status LED ────────────────────────────────────────────────────────────
|
||||||
|
statusLed.update(bleManager.status(), _wifiStatus, _otaInProgress);
|
||||||
|
|
||||||
|
// ── BLE mode tasks ────────────────────────────────────────────────────────
|
||||||
|
if (_mode == AppMode::BLE) {
|
||||||
|
bleManager.update();
|
||||||
|
|
||||||
|
// Show BLE status banner when not connected
|
||||||
|
static AppState::BleStatus prevBleStatus = AppState::BleStatus::IDLE;
|
||||||
|
AppState::BleStatus curStatus = bleManager.status();
|
||||||
|
if (curStatus != prevBleStatus) {
|
||||||
|
prevBleStatus = curStatus;
|
||||||
|
if (curStatus != AppState::BleStatus::CONNECTED) {
|
||||||
|
displayManager.showBleStatus(curStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WiFi mode tasks ───────────────────────────────────────────────────────
|
||||||
|
if (_mode == AppMode::WIFI_AP) {
|
||||||
|
wifiManager.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display render ────────────────────────────────────────────────────────
|
||||||
|
if (_mode == AppMode::BLE && !_otaInProgress) {
|
||||||
|
updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small yield to let background tasks run
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mode transitions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
static void enterBleMode() {
|
||||||
|
Serial.println("[MAIN] Entering BLE mode");
|
||||||
|
_mode = AppMode::BLE;
|
||||||
|
_wifiStatus = AppState::WifiStatus::OFF;
|
||||||
|
|
||||||
|
if (wifiManager.isActive()) wifiManager.stop();
|
||||||
|
|
||||||
|
bleManager.begin();
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void enterApMode() {
|
||||||
|
Serial.println("[MAIN] Entering WiFi AP mode");
|
||||||
|
_mode = AppMode::WIFI_AP;
|
||||||
|
_wifiStatus = AppState::WifiStatus::AP_ACTIVE;
|
||||||
|
|
||||||
|
// BLE and WiFi share the radio; stop BLE first (NimBLE deinit is implicit
|
||||||
|
// when WiFi takes over, but we force a clean state)
|
||||||
|
// Note: NimBLEDevice::deinit(true) could be called here if needed.
|
||||||
|
|
||||||
|
const AppConfig& cfg = configManager.config();
|
||||||
|
wifiManager.begin(cfg.apSSID, cfg.apPassword);
|
||||||
|
displayManager.showApScreen(cfg.apSSID, WifiManager::AP_IP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Touch event dispatcher
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
static void handleTouchEvents() {
|
||||||
|
// AP combo (NEXT + PREV held 3 s)
|
||||||
|
if (touchManager.apCombo()) {
|
||||||
|
if (_mode == AppMode::BLE) {
|
||||||
|
enterApMode();
|
||||||
|
} else {
|
||||||
|
enterBleMode();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_mode == AppMode::BLE) {
|
||||||
|
if (touchManager.nextPressed()) {
|
||||||
|
configManager.nextPage();
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
Serial.printf("[MAIN] Page → %u\n", configManager.activePage());
|
||||||
|
}
|
||||||
|
if (touchManager.prevPressed()) {
|
||||||
|
configManager.prevPage();
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
Serial.printf("[MAIN] Page ← %u\n", configManager.activePage());
|
||||||
|
}
|
||||||
|
if (touchManager.actionPressed()) {
|
||||||
|
// Force a full e-ink refresh (clears ghosting)
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
Serial.println("[MAIN] Manual full refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Display update
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
static void updateDisplay() {
|
||||||
|
BoatState state;
|
||||||
|
bleManager.getBoatState(state);
|
||||||
|
|
||||||
|
const AppConfig& cfg = configManager.config();
|
||||||
|
const PageConfig& page = cfg.pages[cfg.activePage];
|
||||||
|
|
||||||
|
displayManager.render(state, page);
|
||||||
|
}
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../espNav/doc/BLE_Client_Documentation.md
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file BleManager.h
|
||||||
|
* @brief BLE Central (client) manager using NimBLE-Arduino.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Scan for the Marine Gateway peripheral ("MarineGateway")
|
||||||
|
* - Establish a secure, bonded connection (passkey entry via Serial/Web)
|
||||||
|
* - Subscribe to all five NOTIFY characteristics and parse incoming JSON
|
||||||
|
* - Provide thread-safe access to the aggregated BoatState
|
||||||
|
* - Send autopilot and admin commands
|
||||||
|
* - Manage automatic reconnection
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Config.h"
|
||||||
|
#include <NimBLEDevice.h>
|
||||||
|
|
||||||
|
class BleManager : public NimBLEClientCallbacks,
|
||||||
|
public NimBLEScanCallbacks {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Initialise the NimBLE stack and start scanning.
|
||||||
|
* Must be called once from setup().
|
||||||
|
*/
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Call from loop() — handles reconnect scheduling and
|
||||||
|
* dispatches any pending state changes.
|
||||||
|
*/
|
||||||
|
void update();
|
||||||
|
|
||||||
|
// ── Data access (thread-safe) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Copy the current BoatState snapshot into @p dst.
|
||||||
|
* Protected by an internal mutex.
|
||||||
|
*/
|
||||||
|
void getBoatState(BoatState& dst) const;
|
||||||
|
|
||||||
|
/** @brief Returns true when actively connected to the gateway. */
|
||||||
|
bool isConnected() const;
|
||||||
|
|
||||||
|
/** @brief Current BLE status for status LED / UI. */
|
||||||
|
AppState::BleStatus status() const { return _status; }
|
||||||
|
|
||||||
|
// ── Command senders ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @brief Send an autopilot command (e.g. "adjust+1"). */
|
||||||
|
bool sendAutopilotCmd(const char* command);
|
||||||
|
|
||||||
|
/** @brief Send a raw admin JSON command string. */
|
||||||
|
bool sendAdminCmd(const char* jsonCmd);
|
||||||
|
|
||||||
|
// Convenience admin helpers
|
||||||
|
bool sendRestart();
|
||||||
|
bool sendWifiSta(const char* ssid, const char* password);
|
||||||
|
bool sendWifiAp(const char* ssid, const char* password);
|
||||||
|
|
||||||
|
// ── Passkey (pairing) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Provide the passkey entered by the user.
|
||||||
|
* Called by the WiFi web UI when the user submits the PIN.
|
||||||
|
*/
|
||||||
|
void providePasskey(uint32_t pin);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// ── NimBLEClientCallbacks ─────────────────────────────────────────────────
|
||||||
|
void onConnect(NimBLEClient* pClient) override;
|
||||||
|
void onDisconnect(NimBLEClient* pClient, int reason) override;
|
||||||
|
bool onConfirmPasskey(NimBLEConnInfo& connInfo, uint32_t pin) override;
|
||||||
|
void onAuthenticationComplete(NimBLEConnInfo& connInfo) override;
|
||||||
|
|
||||||
|
// ── NimBLEScanCallbacks ───────────────────────────────────────────────────
|
||||||
|
void onResult(const NimBLEAdvertisedDevice* device) override;
|
||||||
|
void onScanEnd(const NimBLEScanResults& results, int reason) override;
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||||
|
bool _connectToServer(const NimBLEAddress& addr);
|
||||||
|
bool _subscribeAll();
|
||||||
|
void _startScan();
|
||||||
|
void _scheduleReconnect();
|
||||||
|
|
||||||
|
// Notification callbacks (one per characteristic)
|
||||||
|
void _onNavNotify (NimBLERemoteCharacteristic*, uint8_t*, size_t, bool);
|
||||||
|
void _onWindNotify (NimBLERemoteCharacteristic*, uint8_t*, size_t, bool);
|
||||||
|
void _onAutopilotNotify(NimBLERemoteCharacteristic*, uint8_t*, size_t, bool);
|
||||||
|
void _onPerfNotify (NimBLERemoteCharacteristic*, uint8_t*, size_t, bool);
|
||||||
|
void _onAdminNotify (NimBLERemoteCharacteristic*, uint8_t*, size_t, bool);
|
||||||
|
|
||||||
|
// JSON parsers
|
||||||
|
void _parseNav (const char* json);
|
||||||
|
void _parseWind (const char* json);
|
||||||
|
void _parseAutopilot(const char* json);
|
||||||
|
void _parsePerf (const char* json);
|
||||||
|
void _parseAdmin (const char* json);
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
NimBLEClient* _pClient = nullptr;
|
||||||
|
NimBLEAddress _targetAddr;
|
||||||
|
bool _targetFound = false;
|
||||||
|
bool _doConnect = false;
|
||||||
|
uint32_t _reconnectAt = 0; ///< millis() when next reconnect is due
|
||||||
|
|
||||||
|
AppState::BleStatus _status = AppState::BleStatus::IDLE;
|
||||||
|
|
||||||
|
mutable portMUX_TYPE _mux = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
BoatState _state;
|
||||||
|
|
||||||
|
// Pending passkey from the web UI
|
||||||
|
volatile bool _passkeyPending = false;
|
||||||
|
volatile uint32_t _passkey = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singleton accessor
|
||||||
|
extern BleManager bleManager;
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file Config.h
|
||||||
|
* @brief Central configuration constants, data structures and enumerations
|
||||||
|
* for the Marine Navigation Display.
|
||||||
|
*
|
||||||
|
* All hardware pin assignments, BLE UUIDs, display layout parameters and
|
||||||
|
* application-level structs are defined here so that every module shares a
|
||||||
|
* single source of truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Firmware version
|
||||||
|
// =============================================================================
|
||||||
|
#ifndef APP_VERSION
|
||||||
|
#define APP_VERSION "1.0.0"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Hardware — SPI pins for the 4.2" GxEPD2 e-ink display
|
||||||
|
// Waveshare ESP32-S3-Zero default SPI wiring
|
||||||
|
// =============================================================================
|
||||||
|
namespace Pins {
|
||||||
|
// ── E-Ink display ─────────────────────────────────────────────────────────
|
||||||
|
constexpr uint8_t EPD_CS = 9; ///< Chip Select
|
||||||
|
constexpr uint8_t EPD_DC = 8; ///< Data/Command
|
||||||
|
constexpr uint8_t EPD_RST = 7; ///< Reset
|
||||||
|
constexpr uint8_t EPD_BUSY = 6; ///< Busy signal (active LOW)
|
||||||
|
constexpr uint8_t EPD_SCK = 10; ///< SPI clock (HSPI SCK)
|
||||||
|
constexpr uint8_t EPD_MOSI = 11; ///< SPI MOSI (HSPI MOSI)
|
||||||
|
|
||||||
|
// ── Capacitive touch buttons (ESP32-S3 native touch) ─────────────────────
|
||||||
|
// Override via platformio.ini build_flags if needed.
|
||||||
|
constexpr uint8_t TOUCH_NEXT = TOUCH_PIN_NEXT; ///< Next page / +
|
||||||
|
constexpr uint8_t TOUCH_PREV = TOUCH_PIN_PREV; ///< Previous page / -
|
||||||
|
constexpr uint8_t TOUCH_ACTION = TOUCH_PIN_ACTION; ///< Action / full refresh
|
||||||
|
|
||||||
|
// ── NeoPixel RGB status LED ───────────────────────────────────────────────
|
||||||
|
constexpr uint8_t LED = STATUS_LED_PIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Touch input parameters
|
||||||
|
// =============================================================================
|
||||||
|
namespace Touch {
|
||||||
|
constexpr uint16_t THRESHOLD = 40; ///< Raw touch value below = touched
|
||||||
|
constexpr uint32_t DEBOUNCE_MS = 50; ///< Minimum ms between events
|
||||||
|
constexpr uint32_t LONG_PRESS_MS = 1500; ///< Hold duration for long-press
|
||||||
|
constexpr uint32_t AP_COMBO_MS = 3000; ///< Hold NEXT+PREV to enter AP mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BLE — Marine Gateway protocol UUIDs
|
||||||
|
// Base: 4D475743-xxxx-4E41-5649-474154494F4E
|
||||||
|
// =============================================================================
|
||||||
|
namespace BleUUID {
|
||||||
|
// ── Services ──────────────────────────────────────────────────────────────
|
||||||
|
constexpr const char* NAV_SVC = "4d475743-0001-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* WIND_SVC = "4d475743-0002-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* AUTOPILOT_SVC = "4d475743-0003-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* PERF_SVC = "4d475743-0004-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* ADMIN_SVC = "4d475743-0005-4e41-5649-474154494f4e";
|
||||||
|
|
||||||
|
// ── Characteristics (read / notify) ───────────────────────────────────────
|
||||||
|
constexpr const char* NAV_DATA = "4d475743-0101-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* WIND_DATA = "4d475743-0201-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* AUTOPILOT_DATA = "4d475743-0301-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* AUTOPILOT_CMD = "4d475743-0302-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* PERF_DATA = "4d475743-0401-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* ADMIN_DATA = "4d475743-0501-4e41-5649-474154494f4e";
|
||||||
|
constexpr const char* ADMIN_CMD = "4d475743-0502-4e41-5649-474154494f4e";
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BLE runtime parameters
|
||||||
|
// =============================================================================
|
||||||
|
namespace BleConfig {
|
||||||
|
constexpr const char* DEVICE_NAME = BLE_DEVICE_NAME;
|
||||||
|
constexpr uint32_t SCAN_DURATION = 10; ///< seconds per scan window
|
||||||
|
constexpr uint32_t RECONNECT_MS = 5000; ///< delay before reconnect attempt
|
||||||
|
constexpr uint32_t STALE_TIMEOUT = 15; ///< seconds before data is marked stale
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Display layout
|
||||||
|
// 400×300 px divided into a 2×2 grid → 4 tiles
|
||||||
|
// =============================================================================
|
||||||
|
namespace Display {
|
||||||
|
constexpr uint16_t WIDTH = 400;
|
||||||
|
constexpr uint16_t HEIGHT = 300;
|
||||||
|
constexpr uint8_t TILE_COLS = 2;
|
||||||
|
constexpr uint8_t TILE_ROWS = 2;
|
||||||
|
constexpr uint8_t TILE_COUNT = TILE_COLS * TILE_ROWS;
|
||||||
|
constexpr uint16_t TILE_W = WIDTH / TILE_COLS; // 200 px
|
||||||
|
constexpr uint16_t TILE_H = HEIGHT / TILE_ROWS; // 150 px
|
||||||
|
constexpr uint8_t BORDER_PX = 2; ///< separator line width
|
||||||
|
constexpr uint8_t LABEL_H = 22; ///< px reserved for the tile label
|
||||||
|
constexpr uint32_t FULL_REFRESH_INTERVAL_MS = 300000; ///< forced full refresh every 5 min
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Available data fields that can be assigned to a tile
|
||||||
|
// =============================================================================
|
||||||
|
enum class DataField : uint8_t {
|
||||||
|
NONE = 0,
|
||||||
|
// Navigation
|
||||||
|
LAT, ///< GPS Latitude
|
||||||
|
LON, ///< GPS Longitude
|
||||||
|
SOG, ///< Speed Over Ground (kn)
|
||||||
|
COG, ///< Course Over Ground (°)
|
||||||
|
STW, ///< Speed Through Water (kn)
|
||||||
|
HDG_MAG, ///< Magnetic Heading (°)
|
||||||
|
DEPTH, ///< Depth (m)
|
||||||
|
// Wind
|
||||||
|
AWS, ///< Apparent Wind Speed (kn)
|
||||||
|
AWA, ///< Apparent Wind Angle (°)
|
||||||
|
TWS, ///< True Wind Speed (kn)
|
||||||
|
TWA, ///< True Wind Angle (°)
|
||||||
|
TWD, ///< True Wind Direction (°)
|
||||||
|
// Autopilot
|
||||||
|
AP_MODE, ///< Autopilot mode string
|
||||||
|
AP_TARGET, ///< Autopilot target heading (°)
|
||||||
|
AP_RUDDER, ///< Rudder angle (°)
|
||||||
|
// Performance
|
||||||
|
VMG, ///< Velocity Made Good (kn)
|
||||||
|
POLAR_PCT, ///< Polar efficiency (%)
|
||||||
|
TARGET_STW, ///< Polar target speed (kn)
|
||||||
|
// Admin / System
|
||||||
|
UPTIME, ///< Device uptime (s)
|
||||||
|
WIFI_MODE, ///< WiFi mode string
|
||||||
|
FREE_HEAP, ///< Free heap (bytes)
|
||||||
|
_COUNT ///< Sentinel — keep last
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tile configuration (persisted in NVS / LittleFS)
|
||||||
|
// =============================================================================
|
||||||
|
struct TileConfig {
|
||||||
|
DataField field = DataField::NONE;
|
||||||
|
char label[16] = ""; ///< User-defined label override (empty = auto)
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Page: a set of 4 tiles
|
||||||
|
// =============================================================================
|
||||||
|
constexpr uint8_t MAX_PAGES = 8;
|
||||||
|
|
||||||
|
struct PageConfig {
|
||||||
|
char name[24] = "";
|
||||||
|
TileConfig tiles[Display::TILE_COUNT];
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Application-wide configuration (serialised to LittleFS as JSON)
|
||||||
|
// =============================================================================
|
||||||
|
struct AppConfig {
|
||||||
|
uint8_t pageCount = 1;
|
||||||
|
uint8_t activePage = 0;
|
||||||
|
PageConfig pages[MAX_PAGES];
|
||||||
|
|
||||||
|
// WiFi AP credentials (used when the display itself opens an AP)
|
||||||
|
char apSSID[32] = "MarineDisplay";
|
||||||
|
char apPassword[32] = "marine123";
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Live data snapshot — filled by BleManager, read by DisplayManager
|
||||||
|
// =============================================================================
|
||||||
|
struct NavData {
|
||||||
|
float lat = NAN;
|
||||||
|
float lon = NAN;
|
||||||
|
float sog = NAN;
|
||||||
|
float cog = NAN;
|
||||||
|
float stw = NAN;
|
||||||
|
float hdgMag = NAN;
|
||||||
|
float depth = NAN;
|
||||||
|
uint32_t updatedAt = 0; ///< millis() of last valid update
|
||||||
|
};
|
||||||
|
|
||||||
|
struct WindData {
|
||||||
|
float aws = NAN;
|
||||||
|
float awa = NAN;
|
||||||
|
float tws = NAN;
|
||||||
|
float twa = NAN;
|
||||||
|
float twd = NAN;
|
||||||
|
uint32_t updatedAt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AutopilotData {
|
||||||
|
char mode[16] = "";
|
||||||
|
char status[16] = "";
|
||||||
|
float headingTarget = NAN;
|
||||||
|
float windTarget = NAN;
|
||||||
|
float rudder = NAN;
|
||||||
|
float lockedHeading = NAN;
|
||||||
|
uint32_t updatedAt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PerfData {
|
||||||
|
float vmg = NAN;
|
||||||
|
float polarPct = NAN;
|
||||||
|
float targetStw = NAN;
|
||||||
|
bool polarLoaded = false;
|
||||||
|
uint32_t updatedAt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AdminData {
|
||||||
|
uint32_t uptimeS = 0;
|
||||||
|
uint64_t datetimeUtc = 0;
|
||||||
|
char wifiMode[8] = "";
|
||||||
|
char wifiSSID[33] = "";
|
||||||
|
char ip[16] = "";
|
||||||
|
uint32_t freeHeap = 0;
|
||||||
|
uint32_t updatedAt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Aggregated snapshot of all BLE-received data.
|
||||||
|
* Access must be guarded by the SemaphoreHandle in AppState.
|
||||||
|
*/
|
||||||
|
struct BoatState {
|
||||||
|
NavData nav;
|
||||||
|
WindData wind;
|
||||||
|
AutopilotData autopilot;
|
||||||
|
PerfData perf;
|
||||||
|
AdminData admin;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Application state — shared between all modules (extern in AppState.cpp)
|
||||||
|
// =============================================================================
|
||||||
|
namespace AppState {
|
||||||
|
enum class BleStatus : uint8_t {
|
||||||
|
IDLE,
|
||||||
|
SCANNING,
|
||||||
|
CONNECTING,
|
||||||
|
CONNECTED,
|
||||||
|
DISCONNECTED,
|
||||||
|
ERROR
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class WifiStatus : uint8_t {
|
||||||
|
OFF,
|
||||||
|
AP_ACTIVE,
|
||||||
|
STA_CONNECTING,
|
||||||
|
STA_CONNECTED
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NeoPixel LED colour palette for status indication
|
||||||
|
// =============================================================================
|
||||||
|
namespace LedColor {
|
||||||
|
constexpr uint32_t OFF = 0x000000;
|
||||||
|
constexpr uint32_t BLE_SCANNING = 0x0000FF; ///< Blue pulsing
|
||||||
|
constexpr uint32_t BLE_CONNECTED= 0x00FF00; ///< Solid green
|
||||||
|
constexpr uint32_t BLE_ERROR = 0xFF0000; ///< Red
|
||||||
|
constexpr uint32_t WIFI_AP = 0xFF8800; ///< Amber
|
||||||
|
constexpr uint32_t WIFI_STA = 0x00FFFF; ///< Cyan
|
||||||
|
constexpr uint32_t OTA = 0xFF00FF; ///< Magenta pulsing
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utility: readable name for a DataField
|
||||||
|
// =============================================================================
|
||||||
|
inline const char* fieldName(DataField f) {
|
||||||
|
switch (f) {
|
||||||
|
case DataField::LAT: return "Latitude";
|
||||||
|
case DataField::LON: return "Longitude";
|
||||||
|
case DataField::SOG: return "SOG";
|
||||||
|
case DataField::COG: return "COG";
|
||||||
|
case DataField::STW: return "STW";
|
||||||
|
case DataField::HDG_MAG: return "Heading";
|
||||||
|
case DataField::DEPTH: return "Depth";
|
||||||
|
case DataField::AWS: return "AWS";
|
||||||
|
case DataField::AWA: return "AWA";
|
||||||
|
case DataField::TWS: return "TWS";
|
||||||
|
case DataField::TWA: return "TWA";
|
||||||
|
case DataField::TWD: return "TWD";
|
||||||
|
case DataField::AP_MODE: return "AP Mode";
|
||||||
|
case DataField::AP_TARGET: return "AP Target";
|
||||||
|
case DataField::AP_RUDDER: return "Rudder";
|
||||||
|
case DataField::VMG: return "VMG";
|
||||||
|
case DataField::POLAR_PCT: return "Polar %";
|
||||||
|
case DataField::TARGET_STW:return "Tgt STW";
|
||||||
|
case DataField::UPTIME: return "Uptime";
|
||||||
|
case DataField::WIFI_MODE: return "WiFi Mode";
|
||||||
|
case DataField::FREE_HEAP: return "Free Heap";
|
||||||
|
default: return "---";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const char* fieldUnit(DataField f) {
|
||||||
|
switch (f) {
|
||||||
|
case DataField::LAT:
|
||||||
|
case DataField::LON:
|
||||||
|
case DataField::COG:
|
||||||
|
case DataField::HDG_MAG:
|
||||||
|
case DataField::TWD:
|
||||||
|
case DataField::AP_TARGET:
|
||||||
|
case DataField::AP_RUDDER:
|
||||||
|
case DataField::AWA:
|
||||||
|
case DataField::TWA: return "°";
|
||||||
|
case DataField::SOG:
|
||||||
|
case DataField::STW:
|
||||||
|
case DataField::AWS:
|
||||||
|
case DataField::TWS:
|
||||||
|
case DataField::VMG:
|
||||||
|
case DataField::TARGET_STW:return "kn";
|
||||||
|
case DataField::DEPTH: return "m";
|
||||||
|
case DataField::POLAR_PCT: return "%";
|
||||||
|
case DataField::UPTIME: return "s";
|
||||||
|
case DataField::FREE_HEAP: return "B";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file ConfigManager.h
|
||||||
|
* @brief Persistent storage of application configuration using LittleFS + JSON.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Load/save AppConfig (pages, tiles, WiFi credentials) to /config.json
|
||||||
|
* - Provide typed accessors for individual fields
|
||||||
|
* - Expose helpers for the web UI to read/write pages as JSON fragments
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Config.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
class ConfigManager {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Initialise LittleFS and load configuration from disk.
|
||||||
|
* Must be called before any other method.
|
||||||
|
* @return true on success, false if LittleFS mount failed.
|
||||||
|
*/
|
||||||
|
bool begin();
|
||||||
|
|
||||||
|
/** @brief Access the live configuration object (read-only). */
|
||||||
|
const AppConfig& config() const { return _cfg; }
|
||||||
|
|
||||||
|
/** @brief Write a complete AppConfig and persist it immediately. */
|
||||||
|
bool save(const AppConfig& cfg);
|
||||||
|
|
||||||
|
/** @brief Persist the current in-memory config to disk. */
|
||||||
|
bool persist();
|
||||||
|
|
||||||
|
// ── Page helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @brief Return the currently active page index. */
|
||||||
|
uint8_t activePage() const { return _cfg.activePage; }
|
||||||
|
|
||||||
|
/** @brief Switch active page; persists. */
|
||||||
|
bool setActivePage(uint8_t index);
|
||||||
|
|
||||||
|
/** @brief Advance to the next page (wraps around). */
|
||||||
|
void nextPage();
|
||||||
|
|
||||||
|
/** @brief Go back to the previous page (wraps around). */
|
||||||
|
void prevPage();
|
||||||
|
|
||||||
|
/** @brief Update a single tile in a given page; persists. */
|
||||||
|
bool setTile(uint8_t page, uint8_t tile, DataField field, const char* label = nullptr);
|
||||||
|
|
||||||
|
// ── JSON serialisation for the web UI ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Serialise the full configuration to a JSON string.
|
||||||
|
* @param[out] out Destination String.
|
||||||
|
*/
|
||||||
|
void toJson(String& out) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse a JSON string produced by the web UI and merge it into
|
||||||
|
* the current config. Persists on success.
|
||||||
|
* @return true if parsing and saving succeeded.
|
||||||
|
*/
|
||||||
|
bool fromJson(const String& json);
|
||||||
|
|
||||||
|
// ── Factory reset ─────────────────────────────────────────────────────────
|
||||||
|
void resetToDefaults();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr const char* CONFIG_PATH = "/config.json";
|
||||||
|
|
||||||
|
AppConfig _cfg;
|
||||||
|
|
||||||
|
bool _load();
|
||||||
|
bool _write();
|
||||||
|
void _applyDefaults();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singleton accessor
|
||||||
|
extern ConfigManager configManager;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file DisplayManager.h
|
||||||
|
* @brief E-Ink display driver abstraction using GxEPD2_BW.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Manage the 400×300 Waveshare 4.2" GxEPD2 display via SPI.
|
||||||
|
* - Render a 2×2 tile grid; each tile shows one data field.
|
||||||
|
* - Perform partial refresh for numeric value updates (fast, no flicker).
|
||||||
|
* - Perform full refresh on page change or periodically to prevent ghosting.
|
||||||
|
* - Show a splash/boot screen, BLE status overlay and AP-mode notice.
|
||||||
|
*
|
||||||
|
* Partial refresh strategy:
|
||||||
|
* Each tile's "value area" (excluding the fixed label and border) is
|
||||||
|
* re-drawn with partial update. The borders and labels are only redrawn
|
||||||
|
* during a full refresh, saving ~250 ms per cycle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Config.h"
|
||||||
|
#include <GxEPD2_BW.h>
|
||||||
|
#include <Fonts/FreeMonoBold24pt7b.h>
|
||||||
|
#include <Fonts/FreeMonoBold12pt7b.h>
|
||||||
|
#include <Fonts/FreeMono9pt7b.h>
|
||||||
|
|
||||||
|
// ── Display model — Waveshare 4.2" mono GDEY042T81 (400×300) ─────────────────
|
||||||
|
// Adjust the model constant if your display differs.
|
||||||
|
using DisplayType = GxEPD2_BW<GxEPD2_420_GDEY042T81,
|
||||||
|
GxEPD2_420_GDEY042T81::HEIGHT>;
|
||||||
|
|
||||||
|
class DisplayManager {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Initialise the SPI bus and display. Shows the splash screen.
|
||||||
|
* Must be called from setup() before any render methods.
|
||||||
|
*/
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Main render call — invoke from loop().
|
||||||
|
* Decides between partial and full refresh based on:
|
||||||
|
* - Whether data has changed since the last render.
|
||||||
|
* - Whether FULL_REFRESH_INTERVAL_MS has elapsed.
|
||||||
|
* - An explicit forceFullRefresh() request.
|
||||||
|
* @param state Current boat data snapshot.
|
||||||
|
* @param cfg Current page configuration.
|
||||||
|
*/
|
||||||
|
void render(const BoatState& state, const PageConfig& cfg);
|
||||||
|
|
||||||
|
/** @brief Request a full (slow) refresh on the next render() call. */
|
||||||
|
void forceFullRefresh() { _needFullRefresh = true; }
|
||||||
|
|
||||||
|
/** @brief Show a BLE scanning / connecting notice. */
|
||||||
|
void showBleStatus(AppState::BleStatus status);
|
||||||
|
|
||||||
|
/** @brief Show the AP mode access screen with SSID and IP. */
|
||||||
|
void showApScreen(const char* ssid, const char* ip);
|
||||||
|
|
||||||
|
/** @brief Show the OTA progress screen. */
|
||||||
|
void showOtaScreen(uint8_t percent);
|
||||||
|
|
||||||
|
/** @brief Show the passkey entry prompt. */
|
||||||
|
void showPasskeyPrompt(uint32_t pin);
|
||||||
|
|
||||||
|
/** @brief Hibernate the display to save power (full blank). */
|
||||||
|
void hibernate();
|
||||||
|
|
||||||
|
// Internal access for GxEPD2
|
||||||
|
DisplayType& epd() { return _display; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
DisplayType _display {
|
||||||
|
GxEPD2_420_GDEY042T81(Pins::EPD_CS,
|
||||||
|
Pins::EPD_DC,
|
||||||
|
Pins::EPD_RST,
|
||||||
|
Pins::EPD_BUSY)
|
||||||
|
};
|
||||||
|
|
||||||
|
bool _initialised = false;
|
||||||
|
bool _needFullRefresh = true;
|
||||||
|
uint32_t _lastFullRefresh = 0;
|
||||||
|
uint32_t _lastRender = 0;
|
||||||
|
|
||||||
|
// Cached value strings per tile to detect changes
|
||||||
|
char _prevValue[Display::TILE_COUNT][16];
|
||||||
|
|
||||||
|
// ── Drawing helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _drawSplash();
|
||||||
|
void _drawGrid();
|
||||||
|
void _drawTile(uint8_t idx, const TileConfig& tile, const BoatState& state, bool partial);
|
||||||
|
void _drawTileLabel(uint8_t idx, const TileConfig& tile);
|
||||||
|
void _drawTileValue(uint8_t idx, const char* value, bool partial);
|
||||||
|
|
||||||
|
// Compute the pixel rect for a tile
|
||||||
|
struct Rect { uint16_t x, y, w, h; };
|
||||||
|
Rect _tileRect(uint8_t idx) const;
|
||||||
|
Rect _valuRect(uint8_t idx) const; ///< Sub-rect for the value only
|
||||||
|
|
||||||
|
// Extract a display string from the boat state for a given field
|
||||||
|
void _fieldToString(DataField field, const BoatState& state,
|
||||||
|
char* out, size_t outLen) const;
|
||||||
|
|
||||||
|
// Centre text in a rect
|
||||||
|
void _drawCentred(const char* text, uint16_t x, uint16_t y,
|
||||||
|
uint16_t w, uint16_t h);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singleton accessor
|
||||||
|
extern DisplayManager displayManager;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file StatusLed.h
|
||||||
|
* @brief NeoPixel RGB LED status indicator.
|
||||||
|
*
|
||||||
|
* Encodes BLE and WiFi operational state as colour + blink pattern:
|
||||||
|
*
|
||||||
|
* BLE Scanning → Blue pulse (on 100 ms / off 900 ms)
|
||||||
|
* BLE Connecting → Blue pulse (on 300 ms / off 300 ms)
|
||||||
|
* BLE Connected → Solid green
|
||||||
|
* BLE Disconnected → Red blink (on 200 ms / off 800 ms)
|
||||||
|
* BLE Error → Solid red
|
||||||
|
* WiFi AP active → Amber pulse (on 500 ms / off 500 ms)
|
||||||
|
* OTA in progress → Magenta rapid pulse
|
||||||
|
* All off → Black
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Config.h"
|
||||||
|
#include <Adafruit_NeoPixel.h>
|
||||||
|
|
||||||
|
class StatusLed {
|
||||||
|
public:
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Call from loop() to update the LED animation.
|
||||||
|
* Non-blocking — uses millis() internally.
|
||||||
|
*/
|
||||||
|
void update(AppState::BleStatus ble, AppState::WifiStatus wifi, bool ota = false);
|
||||||
|
|
||||||
|
/** @brief Immediately set a solid colour (overrides animation until next update()). */
|
||||||
|
void setColor(uint32_t color, uint8_t brightness = 40);
|
||||||
|
|
||||||
|
/** @brief Turn off the LED. */
|
||||||
|
void off();
|
||||||
|
|
||||||
|
private:
|
||||||
|
Adafruit_NeoPixel _strip{STATUS_LED_COUNT, STATUS_LED_PIN, NEO_GRB + NEO_KHZ800};
|
||||||
|
|
||||||
|
uint32_t _lastToggle = 0;
|
||||||
|
bool _ledOn = false;
|
||||||
|
|
||||||
|
AppState::BleStatus _prevBle = AppState::BleStatus::IDLE;
|
||||||
|
AppState::WifiStatus _prevWifi = AppState::WifiStatus::OFF;
|
||||||
|
bool _prevOta = false;
|
||||||
|
|
||||||
|
void _pulse(uint32_t color, uint32_t onMs, uint32_t offMs, uint8_t brightness = 40);
|
||||||
|
void _solid(uint32_t color, uint8_t brightness = 40);
|
||||||
|
};
|
||||||
|
|
||||||
|
extern StatusLed statusLed;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file TouchManager.h
|
||||||
|
* @brief Capacitive touch button handler for ESP32-S3 native touch pins.
|
||||||
|
*
|
||||||
|
* Supports three touch pads:
|
||||||
|
* TOUCH_NEXT — Advance to the next display page
|
||||||
|
* TOUCH_PREV — Go to the previous display page
|
||||||
|
* TOUCH_ACTION — Trigger a full e-ink refresh
|
||||||
|
*
|
||||||
|
* Combo detection:
|
||||||
|
* TOUCH_NEXT + TOUCH_PREV held for AP_COMBO_MS → enter WiFi AP mode
|
||||||
|
*
|
||||||
|
* The ESP32-S3 touch peripheral returns a raw capacitance value; a reading
|
||||||
|
* *below* the threshold means the pad is being touched (lower = more contact).
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Config.h"
|
||||||
|
|
||||||
|
class TouchManager {
|
||||||
|
public:
|
||||||
|
/** @brief Initialise touch pads. Call from setup(). */
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
/** @brief Poll all pads; must be called from loop(). */
|
||||||
|
void update();
|
||||||
|
|
||||||
|
// ── Event accessors (consumed once per call) ──────────────────────────────
|
||||||
|
bool nextPressed() { bool v = _evNext; _evNext = false; return v; }
|
||||||
|
bool prevPressed() { bool v = _evPrev; _evPrev = false; return v; }
|
||||||
|
bool actionPressed() { bool v = _evAction; _evAction = false; return v; }
|
||||||
|
bool apCombo() { bool v = _evApCombo; _evApCombo = false; return v; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Per-pad state machine
|
||||||
|
struct PadState {
|
||||||
|
uint8_t pin;
|
||||||
|
bool wasDown = false;
|
||||||
|
uint32_t pressedAt = 0;
|
||||||
|
bool longFired = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
PadState _pads[3] = {
|
||||||
|
{Pins::TOUCH_NEXT},
|
||||||
|
{Pins::TOUCH_PREV},
|
||||||
|
{Pins::TOUCH_ACTION}
|
||||||
|
};
|
||||||
|
|
||||||
|
uint32_t _lastDebounce = 0;
|
||||||
|
uint32_t _comboPressAt = 0;
|
||||||
|
bool _comboActive = false;
|
||||||
|
|
||||||
|
bool _evNext = false;
|
||||||
|
bool _evPrev = false;
|
||||||
|
bool _evAction = false;
|
||||||
|
bool _evApCombo = false;
|
||||||
|
|
||||||
|
bool _isTouched(uint8_t pin) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
extern TouchManager touchManager;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file WifiManager.h
|
||||||
|
* @brief WiFi Access Point + Async Web Server for configuration and OTA.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Start a WPA2 Access Point with credentials from AppConfig.
|
||||||
|
* - Host a lightweight web UI on port 80:
|
||||||
|
* GET / → configuration page (tile/page editor)
|
||||||
|
* GET /api/config → current config as JSON
|
||||||
|
* POST /api/config → apply new config (JSON body)
|
||||||
|
* POST /api/ble/cmd → forward BLE command (restart, wifi_sta, wifi_ap)
|
||||||
|
* GET /api/state → current BoatState as JSON (live data snapshot)
|
||||||
|
* POST /update → OTA firmware upload
|
||||||
|
* - Signal OTA progress to the display.
|
||||||
|
* - Accept a BLE passkey submission from the web UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Config.h"
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
|
||||||
|
class WifiManager {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Start the AP and the async web server.
|
||||||
|
* Must be called from setup() or on button combo.
|
||||||
|
*/
|
||||||
|
void begin(const char* ssid, const char* password);
|
||||||
|
|
||||||
|
/** @brief Stop the AP and server (e.g. when switching back to BLE-only). */
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
/** @brief Returns true when the AP is active and the server is running. */
|
||||||
|
bool isActive() const { return _active; }
|
||||||
|
|
||||||
|
/** @brief IP address of the AP interface (always 192.168.4.1). */
|
||||||
|
static constexpr const char* AP_IP = "192.168.4.1";
|
||||||
|
|
||||||
|
/** @brief Call from loop() — no-op currently, kept for future use. */
|
||||||
|
void update();
|
||||||
|
|
||||||
|
private:
|
||||||
|
AsyncWebServer _server{80};
|
||||||
|
bool _active = false;
|
||||||
|
|
||||||
|
void _setupRoutes();
|
||||||
|
|
||||||
|
// Route handlers
|
||||||
|
void _handleRoot (AsyncWebServerRequest*);
|
||||||
|
void _handleGetConfig (AsyncWebServerRequest*);
|
||||||
|
void _handlePostConfig (AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t);
|
||||||
|
void _handleGetState (AsyncWebServerRequest*);
|
||||||
|
void _handleBleCmd (AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t);
|
||||||
|
void _handlePasskey (AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t);
|
||||||
|
void _handleOtaUpload (AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool);
|
||||||
|
void _handleNotFound (AsyncWebServerRequest*);
|
||||||
|
|
||||||
|
// Serve the embedded HTML (stored in PROGMEM)
|
||||||
|
static const char INDEX_HTML[] PROGMEM;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singleton accessor
|
||||||
|
extern WifiManager wifiManager;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Marine Display — Custom Partition Table
|
||||||
|
# Flash size: 8 MB (Waveshare ESP32-S3-Zero has 8 MB flash)
|
||||||
|
#
|
||||||
|
# Name, Type, SubType, Offset, Size, Flags
|
||||||
|
nvs, data, nvs, 0x9000, 0x5000,
|
||||||
|
otadata, data, ota, 0xe000, 0x2000,
|
||||||
|
app0, app, ota_0, 0x10000, 0x180000,
|
||||||
|
app1, app, ota_1, 0x190000, 0x180000,
|
||||||
|
littlefs, data, spiffs, 0x310000, 0x100000,
|
||||||
|
coredump, data, coredump, 0x410000, 0x10000,
|
||||||
|
@@ -0,0 +1,98 @@
|
|||||||
|
; =============================================================================
|
||||||
|
; Marine Navigation Display — PlatformIO Configuration
|
||||||
|
; Target: Waveshare ESP32-S3-Zero + 4.2" E-Ink (400x300)
|
||||||
|
; =============================================================================
|
||||||
|
|
||||||
|
[platformio]
|
||||||
|
default_envs = esp32s3_marine_display
|
||||||
|
|
||||||
|
; -----------------------------------------------------------------------------
|
||||||
|
; Common settings shared across environments
|
||||||
|
; -----------------------------------------------------------------------------
|
||||||
|
[env]
|
||||||
|
platform = espressif32 @ ^6.9.0
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
monitor_filters = esp32_exception_decoder, time
|
||||||
|
|
||||||
|
; -----------------------------------------------------------------------------
|
||||||
|
; Production environment
|
||||||
|
; -----------------------------------------------------------------------------
|
||||||
|
[env:esp32s3_marine_display]
|
||||||
|
board = esp32-s3-devkitm-1
|
||||||
|
|
||||||
|
; Custom partition table enabling:
|
||||||
|
; - OTA_0 / OTA_1 (each 1.5 MB)
|
||||||
|
; - LittleFS (1 MB) for config + web assets
|
||||||
|
board_build.partitions = partitions/custom_ota.csv
|
||||||
|
|
||||||
|
; Flash mode: QIO for speed, 80 MHz
|
||||||
|
board_build.flash_mode = qio
|
||||||
|
board_build.f_flash = 80000000L
|
||||||
|
|
||||||
|
; Enable PSRAM if available on the S3-Zero variant
|
||||||
|
build_flags =
|
||||||
|
-D ARDUINO_USB_MODE=1
|
||||||
|
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||||
|
-D CONFIG_SPIRAM_USE_MALLOC=1
|
||||||
|
; ── Application build flags ──────────────────────────────────────────
|
||||||
|
-D APP_VERSION=\"1.0.0\"
|
||||||
|
; ── Display ──────────────────────────────────────────────────────────
|
||||||
|
-D DISPLAY_WIDTH=400
|
||||||
|
-D DISPLAY_HEIGHT=300
|
||||||
|
; ── BLE UUIDs (Marine Gateway protocol) ──────────────────────────────
|
||||||
|
-D BLE_DEVICE_NAME=\"MarineGateway\"
|
||||||
|
; ── Touch pins (ESP32-S3 native capacitive touch) ────────────────────
|
||||||
|
-D TOUCH_PIN_NEXT=1
|
||||||
|
-D TOUCH_PIN_PREV=2
|
||||||
|
-D TOUCH_PIN_ACTION=3
|
||||||
|
; ── NeoPixel status LED ───────────────────────────────────────────────
|
||||||
|
-D STATUS_LED_PIN=21
|
||||||
|
-D STATUS_LED_COUNT=1
|
||||||
|
; ── Misc ──────────────────────────────────────────────────────────────
|
||||||
|
-D CORE_DEBUG_LEVEL=3
|
||||||
|
-DCONFIG_NIMBLE_ENABLED=1
|
||||||
|
|
||||||
|
; LittleFS filesystem image (contains web UI assets)
|
||||||
|
board_build.filesystem = littlefs
|
||||||
|
|
||||||
|
; Upload filesystem image: pio run -t uploadfs
|
||||||
|
extra_scripts = pre:scripts/gen_littlefs.py
|
||||||
|
|
||||||
|
; ── Libraries ────────────────────────────────────────────────────────────────
|
||||||
|
lib_deps =
|
||||||
|
; E-Ink display driver (GxEPD2) — latest stable
|
||||||
|
zinggjm/GxEPD2 @ ^1.6.3
|
||||||
|
|
||||||
|
; Adafruit GFX (required by GxEPD2)
|
||||||
|
adafruit/Adafruit GFX Library @ ^1.11.11
|
||||||
|
|
||||||
|
; JSON parsing/serialisation
|
||||||
|
bblanchon/ArduinoJson @ ^7.3.1
|
||||||
|
|
||||||
|
; BLE stack — NimBLE (much lighter than Bluedroid)
|
||||||
|
h2zero/NimBLE-Arduino @ ^2.3.2
|
||||||
|
|
||||||
|
; Async HTTP server for the web UI + OTA
|
||||||
|
ESP32Async/ESPAsyncWebServer @ ^3.7.4
|
||||||
|
ESP32Async/AsyncTCP @ ^3.3.7
|
||||||
|
|
||||||
|
; NeoPixel status LED
|
||||||
|
adafruit/Adafruit NeoPixel @ ^1.12.3
|
||||||
|
|
||||||
|
; ── Upload / Debug ───────────────────────────────────────────────────────────
|
||||||
|
upload_speed = 921600
|
||||||
|
; OTA upload (uncomment and set IP when device is on the network):
|
||||||
|
; upload_protocol = espota
|
||||||
|
; upload_port = 192.168.4.1
|
||||||
|
|
||||||
|
; -----------------------------------------------------------------------------
|
||||||
|
; Development / verbose environment (same board, extra logging)
|
||||||
|
; -----------------------------------------------------------------------------
|
||||||
|
[env:esp32s3_marine_display_dev]
|
||||||
|
extends = env:esp32s3_marine_display
|
||||||
|
build_flags =
|
||||||
|
${env:esp32s3_marine_display.build_flags}
|
||||||
|
-D CORE_DEBUG_LEVEL=5
|
||||||
|
-D DEBUG_BLE=1
|
||||||
|
-D DEBUG_DISPLAY=1
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
; ============================================================
|
||||||
|
; PlatformIO configuration – LittleFS test
|
||||||
|
; Boards: esp32s3_zero | esp32s3_n16r8v
|
||||||
|
; ============================================================
|
||||||
|
|
||||||
|
[platformio]
|
||||||
|
default_envs = esp32s3_zero
|
||||||
|
|
||||||
|
; ─────────────────────────────────────────
|
||||||
|
; Common settings shared by all envs
|
||||||
|
; ─────────────────────────────────────────
|
||||||
|
[env]
|
||||||
|
platform = espressif32@6
|
||||||
|
framework = arduino
|
||||||
|
|
||||||
|
; LittleFS filesystem upload tool
|
||||||
|
board_build.filesystem = littlefs
|
||||||
|
|
||||||
|
; Serial monitor speed
|
||||||
|
monitor_speed = 115200
|
||||||
|
|
||||||
|
monitor_rts = 0
|
||||||
|
monitor_dtr = 0
|
||||||
|
|
||||||
|
; Build flags common to all targets
|
||||||
|
build_flags =
|
||||||
|
-DCORE_DEBUG_LEVEL=5
|
||||||
|
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||||
|
|
||||||
|
lib_deps =
|
||||||
|
zinggjm/GxEPD2 @ ^1.5.5
|
||||||
|
bblanchon/ArduinoJson @ ^7.0.4
|
||||||
|
h2zero/NimBLE-Arduino @ ^2.4.0
|
||||||
|
ESP32Async/ESPAsyncWebServer @ ^3.6.0
|
||||||
|
adafruit/Adafruit NeoPixel @ ^1.12.0
|
||||||
|
|
||||||
|
; Pas de lib_deps : LittleFS est inclus dans espressif32@6 (framework-arduinoespressif32)
|
||||||
|
; #include <LittleFS.h> suffit
|
||||||
|
|
||||||
|
; ─────────────────────────────────────────
|
||||||
|
; Waveshare ESP32-S3-Zero
|
||||||
|
; Flash : 4 MB QIO / PSRAM : none
|
||||||
|
; ─────────────────────────────────────────
|
||||||
|
[env:esp32s3_zero]
|
||||||
|
board = esp32-s3-devkitm-1
|
||||||
|
board_build.flash_mode = qio
|
||||||
|
board_build.arduino.memory_type = qio_qspi
|
||||||
|
|
||||||
|
; Partition scheme with LittleFS (default_8MB has ota+littlefs)
|
||||||
|
board_build.partitions = default.csv
|
||||||
|
upload_speed = 921600
|
||||||
|
monitor_speed = 115200
|
||||||
|
|
||||||
|
board_upload.flash_size = 4MB
|
||||||
|
board_build.arduino.flash_size = 4MB
|
||||||
|
|
||||||
|
build_flags =
|
||||||
|
${env.build_flags}
|
||||||
|
-DBOARD_HAS_PSRAM
|
||||||
|
-DBOARD_ESP32S3_ZERO
|
||||||
|
-mfix-esp32-psram-cache-issue
|
||||||
|
|
||||||
|
; ─────────────────────────────────────────
|
||||||
|
; Generic ESP32-S3 N16R8V
|
||||||
|
; Flash : 16 MB QIO / PSRAM : 8 MB OPI
|
||||||
|
; ─────────────────────────────────────────
|
||||||
|
[env:esp32s3_n16r8v]
|
||||||
|
board = esp32-s3-devkitc-1
|
||||||
|
board_build.partitions = default_16MB.csv
|
||||||
|
|
||||||
|
board_upload.flash_size = 16MB
|
||||||
|
board_build.f_flash = 80000000L ; Force la flash à 80MHz
|
||||||
|
|
||||||
|
board_upload.flash_mode = dio
|
||||||
|
board_build.arduino.memory_type = dio_opi
|
||||||
|
board_build.flash_mode = dio
|
||||||
|
|
||||||
|
upload_speed = 921600
|
||||||
|
monitor_speed = 115200
|
||||||
|
build_flags =
|
||||||
|
${env.build_flags}
|
||||||
|
-DBOARD_HAS_PSRAM
|
||||||
|
-mfix-esp32-psram-cache-issue
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
gen_littlefs.py — PlatformIO pre-build script placeholder.
|
||||||
|
|
||||||
|
This script is referenced in platformio.ini under extra_scripts.
|
||||||
|
It is a no-op here because PlatformIO's built-in 'uploadfs' target
|
||||||
|
(pio run -t uploadfs) already handles LittleFS image creation from
|
||||||
|
the /data directory.
|
||||||
|
|
||||||
|
If you need to auto-generate files into /data before every build,
|
||||||
|
add your logic inside the pre_build() function below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
Import("env") # noqa: F821 — provided by PlatformIO's SCons environment
|
||||||
|
|
||||||
|
|
||||||
|
def pre_build(source, target, env):
|
||||||
|
"""Called before every firmware build."""
|
||||||
|
data_dir = os.path.join(env.subst("$PROJECT_DIR"), "data")
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
# Example: auto-generate a version file in the filesystem
|
||||||
|
version = env.GetProjectOption("build_flags", "")
|
||||||
|
version_file = os.path.join(data_dir, "version.txt")
|
||||||
|
with open(version_file, "w") as f:
|
||||||
|
import datetime
|
||||||
|
f.write(f"Built: {datetime.datetime.utcnow().isoformat()}Z\n")
|
||||||
|
|
||||||
|
|
||||||
|
env.AddPreAction("buildprog", pre_build)
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/**
|
||||||
|
* @file BleManager.cpp
|
||||||
|
* @brief BLE Central implementation using NimBLE-Arduino.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
* - NimBLE runs its own FreeRTOS task; callbacks arrive on that task's stack.
|
||||||
|
* - All writes to _state are protected by _mux (portENTER/EXIT_CRITICAL).
|
||||||
|
* - Reconnection is scheduled from the main loop() via _reconnectAt to avoid
|
||||||
|
* blocking inside BLE callbacks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "BleManager.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
BleManager bleManager;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers — static notify callback trampolines
|
||||||
|
// NimBLE requires a free function or a capturing lambda; we use lambdas that
|
||||||
|
// capture the singleton pointer.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
static void navNotifyCB(NimBLERemoteCharacteristic* c, uint8_t* d, size_t l, bool n) {
|
||||||
|
bleManager._onNavNotify(c, d, l, n);
|
||||||
|
}
|
||||||
|
static void windNotifyCB(NimBLERemoteCharacteristic* c, uint8_t* d, size_t l, bool n) {
|
||||||
|
bleManager._onWindNotify(c, d, l, n);
|
||||||
|
}
|
||||||
|
static void autopilotNotifyCB(NimBLERemoteCharacteristic* c, uint8_t* d, size_t l, bool n) {
|
||||||
|
bleManager._onAutopilotNotify(c, d, l, n);
|
||||||
|
}
|
||||||
|
static void perfNotifyCB(NimBLERemoteCharacteristic* c, uint8_t* d, size_t l, bool n) {
|
||||||
|
bleManager._onPerfNotify(c, d, l, n);
|
||||||
|
}
|
||||||
|
static void adminNotifyCB(NimBLERemoteCharacteristic* c, uint8_t* d, size_t l, bool n) {
|
||||||
|
bleManager._onAdminNotify(c, d, l, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void BleManager::begin() {
|
||||||
|
Serial.println("[BLE] Initialising NimBLE stack");
|
||||||
|
NimBLEDevice::init(""); // No local name needed for a pure client
|
||||||
|
NimBLEDevice::setSecurityAuth(BLE_SM_PAIR_AUTHREQ_SC |
|
||||||
|
BLE_SM_PAIR_AUTHREQ_MITM |
|
||||||
|
BLE_SM_PAIR_AUTHREQ_BOND);
|
||||||
|
NimBLEDevice::setSecurityIOCap(BLE_HS_IO_KEYBOARD_ONLY);
|
||||||
|
|
||||||
|
_status = AppState::BleStatus::IDLE;
|
||||||
|
_startScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::update() {
|
||||||
|
// Handle deferred connect (flagged from onResult callback)
|
||||||
|
if (_doConnect && _targetFound) {
|
||||||
|
_doConnect = false;
|
||||||
|
_status = AppState::BleStatus::CONNECTING;
|
||||||
|
if (!_connectToServer(_targetAddr)) {
|
||||||
|
Serial.println("[BLE] Connection failed — scheduling reconnect");
|
||||||
|
_scheduleReconnect();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scheduled reconnect
|
||||||
|
if (!isConnected() && _reconnectAt > 0 && millis() >= _reconnectAt) {
|
||||||
|
_reconnectAt = 0;
|
||||||
|
Serial.println("[BLE] Reconnect attempt — restarting scan");
|
||||||
|
_startScan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::getBoatState(BoatState& dst) const {
|
||||||
|
portENTER_CRITICAL(&_mux);
|
||||||
|
dst = _state;
|
||||||
|
portEXIT_CRITICAL(&_mux);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BleManager::isConnected() const {
|
||||||
|
return (_pClient && _pClient->isConnected());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command senders ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
bool BleManager::sendAutopilotCmd(const char* command) {
|
||||||
|
if (!isConnected()) return false;
|
||||||
|
|
||||||
|
auto* svc = _pClient->getService(BleUUID::AUTOPILOT_SVC);
|
||||||
|
if (!svc) return false;
|
||||||
|
auto* chr = svc->getCharacteristic(BleUUID::AUTOPILOT_CMD);
|
||||||
|
if (!chr) return false;
|
||||||
|
|
||||||
|
String json = "{\"command\":\"";
|
||||||
|
json += command;
|
||||||
|
json += "\"}";
|
||||||
|
return chr->writeValue(json.c_str(), json.length(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BleManager::sendAdminCmd(const char* jsonCmd) {
|
||||||
|
if (!isConnected()) return false;
|
||||||
|
|
||||||
|
auto* svc = _pClient->getService(BleUUID::ADMIN_SVC);
|
||||||
|
if (!svc) return false;
|
||||||
|
auto* chr = svc->getCharacteristic(BleUUID::ADMIN_CMD);
|
||||||
|
if (!chr) return false;
|
||||||
|
|
||||||
|
return chr->writeValue(jsonCmd, strlen(jsonCmd), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BleManager::sendRestart() {
|
||||||
|
return sendAdminCmd("{\"command\":\"restart\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BleManager::sendWifiSta(const char* ssid, const char* password) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["command"] = "wifi_sta";
|
||||||
|
doc["ssid"] = ssid;
|
||||||
|
doc["password"] = password;
|
||||||
|
String out;
|
||||||
|
serializeJson(doc, out);
|
||||||
|
return sendAdminCmd(out.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BleManager::sendWifiAp(const char* ssid, const char* password) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["command"] = "wifi_ap";
|
||||||
|
doc["ssid"] = ssid;
|
||||||
|
doc["password"] = password;
|
||||||
|
String out;
|
||||||
|
serializeJson(doc, out);
|
||||||
|
return sendAdminCmd(out.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::providePasskey(uint32_t pin) {
|
||||||
|
_passkey = pin;
|
||||||
|
_passkeyPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NimBLEScanCallbacks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void BleManager::onResult(const NimBLEAdvertisedDevice* device) {
|
||||||
|
Serial.printf("[BLE] Found device: %s name: %s\n",
|
||||||
|
device->getAddress().toString().c_str(),
|
||||||
|
device->getName().c_str());
|
||||||
|
|
||||||
|
if (device->getName() == BleConfig::DEVICE_NAME) {
|
||||||
|
Serial.println("[BLE] Target found — stopping scan");
|
||||||
|
NimBLEDevice::getScan()->stop();
|
||||||
|
_targetAddr = device->getAddress();
|
||||||
|
_targetFound = true;
|
||||||
|
_doConnect = true;
|
||||||
|
_status = AppState::BleStatus::CONNECTING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::onScanEnd(const NimBLEScanResults& results, int reason) {
|
||||||
|
if (!_targetFound && !isConnected()) {
|
||||||
|
Serial.println("[BLE] Scan ended without target — retrying");
|
||||||
|
_scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NimBLEClientCallbacks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void BleManager::onConnect(NimBLEClient* pClient) {
|
||||||
|
Serial.println("[BLE] Connected to Marine Gateway");
|
||||||
|
_status = AppState::BleStatus::CONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::onDisconnect(NimBLEClient* pClient, int reason) {
|
||||||
|
Serial.printf("[BLE] Disconnected, reason=%d\n", reason);
|
||||||
|
_status = AppState::BleStatus::DISCONNECTED;
|
||||||
|
_targetFound = false;
|
||||||
|
_scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BleManager::onConfirmPasskey(NimBLEConnInfo& connInfo, uint32_t pin) {
|
||||||
|
Serial.printf("[BLE] Gateway requests passkey confirmation. PIN displayed: %06lu\n", (unsigned long)pin);
|
||||||
|
// Wait up to 30 s for the web UI / Serial to provide the key
|
||||||
|
uint32_t deadline = millis() + 30000;
|
||||||
|
while (!_passkeyPending && millis() < deadline) {
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
if (_passkeyPending) {
|
||||||
|
_passkeyPending = false;
|
||||||
|
bool match = (_passkey == pin);
|
||||||
|
Serial.printf("[BLE] Passkey match: %s\n", match ? "YES" : "NO");
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
// Timeout — accept the PIN directly (user must have entered it on the device)
|
||||||
|
Serial.println("[BLE] Passkey timeout — auto-confirming");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::onAuthenticationComplete(NimBLEConnInfo& connInfo) {
|
||||||
|
if (!connInfo.isEncrypted()) {
|
||||||
|
Serial.println("[BLE] Encryption failed — disconnecting");
|
||||||
|
_pClient->disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Serial.println("[BLE] Authentication complete — subscribing to characteristics");
|
||||||
|
if (!_subscribeAll()) {
|
||||||
|
Serial.println("[BLE] Subscription failed — disconnecting");
|
||||||
|
_pClient->disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Private — connection and subscription
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
bool BleManager::_connectToServer(const NimBLEAddress& addr) {
|
||||||
|
_pClient = NimBLEDevice::createClient();
|
||||||
|
_pClient->setClientCallbacks(this, false);
|
||||||
|
_pClient->setConnectionParams(12, 12, 0, 51); // 15 ms interval, 510 ms timeout
|
||||||
|
_pClient->setConnectTimeout(10);
|
||||||
|
|
||||||
|
if (!_pClient->connect(addr)) {
|
||||||
|
Serial.println("[BLE] connect() failed");
|
||||||
|
NimBLEDevice::deleteClient(_pClient);
|
||||||
|
_pClient = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_pClient->secureConnection()) {
|
||||||
|
Serial.println("[BLE] Secure pairing failed");
|
||||||
|
_pClient->disconnect();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BleManager::_subscribeAll() {
|
||||||
|
struct SubEntry {
|
||||||
|
const char* svcUUID;
|
||||||
|
const char* chrUUID;
|
||||||
|
notify_callback cb;
|
||||||
|
};
|
||||||
|
|
||||||
|
SubEntry entries[] = {
|
||||||
|
{ BleUUID::NAV_SVC, BleUUID::NAV_DATA, navNotifyCB },
|
||||||
|
{ BleUUID::WIND_SVC, BleUUID::WIND_DATA, windNotifyCB },
|
||||||
|
{ BleUUID::AUTOPILOT_SVC, BleUUID::AUTOPILOT_DATA, autopilotNotifyCB },
|
||||||
|
{ BleUUID::PERF_SVC, BleUUID::PERF_DATA, perfNotifyCB },
|
||||||
|
{ BleUUID::ADMIN_SVC, BleUUID::ADMIN_DATA, adminNotifyCB },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (auto& e : entries) {
|
||||||
|
auto* svc = _pClient->getService(e.svcUUID);
|
||||||
|
if (!svc) {
|
||||||
|
Serial.printf("[BLE] Service %s not found\n", e.svcUUID);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto* chr = svc->getCharacteristic(e.chrUUID);
|
||||||
|
if (!chr) {
|
||||||
|
Serial.printf("[BLE] Characteristic %s not found\n", e.chrUUID);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chr->canNotify()) {
|
||||||
|
if (!chr->subscribe(true, e.cb)) {
|
||||||
|
Serial.printf("[BLE] Subscribe failed for %s\n", e.chrUUID);
|
||||||
|
} else {
|
||||||
|
Serial.printf("[BLE] Subscribed to %s\n", e.chrUUID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::_startScan() {
|
||||||
|
_targetFound = false;
|
||||||
|
_status = AppState::BleStatus::SCANNING;
|
||||||
|
|
||||||
|
NimBLEScan* pScan = NimBLEDevice::getScan();
|
||||||
|
pScan->setScanCallbacks(this, false);
|
||||||
|
pScan->setActiveScan(true);
|
||||||
|
pScan->setInterval(100);
|
||||||
|
pScan->setWindow(99);
|
||||||
|
pScan->start(BleConfig::SCAN_DURATION, false);
|
||||||
|
|
||||||
|
Serial.printf("[BLE] Scanning for \"%s\" (%u s)\n",
|
||||||
|
BleConfig::DEVICE_NAME, BleConfig::SCAN_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::_scheduleReconnect() {
|
||||||
|
_reconnectAt = millis() + BleConfig::RECONNECT_MS;
|
||||||
|
_status = AppState::BleStatus::DISCONNECTED;
|
||||||
|
Serial.printf("[BLE] Reconnect scheduled in %u ms\n", BleConfig::RECONNECT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notify callbacks (run on NimBLE task)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void BleManager::_onNavNotify(NimBLERemoteCharacteristic*, uint8_t* data, size_t len, bool) {
|
||||||
|
String json((char*)data, len);
|
||||||
|
_parseNav(json.c_str());
|
||||||
|
}
|
||||||
|
void BleManager::_onWindNotify(NimBLERemoteCharacteristic*, uint8_t* data, size_t len, bool) {
|
||||||
|
_parseWind(String((char*)data, len).c_str());
|
||||||
|
}
|
||||||
|
void BleManager::_onAutopilotNotify(NimBLERemoteCharacteristic*, uint8_t* data, size_t len, bool) {
|
||||||
|
_parseAutopilot(String((char*)data, len).c_str());
|
||||||
|
}
|
||||||
|
void BleManager::_onPerfNotify(NimBLERemoteCharacteristic*, uint8_t* data, size_t len, bool) {
|
||||||
|
_parsePerf(String((char*)data, len).c_str());
|
||||||
|
}
|
||||||
|
void BleManager::_onAdminNotify(NimBLERemoteCharacteristic*, uint8_t* data, size_t len, bool) {
|
||||||
|
_parseAdmin(String((char*)data, len).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// JSON parsers — use a local JsonDocument to minimise stack pressure
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void BleManager::_parseNav(const char* json) {
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, json) != DeserializationError::Ok) return;
|
||||||
|
|
||||||
|
portENTER_CRITICAL(&_mux);
|
||||||
|
_state.nav.lat = doc["lat"] | NAN;
|
||||||
|
_state.nav.lon = doc["lon"] | NAN;
|
||||||
|
_state.nav.sog = doc["sog"] | NAN;
|
||||||
|
_state.nav.cog = doc["cog"] | NAN;
|
||||||
|
_state.nav.stw = doc["stw"] | NAN;
|
||||||
|
_state.nav.hdgMag = doc["hdg_mag"] | NAN;
|
||||||
|
_state.nav.depth = doc["depth"] | NAN;
|
||||||
|
_state.nav.updatedAt = millis();
|
||||||
|
portEXIT_CRITICAL(&_mux);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::_parseWind(const char* json) {
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, json) != DeserializationError::Ok) return;
|
||||||
|
|
||||||
|
portENTER_CRITICAL(&_mux);
|
||||||
|
_state.wind.aws = doc["aws"] | NAN;
|
||||||
|
_state.wind.awa = doc["awa"] | NAN;
|
||||||
|
_state.wind.tws = doc["tws"] | NAN;
|
||||||
|
_state.wind.twa = doc["twa"] | NAN;
|
||||||
|
_state.wind.twd = doc["twd"] | NAN;
|
||||||
|
_state.wind.updatedAt = millis();
|
||||||
|
portEXIT_CRITICAL(&_mux);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::_parseAutopilot(const char* json) {
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, json) != DeserializationError::Ok) return;
|
||||||
|
|
||||||
|
portENTER_CRITICAL(&_mux);
|
||||||
|
strlcpy(_state.autopilot.mode, doc["mode"] | "", sizeof(_state.autopilot.mode));
|
||||||
|
strlcpy(_state.autopilot.status, doc["status"] | "", sizeof(_state.autopilot.status));
|
||||||
|
_state.autopilot.headingTarget = doc["heading_target"] | NAN;
|
||||||
|
_state.autopilot.windTarget = doc["wind_target"] | NAN;
|
||||||
|
_state.autopilot.rudder = doc["rudder"] | NAN;
|
||||||
|
_state.autopilot.lockedHeading = doc["locked_heading"] | NAN;
|
||||||
|
_state.autopilot.updatedAt = millis();
|
||||||
|
portEXIT_CRITICAL(&_mux);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::_parsePerf(const char* json) {
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, json) != DeserializationError::Ok) return;
|
||||||
|
|
||||||
|
portENTER_CRITICAL(&_mux);
|
||||||
|
_state.perf.vmg = doc["vmg"] | NAN;
|
||||||
|
_state.perf.polarPct = doc["polar_pct"] | NAN;
|
||||||
|
_state.perf.targetStw = doc["target_stw"] | NAN;
|
||||||
|
_state.perf.polarLoaded = doc["polar_loaded"] | false;
|
||||||
|
_state.perf.updatedAt = millis();
|
||||||
|
portEXIT_CRITICAL(&_mux);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleManager::_parseAdmin(const char* json) {
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, json) != DeserializationError::Ok) return;
|
||||||
|
|
||||||
|
portENTER_CRITICAL(&_mux);
|
||||||
|
_state.admin.uptimeS = doc["uptime_s"] | (uint32_t)0;
|
||||||
|
_state.admin.datetimeUtc = doc["datetime_utc"] | (uint64_t)0;
|
||||||
|
strlcpy(_state.admin.wifiMode, doc["wifi_mode"] | "", sizeof(_state.admin.wifiMode));
|
||||||
|
strlcpy(_state.admin.wifiSSID, doc["wifi_ssid"] | "", sizeof(_state.admin.wifiSSID));
|
||||||
|
strlcpy(_state.admin.ip, doc["ip"] | "", sizeof(_state.admin.ip));
|
||||||
|
_state.admin.freeHeap = doc["free_heap"] | (uint32_t)0;
|
||||||
|
_state.admin.updatedAt = millis();
|
||||||
|
portEXIT_CRITICAL(&_mux);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* @file ConfigManager.cpp
|
||||||
|
* @brief Implementation of persistent configuration management via LittleFS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "ConfigManager.h"
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
ConfigManager configManager;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
bool ConfigManager::begin() {
|
||||||
|
if (!LittleFS.begin(true /* format on fail */)) {
|
||||||
|
Serial.println("[Config] ERROR: LittleFS mount failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Serial.println("[Config] LittleFS mounted");
|
||||||
|
|
||||||
|
if (!_load()) {
|
||||||
|
Serial.println("[Config] No valid config found — applying defaults");
|
||||||
|
_applyDefaults();
|
||||||
|
_write();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::save(const AppConfig& cfg) {
|
||||||
|
_cfg = cfg;
|
||||||
|
return _write();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::persist() {
|
||||||
|
return _write();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::setActivePage(uint8_t index) {
|
||||||
|
if (index >= _cfg.pageCount) return false;
|
||||||
|
_cfg.activePage = index;
|
||||||
|
return _write();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::nextPage() {
|
||||||
|
_cfg.activePage = (_cfg.activePage + 1) % _cfg.pageCount;
|
||||||
|
_write();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::prevPage() {
|
||||||
|
_cfg.activePage = (_cfg.activePage == 0)
|
||||||
|
? _cfg.pageCount - 1
|
||||||
|
: _cfg.activePage - 1;
|
||||||
|
_write();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::setTile(uint8_t page, uint8_t tile, DataField field, const char* label) {
|
||||||
|
if (page >= MAX_PAGES || tile >= Display::TILE_COUNT) return false;
|
||||||
|
_cfg.pages[page].tiles[tile].field = field;
|
||||||
|
if (label && strlen(label) < sizeof(TileConfig::label)) {
|
||||||
|
strlcpy(_cfg.pages[page].tiles[tile].label, label, sizeof(TileConfig::label));
|
||||||
|
}
|
||||||
|
return _write();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::toJson(String& out) const {
|
||||||
|
// Use a JsonDocument sized for the whole config
|
||||||
|
JsonDocument doc;
|
||||||
|
|
||||||
|
doc["activePage"] = _cfg.activePage;
|
||||||
|
doc["pageCount"] = _cfg.pageCount;
|
||||||
|
doc["apSSID"] = _cfg.apSSID;
|
||||||
|
// Do not serialise AP password to the web UI for security
|
||||||
|
|
||||||
|
JsonArray pages = doc["pages"].to<JsonArray>();
|
||||||
|
for (uint8_t p = 0; p < _cfg.pageCount; p++) {
|
||||||
|
JsonObject page = pages.add<JsonObject>();
|
||||||
|
page["name"] = _cfg.pages[p].name;
|
||||||
|
|
||||||
|
JsonArray tiles = page["tiles"].to<JsonArray>();
|
||||||
|
for (uint8_t t = 0; t < Display::TILE_COUNT; t++) {
|
||||||
|
JsonObject tile = tiles.add<JsonObject>();
|
||||||
|
tile["field"] = static_cast<uint8_t>(_cfg.pages[p].tiles[t].field);
|
||||||
|
tile["fieldName"] = fieldName(_cfg.pages[p].tiles[t].field);
|
||||||
|
tile["label"] = _cfg.pages[p].tiles[t].label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append available fields list so the web UI can build dropdowns
|
||||||
|
JsonArray fields = doc["availableFields"].to<JsonArray>();
|
||||||
|
for (uint8_t i = 0; i < static_cast<uint8_t>(DataField::_COUNT); i++) {
|
||||||
|
JsonObject f = fields.add<JsonObject>();
|
||||||
|
f["id"] = i;
|
||||||
|
f["name"] = fieldName(static_cast<DataField>(i));
|
||||||
|
f["unit"] = fieldUnit(static_cast<DataField>(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
serializeJson(doc, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::fromJson(const String& json) {
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, json);
|
||||||
|
if (err) {
|
||||||
|
Serial.printf("[Config] JSON parse error: %s\n", err.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.containsKey("apSSID")) {
|
||||||
|
strlcpy(_cfg.apSSID, doc["apSSID"] | _cfg.apSSID, sizeof(_cfg.apSSID));
|
||||||
|
}
|
||||||
|
if (doc.containsKey("apPassword")) {
|
||||||
|
strlcpy(_cfg.apPassword, doc["apPassword"] | _cfg.apPassword, sizeof(_cfg.apPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray pages = doc["pages"].as<JsonArray>();
|
||||||
|
if (pages) {
|
||||||
|
uint8_t pCount = 0;
|
||||||
|
for (JsonObject page : pages) {
|
||||||
|
if (pCount >= MAX_PAGES) break;
|
||||||
|
strlcpy(_cfg.pages[pCount].name, page["name"] | "", sizeof(PageConfig::name));
|
||||||
|
|
||||||
|
uint8_t tIdx = 0;
|
||||||
|
for (JsonObject tile : page["tiles"].as<JsonArray>()) {
|
||||||
|
if (tIdx >= Display::TILE_COUNT) break;
|
||||||
|
uint8_t fid = tile["field"] | 0;
|
||||||
|
if (fid < static_cast<uint8_t>(DataField::_COUNT)) {
|
||||||
|
_cfg.pages[pCount].tiles[tIdx].field = static_cast<DataField>(fid);
|
||||||
|
}
|
||||||
|
strlcpy(_cfg.pages[pCount].tiles[tIdx].label,
|
||||||
|
tile["label"] | "",
|
||||||
|
sizeof(TileConfig::label));
|
||||||
|
tIdx++;
|
||||||
|
}
|
||||||
|
pCount++;
|
||||||
|
}
|
||||||
|
if (pCount > 0) _cfg.pageCount = pCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.containsKey("activePage")) {
|
||||||
|
uint8_t ap = doc["activePage"];
|
||||||
|
if (ap < _cfg.pageCount) _cfg.activePage = ap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _write();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::resetToDefaults() {
|
||||||
|
_applyDefaults();
|
||||||
|
_write();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Private helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
bool ConfigManager::_load() {
|
||||||
|
if (!LittleFS.exists(CONFIG_PATH)) return false;
|
||||||
|
|
||||||
|
File f = LittleFS.open(CONFIG_PATH, "r");
|
||||||
|
if (!f) return false;
|
||||||
|
|
||||||
|
String json = f.readString();
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
// Re-use fromJson but do not re-persist to avoid re-entry
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, json) != DeserializationError::Ok) return false;
|
||||||
|
|
||||||
|
// --- activePage / pageCount ---
|
||||||
|
_cfg.activePage = doc["activePage"] | 0;
|
||||||
|
_cfg.pageCount = doc["pageCount"] | 1;
|
||||||
|
if (_cfg.pageCount == 0 || _cfg.pageCount > MAX_PAGES) _cfg.pageCount = 1;
|
||||||
|
if (_cfg.activePage >= _cfg.pageCount) _cfg.activePage = 0;
|
||||||
|
|
||||||
|
strlcpy(_cfg.apSSID, doc["apSSID"] | "MarineDisplay", sizeof(_cfg.apSSID));
|
||||||
|
strlcpy(_cfg.apPassword, doc["apPassword"] | "marine123", sizeof(_cfg.apPassword));
|
||||||
|
|
||||||
|
JsonArray pages = doc["pages"].as<JsonArray>();
|
||||||
|
uint8_t pIdx = 0;
|
||||||
|
for (JsonObject page : pages) {
|
||||||
|
if (pIdx >= MAX_PAGES) break;
|
||||||
|
strlcpy(_cfg.pages[pIdx].name, page["name"] | "", sizeof(PageConfig::name));
|
||||||
|
uint8_t tIdx = 0;
|
||||||
|
for (JsonObject tile : page["tiles"].as<JsonArray>()) {
|
||||||
|
if (tIdx >= Display::TILE_COUNT) break;
|
||||||
|
uint8_t fid = tile["field"] | 0;
|
||||||
|
_cfg.pages[pIdx].tiles[tIdx].field =
|
||||||
|
(fid < static_cast<uint8_t>(DataField::_COUNT))
|
||||||
|
? static_cast<DataField>(fid)
|
||||||
|
: DataField::NONE;
|
||||||
|
strlcpy(_cfg.pages[pIdx].tiles[tIdx].label,
|
||||||
|
tile["label"] | "",
|
||||||
|
sizeof(TileConfig::label));
|
||||||
|
tIdx++;
|
||||||
|
}
|
||||||
|
pIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[Config] Loaded %u page(s), active=%u\n", _cfg.pageCount, _cfg.activePage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::_write() {
|
||||||
|
File f = LittleFS.open(CONFIG_PATH, "w");
|
||||||
|
if (!f) {
|
||||||
|
Serial.println("[Config] ERROR: Cannot open config file for writing");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["activePage"] = _cfg.activePage;
|
||||||
|
doc["pageCount"] = _cfg.pageCount;
|
||||||
|
doc["apSSID"] = _cfg.apSSID;
|
||||||
|
doc["apPassword"] = _cfg.apPassword;
|
||||||
|
|
||||||
|
JsonArray pages = doc["pages"].to<JsonArray>();
|
||||||
|
for (uint8_t p = 0; p < _cfg.pageCount; p++) {
|
||||||
|
JsonObject page = pages.add<JsonObject>();
|
||||||
|
page["name"] = _cfg.pages[p].name;
|
||||||
|
JsonArray tiles = page["tiles"].to<JsonArray>();
|
||||||
|
for (uint8_t t = 0; t < Display::TILE_COUNT; t++) {
|
||||||
|
JsonObject tile = tiles.add<JsonObject>();
|
||||||
|
tile["field"] = static_cast<uint8_t>(_cfg.pages[p].tiles[t].field);
|
||||||
|
tile["label"] = _cfg.pages[p].tiles[t].label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t written = serializeJson(doc, f);
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
Serial.printf("[Config] Saved %u bytes to %s\n", written, CONFIG_PATH);
|
||||||
|
return written > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::_applyDefaults() {
|
||||||
|
_cfg = AppConfig{}; // zero-init
|
||||||
|
|
||||||
|
// Page 0 — Essential sailing data
|
||||||
|
_cfg.pageCount = 2;
|
||||||
|
_cfg.activePage = 0;
|
||||||
|
|
||||||
|
strlcpy(_cfg.pages[0].name, "Sailing", sizeof(PageConfig::name));
|
||||||
|
_cfg.pages[0].tiles[0] = { DataField::SOG, "SOG" };
|
||||||
|
_cfg.pages[0].tiles[1] = { DataField::TWA, "TWA" };
|
||||||
|
_cfg.pages[0].tiles[2] = { DataField::TWS, "TWS" };
|
||||||
|
_cfg.pages[0].tiles[3] = { DataField::VMG, "VMG" };
|
||||||
|
|
||||||
|
// Page 1 — Performance
|
||||||
|
strlcpy(_cfg.pages[1].name, "Performance", sizeof(PageConfig::name));
|
||||||
|
_cfg.pages[1].tiles[0] = { DataField::POLAR_PCT, "Polar %" };
|
||||||
|
_cfg.pages[1].tiles[1] = { DataField::TARGET_STW, "Tgt STW" };
|
||||||
|
_cfg.pages[1].tiles[2] = { DataField::AWA, "AWA" };
|
||||||
|
_cfg.pages[1].tiles[3] = { DataField::DEPTH, "Depth" };
|
||||||
|
|
||||||
|
strlcpy(_cfg.apSSID, "MarineDisplay", sizeof(_cfg.apSSID));
|
||||||
|
strlcpy(_cfg.apPassword, "marine123", sizeof(_cfg.apPassword));
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* @file DisplayManager.cpp
|
||||||
|
* @brief E-Ink display rendering with partial refresh optimisation.
|
||||||
|
*
|
||||||
|
* Partial refresh policy
|
||||||
|
* ──────────────────────
|
||||||
|
* The 4.2" Waveshare display supports partial updates on a sub-window.
|
||||||
|
* We exploit this by redrawing only the "value area" of each tile (the
|
||||||
|
* large numeric area below the label) when only data values change.
|
||||||
|
* Full refresh is triggered:
|
||||||
|
* 1. On boot / page change.
|
||||||
|
* 2. Every FULL_REFRESH_INTERVAL_MS (5 min) to prevent ghosting.
|
||||||
|
* 3. On explicit forceFullRefresh() call (user button press).
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "DisplayManager.h"
|
||||||
|
#include <SPI.h>
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
DisplayManager displayManager;
|
||||||
|
|
||||||
|
// Custom SPI instance on HSPI so the default SPI remains free for other use
|
||||||
|
static SPIClass ePaperSpi(HSPI);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Font shorthands
|
||||||
|
// =============================================================================
|
||||||
|
#define FONT_VALUE FreeMonoBold24pt7b // large numeric value
|
||||||
|
#define FONT_LABEL FreeMonoBold12pt7b // tile label
|
||||||
|
#define FONT_SMALL FreeMono9pt7b // small / status text
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void DisplayManager::begin() {
|
||||||
|
ePaperSpi.begin(Pins::EPD_SCK, -1, Pins::EPD_MOSI, Pins::EPD_CS);
|
||||||
|
_display.epd2.selectSPI(ePaperSpi, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||||
|
_display.init(115200, true, 50, false);
|
||||||
|
_display.setRotation(0); // landscape 400×300
|
||||||
|
|
||||||
|
// Blank the value cache
|
||||||
|
for (uint8_t i = 0; i < Display::TILE_COUNT; i++) {
|
||||||
|
_prevValue[i][0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialised = true;
|
||||||
|
_drawSplash();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::render(const BoatState& state, const PageConfig& cfg) {
|
||||||
|
if (!_initialised) return;
|
||||||
|
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
// Throttle to ~2 Hz (500 ms min between renders)
|
||||||
|
if (now - _lastRender < 500) return;
|
||||||
|
_lastRender = now;
|
||||||
|
|
||||||
|
// Determine refresh type
|
||||||
|
bool doFull = _needFullRefresh ||
|
||||||
|
(now - _lastFullRefresh >= Display::FULL_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
if (doFull) {
|
||||||
|
// ── Full refresh ───────────────────────────────────────────────────
|
||||||
|
_display.setFullWindow();
|
||||||
|
_display.firstPage();
|
||||||
|
do {
|
||||||
|
_display.fillScreen(GxEPD_WHITE);
|
||||||
|
_drawGrid();
|
||||||
|
for (uint8_t i = 0; i < Display::TILE_COUNT; i++) {
|
||||||
|
_drawTile(i, cfg.tiles[i], state, false);
|
||||||
|
}
|
||||||
|
} while (_display.nextPage());
|
||||||
|
|
||||||
|
_lastFullRefresh = now;
|
||||||
|
_needFullRefresh = false;
|
||||||
|
|
||||||
|
// Invalidate cache to force re-render on next partial pass
|
||||||
|
for (uint8_t i = 0; i < Display::TILE_COUNT; i++) {
|
||||||
|
_prevValue[i][0] = '\0';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Partial refresh — only update changed values ───────────────────
|
||||||
|
for (uint8_t i = 0; i < Display::TILE_COUNT; i++) {
|
||||||
|
char newVal[16];
|
||||||
|
_fieldToString(cfg.tiles[i].field, state, newVal, sizeof(newVal));
|
||||||
|
|
||||||
|
if (strcmp(newVal, _prevValue[i]) != 0) {
|
||||||
|
_drawTileValue(i, newVal, true);
|
||||||
|
strlcpy(_prevValue[i], newVal, sizeof(_prevValue[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showBleStatus(AppState::BleStatus status) {
|
||||||
|
if (!_initialised) return;
|
||||||
|
|
||||||
|
const char* msg = "";
|
||||||
|
switch (status) {
|
||||||
|
case AppState::BleStatus::SCANNING: msg = "BLE Scanning..."; break;
|
||||||
|
case AppState::BleStatus::CONNECTING: msg = "BLE Connecting..."; break;
|
||||||
|
case AppState::BleStatus::DISCONNECTED:msg = "BLE Disconnected"; break;
|
||||||
|
case AppState::BleStatus::ERROR: msg = "BLE Error"; break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a small banner at the bottom of the screen using partial refresh
|
||||||
|
constexpr uint16_t BANNER_H = 28;
|
||||||
|
constexpr uint16_t BANNER_Y = Display::HEIGHT - BANNER_H;
|
||||||
|
|
||||||
|
_display.setPartialWindow(0, BANNER_Y, Display::WIDTH, BANNER_H);
|
||||||
|
_display.firstPage();
|
||||||
|
do {
|
||||||
|
_display.fillRect(0, BANNER_Y, Display::WIDTH, BANNER_H, GxEPD_WHITE);
|
||||||
|
_display.setFont(&FONT_SMALL);
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
int16_t tx, ty; uint16_t tw, th;
|
||||||
|
_display.getTextBounds(msg, 0, 0, &tx, &ty, &tw, &th);
|
||||||
|
_display.setCursor((Display::WIDTH - tw) / 2, BANNER_Y + th + 4);
|
||||||
|
_display.print(msg);
|
||||||
|
} while (_display.nextPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showApScreen(const char* ssid, const char* ip) {
|
||||||
|
if (!_initialised) return;
|
||||||
|
_needFullRefresh = true;
|
||||||
|
|
||||||
|
_display.setFullWindow();
|
||||||
|
_display.firstPage();
|
||||||
|
do {
|
||||||
|
_display.fillScreen(GxEPD_WHITE);
|
||||||
|
_display.setFont(&FONT_LABEL);
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
|
||||||
|
int16_t x, y; uint16_t w, h;
|
||||||
|
auto centre = [&](const char* text, uint16_t cy) {
|
||||||
|
_display.getTextBounds(text, 0, 0, &x, &y, &w, &h);
|
||||||
|
_display.setCursor((Display::WIDTH - w) / 2, cy);
|
||||||
|
_display.print(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
centre("-- SETUP MODE --", 50);
|
||||||
|
centre("Connect to WiFi:", 100);
|
||||||
|
centre(ssid, 130);
|
||||||
|
centre("Then open browser:", 175);
|
||||||
|
char url[64];
|
||||||
|
snprintf(url, sizeof(url), "http://%s", ip);
|
||||||
|
centre(url, 205);
|
||||||
|
centre("Password: marine123", 250);
|
||||||
|
} while (_display.nextPage());
|
||||||
|
|
||||||
|
_lastFullRefresh = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showOtaScreen(uint8_t percent) {
|
||||||
|
if (!_initialised) return;
|
||||||
|
|
||||||
|
// Use partial refresh to update just the progress bar area
|
||||||
|
constexpr uint16_t BAR_X = 40, BAR_Y = 160, BAR_W = 320, BAR_H = 30;
|
||||||
|
|
||||||
|
_display.setPartialWindow(0, 100, Display::WIDTH, 120);
|
||||||
|
_display.firstPage();
|
||||||
|
do {
|
||||||
|
_display.fillRect(0, 100, Display::WIDTH, 120, GxEPD_WHITE);
|
||||||
|
|
||||||
|
_display.setFont(&FONT_LABEL);
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
char msg[32];
|
||||||
|
snprintf(msg, sizeof(msg), "OTA Update %u%%", percent);
|
||||||
|
int16_t tx, ty; uint16_t tw, th;
|
||||||
|
_display.getTextBounds(msg, 0, 0, &tx, &ty, &tw, &th);
|
||||||
|
_display.setCursor((Display::WIDTH - tw) / 2, 140);
|
||||||
|
_display.print(msg);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
_display.drawRect(BAR_X, BAR_Y, BAR_W, BAR_H, GxEPD_BLACK);
|
||||||
|
// Fill
|
||||||
|
uint16_t fill = (uint32_t)BAR_W * percent / 100;
|
||||||
|
_display.fillRect(BAR_X, BAR_Y, fill, BAR_H, GxEPD_BLACK);
|
||||||
|
} while (_display.nextPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showPasskeyPrompt(uint32_t pin) {
|
||||||
|
if (!_initialised) return;
|
||||||
|
|
||||||
|
_display.setFullWindow();
|
||||||
|
_display.firstPage();
|
||||||
|
do {
|
||||||
|
_display.fillScreen(GxEPD_WHITE);
|
||||||
|
_display.setFont(&FONT_LABEL);
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
|
||||||
|
int16_t x, y; uint16_t w, h;
|
||||||
|
auto centre = [&](const char* text, uint16_t cy) {
|
||||||
|
_display.getTextBounds(text, 0, 0, &x, &y, &w, &h);
|
||||||
|
_display.setCursor((Display::WIDTH - w) / 2, cy);
|
||||||
|
_display.print(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
centre("BLE PAIRING", 60);
|
||||||
|
centre("Enter this PIN on gateway:", 110);
|
||||||
|
|
||||||
|
// Large PIN display
|
||||||
|
char pinStr[8];
|
||||||
|
snprintf(pinStr, sizeof(pinStr), "%06lu", (unsigned long)pin);
|
||||||
|
_display.setFont(&FONT_VALUE);
|
||||||
|
_display.getTextBounds(pinStr, 0, 0, &x, &y, &w, &h);
|
||||||
|
_display.setCursor((Display::WIDTH - w) / 2, 190);
|
||||||
|
_display.print(pinStr);
|
||||||
|
} while (_display.nextPage());
|
||||||
|
|
||||||
|
_lastFullRefresh = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::hibernate() {
|
||||||
|
_display.hibernate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Private — drawing helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void DisplayManager::_drawSplash() {
|
||||||
|
_display.setFullWindow();
|
||||||
|
_display.firstPage();
|
||||||
|
do {
|
||||||
|
_display.fillScreen(GxEPD_WHITE);
|
||||||
|
|
||||||
|
_display.setFont(&FONT_VALUE);
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
int16_t x, y; uint16_t w, h;
|
||||||
|
const char* title = "MARINE";
|
||||||
|
_display.getTextBounds(title, 0, 0, &x, &y, &w, &h);
|
||||||
|
_display.setCursor((Display::WIDTH - w) / 2, 110);
|
||||||
|
_display.print(title);
|
||||||
|
|
||||||
|
_display.setFont(&FONT_LABEL);
|
||||||
|
const char* sub = "Navigation Display";
|
||||||
|
_display.getTextBounds(sub, 0, 0, &x, &y, &w, &h);
|
||||||
|
_display.setCursor((Display::WIDTH - w) / 2, 160);
|
||||||
|
_display.print(sub);
|
||||||
|
|
||||||
|
_display.setFont(&FONT_SMALL);
|
||||||
|
char ver[32];
|
||||||
|
snprintf(ver, sizeof(ver), "v" APP_VERSION " — Starting...");
|
||||||
|
_display.getTextBounds(ver, 0, 0, &x, &y, &w, &h);
|
||||||
|
_display.setCursor((Display::WIDTH - w) / 2, 200);
|
||||||
|
_display.print(ver);
|
||||||
|
} while (_display.nextPage());
|
||||||
|
|
||||||
|
_lastFullRefresh = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::_drawGrid() {
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
|
||||||
|
// Vertical separator
|
||||||
|
_display.fillRect(Display::TILE_W - Display::BORDER_PX / 2,
|
||||||
|
0,
|
||||||
|
Display::BORDER_PX,
|
||||||
|
Display::HEIGHT,
|
||||||
|
GxEPD_BLACK);
|
||||||
|
// Horizontal separator
|
||||||
|
_display.fillRect(0,
|
||||||
|
Display::TILE_H - Display::BORDER_PX / 2,
|
||||||
|
Display::WIDTH,
|
||||||
|
Display::BORDER_PX,
|
||||||
|
GxEPD_BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::_drawTile(uint8_t idx, const TileConfig& tile,
|
||||||
|
const BoatState& state, bool partial) {
|
||||||
|
_drawTileLabel(idx, tile);
|
||||||
|
|
||||||
|
char val[16];
|
||||||
|
_fieldToString(tile.field, state, val, sizeof(val));
|
||||||
|
strlcpy(_prevValue[idx], val, sizeof(_prevValue[idx]));
|
||||||
|
_drawTileValue(idx, val, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::_drawTileLabel(uint8_t idx, const TileConfig& tile) {
|
||||||
|
Rect r = _tileRect(idx);
|
||||||
|
|
||||||
|
// Label background strip
|
||||||
|
_display.fillRect(r.x, r.y, r.w, Display::LABEL_H, GxEPD_BLACK);
|
||||||
|
|
||||||
|
// Label text
|
||||||
|
const char* lbl = (tile.label[0] != '\0') ? tile.label : fieldName(tile.field);
|
||||||
|
_display.setFont(&FONT_SMALL);
|
||||||
|
_display.setTextColor(GxEPD_WHITE);
|
||||||
|
int16_t tx, ty; uint16_t tw, th;
|
||||||
|
_display.getTextBounds(lbl, 0, 0, &tx, &ty, &tw, &th);
|
||||||
|
uint16_t cx = r.x + (r.w - tw) / 2;
|
||||||
|
uint16_t cy = r.y + (Display::LABEL_H + th) / 2;
|
||||||
|
_display.setCursor(cx, cy);
|
||||||
|
_display.print(lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::_drawTileValue(uint8_t idx, const char* value, bool partial) {
|
||||||
|
Rect vr = _valuRect(idx);
|
||||||
|
|
||||||
|
if (partial) {
|
||||||
|
_display.setPartialWindow(vr.x, vr.y, vr.w, vr.h);
|
||||||
|
_display.firstPage();
|
||||||
|
do {
|
||||||
|
_display.fillRect(vr.x, vr.y, vr.w, vr.h, GxEPD_WHITE);
|
||||||
|
_display.setFont(&FONT_VALUE);
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
int16_t tx, ty; uint16_t tw, th;
|
||||||
|
_display.getTextBounds(value, 0, 0, &tx, &ty, &tw, &th);
|
||||||
|
// Clamp to tile width
|
||||||
|
uint16_t cx = vr.x + (tw < vr.w ? (vr.w - tw) / 2 : 2);
|
||||||
|
uint16_t cy = vr.y + (vr.h + th) / 2;
|
||||||
|
_display.setCursor(cx, cy);
|
||||||
|
_display.print(value);
|
||||||
|
} while (_display.nextPage());
|
||||||
|
} else {
|
||||||
|
// During full refresh the caller already set the full window;
|
||||||
|
// just draw without touching partial window settings.
|
||||||
|
_display.setFont(&FONT_VALUE);
|
||||||
|
_display.setTextColor(GxEPD_BLACK);
|
||||||
|
int16_t tx, ty; uint16_t tw, th;
|
||||||
|
_display.getTextBounds(value, 0, 0, &tx, &ty, &tw, &th);
|
||||||
|
uint16_t cx = vr.x + (tw < vr.w ? (vr.w - tw) / 2 : 2);
|
||||||
|
uint16_t cy = vr.y + (vr.h + th) / 2;
|
||||||
|
_display.setCursor(cx, cy);
|
||||||
|
_display.print(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DisplayManager::Rect DisplayManager::_tileRect(uint8_t idx) const {
|
||||||
|
uint8_t col = idx % Display::TILE_COLS;
|
||||||
|
uint8_t row = idx / Display::TILE_COLS;
|
||||||
|
return {
|
||||||
|
(uint16_t)(col * Display::TILE_W),
|
||||||
|
(uint16_t)(row * Display::TILE_H),
|
||||||
|
Display::TILE_W,
|
||||||
|
Display::TILE_H
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DisplayManager::Rect DisplayManager::_valuRect(uint8_t idx) const {
|
||||||
|
Rect r = _tileRect(idx);
|
||||||
|
return {
|
||||||
|
r.x,
|
||||||
|
(uint16_t)(r.y + Display::LABEL_H),
|
||||||
|
r.w,
|
||||||
|
(uint16_t)(r.h - Display::LABEL_H)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data → string conversion ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void DisplayManager::_fieldToString(DataField field, const BoatState& state,
|
||||||
|
char* out, size_t outLen) const {
|
||||||
|
auto fmtFloat = [&](float v, int dec = 1) {
|
||||||
|
if (isnan(v)) strlcpy(out, "---", outLen);
|
||||||
|
else snprintf(out, outLen, "%.*f", dec, v);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case DataField::LAT: fmtFloat(state.nav.lat, 4); break;
|
||||||
|
case DataField::LON: fmtFloat(state.nav.lon, 4); break;
|
||||||
|
case DataField::SOG: fmtFloat(state.nav.sog); break;
|
||||||
|
case DataField::COG: fmtFloat(state.nav.cog, 0); break;
|
||||||
|
case DataField::STW: fmtFloat(state.nav.stw); break;
|
||||||
|
case DataField::HDG_MAG: fmtFloat(state.nav.hdgMag, 0); break;
|
||||||
|
case DataField::DEPTH: fmtFloat(state.nav.depth); break;
|
||||||
|
case DataField::AWS: fmtFloat(state.wind.aws); break;
|
||||||
|
case DataField::AWA: fmtFloat(state.wind.awa, 0); break;
|
||||||
|
case DataField::TWS: fmtFloat(state.wind.tws); break;
|
||||||
|
case DataField::TWA: fmtFloat(state.wind.twa, 0); break;
|
||||||
|
case DataField::TWD: fmtFloat(state.wind.twd, 0); break;
|
||||||
|
case DataField::AP_MODE:
|
||||||
|
strlcpy(out, state.autopilot.mode[0] ? state.autopilot.mode : "---", outLen);
|
||||||
|
break;
|
||||||
|
case DataField::AP_TARGET: fmtFloat(state.autopilot.headingTarget, 0); break;
|
||||||
|
case DataField::AP_RUDDER: fmtFloat(state.autopilot.rudder); break;
|
||||||
|
case DataField::VMG: fmtFloat(state.perf.vmg); break;
|
||||||
|
case DataField::POLAR_PCT:
|
||||||
|
if (state.perf.polarLoaded) fmtFloat(state.perf.polarPct, 0);
|
||||||
|
else strlcpy(out, "N/A", outLen);
|
||||||
|
break;
|
||||||
|
case DataField::TARGET_STW:
|
||||||
|
if (state.perf.polarLoaded) fmtFloat(state.perf.targetStw);
|
||||||
|
else strlcpy(out, "N/A", outLen);
|
||||||
|
break;
|
||||||
|
case DataField::UPTIME:
|
||||||
|
snprintf(out, outLen, "%lus", (unsigned long)state.admin.uptimeS);
|
||||||
|
break;
|
||||||
|
case DataField::WIFI_MODE:
|
||||||
|
strlcpy(out, state.admin.wifiMode[0] ? state.admin.wifiMode : "---", outLen);
|
||||||
|
break;
|
||||||
|
case DataField::FREE_HEAP:
|
||||||
|
snprintf(out, outLen, "%luB", (unsigned long)state.admin.freeHeap);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
strlcpy(out, "---", outLen);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* @file StatusLed.cpp
|
||||||
|
* @brief NeoPixel status LED implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "StatusLed.h"
|
||||||
|
|
||||||
|
StatusLed statusLed;
|
||||||
|
|
||||||
|
void StatusLed::begin() {
|
||||||
|
_strip.begin();
|
||||||
|
_strip.setBrightness(40);
|
||||||
|
_strip.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StatusLed::update(AppState::BleStatus ble, AppState::WifiStatus wifi, bool ota) {
|
||||||
|
// OTA takes priority
|
||||||
|
if (ota) {
|
||||||
|
_pulse(LedColor::OTA, 100, 100, 60);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi AP overrides BLE disconnected/idle states
|
||||||
|
if (wifi == AppState::WifiStatus::AP_ACTIVE) {
|
||||||
|
_pulse(LedColor::WIFI_AP, 500, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wifi == AppState::WifiStatus::STA_CONNECTED) {
|
||||||
|
_solid(LedColor::WIFI_STA);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BLE status
|
||||||
|
switch (ble) {
|
||||||
|
case AppState::BleStatus::SCANNING:
|
||||||
|
_pulse(LedColor::BLE_SCANNING, 100, 900);
|
||||||
|
break;
|
||||||
|
case AppState::BleStatus::CONNECTING:
|
||||||
|
_pulse(LedColor::BLE_SCANNING, 300, 300, 60);
|
||||||
|
break;
|
||||||
|
case AppState::BleStatus::CONNECTED:
|
||||||
|
_solid(LedColor::BLE_CONNECTED, 25);
|
||||||
|
break;
|
||||||
|
case AppState::BleStatus::DISCONNECTED:
|
||||||
|
_pulse(LedColor::BLE_ERROR, 200, 800);
|
||||||
|
break;
|
||||||
|
case AppState::BleStatus::ERROR:
|
||||||
|
_solid(LedColor::BLE_ERROR);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
off();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StatusLed::setColor(uint32_t color, uint8_t brightness) {
|
||||||
|
_strip.setBrightness(brightness);
|
||||||
|
_strip.setPixelColor(0, color);
|
||||||
|
_strip.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StatusLed::off() {
|
||||||
|
_strip.setPixelColor(0, 0);
|
||||||
|
_strip.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Private
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void StatusLed::_pulse(uint32_t color, uint32_t onMs, uint32_t offMs, uint8_t brightness) {
|
||||||
|
uint32_t now = millis();
|
||||||
|
uint32_t period = _ledOn ? onMs : offMs;
|
||||||
|
|
||||||
|
if (now - _lastToggle >= period) {
|
||||||
|
_lastToggle = now;
|
||||||
|
_ledOn = !_ledOn;
|
||||||
|
_strip.setBrightness(_ledOn ? brightness : 0);
|
||||||
|
_strip.setPixelColor(0, color);
|
||||||
|
_strip.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StatusLed::_solid(uint32_t color, uint8_t brightness) {
|
||||||
|
_strip.setBrightness(brightness);
|
||||||
|
_strip.setPixelColor(0, color);
|
||||||
|
_strip.show();
|
||||||
|
_ledOn = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @file TouchManager.cpp
|
||||||
|
* @brief ESP32-S3 native capacitive touch pad handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "TouchManager.h"
|
||||||
|
|
||||||
|
TouchManager touchManager;
|
||||||
|
|
||||||
|
void TouchManager::begin() {
|
||||||
|
// ESP32-S3 touch pads are initialised by the Arduino framework
|
||||||
|
// automatically. No explicit pinMode needed for touch pins.
|
||||||
|
Serial.printf("[Touch] Pads: NEXT=%u PREV=%u ACTION=%u threshold=%u\n",
|
||||||
|
Pins::TOUCH_NEXT, Pins::TOUCH_PREV, Pins::TOUCH_ACTION,
|
||||||
|
Touch::THRESHOLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TouchManager::update() {
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
// Debounce gate
|
||||||
|
if (now - _lastDebounce < Touch::DEBOUNCE_MS) return;
|
||||||
|
_lastDebounce = now;
|
||||||
|
|
||||||
|
bool downNext = _isTouched(Pins::TOUCH_NEXT);
|
||||||
|
bool downPrev = _isTouched(Pins::TOUCH_PREV);
|
||||||
|
bool downAction = _isTouched(Pins::TOUCH_ACTION);
|
||||||
|
|
||||||
|
// ── Combo: NEXT + PREV ────────────────────────────────────────────────────
|
||||||
|
if (downNext && downPrev) {
|
||||||
|
if (!_comboActive) {
|
||||||
|
_comboActive = true;
|
||||||
|
_comboPressAt = now;
|
||||||
|
} else if (!_evApCombo && (now - _comboPressAt >= Touch::AP_COMBO_MS)) {
|
||||||
|
_evApCombo = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_comboActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Individual pads ───────────────────────────────────────────────────────
|
||||||
|
auto processPad = [&](PadState& pad, bool down, bool& ev) {
|
||||||
|
if (down && !pad.wasDown) {
|
||||||
|
pad.pressedAt = now;
|
||||||
|
pad.longFired = false;
|
||||||
|
}
|
||||||
|
if (!down && pad.wasDown) {
|
||||||
|
// Rising edge → short press event
|
||||||
|
if (!pad.longFired) ev = true;
|
||||||
|
}
|
||||||
|
pad.wasDown = down;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!_comboActive) {
|
||||||
|
processPad(_pads[0], downNext, _evNext);
|
||||||
|
processPad(_pads[1], downPrev, _evPrev);
|
||||||
|
}
|
||||||
|
processPad(_pads[2], downAction, _evAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TouchManager::_isTouched(uint8_t pin) const {
|
||||||
|
// touchRead returns raw ADC value; lower = more capacitance = touched
|
||||||
|
return touchRead(pin) < Touch::THRESHOLD;
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
/**
|
||||||
|
* @file WifiManager.cpp
|
||||||
|
* @brief WiFi AP + Async Web Server implementation.
|
||||||
|
*
|
||||||
|
* The web UI is served from PROGMEM to avoid LittleFS reads in the hot path.
|
||||||
|
* All API handlers return JSON. OTA uses the built-in Update library.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "WifiManager.h"
|
||||||
|
#include "ConfigManager.h"
|
||||||
|
#include "BleManager.h"
|
||||||
|
#include "DisplayManager.h"
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <Update.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
|
||||||
|
WifiManager wifiManager;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Embedded HTML (PROGMEM)
|
||||||
|
// Served at GET / — a self-contained single page using vanilla JS + fetch API.
|
||||||
|
// =============================================================================
|
||||||
|
const char WifiManager::INDEX_HTML[] PROGMEM = R"rawhtml(
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Marine Display — Config</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#0a1628; --surface:#112240; --accent:#64ffda;
|
||||||
|
--text:#ccd6f6; --muted:#8892b0; --border:#1d3461;
|
||||||
|
--danger:#ff6b6b; --warn:#ffd93d;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:var(--bg);color:var(--text);font:14px/1.5 'Courier New',monospace;padding:16px}
|
||||||
|
h1{color:var(--accent);font-size:1.4rem;margin-bottom:4px}
|
||||||
|
.sub{color:var(--muted);font-size:.8rem;margin-bottom:20px}
|
||||||
|
section{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:16px}
|
||||||
|
h2{color:var(--accent);font-size:1rem;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:6px}
|
||||||
|
label{display:block;color:var(--muted);font-size:.8rem;margin-bottom:2px;margin-top:8px}
|
||||||
|
input,select{width:100%;background:#0d1b2a;border:1px solid var(--border);border-radius:4px;
|
||||||
|
color:var(--text);padding:6px 8px;font-family:inherit;font-size:.85rem}
|
||||||
|
input:focus,select:focus{outline:none;border-color:var(--accent)}
|
||||||
|
.btn{display:inline-block;padding:8px 18px;border-radius:4px;border:none;cursor:pointer;
|
||||||
|
font:inherit;font-size:.85rem;transition:opacity .15s}
|
||||||
|
.btn-primary{background:var(--accent);color:#0a1628;font-weight:700}
|
||||||
|
.btn-danger{background:var(--danger);color:#fff}
|
||||||
|
.btn-warn{background:var(--warn);color:#0a1628}
|
||||||
|
.btn:hover{opacity:.85}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.tile{background:#0d1b2a;border:1px solid var(--border);border-radius:4px;padding:12px}
|
||||||
|
.tile h3{color:var(--warn);font-size:.85rem;margin-bottom:8px}
|
||||||
|
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:8px}
|
||||||
|
.status{padding:4px 10px;border-radius:12px;font-size:.75rem;
|
||||||
|
background:#1d3461;color:var(--accent);margin-left:auto}
|
||||||
|
.pages-nav{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
|
||||||
|
.page-tab{padding:4px 12px;border:1px solid var(--border);border-radius:4px;
|
||||||
|
cursor:pointer;background:#0d1b2a;color:var(--muted);font-size:.8rem}
|
||||||
|
.page-tab.active{border-color:var(--accent);color:var(--accent)}
|
||||||
|
#toast{position:fixed;bottom:20px;right:20px;background:var(--accent);color:#0a1628;
|
||||||
|
padding:8px 16px;border-radius:4px;font-weight:700;display:none;font-size:.85rem}
|
||||||
|
.ota-bar{height:12px;background:#1d3461;border-radius:6px;margin-top:8px;overflow:hidden}
|
||||||
|
.ota-fill{height:100%;background:var(--accent);width:0%;transition:width .3s}
|
||||||
|
@media(max-width:500px){.grid{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>⚓ Marine Display</h1>
|
||||||
|
<p class="sub">v<span id="ver">-</span> | AP: <span id="apip">192.168.4.1</span></p>
|
||||||
|
|
||||||
|
<!-- Live data -->
|
||||||
|
<section>
|
||||||
|
<h2>Live Data <span class="status" id="ble-status">-</span></h2>
|
||||||
|
<div class="grid" id="live-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Page / Tile config -->
|
||||||
|
<section>
|
||||||
|
<h2>Display Pages</h2>
|
||||||
|
<div class="pages-nav" id="page-tabs"></div>
|
||||||
|
<div class="grid" id="tile-grid"></div>
|
||||||
|
<div class="row" style="margin-top:16px">
|
||||||
|
<button class="btn btn-primary" onclick="saveConfig()">Save & Apply</button>
|
||||||
|
<button class="btn btn-warn" onclick="addPage()" style="margin-left:8px">+ Add Page</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BLE PIN -->
|
||||||
|
<section>
|
||||||
|
<h2>BLE Pairing PIN</h2>
|
||||||
|
<label>6-digit PIN shown on the Marine Gateway dashboard</label>
|
||||||
|
<div class="row">
|
||||||
|
<input type="number" id="ble-pin" placeholder="123456" min="0" max="999999" style="width:140px">
|
||||||
|
<button class="btn btn-primary" onclick="submitPin()">Submit PIN</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Gateway WiFi control -->
|
||||||
|
<section>
|
||||||
|
<h2>Marine Gateway WiFi</h2>
|
||||||
|
<label>Target network SSID</label>
|
||||||
|
<input type="text" id="gw-ssid" placeholder="MyBoatNetwork">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="gw-pw" placeholder="">
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button class="btn btn-primary" onclick="gwWifiSta()">Switch Gateway to STA</button>
|
||||||
|
<button class="btn btn-warn" onclick="gwRestart()" style="margin-left:8px">Restart Gateway</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- OTA -->
|
||||||
|
<section>
|
||||||
|
<h2>Firmware Update (OTA)</h2>
|
||||||
|
<input type="file" id="fw-file" accept=".bin">
|
||||||
|
<div class="ota-bar"><div class="ota-fill" id="ota-fill"></div></div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<button class="btn btn-danger" onclick="uploadFw()">Flash Firmware</button>
|
||||||
|
<span id="ota-msg" style="color:var(--muted);font-size:.8rem;margin-left:8px"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let cfg = {pages:[], activePage:0, availableFields:[]};
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
async function api(path, opts) {
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
return r.json().catch(()=>({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, err) {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.style.background = err ? 'var(--danger)' : 'var(--accent)';
|
||||||
|
t.style.display = 'block';
|
||||||
|
setTimeout(()=>t.style.display='none', 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
cfg = await api('/api/config');
|
||||||
|
document.getElementById('ver').textContent = cfg.version || '-';
|
||||||
|
currentPage = cfg.activePage || 0;
|
||||||
|
renderPageTabs();
|
||||||
|
renderTiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPageTabs() {
|
||||||
|
const el = document.getElementById('page-tabs');
|
||||||
|
el.innerHTML = '';
|
||||||
|
(cfg.pages || []).forEach((p,i) => {
|
||||||
|
const b = document.createElement('div');
|
||||||
|
b.className = 'page-tab' + (i===currentPage ? ' active' : '');
|
||||||
|
b.textContent = p.name || `Page ${i+1}`;
|
||||||
|
b.onclick = () => { currentPage=i; renderPageTabs(); renderTiles(); };
|
||||||
|
el.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTiles() {
|
||||||
|
const page = (cfg.pages || [])[currentPage];
|
||||||
|
if (!page) return;
|
||||||
|
const grid = document.getElementById('tile-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const positions = ['Top-Left','Top-Right','Bottom-Left','Bottom-Right'];
|
||||||
|
(page.tiles || []).forEach((tile,i) => {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'tile';
|
||||||
|
d.innerHTML = `<h3>${positions[i]}</h3>
|
||||||
|
<label>Data Field</label>
|
||||||
|
<select id="tile-field-${i}">
|
||||||
|
${(cfg.availableFields||[]).map(f=>
|
||||||
|
`<option value="${f.id}" ${f.id==tile.field?'selected':''}>${f.name} ${f.unit?'('+f.unit+')':''}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
<label>Label override (leave blank for auto)</label>
|
||||||
|
<input type="text" id="tile-label-${i}" value="${tile.label||''}" maxlength="15" placeholder="${tile.fieldName||''}">`;
|
||||||
|
grid.appendChild(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const page = cfg.pages[currentPage];
|
||||||
|
for (let i=0;i<4;i++) {
|
||||||
|
const fEl = document.getElementById(`tile-field-${i}`);
|
||||||
|
const lEl = document.getElementById(`tile-label-${i}`);
|
||||||
|
if (fEl) page.tiles[i].field = parseInt(fEl.value);
|
||||||
|
if (lEl) page.tiles[i].label = lEl.value;
|
||||||
|
}
|
||||||
|
cfg.activePage = currentPage;
|
||||||
|
const r = await api('/api/config', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(cfg)
|
||||||
|
});
|
||||||
|
toast(r.ok ? 'Saved!' : 'Save failed', !r.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPage() {
|
||||||
|
if ((cfg.pages||[]).length >= 8) { toast('Max 8 pages', true); return; }
|
||||||
|
const name = prompt('Page name:');
|
||||||
|
if (!name) return;
|
||||||
|
cfg.pages.push({name, tiles:[{field:0,label:''},{field:0,label:''},{field:0,label:''},{field:0,label:''}]});
|
||||||
|
currentPage = cfg.pages.length-1;
|
||||||
|
renderPageTabs();
|
||||||
|
renderTiles();
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
const s = await api('/api/state');
|
||||||
|
const grid = document.getElementById('live-grid');
|
||||||
|
const items = [
|
||||||
|
['SOG', s.nav?.sog, 'kn'], ['COG', s.nav?.cog, '°'],
|
||||||
|
['TWS', s.wind?.tws, 'kn'], ['TWA', s.wind?.twa, '°'],
|
||||||
|
['AWS', s.wind?.aws, 'kn'], ['AWA', s.wind?.awa, '°'],
|
||||||
|
['VMG', s.perf?.vmg, 'kn'], ['Polar', s.perf?.polar_pct, '%'],
|
||||||
|
['Depth', s.nav?.depth, 'm'], ['Hdg', s.nav?.hdg_mag, '°'],
|
||||||
|
];
|
||||||
|
grid.innerHTML = items.map(([l,v,u]) =>
|
||||||
|
`<div style="padding:4px 0"><span style="color:var(--muted);font-size:.75rem">${l}</span>
|
||||||
|
<span style="float:right;color:var(--accent);font-weight:700">
|
||||||
|
${v===null||v===undefined?'---':parseFloat(v).toFixed(1)}${u}</span></div>`
|
||||||
|
).join('');
|
||||||
|
document.getElementById('ble-status').textContent = s.bleStatus || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPin() {
|
||||||
|
const pin = document.getElementById('ble-pin').value;
|
||||||
|
if (!pin) return;
|
||||||
|
const r = await api('/api/ble/passkey', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({pin: parseInt(pin)})
|
||||||
|
});
|
||||||
|
toast(r.ok ? 'PIN sent!' : 'Failed', !r.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gwWifiSta() {
|
||||||
|
const ssid = document.getElementById('gw-ssid').value;
|
||||||
|
const pw = document.getElementById('gw-pw').value;
|
||||||
|
if (!ssid) { toast('SSID required', true); return; }
|
||||||
|
const r = await api('/api/ble/cmd', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({command:'wifi_sta', ssid, password:pw})
|
||||||
|
});
|
||||||
|
toast(r.ok ? 'Command sent — gateway rebooting' : 'Failed', !r.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gwRestart() {
|
||||||
|
if (!confirm('Restart the Marine Gateway?')) return;
|
||||||
|
const r = await api('/api/ble/cmd', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({command:'restart'})
|
||||||
|
});
|
||||||
|
toast(r.ok ? 'Gateway restarting...' : 'Failed', !r.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFw() {
|
||||||
|
const file = document.getElementById('fw-file').files[0];
|
||||||
|
if (!file) { toast('Select a .bin file first', true); return; }
|
||||||
|
const fill = document.getElementById('ota-fill');
|
||||||
|
const msg = document.getElementById('ota-msg');
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('firmware', file, file.name);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.upload.onprogress = e => {
|
||||||
|
const p = Math.round(e.loaded*100/e.total);
|
||||||
|
fill.style.width = p+'%';
|
||||||
|
msg.textContent = p+'%';
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status===200) { toast('OTA done! Rebooting...'); msg.textContent='Done'; }
|
||||||
|
else { toast('OTA failed', true); }
|
||||||
|
};
|
||||||
|
xhr.onerror = () => toast('OTA error', true);
|
||||||
|
xhr.open('POST', '/update');
|
||||||
|
xhr.send(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
loadConfig();
|
||||||
|
loadState();
|
||||||
|
setInterval(loadState, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)rawhtml";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void WifiManager::begin(const char* ssid, const char* password) {
|
||||||
|
WiFi.mode(WIFI_AP);
|
||||||
|
WiFi.softAP(ssid, password);
|
||||||
|
Serial.printf("[WiFi] AP started: SSID=%s IP=%s\n", ssid, AP_IP);
|
||||||
|
|
||||||
|
_setupRoutes();
|
||||||
|
_server.begin();
|
||||||
|
_active = true;
|
||||||
|
|
||||||
|
Serial.println("[WiFi] Web server listening on port 80");
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::stop() {
|
||||||
|
_server.end();
|
||||||
|
WiFi.softAPdisconnect(true);
|
||||||
|
WiFi.mode(WIFI_OFF);
|
||||||
|
_active = false;
|
||||||
|
Serial.println("[WiFi] AP stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::update() {
|
||||||
|
// ESPAsyncWebServer is interrupt-driven; no polling needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Routes
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void WifiManager::_setupRoutes() {
|
||||||
|
// ── Static page ───────────────────────────────────────────────────────────
|
||||||
|
_server.on("/", HTTP_GET, [this](AsyncWebServerRequest* req) {
|
||||||
|
_handleRoot(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Config API ────────────────────────────────────────────────────────────
|
||||||
|
_server.on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
|
||||||
|
_handleGetConfig(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/config", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) {},
|
||||||
|
nullptr,
|
||||||
|
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
|
||||||
|
_handlePostConfig(req, data, len, index, total);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Live state API ────────────────────────────────────────────────────────
|
||||||
|
_server.on("/api/state", HTTP_GET, [this](AsyncWebServerRequest* req) {
|
||||||
|
_handleGetState(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BLE command forwarding ─────────────────────────────────────────────
|
||||||
|
_server.on("/api/ble/cmd", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) {},
|
||||||
|
nullptr,
|
||||||
|
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
|
||||||
|
_handleBleCmd(req, data, len, index, total);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── BLE passkey ───────────────────────────────────────────────────────────
|
||||||
|
_server.on("/api/ble/passkey", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) {},
|
||||||
|
nullptr,
|
||||||
|
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
|
||||||
|
_handlePasskey(req, data, len, index, total);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── OTA update ────────────────────────────────────────────────────────────
|
||||||
|
_server.on("/update", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) {
|
||||||
|
bool ok = !Update.hasError();
|
||||||
|
AsyncWebServerResponse* resp = req->beginResponse(
|
||||||
|
200, "application/json",
|
||||||
|
ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"Update failed\"}");
|
||||||
|
resp->addHeader("Connection", "close");
|
||||||
|
req->send(resp);
|
||||||
|
if (ok) {
|
||||||
|
delay(500);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[this](AsyncWebServerRequest* req, const String& filename,
|
||||||
|
size_t index, uint8_t* data, size_t len, bool final) {
|
||||||
|
_handleOtaUpload(req, filename, index, data, len, final);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
_server.onNotFound([this](AsyncWebServerRequest* req) {
|
||||||
|
_handleNotFound(req);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler implementations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void WifiManager::_handleRoot(AsyncWebServerRequest* req) {
|
||||||
|
req->send_P(200, "text/html", INDEX_HTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::_handleGetConfig(AsyncWebServerRequest* req) {
|
||||||
|
String json;
|
||||||
|
configManager.toJson(json);
|
||||||
|
// Inject version and AP IP
|
||||||
|
// Quick patch: insert before closing brace
|
||||||
|
json.remove(json.length() - 1);
|
||||||
|
json += ",\"version\":\"" APP_VERSION "\",\"apip\":\"" + String(AP_IP) + "\"}";
|
||||||
|
req->send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::_handlePostConfig(AsyncWebServerRequest* req,
|
||||||
|
uint8_t* data, size_t len,
|
||||||
|
size_t index, size_t total) {
|
||||||
|
String body((char*)data, len);
|
||||||
|
bool ok = configManager.fromJson(body);
|
||||||
|
req->send(200, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
|
||||||
|
if (ok) displayManager.forceFullRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::_handleGetState(AsyncWebServerRequest* req) {
|
||||||
|
BoatState state;
|
||||||
|
bleManager.getBoatState(state);
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
// Nav
|
||||||
|
auto nav = doc["nav"].to<JsonObject>();
|
||||||
|
nav["lat"] = isnan(state.nav.lat) ? JsonVariant() : state.nav.lat;
|
||||||
|
nav["lon"] = isnan(state.nav.lon) ? JsonVariant() : state.nav.lon;
|
||||||
|
nav["sog"] = isnan(state.nav.sog) ? JsonVariant() : state.nav.sog;
|
||||||
|
nav["cog"] = isnan(state.nav.cog) ? JsonVariant() : state.nav.cog;
|
||||||
|
nav["stw"] = isnan(state.nav.stw) ? JsonVariant() : state.nav.stw;
|
||||||
|
nav["hdg_mag"] = isnan(state.nav.hdgMag) ? JsonVariant() : state.nav.hdgMag;
|
||||||
|
nav["depth"] = isnan(state.nav.depth) ? JsonVariant() : state.nav.depth;
|
||||||
|
// Wind
|
||||||
|
auto wind = doc["wind"].to<JsonObject>();
|
||||||
|
wind["aws"] = isnan(state.wind.aws) ? JsonVariant() : state.wind.aws;
|
||||||
|
wind["awa"] = isnan(state.wind.awa) ? JsonVariant() : state.wind.awa;
|
||||||
|
wind["tws"] = isnan(state.wind.tws) ? JsonVariant() : state.wind.tws;
|
||||||
|
wind["twa"] = isnan(state.wind.twa) ? JsonVariant() : state.wind.twa;
|
||||||
|
wind["twd"] = isnan(state.wind.twd) ? JsonVariant() : state.wind.twd;
|
||||||
|
// Perf
|
||||||
|
auto perf = doc["perf"].to<JsonObject>();
|
||||||
|
perf["vmg"] = isnan(state.perf.vmg) ? JsonVariant() : state.perf.vmg;
|
||||||
|
perf["polar_pct"] = isnan(state.perf.polarPct) ? JsonVariant() : state.perf.polarPct;
|
||||||
|
perf["polar_loaded"] = state.perf.polarLoaded;
|
||||||
|
// BLE status string
|
||||||
|
const char* bleStr = "IDLE";
|
||||||
|
switch (bleManager.status()) {
|
||||||
|
case AppState::BleStatus::SCANNING: bleStr = "SCANNING"; break;
|
||||||
|
case AppState::BleStatus::CONNECTING: bleStr = "CONNECTING"; break;
|
||||||
|
case AppState::BleStatus::CONNECTED: bleStr = "CONNECTED"; break;
|
||||||
|
case AppState::BleStatus::DISCONNECTED: bleStr = "DISCONNECTED";break;
|
||||||
|
case AppState::BleStatus::ERROR: bleStr = "ERROR"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
doc["bleStatus"] = bleStr;
|
||||||
|
|
||||||
|
String out;
|
||||||
|
serializeJson(doc, out);
|
||||||
|
req->send(200, "application/json", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::_handleBleCmd(AsyncWebServerRequest* req,
|
||||||
|
uint8_t* data, size_t len,
|
||||||
|
size_t index, size_t total) {
|
||||||
|
String body((char*)data, len);
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, body) != DeserializationError::Ok) {
|
||||||
|
req->send(400, "application/json", "{\"ok\":false,\"error\":\"bad json\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* cmd = doc["command"] | "";
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
if (strcmp(cmd, "restart") == 0) {
|
||||||
|
ok = bleManager.sendRestart();
|
||||||
|
} else if (strcmp(cmd, "wifi_sta") == 0) {
|
||||||
|
ok = bleManager.sendWifiSta(doc["ssid"] | "", doc["password"] | "");
|
||||||
|
} else if (strcmp(cmd, "wifi_ap") == 0) {
|
||||||
|
ok = bleManager.sendWifiAp(doc["ssid"] | "", doc["password"] | "");
|
||||||
|
} else if (strlen(cmd) > 0) {
|
||||||
|
// Forward raw autopilot command
|
||||||
|
ok = bleManager.sendAutopilotCmd(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
req->send(200, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::_handlePasskey(AsyncWebServerRequest* req,
|
||||||
|
uint8_t* data, size_t len,
|
||||||
|
size_t index, size_t total) {
|
||||||
|
JsonDocument doc;
|
||||||
|
deserializeJson(doc, (char*)data, len);
|
||||||
|
uint32_t pin = doc["pin"] | 0;
|
||||||
|
bleManager.providePasskey(pin);
|
||||||
|
req->send(200, "application/json", "{\"ok\":true}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::_handleOtaUpload(AsyncWebServerRequest* req,
|
||||||
|
const String& filename,
|
||||||
|
size_t index, uint8_t* data,
|
||||||
|
size_t len, bool final) {
|
||||||
|
if (index == 0) {
|
||||||
|
Serial.printf("[OTA] Start: %s\n", filename.c_str());
|
||||||
|
if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
|
||||||
|
Update.printError(Serial);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
displayManager.showOtaScreen(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Update.write(data, len) != len) {
|
||||||
|
Update.printError(Serial);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate progress based on content-length header
|
||||||
|
if (req->contentLength() > 0) {
|
||||||
|
uint8_t pct = (uint8_t)((index + len) * 100 / req->contentLength());
|
||||||
|
displayManager.showOtaScreen(pct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (final) {
|
||||||
|
if (Update.end(true)) {
|
||||||
|
Serial.printf("[OTA] Success: %u bytes\n", index + len);
|
||||||
|
displayManager.showOtaScreen(100);
|
||||||
|
} else {
|
||||||
|
Update.printError(Serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WifiManager::_handleNotFound(AsyncWebServerRequest* req) {
|
||||||
|
req->send(404, "application/json", "{\"error\":\"not found\"}");
|
||||||
|
}
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* @file main.cpp
|
||||||
|
* @brief Marine Navigation Display — application entry point.
|
||||||
|
*
|
||||||
|
* Boot sequence
|
||||||
|
* ─────────────
|
||||||
|
* 1. Initialise Serial, NeoPixel LED, LittleFS config.
|
||||||
|
* 2. Check for AP-mode button combo held at boot.
|
||||||
|
* If combo detected (or first boot): start WiFi AP + web server.
|
||||||
|
* 3. Otherwise start BLE scanning for the Marine Gateway.
|
||||||
|
* 4. main loop:
|
||||||
|
* a. Poll touch buttons.
|
||||||
|
* b. Handle page navigation events.
|
||||||
|
* c. Check BLE status → update LED.
|
||||||
|
* d. Render display (partial refresh where possible).
|
||||||
|
* e. Run WiFi server tasks.
|
||||||
|
*
|
||||||
|
* WiFi AP ↔ BLE co-existence
|
||||||
|
* ──────────────────────────
|
||||||
|
* The ESP32-S3 radio is shared. We run in one of two mutually exclusive modes:
|
||||||
|
* MODE_BLE — BLE client active, WiFi off.
|
||||||
|
* MODE_WIFI — WiFi AP active, BLE off (configuration / OTA mode).
|
||||||
|
*
|
||||||
|
* The user switches modes via the NEXT+PREV button combo (hold 3 s).
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "Config.h"
|
||||||
|
#include "ConfigManager.h"
|
||||||
|
#include "BleManager.h"
|
||||||
|
#include "DisplayManager.h"
|
||||||
|
#include "WifiManager.h"
|
||||||
|
#include "TouchManager.h"
|
||||||
|
#include "StatusLed.h"
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Application state
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum class AppMode { BLE, WIFI_AP };
|
||||||
|
static AppMode _mode = AppMode::BLE;
|
||||||
|
static bool _otaInProgress = false;
|
||||||
|
|
||||||
|
// Cached WiFi status for LED
|
||||||
|
static AppState::WifiStatus _wifiStatus = AppState::WifiStatus::OFF;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Forward declarations
|
||||||
|
// =============================================================================
|
||||||
|
static void enterBleMode();
|
||||||
|
static void enterApMode();
|
||||||
|
static void handleTouchEvents();
|
||||||
|
static void updateDisplay();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// setup()
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(200);
|
||||||
|
Serial.println("\n╔═══════════════════════════════╗");
|
||||||
|
Serial.println("║ Marine Navigation Display ║");
|
||||||
|
Serial.printf( "║ v%-28s║\n", APP_VERSION);
|
||||||
|
Serial.println("╚═══════════════════════════════╝");
|
||||||
|
|
||||||
|
// ── Status LED ────────────────────────────────────────────────────────────
|
||||||
|
statusLed.begin();
|
||||||
|
statusLed.setColor(0xFFFFFF, 20); // brief white flash on boot
|
||||||
|
|
||||||
|
// ── Persistent config ─────────────────────────────────────────────────────
|
||||||
|
if (!configManager.begin()) {
|
||||||
|
Serial.println("[MAIN] FATAL: Config init failed");
|
||||||
|
// Continue with defaults — non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display ───────────────────────────────────────────────────────────────
|
||||||
|
displayManager.begin(); // Shows splash screen
|
||||||
|
|
||||||
|
// ── Touch ─────────────────────────────────────────────────────────────────
|
||||||
|
touchManager.begin();
|
||||||
|
|
||||||
|
// ── Detect boot-time AP combo (hold NEXT+PREV while powering on) ──────────
|
||||||
|
// Sample touch pads for 500 ms at boot
|
||||||
|
uint32_t comboStart = millis();
|
||||||
|
bool comboDetected = false;
|
||||||
|
while (millis() - comboStart < 500) {
|
||||||
|
if (touchRead(Pins::TOUCH_NEXT) < Touch::THRESHOLD &&
|
||||||
|
touchRead(Pins::TOUCH_PREV) < Touch::THRESHOLD) {
|
||||||
|
comboDetected = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
delay(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comboDetected) {
|
||||||
|
Serial.println("[MAIN] Boot combo detected → entering AP mode");
|
||||||
|
enterApMode();
|
||||||
|
} else {
|
||||||
|
enterBleMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// loop()
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// ── Touch input ───────────────────────────────────────────────────────────
|
||||||
|
touchManager.update();
|
||||||
|
handleTouchEvents();
|
||||||
|
|
||||||
|
// ── Status LED ────────────────────────────────────────────────────────────
|
||||||
|
statusLed.update(bleManager.status(), _wifiStatus, _otaInProgress);
|
||||||
|
|
||||||
|
// ── BLE mode tasks ────────────────────────────────────────────────────────
|
||||||
|
if (_mode == AppMode::BLE) {
|
||||||
|
bleManager.update();
|
||||||
|
|
||||||
|
// Show BLE status banner when not connected
|
||||||
|
static AppState::BleStatus prevBleStatus = AppState::BleStatus::IDLE;
|
||||||
|
AppState::BleStatus curStatus = bleManager.status();
|
||||||
|
if (curStatus != prevBleStatus) {
|
||||||
|
prevBleStatus = curStatus;
|
||||||
|
if (curStatus != AppState::BleStatus::CONNECTED) {
|
||||||
|
displayManager.showBleStatus(curStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WiFi mode tasks ───────────────────────────────────────────────────────
|
||||||
|
if (_mode == AppMode::WIFI_AP) {
|
||||||
|
wifiManager.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display render ────────────────────────────────────────────────────────
|
||||||
|
if (_mode == AppMode::BLE && !_otaInProgress) {
|
||||||
|
updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small yield to let background tasks run
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mode transitions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
static void enterBleMode() {
|
||||||
|
Serial.println("[MAIN] Entering BLE mode");
|
||||||
|
_mode = AppMode::BLE;
|
||||||
|
_wifiStatus = AppState::WifiStatus::OFF;
|
||||||
|
|
||||||
|
if (wifiManager.isActive()) wifiManager.stop();
|
||||||
|
|
||||||
|
bleManager.begin();
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void enterApMode() {
|
||||||
|
Serial.println("[MAIN] Entering WiFi AP mode");
|
||||||
|
_mode = AppMode::WIFI_AP;
|
||||||
|
_wifiStatus = AppState::WifiStatus::AP_ACTIVE;
|
||||||
|
|
||||||
|
// BLE and WiFi share the radio; stop BLE first (NimBLE deinit is implicit
|
||||||
|
// when WiFi takes over, but we force a clean state)
|
||||||
|
// Note: NimBLEDevice::deinit(true) could be called here if needed.
|
||||||
|
|
||||||
|
const AppConfig& cfg = configManager.config();
|
||||||
|
wifiManager.begin(cfg.apSSID, cfg.apPassword);
|
||||||
|
displayManager.showApScreen(cfg.apSSID, WifiManager::AP_IP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Touch event dispatcher
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
static void handleTouchEvents() {
|
||||||
|
// AP combo (NEXT + PREV held 3 s)
|
||||||
|
if (touchManager.apCombo()) {
|
||||||
|
if (_mode == AppMode::BLE) {
|
||||||
|
enterApMode();
|
||||||
|
} else {
|
||||||
|
enterBleMode();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_mode == AppMode::BLE) {
|
||||||
|
if (touchManager.nextPressed()) {
|
||||||
|
configManager.nextPage();
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
Serial.printf("[MAIN] Page → %u\n", configManager.activePage());
|
||||||
|
}
|
||||||
|
if (touchManager.prevPressed()) {
|
||||||
|
configManager.prevPage();
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
Serial.printf("[MAIN] Page ← %u\n", configManager.activePage());
|
||||||
|
}
|
||||||
|
if (touchManager.actionPressed()) {
|
||||||
|
// Force a full e-ink refresh (clears ghosting)
|
||||||
|
displayManager.forceFullRefresh();
|
||||||
|
Serial.println("[MAIN] Manual full refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Display update
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
static void updateDisplay() {
|
||||||
|
BoatState state;
|
||||||
|
bleManager.getBoatState(state);
|
||||||
|
|
||||||
|
const AppConfig& cfg = configManager.config();
|
||||||
|
const PageConfig& page = cfg.pages[cfg.activePage];
|
||||||
|
|
||||||
|
displayManager.render(state, page);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user