diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..a545664 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,770 @@ +#include +#include +#include +#include +#include +#include +#include +#include "wifi_scanner.h" +#include "unifont24_vlw.h" + +// ============================================================ +// Face state enum — drives eye/mouth expressions +// ============================================================ +enum FaceState { + FACE_IDLE, // (ʘ_ʘ) default, waiting + FACE_SCANNING, // (⌐■_■) active WiFi/BLE sniff + FACE_HANDSHAKE, // (★_★) valuable PCAP captured + FACE_ERROR, // (>_<) SD failure or system error + FACE_HTTP, // (O_O) hosting FTP server + management AP + FACE_SLEEP // (x_x) deep-sleep transition +}; + +// ============================================================ +// System state +// ============================================================ +bool scanActive = false; +bool bleActive = false; +bool gpsActive = false; +bool autowActive = false; +bool ftpActive = false; +bool int5Active = false; +static const uint8_t INT_INTERVALS[] = {5, 10, 20}; +uint8_t intIntervalIdx = 0; // index into INT_INTERVALS + +FaceState currentFace = FACE_IDLE; +char faceLabel[32] = "IDLE"; +char faceCaption[32] = ""; +char discoveredStr[64] = ""; +uint8_t battPct = 0; + +unsigned long runStart = 0; +bool runActive = false; + +// ============================================================ +// Management AP + FTP server +// ============================================================ +static const char FTP_AP_SSID[] = "Shikigami"; +static const char FTP_AP_PASS[] = "wardriving"; // WPA2 min-8 +static const char FTP_USER[] = "admin"; +static const char FTP_PASS[] = "shiki1337"; +static const char FTP_AP_IP[] = "192.168.4.1"; + +static FtpServer ftpSrv; + +// ============================================================ +// Auto-wake cycle state — persisted across deep sleep via RTC RAM +// ============================================================ + +// Maximum BSSIDs tracked across sleep/wake cycles (~2400 bytes RTC RAM) +static const int AUTOWAKE_MAX_SEEN = 400; + +RTC_DATA_ATTR static bool rAutoWakeActive = false; +RTC_DATA_ATTR static uint8_t rAutoWakeIntervalMins = 5; +RTC_DATA_ATTR static int rSeenCount = 0; +RTC_DATA_ATTR static uint8_t rSeenBSSIDs[AUTOWAKE_MAX_SEEN][6]; + +// Run timer — elapsed ms at time of last sleep, so it survives deep-sleep cycles. +RTC_DATA_ATTR static uint32_t rRunElapsedMs = 0; +RTC_DATA_ATTR static bool rRunWasActive = false; + +static bool bssidIsSeen(const uint8_t* b) { + for (int i = 0; i < rSeenCount; i++) { + if (memcmp(rSeenBSSIDs[i], b, 6) == 0) return true; + } + return false; +} + +static void bssidMarkSeen(const uint8_t* b) { + if (rSeenCount < AUTOWAKE_MAX_SEEN) { + memcpy(rSeenBSSIDs[rSeenCount++], b, 6); + } +} + +// ============================================================ +// Layout constants — 540 × 960 display +// ============================================================ + +// Header bar (full width) +static const int HDR_X = 0; +static const int HDR_Y = 5; +static const int HDR_W = 540; +static const int HDR_H = 45; + +// Version string +static const int VER_X = 10; +static const int VER_Y = 65; + +// Side indicators (GPS left / ACCEL right) +static const int IND_Y = 98; + +// Face region bounding box — partial-refresh target +static const int FACE_BOX_X = 70; +static const int FACE_BOX_Y = 115; +static const int FACE_BOX_W = 400; +static const int FACE_BOX_H = 285; +static const int FACE_CX = 270; // horizontal centre of display +static const int FACE_CY = 245; // vertical centre of face bbox + +// Labels above / below face +static const int FLAVOR_Y = 123; +static const int FLAVOR2_Y = 408; + +// POWER OFF bar (full width) +static const int PWROFF_X = 0; +static const int PWROFF_Y = 425; +static const int PWROFF_W = 540; +static const int PWROFF_H = 50; + +// Discovered device text +static const int DISC_Y = 502; + +// Button grid +static const int BTN_W = 270; +static const int BTN_H = 80; +static const int COL1_X = 0; +static const int COL2_X = 270; +static const int ROW1_Y = 640; +static const int ROW2_Y = 720; +static const int ROW3_Y = 800; +static const int ROW4_Y = 880; +static const int FULLBTN_W = 540; + +// ============================================================ +// Forward declarations +// ============================================================ +void drawFullUI(); +void updateHeader(); +void drawVersionLine(); +void drawIndicators(); +void updateFaceRegion(); +void drawFaceGraphic(); +void drawPowerOffBar(); +void drawDiscoveredText(); +void updateButtonGrid(); +void drawButton(int x, int y, int w, int h, const char* label, bool isActive); +void handleTouch(int x, int y); + +// ============================================================ +// drawButton — inverted when active, outlined when inactive +// ============================================================ +void drawButton(int x, int y, int w, int h, const char* label, bool isActive) { + if (isActive) { + M5.Display.fillRect(x, y, w, h, TFT_BLACK); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + } else { + M5.Display.fillRect(x, y, w, h, TFT_WHITE); + // 3-pixel thick border for inactive buttons + M5.Display.drawRect(x, y, w, h, TFT_BLACK); + M5.Display.drawRect(x+1, y+1, w - 2, h - 2, TFT_BLACK); + M5.Display.drawRect(x+2, y+2, w - 4, h - 4, TFT_BLACK); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + } + M5.Display.setTextDatum(middle_center); + M5.Display.setTextSize(2); + M5.Display.drawString(label, x + w / 2, y + h / 2); +} + +// ============================================================ +// Header bar — STAT / RUN timer / battery +// ============================================================ +void updateHeader() { + M5.Display.fillRect(HDR_X, HDR_Y, HDR_W, HDR_H, TFT_WHITE); + + // Battery — right-aligned, nub on LEFT facing the line + const int BW = 48, BH = 20, R = 3; + const int NUB_W = 5, NUB_H = 10; + const int BX = HDR_W - 6 - BW; + const int LINE_Y = HDR_Y + HDR_H - BH / 2 - 2; // leave 2px gap below battery + const int BY = LINE_Y - BH / 2; // battery centered on the line + const int NUB_X = BX - NUB_W; + const int NUB_Y = LINE_Y - NUB_H / 2; // centered on the line + + // Nub (solid, left of body) + M5.Display.fillRect(NUB_X, NUB_Y, NUB_W, NUB_H, TFT_BLACK); + + // Battery body + M5.Display.fillRoundRect(BX, BY, BW, BH, R, TFT_WHITE); + M5.Display.drawRoundRect(BX, BY, BW, BH, R, TFT_BLACK); + M5.Display.drawRoundRect(BX + 1, BY + 1, BW - 2, BH - 2, R, TFT_BLACK); + + // Fill bar + const int fillW = (BW - 6) * battPct / 100; + if (fillW > 0) + M5.Display.fillRoundRect(BX + 3, BY + 3, fillW, BH - 6, 1, TFT_BLACK); + + // Percent label + char batBuf[8]; + snprintf(batBuf, sizeof(batBuf), "%d%%", battPct); + M5.Display.setTextDatum(middle_center); + M5.Display.setTextSize(1); + M5.Display.setTextColor(fillW > 0 && battPct > 50 ? TFT_WHITE : TFT_BLACK, TFT_WHITE); + M5.Display.drawString(batBuf, BX + BW / 2, BY + BH / 2); + + // Bottom line — left edge to 4px before nub + M5.Display.drawFastHLine(HDR_X, LINE_Y, NUB_X - 4, TFT_BLACK); + M5.Display.drawFastHLine(HDR_X, LINE_Y - 1, NUB_X - 4, TFT_BLACK); + M5.Display.drawFastHLine(HDR_X, LINE_Y - 2, NUB_X - 4, TFT_BLACK); + + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextSize(2); + + // Left — status label reflects current operating state + const char* statStr; + if (currentFace == FACE_SLEEP) statStr = "STAT: SLEEP"; + else if (ftpActive) statStr = "STAT: FTP"; + else if (scanActive) statStr = "STAT: SCAN"; + else statStr = "STAT: WAIT"; + M5.Display.setTextDatum(middle_left); + M5.Display.drawString(statStr, HDR_X + 8, HDR_Y + HDR_H / 2); + + // Right-aligned — run timer + char timeBuf[20]; + if (runActive) { + unsigned long e = (millis() - runStart) / 1000; + snprintf(timeBuf, sizeof(timeBuf), "RUN: %02lu:%02lu:%02lu", + e / 3600, (e % 3600) / 60, e % 60); + } else { + snprintf(timeBuf, sizeof(timeBuf), "RUN: 00:00:00"); + } + M5.Display.setTextDatum(middle_right); + M5.Display.drawString(timeBuf, NUB_X - 8, HDR_Y + HDR_H / 2); +} + +// ============================================================ +// Version string +// ============================================================ +void drawVersionLine() { + M5.Display.fillRect(0, VER_Y - 12, 540, 24, TFT_WHITE); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextDatum(middle_left); + M5.Display.setTextSize(1); + M5.Display.drawString("Shikigami v0.1-{git}", VER_X, VER_Y); + M5.Display.setTextSize(2); +} + +// ============================================================ +// Side indicators — GPS (left) / ACCEL (right) +// ============================================================ +void drawIndicators() { + M5.Display.fillRect(0, IND_Y - 12, 540, 24, TFT_WHITE); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextSize(1); + M5.Display.setTextDatum(middle_left); + M5.Display.drawString("GPS", 10, IND_Y); + M5.Display.setTextDatum(middle_right); + M5.Display.drawString("ACCEL", 530, IND_Y); + M5.Display.setTextSize(2); +} + +// ============================================================ +// Face graphic — drawn into FACE_BOX bounding box +// Eye geometry shared across all states; only eye/mouth style changes. +// ============================================================ +void drawFaceGraphic() { + static const char* const FACE_STR[] = { + "(\xCA\x98_\xCA\x98)", // FACE_IDLE (ʘ_ʘ) + "(\xE2\x8C\x90\xE2\x96\xA0_\xE2\x96\xA0)", // FACE_SCANNING (⌐■_■) + "(\xE2\x98\x85_\xE2\x98\x85)", // FACE_HANDSHAKE (★_★) + "(>_<)", // FACE_ERROR + "(O_O)", // FACE_HTTP + "(x_x)", // FACE_SLEEP + }; + M5.Display.loadFont(unifont24_vlw); + M5.Display.setTextSize(4); + M5.Display.setTextDatum(middle_center); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.drawString(FACE_STR[currentFace], FACE_CX, FACE_CY); + M5.Display.unloadFont(); +} + +// Clears the face bounding box and redraws labels + graphic. +// When FTP is active, replaces the face with connection info instead. +void updateFaceRegion() { + M5.Display.fillRect(FACE_BOX_X, FACE_BOX_Y, FACE_BOX_W, FACE_BOX_H, TFT_WHITE); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextDatum(middle_center); + M5.Display.setTextSize(1); + M5.Display.drawString(faceLabel, FACE_CX, FLAVOR_Y); + + if (ftpActive) { + // Replace face graphic with FTP/AP connection details + char line1[48], line2[48], line3[48]; + snprintf(line1, sizeof(line1), "SSID: %s", FTP_AP_SSID); + snprintf(line2, sizeof(line2), "ftp://%s", FTP_AP_IP); + snprintf(line3, sizeof(line3), "%s / %s", FTP_USER, FTP_PASS); + const int infoSpacing = 45; + const int infoTop = FACE_CY - infoSpacing; + M5.Display.setTextSize(2); + M5.Display.drawString(line1, FACE_CX, infoTop); + M5.Display.drawString(line2, FACE_CX, infoTop + infoSpacing); + M5.Display.drawString(line3, FACE_CX, infoTop + infoSpacing * 2); + } else { + drawFaceGraphic(); + } + + M5.Display.setTextSize(1); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextDatum(middle_center); + M5.Display.drawString(faceCaption, FACE_CX, FLAVOR2_Y); + M5.Display.setTextSize(2); +} + +// ============================================================ +// POWER OFF bar — full-width inverted +// ============================================================ +void drawPowerOffBar() { + M5.Display.fillRect(PWROFF_X, PWROFF_Y, PWROFF_W, PWROFF_H, TFT_BLACK); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + M5.Display.setTextDatum(middle_center); + M5.Display.setTextSize(2); + M5.Display.drawString("POWER OFF", PWROFF_X + PWROFF_W / 2, PWROFF_Y + PWROFF_H / 2); +} + +// ============================================================ +// Discovered device / status text +// ============================================================ +void drawDiscoveredText() { + M5.Display.fillRect(0, DISC_Y - 20, 540, 40, TFT_WHITE); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextDatum(middle_center); + M5.Display.setTextSize(1); + M5.Display.drawString(discoveredStr, 270, DISC_Y); + M5.Display.setTextSize(2); +} + +// ============================================================ +// Button grid — 2×2 split + 2 full-width rows +// ============================================================ +void updateButtonGrid() { + drawButton(COL1_X, ROW1_Y, BTN_W, BTN_H, "SCAN", scanActive); + drawButton(COL2_X, ROW1_Y, BTN_W, BTN_H, "BLE", bleActive); + drawButton(COL1_X, ROW2_Y, BTN_W, BTN_H, "GPS", gpsActive); + drawButton(COL2_X, ROW2_Y, BTN_W, BTN_H, "AUTOW", autowActive); + drawButton(COL1_X, ROW3_Y, FULLBTN_W, BTN_H, "FTP", ftpActive); + char intLabel[16]; + if (int5Active) { + snprintf(intLabel, sizeof(intLabel), "INT %d", INT_INTERVALS[intIntervalIdx]); + } else { + snprintf(intLabel, sizeof(intLabel), "INT 5"); + } + drawButton(COL1_X, ROW4_Y, FULLBTN_W, BTN_H, intLabel, int5Active); +} + +// ============================================================ +// Full-screen draw — used on boot and scheduled full refresh +// ============================================================ +void drawFullUI() { + M5.Display.fillScreen(TFT_WHITE); + updateHeader(); + drawVersionLine(); + drawIndicators(); + updateFaceRegion(); + if (currentFace == FACE_SLEEP) { + drawPowerOffBar(); + } else { + M5.Display.fillRect(PWROFF_X, PWROFF_Y, PWROFF_W, PWROFF_H, TFT_WHITE); + } + drawDiscoveredText(); + updateButtonGrid(); +} + +// ============================================================ +// Deep-sleep entry — draw sleep UI, flush, start timer, sleep +// ============================================================ +static void enterDeepSleep(uint8_t minutes) { + wifiScanner_stop(); + + // Snapshot run-timer elapsed before millis() disappears in deep sleep + rRunWasActive = runActive; + rRunElapsedMs = runActive ? static_cast(millis() - runStart) : rRunElapsedMs; + + // Draw sleep state — switch to quality mode so white-on-black text renders + // correctly (epd_fast can't resolve the non-monotone transition in one pass) + currentFace = FACE_SLEEP; + strncpy(faceLabel, "SLEEPING", sizeof(faceLabel) - 1); + snprintf(faceCaption, sizeof(faceCaption), "Wake in %d min", minutes); + + M5.Display.setEpdMode(epd_mode_t::epd_quality); + M5.Display.fillScreen(TFT_WHITE); + updateHeader(); + updateFaceRegion(); + drawPowerOffBar(); + M5.Display.display(); // blocking full refresh before power-down + + esp_sleep_enable_timer_wakeup((uint64_t)minutes * 60ULL * 1000000ULL); + esp_deep_sleep_start(); + // never returns +} + +// ============================================================ +// Auto-wake scan cycle — runs in setup() on timer wakeup, +// never returns (re-enters deep sleep when done). +// ============================================================ +static const unsigned long AUTOWAKE_SCAN_MS = 30000UL; + +static void autoWakeScanCycle() { + int rawBatt = M5.Power.getBatteryLevel(); + battPct = (uint8_t)(rawBatt > 0 ? rawBatt : 0); + + wifiScanner_init(); + wifiScanner_startTask(); + wifiScanner_start(); + + // Show minimal scanning UI — e-ink retains POWER OFF bar from sleep screen, + // only partial-refresh the header and face region + currentFace = FACE_SCANNING; + strncpy(faceLabel, "AUTO-SCAN", sizeof(faceLabel) - 1); + strncpy(faceCaption, "Scanning...", sizeof(faceCaption) - 1); + updateHeader(); + updateFaceRegion(); + M5.Display.display(); + + // Scan for AUTOWAKE_SCAN_MS, draining packet queue to avoid overflow + const unsigned long scanEnd = millis() + AUTOWAKE_SCAN_MS; + while (millis() < scanEnd) { + WifiPacket pkt; + while (xQueueReceive(wifiPacketQueue, &pkt, 0) == pdTRUE) { + (void)pkt; // TODO: pass to pcapWriter_enqueue() + } + vTaskDelay(100 / portTICK_PERIOD_MS); + } + + wifiScanner_stop(); + + // Count new APs not seen in previous cycles + const WifiAP* apList = nullptr; + int apCount = 0; + wifiScanner_getAPList(&apList, &apCount); + + int newCount = 0; + if (apList) { + for (int i = 0; i < apCount; i++) { + if (!bssidIsSeen(apList[i].bssid)) { + bssidMarkSeen(apList[i].bssid); + newCount++; + } + } + } + + // Show results briefly before sleeping again + if (newCount > 0) { + currentFace = FACE_HANDSHAKE; + snprintf(faceCaption, sizeof(faceCaption), "+%d new AP%s", newCount, newCount == 1 ? "" : "s"); + } else { + currentFace = FACE_IDLE; + strncpy(faceCaption, "No new APs", sizeof(faceCaption) - 1); + } + snprintf(faceLabel, sizeof(faceLabel), "Seen: %d total", rSeenCount); + // Partial refresh only — POWER OFF bar already on screen from sleep render + updateFaceRegion(); + M5.Display.display(); + + vTaskDelay(2500 / portTICK_PERIOD_MS); + + enterDeepSleep(rAutoWakeIntervalMins); + // never returns +} + +// ============================================================ +// Management AP helpers +// ============================================================ + +// AP netif handle — created once, reused on subsequent enable/disable cycles. +static esp_netif_t* s_apNetif = nullptr; + +// Start softAP using raw IDF so we stay consistent with the scanner's +// esp_wifi_* setup and avoid Arduino WiFi library reinitialising the stack. +static void startManagementAP() { + if (!s_apNetif) { + s_apNetif = esp_netif_create_default_wifi_ap(); + } + + // Switch to AP-only mode (scanner is stopped before this is called) + esp_wifi_set_mode(WIFI_MODE_AP); + + wifi_config_t ap_cfg = {}; + memcpy(ap_cfg.ap.ssid, FTP_AP_SSID, strlen(FTP_AP_SSID)); + memcpy(ap_cfg.ap.password, FTP_AP_PASS, strlen(FTP_AP_PASS)); + ap_cfg.ap.ssid_len = static_cast(strlen(FTP_AP_SSID)); + ap_cfg.ap.channel = 6; + ap_cfg.ap.max_connection = 2; + ap_cfg.ap.authmode = WIFI_AUTH_WPA2_PSK; + esp_wifi_set_config(WIFI_IF_AP, &ap_cfg); + + // ftpSrv.begin(FTP_USER, FTP_PASS); // TODO: enable once SD card is mounted +} + +static void stopManagementAP() { + // Return to STA mode so the scanner can re-enable promiscuous + esp_wifi_set_mode(WIFI_MODE_STA); +} + +// ============================================================ +// Run-timer helpers +// ============================================================ + +// Start the run timer the first time any capture feature is enabled; +// stop it when all capture features are disabled. +static void updateRunTimer() { + bool anyCapture = scanActive || bleActive; + if (anyCapture && !runActive) { + runActive = true; + runStart = millis(); + } else if (!anyCapture) { + runActive = false; + } +} + +// ============================================================ +// Touch handling — each button does its own partial redraw +// ============================================================ +void handleTouch(int tx, int ty) { + // Row 1 — SCAN / BLE + if (ty >= ROW1_Y && ty < ROW1_Y + BTN_H) { + if (tx >= COL1_X && tx < COL1_X + BTN_W) { + scanActive = !scanActive; + if (scanActive) { + wifiScanner_start(); + currentFace = FACE_SCANNING; + strncpy(faceLabel, "SCANNING", sizeof(faceLabel) - 1); + snprintf(faceCaption, sizeof(faceCaption), "APs: %d", rSeenCount); + strncpy(discoveredStr, "Listening...", sizeof(discoveredStr) - 1); + } else { + wifiScanner_stop(); + currentFace = FACE_IDLE; + strncpy(faceLabel, "IDLE", sizeof(faceLabel) - 1); + faceCaption[0] = '\0'; + discoveredStr[0] = '\0'; + } + updateRunTimer(); + drawButton(COL1_X, ROW1_Y, BTN_W, BTN_H, "SCAN", scanActive); + updateHeader(); + updateFaceRegion(); + drawDiscoveredText(); + } else if (tx >= COL2_X && tx < COL2_X + BTN_W) { + bleActive = !bleActive; + updateRunTimer(); + drawButton(COL2_X, ROW1_Y, BTN_W, BTN_H, "BLE", bleActive); + updateHeader(); + } + } + // Row 2 — GPS / AUTOW + else if (ty >= ROW2_Y && ty < ROW2_Y + BTN_H) { + if (tx >= COL1_X && tx < COL1_X + BTN_W) { + gpsActive = !gpsActive; + drawButton(COL1_X, ROW2_Y, BTN_W, BTN_H, "GPS", gpsActive); + } else if (tx >= COL2_X && tx < COL2_X + BTN_W) { + autowActive = !autowActive; + drawButton(COL2_X, ROW2_Y, BTN_W, BTN_H, "AUTOW", autowActive); + } + } + // Row 3 — FTP management AP (full width) + // Disables scanner while active; scanner is re-enabled on toggle-off. + else if (ty >= ROW3_Y && ty < ROW3_Y + BTN_H) { + if (tx >= COL1_X && tx < COL1_X + FULLBTN_W) { + ftpActive = !ftpActive; + if (ftpActive) { + // Stop scanning before switching WiFi mode + if (scanActive) { + wifiScanner_stop(); + scanActive = false; + updateRunTimer(); + drawButton(COL1_X, ROW1_Y, BTN_W, BTN_H, "SCAN", false); + } + startManagementAP(); + currentFace = FACE_HTTP; + strncpy(faceLabel, "FTP SERVER", sizeof(faceLabel) - 1); + strncpy(faceCaption, FTP_AP_SSID, sizeof(faceCaption) - 1); + } else { + stopManagementAP(); + currentFace = FACE_IDLE; + strncpy(faceLabel, "IDLE", sizeof(faceLabel) - 1); + faceCaption[0] = '\0'; + } + drawButton(COL1_X, ROW3_Y, FULLBTN_W, BTN_H, "FTP", ftpActive); + updateFaceRegion(); + } + } + // Row 4 — INT interval: first tap enables auto-wake and sleeps immediately. + // While active, subsequent taps cycle the interval and re-sleep. + // Final cycle (wraps back to 0) turns auto-wake off. + else if (ty >= ROW4_Y && ty < ROW4_Y + BTN_H) { + if (tx >= COL1_X && tx < COL1_X + FULLBTN_W) { + if (!int5Active) { + // Turn on — start at 5 min and enter sleep cycle + int5Active = true; + intIntervalIdx = 0; + rAutoWakeActive = true; + rAutoWakeIntervalMins = INT_INTERVALS[intIntervalIdx]; + char intLabel[16]; + snprintf(intLabel, sizeof(intLabel), "INT %d", rAutoWakeIntervalMins); + drawButton(COL1_X, ROW4_Y, FULLBTN_W, BTN_H, intLabel, true); + vTaskDelay(1200 / portTICK_PERIOD_MS); + enterDeepSleep(rAutoWakeIntervalMins); // never returns + } else { + // Cycle interval; wrapping to index 0 turns it off + intIntervalIdx = (intIntervalIdx + 1) % 3; + if (intIntervalIdx == 0) { + int5Active = false; + rAutoWakeActive = false; + drawButton(COL1_X, ROW4_Y, FULLBTN_W, BTN_H, "INT 5", false); + } else { + rAutoWakeIntervalMins = INT_INTERVALS[intIntervalIdx]; + char intLabel[16]; + snprintf(intLabel, sizeof(intLabel), "INT %d", rAutoWakeIntervalMins); + drawButton(COL1_X, ROW4_Y, FULLBTN_W, BTN_H, intLabel, true); + vTaskDelay(1200 / portTICK_PERIOD_MS); + enterDeepSleep(rAutoWakeIntervalMins); // never returns + } + } + } + } + // POWER OFF bar + else if (ty >= PWROFF_Y && ty < PWROFF_Y + PWROFF_H) { + // TODO: initiate graceful power-off sequence (flush SD, draw sleep face) + } +} + +// ============================================================ +// Setup +// ============================================================ +void setup() { + auto cfg = M5.config(); + M5.begin(cfg); + + // Initial battery reading + int rawBatt = M5.Power.getBatteryLevel(); + battPct = (uint8_t)(rawBatt > 0 ? rawBatt : 0); + + M5.Display.setEpdMode(epd_mode_t::epd_fast); + M5.Display.setTextDatum(middle_center); + M5.Display.setTextSize(2); + + // ---- Woke from timer: run auto-scan cycle and go back to sleep ---- + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_TIMER && rAutoWakeActive) { + autoWakeScanCycle(); // never returns + } + + // ---- Normal boot (first power-on or manual reset) ---- + // Default to INT 5 active: the device is a wardriving appliance — + // auto-wake is on from the start. + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_UNDEFINED) { + rAutoWakeActive = true; + rAutoWakeIntervalMins = INT_INTERVALS[0]; // 5 min + intIntervalIdx = 0; + } + + // Restore INT button UI state from RTC vars + if (rAutoWakeActive) { + int5Active = true; + for (uint8_t i = 0; i < 3; i++) { + if (INT_INTERVALS[i] == rAutoWakeIntervalMins) { intIntervalIdx = i; break; } + } + } + + // Restore run timer — offset runStart so elapsed continues from where it paused + if (rRunWasActive) { + runActive = true; + runStart = millis() - rRunElapsedMs; + } + + wifiScanner_init(); + wifiScanner_startTask(); + + drawFullUI(); +} + +// ============================================================ +// Loop +// ============================================================ +void loop() { + M5.update(); + + // Drain WiFi packet queue on Core 1 — hand off to PCAP writer (future module). + { + WifiPacket pkt; + while (xQueueReceive(wifiPacketQueue, &pkt, 0) == pdTRUE) { + // TODO: pass pkt to pcapWriter_enqueue() + (void)pkt; + } + } + + // Refresh scanner-driven UI fields every 2 s while scanning is active. + if (scanActive) { + static unsigned long lastScanUIUpdate = 0; + const unsigned long now = millis(); + if (now - lastScanUIUpdate >= 2000UL) { + lastScanUIUpdate = now; + + const WifiAP* apList = nullptr; + int apCount = 0; + wifiScanner_getAPList(&apList, &apCount); + + // Absorb any new BSSIDs into RTC lifetime tally + if (apList) { + for (int i = 0; i < apCount; i++) { + if (!bssidIsSeen(apList[i].bssid)) { + bssidMarkSeen(apList[i].bssid); + } + } + } + + // faceCaption — lifetime unique AP count across all sessions + snprintf(faceCaption, sizeof(faceCaption), "APs: %d", rSeenCount); + + // discoveredStr — most recently seen AP (last entry in the list) + if (apList && apCount > 0) { + const WifiAP& newest = apList[apCount - 1]; + const uint8_t* b = newest.bssid; + if (newest.ssid[0] != '\0') { + snprintf(discoveredStr, sizeof(discoveredStr), + "%.32s [%02X:%02X:%02X:%02X:%02X:%02X]", + newest.ssid, b[0], b[1], b[2], b[3], b[4], b[5]); + } else { + snprintf(discoveredStr, sizeof(discoveredStr), + "[%02X:%02X:%02X:%02X:%02X:%02X] (hidden)", + b[0], b[1], b[2], b[3], b[4], b[5]); + } + } + + updateFaceRegion(); + drawDiscoveredText(); + } + } + + auto t = M5.Touch.getDetail(); + if (t.wasPressed()) { + handleTouch(t.x, t.y); + } + + // Service FTP server on Core 1 when active. + if (ftpActive) { + ftpSrv.handleFTP(); + } + + // Header refresh — tick the run timer every second; poll battery every 30s. + { + static unsigned long lastHeaderTick = 0; + static uint8_t battTickCount = 0; + const unsigned long now = millis(); + if (now - lastHeaderTick >= 1000UL) { + lastHeaderTick = now; + if (++battTickCount >= 30) { + battTickCount = 0; + int rawBatt = M5.Power.getBatteryLevel(); + battPct = (uint8_t)(rawBatt > 0 ? rawBatt : 0); + } + updateHeader(); + } + } + + // Auto-sleep watchdog — if INT mode is active and we've been awake for the + // full interval, go to sleep now (kicks in whether or not the user is scanning). + if (int5Active && !ftpActive) { + const unsigned long intervalMs = (unsigned long)rAutoWakeIntervalMins * 60UL * 1000UL; + if (millis() >= intervalMs) { + enterDeepSleep(rAutoWakeIntervalMins); // never returns + } + } +} \ No newline at end of file