init commit

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