diff --git a/firmware/keira/platformio.ini b/firmware/keira/platformio.ini index 6dc9555..1baa284 100644 --- a/firmware/keira/platformio.ini +++ b/firmware/keira/platformio.ini @@ -15,6 +15,7 @@ lib_deps = bitbank2/PNGenc @ ^1.1.1 bblanchon/ArduinoJson @ ^7.0.4 bitbank2/AnimatedGIF@^2.1.0 + lemmingdev/ESP32-BLE-Gamepad@^0.5.5 lib_extra_dirs = ./lib build_flags = -D LILKA_VERSION=1 board_build.partitions = ./legacy/v1_partitions.csv @@ -32,5 +33,6 @@ lib_deps = lennarthennigs/ESP Telnet @ ^2.2.1 https://github.com/earlephilhower/ESP8266Audio.git bitbank2/AnimatedGIF@^2.1.0 + lemmingdev/ESP32-BLE-Gamepad@^0.5.5 lib_extra_dirs = ./lib extra_scripts = targets.py diff --git a/firmware/keira/src/apps/ble_gamepad/app.cpp b/firmware/keira/src/apps/ble_gamepad/app.cpp new file mode 100644 index 0000000..385db9a --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/app.cpp @@ -0,0 +1,70 @@ +#include "defines.h" +#include "app.h" +#include "ui.h" + +#define EXIT_BUTTON_PRESS_SECONDS 5 +#define EXIT_BUTTON_PRESS_DELAY ONE_SECOND* EXIT_BUTTON_PRESS_SECONDS + +namespace ble_gamepad_app { + +MainApp::MainApp() : + App(BLE_GAMEPAD_APP_NAME), uiFps(UI_FPS_WATCHER, UI_DELAY_MILLIS), lastSecondsToExit(EXIT_BUTTON_PRESS_SECONDS) { +} + +void MainApp::run() { + if (!bleGamepadController.start()) { + lilka::serial_err("[%s] Starting BLE error!", LOG_TAG); + bleGamepadController.stop(); + } + uiLoop(); +} + +void MainApp::uiLoop() { + UI ui(canvas->width(), canvas->height()); + while (bleGamepadController.isActive()) { + uiFps.onStartFrame(); + if (isExitHotkeyPressed()) { + onStop(); + return; + } + ui.drawFrame(bleGamepadController.isConnected(), lastSecondsToExit); + canvas->drawCanvas(ui.getFrameBuffer()); + queueDraw(); + uiFps.onEndFrame(); + vTaskDelay(uiFps.getLimitMillis() / portTICK_PERIOD_MS); + if (DEBUG) { + uiFps.logEveryOneSecond(); + } + } +} + +bool MainApp::isExitHotkeyPressed() { + lilka::State st = lilka::controller.peekState(); + lilka::ButtonState hotkeyState = st.select; + if (!hotkeyState.pressed) { + lastSecondsToExit = EXIT_BUTTON_PRESS_SECONDS; + return false; + } + uint64_t hotkeyPressTime = hotkeyState.time; + uint64_t curTime = millis(); + uint64_t delta = curTime - hotkeyPressTime; + lastSecondsToExit = EXIT_BUTTON_PRESS_SECONDS - delta / ONE_SECOND; + if (lastSecondsToExit < 0) { + lastSecondsToExit = 0; + } + return lastSecondsToExit == 0; +} + +void MainApp::cleanUp() { + bleGamepadController.stop(); +} + +void MainApp::onStop() { + cleanUp(); +} + +MainApp::~MainApp() { + cleanUp(); +} + +} // namespace ble_gamepad_app diff --git a/firmware/keira/src/apps/ble_gamepad/app.h b/firmware/keira/src/apps/ble_gamepad/app.h new file mode 100644 index 0000000..f800289 --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/app.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include "fps.h" +#include "controller.h" + +namespace ble_gamepad_app { + +class MainApp : public App { +public: + MainApp(); + ~MainApp() override; + +private: + void run() override; + void uiLoop(); + void onStop() override; + bool isExitHotkeyPressed(); + void cleanUp(); + + Controller bleGamepadController; + FPS uiFps; + int lastSecondsToExit; +}; + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/controller.cpp b/firmware/keira/src/apps/ble_gamepad/controller.cpp new file mode 100644 index 0000000..1b370dd --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/controller.cpp @@ -0,0 +1,189 @@ +#include +#include +#include +#include "defines.h" +#include "controller.h" + +#define BUTTON_A BUTTON_2 +#define BUTTON_B BUTTON_1 +#define BUTTON_C BUTTON_4 +#define BUTTON_D BUTTON_3 + +namespace ble_gamepad_app { + +Controller::Controller() : + BleGamepad(DEVICE_NAME), + active(false), + mainLoopFps(MAIN_LOOP_FPS_WATCHER, CONTROLLER_TIMER_DELAY_MILLIS), + batteryLevel(BATTERY_LEVEL_UNKNOWN), + checkBatteryLevelTimer(ONE_MINUTE) { +} + +bool Controller::start() { + lilka::serial_log("[%s] Starting BLE", LOG_TAG); + + controllerTimer = xTimerCreate( + "bleControllerTimer", + pdMS_TO_TICKS(CONTROLLER_TIMER_DELAY_MILLIS), + pdTRUE, + static_cast(this), + Controller::controllerTimerCallback + ); + if (controllerTimer != NULL) { + xTimerStart(controllerTimer, 0); + + BleGamepadConfiguration cfg; + cfg.setControllerType(CONTROLLER_TYPE_GAMEPAD); + cfg.setAutoReport(false); + cfg.setButtonCount(NUM_OF_BUTTONS); + cfg.setIncludeStart(true); + cfg.setIncludeSelect(true); + cfg.setWhichAxes(false, false, false, false, false, false, false, false); + cfg.setWhichSimulationControls(false, false, false, false, false); + begin(&cfg); + + return active = true; + } + return active; +} + +bool Controller::isActive() { + return active; +} + +void Controller::controllerTimerCallback(TimerHandle_t xTimer) { + Controller* instance = static_cast(pvTimerGetTimerID(xTimer)); + if (instance) { + instance->updateControllerState(); + } +} + +void Controller::updateControllerState() { + mainLoopFps.onStartFrame(); + if (!isConnected()) { + return; + } + bool needReport = false; + if (checkBatteryLevelTimer.isTimeOnFirstCall()) { + checkBatteryLevelTimer.go(); + needReport |= updateBatteryLevel(); + } + needReport |= updateButtons(); + if (needReport) { + sendReport(); + } + mainLoopFps.onEndFrame(); + if (DEBUG) { + mainLoopFps.logEveryOneSecond(); + } +} + +bool Controller::updateBatteryLevel() { + int newBatteryLevel = lilka::battery.readLevel(); + if (batteryLevel == newBatteryLevel || newBatteryLevel < BATTERY_LEVEL_NIN || newBatteryLevel > BATTERY_LEVEL_MAX) { + return false; + } + batteryLevel = newBatteryLevel; + setBatteryLevel(batteryLevel); + if (DEBUG) { + lilka::serial_log("[%s] New battery level: %d", LOG_TAG, batteryLevel); + } + return true; +} + +bool Controller::updateButtons() { + lilka::State st = lilka::controller.getState(); + bool needReport = st.any.justPressed || st.any.justReleased; + if (st.up.justPressed || st.right.justPressed || st.down.justPressed || st.left.justPressed || st.up.justReleased || + st.right.justReleased || st.down.justReleased || st.left.justReleased) { + if (!st.up.pressed && !st.right.pressed && !st.down.pressed && !st.left.pressed) { + setHat(DPAD_CENTERED); + } else { + if (st.up.pressed) { + if (st.right.pressed) { + setHat(DPAD_UP_RIGHT); + } else if (st.left.pressed) { + setHat(DPAD_UP_LEFT); + } else { + setHat(DPAD_UP); + } + } else if (st.down.pressed) { + if (st.right.pressed) { + setHat(DPAD_DOWN_RIGHT); + } else if (st.left.pressed) { + setHat(DPAD_DOWN_LEFT); + } else { + setHat(DPAD_DOWN); + } + } else if (st.right.pressed) { + setHat(DPAD_RIGHT); + } else { // st.left.pressed + setHat(DPAD_LEFT); + } + } + } + if (st.a.justPressed) { + press(BUTTON_A); + } else if (st.a.justReleased) { + release(BUTTON_A); + } + if (st.b.justPressed) { + press(BUTTON_B); + } else if (st.b.justReleased) { + release(BUTTON_B); + } + if (st.c.justPressed) { + press(BUTTON_C); + } else if (st.c.justReleased) { + release(BUTTON_C); + } + if (st.d.justPressed) { + press(BUTTON_D); + } else if (st.d.justReleased) { + release(BUTTON_D); + } + if (st.select.justPressed) { + pressSelect(); + } else if (st.select.justReleased) { + releaseSelect(); + } + if (st.start.justPressed) { + pressStart(); + } else if (st.start.justReleased) { + releaseStart(); + } + return needReport; +} + +void Controller::stop() { + if (!active) { + return; + } + lilka::serial_log("[%s] Stopping BLE", LOG_TAG); + int waitTime = 100 / portTICK_PERIOD_MS; + end(); + if (controllerTimer != NULL) { + xTimerStop(controllerTimer, pdFALSE); + xTimerDelete(controllerTimer, 0); + } + vTaskDelay(waitTime); + NimBLEDevice::stopAdvertising(); + vTaskDelay(waitTime); + std::list* clients = NimBLEDevice::getClientList(); + for (auto it = clients->begin(); it != clients->end(); ++it) { + NimBLEClient* client = *it; + NimBLEDevice::deleteClient(client); + } + clients = nullptr; + vTaskDelay(waitTime); + NimBLEDevice::deinit(true); + vTaskDelay(waitTime); + esp_restart(); + active = false; +} + +Controller::~Controller() { + stop(); +} + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/controller.h b/firmware/keira/src/apps/ble_gamepad/controller.h new file mode 100644 index 0000000..d7ec849 --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/controller.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include "fps.h" +#include "timer.h" + +namespace ble_gamepad_app { + +class Controller : public BleGamepad { +public: + Controller(); + ~Controller(); + bool start(); + void stop(); + static void controllerTimerCallback(TimerHandle_t xTimer); + void updateControllerState(); + bool isActive(); + +private: + static constexpr const int NUM_OF_BUTTONS = 4; + + int batteryLevel; + FPS mainLoopFps; + TimerHandle_t controllerTimer; + Timer checkBatteryLevelTimer; + bool active; + + bool updateBatteryLevel(); + bool updateButtons(); +}; + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/defines.h b/firmware/keira/src/apps/ble_gamepad/defines.h new file mode 100644 index 0000000..e64e1af --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/defines.h @@ -0,0 +1,15 @@ +#pragma once + +#define BLE_GAMEPAD_APP_NAME "BLE Геймпад" +#define DEBUG false +#define DEVICE_NAME "Lilka BLE Gamepad" +#define UI_DELAY_MILLIS 68 // 15 fps +#define CONTROLLER_TIMER_DELAY_MILLIS 34 // 30 fps +#define LOG_TAG "BLE Gamepad" +#define UI_FPS_WATCHER "VIEW LOOP" +#define MAIN_LOOP_FPS_WATCHER "MAIN LOOP" +#define ONE_SECOND 1000 +#define ONE_MINUTE 60 * ONE_SECOND +#define BATTERY_LEVEL_UNKNOWN -1 +#define BATTERY_LEVEL_NIN 0 +#define BATTERY_LEVEL_MAX 100 diff --git a/firmware/keira/src/apps/ble_gamepad/fps.cpp b/firmware/keira/src/apps/ble_gamepad/fps.cpp new file mode 100644 index 0000000..5dbc96d --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/fps.cpp @@ -0,0 +1,97 @@ +#include +#include +#include "defines.h" +#include "fps.h" + +namespace ble_gamepad_app { + +FPS::FPS(const char* name, int delayLimitMillis) : + name(name), + delayLimitMillis(delayLimitMillis), + fpsLimit(ONE_SECOND / delayLimitMillis), + startFrameTime(0), + endFrameTime(0), + startLogTime(0), + fpsPerSecondTotal(0), + minFrameTime(0), + maxFrameTime(0), + allFrameTime(0) { +} + +void FPS::onStartFrame() { + startFrameTime = millis(); +} + +void FPS::onEndFrame() { + endFrameTime = millis(); +} + +unsigned long FPS::getDelta() { + if (startFrameTime > endFrameTime) { + return (ULONG_MAX - startFrameTime) + endFrameTime + 1; + } + return endFrameTime - startFrameTime; +} + +unsigned long FPS::getLimitMillis() { + unsigned long delta = getDelta(); + // Incorrect delay time. Using default limits + if (delta == 0) { + return delayLimitMillis; + } + // Too long delay time. Using min limits + if (delta >= delayLimitMillis) { + return 1; + } + // Delay adjustment + unsigned long res = delayLimitMillis - delta; + return res; +} + +void FPS::logEveryOneSecond() { + if (!startLogTime) { + startLogTime = startFrameTime; + } + if (!startLogTime) { + lilka::serial_err("[%s] [%s] The onStartFrame method must be called", LOG_TAG, name); + return; + } + if (!endFrameTime) { + lilka::serial_err("[%s] [%s] The onEndFrame method must be called", LOG_TAG, name); + return; + } + unsigned long deltaFrame = getDelta(); + if (!minFrameTime) { + minFrameTime = deltaFrame; + } else if (deltaFrame < minFrameTime) { + minFrameTime = deltaFrame; + } + if (!maxFrameTime) { + maxFrameTime = deltaFrame; + } else if (deltaFrame > maxFrameTime) { + maxFrameTime = deltaFrame; + } + allFrameTime += deltaFrame; + unsigned long delta = millis() - startLogTime; + fpsPerSecondTotal++; + if (delta < ONE_SECOND) { + return; + } + if (DEBUG) { + lilka::serial_log( + "[%s] [%s] Total: %lu millis, %d fps; Min: %lu millis; Avg: %lu; Max: %lu millis", + LOG_TAG, + name, + delta, + fpsPerSecondTotal, + minFrameTime, + allFrameTime / fpsPerSecondTotal, + maxFrameTime + ); + } + + startLogTime = maxFrameTime = minFrameTime = allFrameTime = 0; + fpsPerSecondTotal = 0; +} + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/fps.h b/firmware/keira/src/apps/ble_gamepad/fps.h new file mode 100644 index 0000000..54a0b23 --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/fps.h @@ -0,0 +1,21 @@ +#pragma once + +namespace ble_gamepad_app { + +class FPS { +public: + FPS(const char* name, int delayLimitMillis); + void onStartFrame(); + void onEndFrame(); + unsigned long getLimitMillis(); + void logEveryOneSecond(); + unsigned long getDelta(); + +private: + const char* name; + const int delayLimitMillis, fpsLimit; + unsigned long startFrameTime, endFrameTime, startLogTime, minFrameTime, maxFrameTime, allFrameTime; + int fpsPerSecondTotal; +}; + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/timer.cpp b/firmware/keira/src/apps/ble_gamepad/timer.cpp new file mode 100644 index 0000000..a6aa687 --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/timer.cpp @@ -0,0 +1,33 @@ +#include +#include "timer.h" + +namespace ble_gamepad_app { + +Timer::Timer(int periodMillis) : period(periodMillis), start(0) { +} + +unsigned long Timer::go() { + return start = getTime(); +} + +bool Timer::isTimeOnFirstCall() { + return isTime(true); +} + +bool Timer::isTime(bool triggerOnFirstCall) { + if (!start) { + go(); + return triggerOnFirstCall; + } + unsigned long curTime = getTime(); + if (start > curTime) { + return (ULONG_MAX - start) + curTime + 1; + } + return (curTime - start) >= period; +} + +unsigned long Timer::getTime() { + return millis(); +} + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/timer.h b/firmware/keira/src/apps/ble_gamepad/timer.h new file mode 100644 index 0000000..01d7a88 --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/timer.h @@ -0,0 +1,18 @@ +#pragma once + +namespace ble_gamepad_app { + +class Timer { +public: + explicit Timer(int periodMillis); + static unsigned long getTime(); + unsigned long go(); + bool isTime(bool triggerOnFirstCall = false); + bool isTimeOnFirstCall(); + +private: + int period; + unsigned long start; +}; + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/ui.cpp b/firmware/keira/src/apps/ble_gamepad/ui.cpp new file mode 100644 index 0000000..6fa388e --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/ui.cpp @@ -0,0 +1,104 @@ +#include +#include "ui.h" + +namespace ble_gamepad_app { + +constexpr const uint16_t UI::GRAY_COLORS[]; +constexpr const uint16_t UI::YELLOW_COLORS[]; +constexpr const uint16_t UI::BLUE_COLORS[]; + +UI::UI(uint16_t width, uint16_t height) : + buffer(width, height), + sizeX(8), + sizeY(8), + width(width), + height(height), + maxPosSide0(width / sizeX - 1), + maxPosSide1(maxPosSide0 + height / sizeY - 1), + maxPosSide2(maxPosSide1 + width / sizeX - 1), + maxPosSide3(maxPosSide2 + height / sizeY - 1), + quarter((maxPosSide3 + 1) / 4), + userMessageOriginX(85), + userMessageOriginY(80), + userMessageDxMin(-20), + userMessageDxMax(20) { + userMessageDirection = -1; + userMessageDx = userMessageDy = 0; + boundPos = 0; +} + +void UI::drawBoundPoint(uint16_t color, int pos) { + int16_t x = 0, y = 0; + if (pos < 0) { + pos += maxPosSide3; + } else if (pos > maxPosSide3) { + pos = pos % maxPosSide3; + } + if (pos >= 0 && pos <= maxPosSide0) { + x = pos * sizeX; + y = 0; + } else if (pos > maxPosSide0 && pos <= maxPosSide1) { + x = width - sizeX; + y = (pos - maxPosSide0) * sizeY; + } else if (pos > maxPosSide1 && pos <= maxPosSide2) { + x = width - sizeX - (pos - maxPosSide1) * sizeX; + y = height - sizeY; + } else { + x = 0; + y = height - sizeY - (pos - maxPosSide2) * sizeY; + } + buffer.fillRect(x, y, sizeX, sizeY, color); +}; + +void UI::drawUserMessage(int lastSecondsToExit) { + char userMessageText3[strlen(USER_MESSAGE_TEXTS[2])]; + sprintf(userMessageText3, USER_MESSAGE_TEXTS[2], lastSecondsToExit); + + int leftIndent = userMessageOriginX + userMessageDx; + int leftIndentCorrection1 = leftIndent + 5; + int leftIndentCorrection2 = leftIndent - 7; + int topIndent = userMessageOriginY + userMessageDy; + int topIndentCorrection1 = topIndent + 18; + int topIndentCorrection2 = topIndent + 50; + buffer.setCursor(leftIndent, topIndent); + buffer.print(USER_MESSAGE_TEXTS[0]); + buffer.setCursor(leftIndentCorrection1, topIndentCorrection1); + buffer.print(USER_MESSAGE_TEXTS[1]); + buffer.setCursor(leftIndentCorrection2, topIndentCorrection2); + buffer.print(userMessageText3); +} + +void UI::drawFrame(bool useDiffColors, int lastSecondsToExit) { + buffer.fillScreen(lilka::colors::Black); + + const uint16_t* partColor0 = useDiffColors ? YELLOW_COLORS : GRAY_COLORS; + const uint16_t* partColor1 = useDiffColors ? BLUE_COLORS : GRAY_COLORS; + + // bounds + for (int8_t i = NUM_OF_STEPS - 1; i >= 0; i--) { + drawBoundPoint(partColor0[i], boundPos - i); + drawBoundPoint(partColor1[i], boundPos - i + quarter); + drawBoundPoint(partColor0[i], boundPos - i + quarter * 2); + drawBoundPoint(partColor1[i], boundPos - i + quarter * 3); + } + boundPos++; + if (boundPos > maxPosSide3) { + boundPos = 0; + } + + // user message + buffer.setTextSize(1); + buffer.setTextColor(lilka::colors::White); + drawUserMessage(lastSecondsToExit); + userMessageDx += userMessageDirection; + userMessageDy = -1 * userMessageDx; + if (userMessageDx > userMessageDxMax || userMessageDx < userMessageDxMin) { + userMessageDirection *= -1; + } +} + +lilka::Canvas* UI::getFrameBuffer() { + return &buffer; +} + +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/ble_gamepad/ui.h b/firmware/keira/src/apps/ble_gamepad/ui.h new file mode 100644 index 0000000..6863328 --- /dev/null +++ b/firmware/keira/src/apps/ble_gamepad/ui.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +namespace ble_gamepad_app { + +class UI { +public: + UI(uint16_t width, uint16_t height); + void drawFrame(bool useDiffColors, int lastSecondsToExit); + lilka::Canvas* getFrameBuffer(); + +private: + static constexpr const int NUM_OF_STEPS = 7; + + static constexpr const uint16_t GRAY_COLORS[] = {0xFFFF, 0xD6BA, 0xAD55, 0x8410, 0x52AA, 0x2965, 0x0}; + static constexpr const uint16_t YELLOW_COLORS[] = {0xFEC0, 0xDDC0, 0xBCE0, 0x93E0, 0x72E0, 0x4A00, 0x2900}; + static constexpr const uint16_t BLUE_COLORS[] = {0x03BF, 0x033B, 0x02B7, 0x0232, 0x01AE, 0x0129, 0x0085}; + + static constexpr const char* USER_MESSAGE_TEXTS[] = {"Для виходу", "затисніть", "select %d сек"}; + + lilka::Canvas buffer; + + void drawBoundPoint(uint16_t color, int pos); + void drawUserMessage(int lastSecondsToExit); + + const uint16_t sizeX, sizeY, width, height; + const int maxPosSide0, maxPosSide1, maxPosSide2, maxPosSide3, quarter; + int boundPos; + + const int16_t userMessageOriginX, userMessageOriginY, userMessageDxMax, userMessageDxMin; + int8_t userMessageDx, userMessageDy, userMessageDirection; +}; +} // namespace ble_gamepad_app \ No newline at end of file diff --git a/firmware/keira/src/apps/launcher.cpp b/firmware/keira/src/apps/launcher.cpp index c7c7bfa..144e182 100644 --- a/firmware/keira/src/apps/launcher.cpp +++ b/firmware/keira/src/apps/launcher.cpp @@ -30,6 +30,7 @@ #include "weather/weather.h" #include "modplayer/modplayer.h" #include "liltracker/liltracker.h" +#include "ble_gamepad/app.h" #include "icons/demos.h" #include "icons/sdcard.h" @@ -76,6 +77,7 @@ ITEM_LIST app_items = { ITEM_APP("Летріс", LetrisApp), ITEM_APP("Тамагочі", TamagotchiApp), ITEM_APP("Погода", WeatherApp), + ITEM_APP("BLE Геймпад", ble_gamepad_app::MainApp) }; ITEM_LIST dev_items = {