From 8bab06b72a86ccefaf25dbfc3411850e277fd5da Mon Sep 17 00:00:00 2001 From: "feeling001@gmail.com" Date: Thu, 18 Jun 2026 15:04:18 +0200 Subject: [PATCH] init commit --- .gitignore | 43 +++ README.md | 220 +++++++++++++ doc/BLE_Client_Documentation.md | 1 + doc/initial_prompt.md | 0 include/BleManager.h | 118 +++++++ include/Config.h | 320 +++++++++++++++++++ include/ConfigManager.h | 80 +++++ include/DisplayManager.h | 110 +++++++ include/StatusLed.h | 52 ++++ include/TouchManager.h | 62 ++++ include/WifiManager.h | 64 ++++ partitions/custom_ota.csv | 10 + platformio.ini | 98 ++++++ platformio.ini_ | 83 +++++ scripts/gen_littlefs.py | 30 ++ src/BleManager.cpp | 395 +++++++++++++++++++++++ src/ConfigManager.cpp | 260 ++++++++++++++++ src/DisplayManager.cpp | 405 ++++++++++++++++++++++++ src/StatusLed.cpp | 89 ++++++ src/TouchManager.cpp | 64 ++++ src/WifiManager.cpp | 534 ++++++++++++++++++++++++++++++++ src/main.cpp | 220 +++++++++++++ 22 files changed, 3258 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 120000 doc/BLE_Client_Documentation.md create mode 100644 doc/initial_prompt.md create mode 100644 include/BleManager.h create mode 100644 include/Config.h create mode 100644 include/ConfigManager.h create mode 100644 include/DisplayManager.h create mode 100644 include/StatusLed.h create mode 100644 include/TouchManager.h create mode 100644 include/WifiManager.h create mode 100644 partitions/custom_ota.csv create mode 100644 platformio.ini create mode 100644 platformio.ini_ create mode 100644 scripts/gen_littlefs.py create mode 100644 src/BleManager.cpp create mode 100644 src/ConfigManager.cpp create mode 100644 src/DisplayManager.cpp create mode 100644 src/StatusLed.cpp create mode 100644 src/TouchManager.cpp create mode 100644 src/WifiManager.cpp create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..081cede --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fbeac3 --- /dev/null +++ b/README.md @@ -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 +#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); +} diff --git a/doc/BLE_Client_Documentation.md b/doc/BLE_Client_Documentation.md new file mode 120000 index 0000000..3d9f85a --- /dev/null +++ b/doc/BLE_Client_Documentation.md @@ -0,0 +1 @@ +../../espNav/doc/BLE_Client_Documentation.md \ No newline at end of file diff --git a/doc/initial_prompt.md b/doc/initial_prompt.md new file mode 100644 index 0000000..e69de29 diff --git a/include/BleManager.h b/include/BleManager.h new file mode 100644 index 0000000..0f30b2b --- /dev/null +++ b/include/BleManager.h @@ -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 + +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; diff --git a/include/Config.h b/include/Config.h new file mode 100644 index 0000000..709422a --- /dev/null +++ b/include/Config.h @@ -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 +#include + +// ============================================================================= +// 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 ""; + } +} diff --git a/include/ConfigManager.h b/include/ConfigManager.h new file mode 100644 index 0000000..b347743 --- /dev/null +++ b/include/ConfigManager.h @@ -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 + +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; diff --git a/include/DisplayManager.h b/include/DisplayManager.h new file mode 100644 index 0000000..664da4a --- /dev/null +++ b/include/DisplayManager.h @@ -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 +#include +#include +#include + +// ── Display model — Waveshare 4.2" mono GDEY042T81 (400×300) ───────────────── +// Adjust the model constant if your display differs. +using DisplayType = GxEPD2_BW; + +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; diff --git a/include/StatusLed.h b/include/StatusLed.h new file mode 100644 index 0000000..c46c7a0 --- /dev/null +++ b/include/StatusLed.h @@ -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 + +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; diff --git a/include/TouchManager.h b/include/TouchManager.h new file mode 100644 index 0000000..7cc3064 --- /dev/null +++ b/include/TouchManager.h @@ -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; diff --git a/include/WifiManager.h b/include/WifiManager.h new file mode 100644 index 0000000..07da4c8 --- /dev/null +++ b/include/WifiManager.h @@ -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 + +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; diff --git a/partitions/custom_ota.csv b/partitions/custom_ota.csv new file mode 100644 index 0000000..8cdbb9d --- /dev/null +++ b/partitions/custom_ota.csv @@ -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, diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..976d48a --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/platformio.ini_ b/platformio.ini_ new file mode 100644 index 0000000..6a9b8b4 --- /dev/null +++ b/platformio.ini_ @@ -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 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 diff --git a/scripts/gen_littlefs.py b/scripts/gen_littlefs.py new file mode 100644 index 0000000..6d02a4b --- /dev/null +++ b/scripts/gen_littlefs.py @@ -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) diff --git a/src/BleManager.cpp b/src/BleManager.cpp new file mode 100644 index 0000000..6cd27db --- /dev/null +++ b/src/BleManager.cpp @@ -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 + +// 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); +} diff --git a/src/ConfigManager.cpp b/src/ConfigManager.cpp new file mode 100644 index 0000000..245b315 --- /dev/null +++ b/src/ConfigManager.cpp @@ -0,0 +1,260 @@ +/** + * @file ConfigManager.cpp + * @brief Implementation of persistent configuration management via LittleFS. + */ + +#include "ConfigManager.h" +#include +#include + +// 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(); + for (uint8_t p = 0; p < _cfg.pageCount; p++) { + JsonObject page = pages.add(); + page["name"] = _cfg.pages[p].name; + + JsonArray tiles = page["tiles"].to(); + for (uint8_t t = 0; t < Display::TILE_COUNT; t++) { + JsonObject tile = tiles.add(); + tile["field"] = static_cast(_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(); + for (uint8_t i = 0; i < static_cast(DataField::_COUNT); i++) { + JsonObject f = fields.add(); + f["id"] = i; + f["name"] = fieldName(static_cast(i)); + f["unit"] = fieldUnit(static_cast(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(); + 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()) { + if (tIdx >= Display::TILE_COUNT) break; + uint8_t fid = tile["field"] | 0; + if (fid < static_cast(DataField::_COUNT)) { + _cfg.pages[pCount].tiles[tIdx].field = static_cast(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(); + 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()) { + if (tIdx >= Display::TILE_COUNT) break; + uint8_t fid = tile["field"] | 0; + _cfg.pages[pIdx].tiles[tIdx].field = + (fid < static_cast(DataField::_COUNT)) + ? static_cast(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(); + for (uint8_t p = 0; p < _cfg.pageCount; p++) { + JsonObject page = pages.add(); + page["name"] = _cfg.pages[p].name; + JsonArray tiles = page["tiles"].to(); + for (uint8_t t = 0; t < Display::TILE_COUNT; t++) { + JsonObject tile = tiles.add(); + tile["field"] = static_cast(_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)); +} diff --git a/src/DisplayManager.cpp b/src/DisplayManager.cpp new file mode 100644 index 0000000..4bda5de --- /dev/null +++ b/src/DisplayManager.cpp @@ -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 + +// 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; + } +} diff --git a/src/StatusLed.cpp b/src/StatusLed.cpp new file mode 100644 index 0000000..1e3312e --- /dev/null +++ b/src/StatusLed.cpp @@ -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; +} diff --git a/src/TouchManager.cpp b/src/TouchManager.cpp new file mode 100644 index 0000000..4a50142 --- /dev/null +++ b/src/TouchManager.cpp @@ -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; +} diff --git a/src/WifiManager.cpp b/src/WifiManager.cpp new file mode 100644 index 0000000..654006f --- /dev/null +++ b/src/WifiManager.cpp @@ -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 +#include +#include +#include + +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( + + + + + +Marine Display — Config + + + +

⚓ Marine Display

+

v-  |  AP: 192.168.4.1

+ + +
+

Live Data -

+
+
+ + +
+

Display Pages

+
+
+
+ + +
+
+ + +
+

BLE Pairing PIN

+ +
+ + +
+
+ + +
+

Marine Gateway WiFi

+ + + + +
+ + +
+
+ + +
+

Firmware Update (OTA)

+ +
+
+ + +
+
+ +
+ + + + +)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(); + 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(); + 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(); + 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\"}"); +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..1fbeac3 --- /dev/null +++ b/src/main.cpp @@ -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 +#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); +}