diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 629a4749..c9a67006 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -305,6 +305,11 @@ STR_DIR_DOWN: "Down" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter" STR_FILTER_CONTRAST: "Contrast" +STR_TILT_PAGE_TURN: "Tilt Page Turn" +STR_TILT_STABILIZATION: "Tilt Mode" +STR_TILT_MODE_RAW: "Raw Acceleration" +STR_TILT_MODE_SMOOTH: "Smoothed Acceleration" +STR_TILT_MODE_KALMAN: "Gyro/Accel Fusion" STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar" STR_CHAPTER_PAGE_COUNT: "Chapter Page Count" STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 25aff662..2a05613d 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -251,6 +251,11 @@ STR_DIR_DOWN: "Bas" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtre écran de veille" STR_FILTER_CONTRAST: "Contraste" +STR_TILT_PAGE_TURN: "Changement de page par inclinaison" +STR_TILT_STABILIZATION: "Mode inclinaison" +STR_TILT_MODE_RAW: "Acceleration brute" +STR_TILT_MODE_SMOOTH: "Acceleration lisse" +STR_TILT_MODE_KALMAN: "Fusion gyroscope/accelerometre" STR_CUSTOMISE_STATUS_BAR: "Personnaliser la barre d'état" STR_CHAPTER_PAGE_COUNT: "Nombre de pages du chapitre" STR_BOOK_PROGRESS_PERCENTAGE: "Pourcentage de progression" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index f7d51c63..0a6080de 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -242,6 +242,11 @@ STR_DIR_DOWN: "Runter" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Standby-Coverfilter" STR_FILTER_CONTRAST: "Kontrast" +STR_TILT_PAGE_TURN: "Seitenblaettern durch Neigen" +STR_TILT_STABILIZATION: "Neigungsmodus" +STR_TILT_MODE_RAW: "Rohbeschleunigung" +STR_TILT_MODE_SMOOTH: "Geglaettete Beschleunigung" +STR_TILT_MODE_KALMAN: "Gyro-/Beschleunigungsfusion" STR_CUSTOMISE_STATUS_BAR: "Statusleiste anpassen" STR_CHAPTER_PAGE_COUNT: "Kapitel-Seitenanzahl" STR_BOOK_PROGRESS_PERCENTAGE: "Buchfortschritt in %" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 49f69682..6eec1151 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -251,6 +251,11 @@ STR_DIR_DOWN: "Giù" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtro copertina" STR_FILTER_CONTRAST: "Contrasto" +STR_TILT_PAGE_TURN: "Cambio pagina con inclinazione" +STR_TILT_STABILIZATION: "Modalita inclinazione" +STR_TILT_MODE_RAW: "Accelerazione grezza" +STR_TILT_MODE_SMOOTH: "Accelerazione smussata" +STR_TILT_MODE_KALMAN: "Fusione giroscopio/accelerometro" STR_CUSTOMISE_STATUS_BAR: "Personalizza barra di stato" STR_CHAPTER_PAGE_COUNT: "Conteggio pagine capitolo" STR_BOOK_PROGRESS_PERCENTAGE: "Percentuale avanzamento lettura" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index d1359d4b..a7e99f31 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -434,6 +434,10 @@ STR_CRASH_DESCRIPTION: "Szczegółowy raport zapisany do crash_report.txt. Prosi STR_CRASH_REASON: "Powód awarii:" STR_CRASH_NO_REASON: "(Nie odnotowano powodu)" STR_TILT_PAGE_TURN: "Kartkowanie przechyłem" +STR_TILT_STABILIZATION: "Tryb przechyłu" +STR_TILT_MODE_RAW: "Surowe przyspieszenie" +STR_TILT_MODE_SMOOTH: "Wygładzone przyspieszenie" +STR_TILT_MODE_KALMAN: "Fuzja żyroskopu/akcelerometru" STR_RENDER_BENCHMARK: "Benchmark renderowania" STR_WEATHER: "Pogoda" STR_WEATHER_LOCATION: "Lokalizacja" diff --git a/lib/hal/HalTiltSensor.cpp b/lib/hal/HalTiltSensor.cpp new file mode 100644 index 00000000..b744d27d --- /dev/null +++ b/lib/hal/HalTiltSensor.cpp @@ -0,0 +1,267 @@ +#include "HalTiltSensor.h" + +#include + +HalTiltSensor halTiltSensor; + +bool HalTiltSensor::writeReg(uint8_t reg, uint8_t val) const { + Wire.beginTransmission(_i2cAddr); + Wire.write(reg); + Wire.write(val); + return Wire.endTransmission() == 0; +} + +bool HalTiltSensor::readReg(uint8_t reg, uint8_t* val) const { + Wire.beginTransmission(_i2cAddr); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) { + return false; + } + Wire.requestFrom(_i2cAddr, static_cast(1)); + if (Wire.available() < 1) { + return false; + } + *val = Wire.read(); + return true; +} + +bool HalTiltSensor::readReg16LE(uint8_t reg, int16_t* val) const { + Wire.beginTransmission(_i2cAddr); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) { + return false; + } + if (Wire.requestFrom(_i2cAddr, static_cast(2), static_cast(true)) < 2) { + return false; + } + const uint8_t lo = Wire.read(); + const uint8_t hi = Wire.read(); + *val = static_cast((static_cast(hi) << 8) | lo); + return true; +} + +bool HalTiltSensor::readAccel(float& ax, float& ay, float& az) const { + int16_t rawAx = 0; + int16_t rawAy = 0; + int16_t rawAz = 0; + if (!readReg16LE(REG_AX_L, &rawAx) || !readReg16LE(REG_AX_L + 2, &rawAy) || !readReg16LE(REG_AX_L + 4, &rawAz)) { + return false; + } + + constexpr float SCALE = 1.0f / 16384.0f; + ax = rawAx * SCALE; + ay = rawAy * SCALE; + az = rawAz * SCALE; + return true; +} + +bool HalTiltSensor::readAccelGyro(float& ax, float& ay, float& az, float& gx, float& gy, float& gz) const { + int16_t rawAx = 0; + int16_t rawAy = 0; + int16_t rawAz = 0; + int16_t rawGx = 0; + int16_t rawGy = 0; + int16_t rawGz = 0; + if (!readReg16LE(REG_AX_L, &rawAx) || !readReg16LE(REG_AX_L + 2, &rawAy) || !readReg16LE(REG_AX_L + 4, &rawAz) || + !readReg16LE(REG_GYRO_X_L, &rawGx) || !readReg16LE(REG_GYRO_Y_L, &rawGy) || !readReg16LE(REG_GYRO_Z_L, &rawGz)) { + return false; + } + + constexpr float ACC_SCALE = 1.0f / 16384.0f; + constexpr float GYRO_SCALE = 512.0f / 32768.0f; + ax = rawAx * ACC_SCALE; + ay = rawAy * ACC_SCALE; + az = rawAz * ACC_SCALE; + gx = rawGx * GYRO_SCALE; + gy = rawGy * GYRO_SCALE; + gz = rawGz * GYRO_SCALE; + return true; +} + +void HalTiltSensor::begin() { + if (!gpio.deviceIsX3()) { + _available = false; + return; + } + + uint8_t whoami = 0; + _i2cAddr = TILT_I2C_ADDR; + if (!readReg(REG_WHO_AM_I, &whoami) || whoami != TILT_WHO_AM_I_VALUE) { + _i2cAddr = TILT_I2C_ADDR_ALT; + if (!readReg(REG_WHO_AM_I, &whoami) || whoami != QMI8658_WHO_AM_I_VALUE) { + LOG_INF("TILT", "QMI8658 IMU not found"); + _available = false; + return; + } + } + + LOG_INF("TILT", "QMI8658 IMU found at 0x%02X", _i2cAddr); + + if (!writeReg(REG_CTRL1, 0x40) || !writeReg(REG_CTRL2, CTRL2_2G_125HZ) || !writeReg(REG_CTRL3, CTRL3_512DPS_125HZ) || + !writeReg(REG_CTRL7, CTRL7_ACCEL_GYRO_EN)) { + LOG_INF("TILT", "QMI8658 register configuration failed"); + _available = false; + return; + } + + _available = true; + _lastPollMs = millis(); + _lastTiltMs = millis(); + _lastKalmanMicros = 0; + _filterInitialized = false; + _filterStartMs = millis(); + LOG_INF("TILT", "QMI8658 accelerometer initialized (±2g, 125 Hz) and gyro enabled"); +} + +void HalTiltSensor::deepSleep() { + if (!_available) { + return; + } + clearPendingEvents(); + _inTilt = false; + _filterInitialized = false; + _lastKalmanMicros = 0; +} + +void HalTiltSensor::clearPendingEvents() { + _tiltForwardEvent = false; + _tiltBackEvent = false; + _hadActivity = false; +} + +void HalTiltSensor::update(bool enabled, uint8_t mode, uint8_t orientation) { + if (!enabled || !_available) { + return; + } + + const unsigned long now = millis(); + if ((now - _lastPollMs) < POLL_INTERVAL_MS) { + return; + } + _lastPollMs = now; + + bool isAngleMode = mode == 2; + float tiltValue = 0.0f; + if (isAngleMode) { + float ax, ay, az, gx, gy, gz; + if (!readAccelGyro(ax, ay, az, gx, gy, gz)) { + return; + } + + const float accRoll = atan2(ay, sqrt(ax * ax + az * az)) * (180.0f / PI); + const float accPitch = atan2(-ax, sqrt(ay * ay + az * az)) * (180.0f / PI); + + const unsigned long nowMicros = micros(); + const unsigned long deltaMicros = nowMicros - _lastKalmanMicros; + float dt = 0.008f; + if (_lastKalmanMicros != 0 && deltaMicros <= 150000u) { + dt = static_cast(deltaMicros) * 1e-6f; + } else { + _kalmanRoll.setAngle(accRoll); + _kalmanPitch.setAngle(accPitch); + } + _lastKalmanMicros = nowMicros; + + const float stableRoll = _kalmanRoll.update(accRoll, gx, dt); + const float stablePitch = _kalmanPitch.update(accPitch, gy, dt); + + switch (orientation) { + case 0: // PORTRAIT + tiltValue = stablePitch; + break; + case 2: // INVERTED + tiltValue = -stablePitch; + break; + case 1: // LANDSCAPE_CW + tiltValue = stableRoll; + break; + case 3: // LANDSCAPE_CCW + tiltValue = -stableRoll; + break; + default: + tiltValue = stablePitch; + break; + } + } else { + float ax, ay, az; + if (!readAccel(ax, ay, az)) { + return; + } + + float tiltAxis; + switch (orientation) { + case 0: // PORTRAIT + tiltAxis = ay; + break; + case 2: // INVERTED + tiltAxis = -ay; + break; + case 1: // LANDSCAPE_CW + tiltAxis = ax; + break; + case 3: // LANDSCAPE_CCW + tiltAxis = -ax; + break; + default: + tiltAxis = ay; + break; + } + + if (mode == 1) { + if (!_filterInitialized) { + _filteredAxis = tiltAxis; + _filterInitialized = true; + _filterStartMs = now; + } else { + _filteredAxis = _filteredAxis * (1.0f - FILTER_ALPHA) + tiltAxis * FILTER_ALPHA; + } + if ((now - _filterStartMs) < FILTER_WARMUP_MS) { + return; + } + tiltAxis = _filteredAxis; + } + tiltValue = tiltAxis; + } + + if (_inTilt) { + const float neutralThreshold = isAngleMode ? NEUTRAL_THRESHOLD_DEG : NEUTRAL_THRESHOLD_G; + if (fabsf(tiltValue) < neutralThreshold) { + _inTilt = false; + } + return; + } + + if ((now - _lastTiltMs) < COOLDOWN_MS) { + return; + } + + if (tiltValue > (isAngleMode ? TILT_THRESHOLD_DEG : TILT_THRESHOLD_G)) { + _tiltForwardEvent = true; + _inTilt = true; + _hadActivity = true; + _lastTiltMs = now; + } else if (tiltValue < (isAngleMode ? -TILT_THRESHOLD_DEG : -TILT_THRESHOLD_G)) { + _tiltBackEvent = true; + _inTilt = true; + _hadActivity = true; + _lastTiltMs = now; + } +} + +bool HalTiltSensor::wasTiltedForward() { + const bool val = _tiltForwardEvent; + _tiltForwardEvent = false; + return val; +} + +bool HalTiltSensor::wasTiltedBack() { + const bool val = _tiltBackEvent; + _tiltBackEvent = false; + return val; +} + +bool HalTiltSensor::hadActivity() { + const bool val = _hadActivity; + _hadActivity = false; + return val; +} diff --git a/lib/hal/HalTiltSensor.h b/lib/hal/HalTiltSensor.h new file mode 100644 index 00000000..a5cfa45c --- /dev/null +++ b/lib/hal/HalTiltSensor.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include + +#include "HalGPIO.h" + +class HalTiltSensor; +extern HalTiltSensor halTiltSensor; + +class HalTiltSensor { + bool _available = false; + uint8_t _i2cAddr = 0; + bool _tiltForwardEvent = false; + bool _tiltBackEvent = false; + bool _inTilt = false; + bool _hadActivity = false; + bool _filterInitialized = false; + float _filteredAxis = 0.0f; + unsigned long _filterStartMs = 0; + unsigned long _lastTiltMs = 0; + unsigned long _lastKalmanMicros = 0; + mutable unsigned long _lastPollMs = 0; + + static constexpr float TILT_THRESHOLD_G = 0.45f; // ~27° tilt to trigger + static constexpr float NEUTRAL_THRESHOLD_G = 0.25f; // Must return below this before next trigger + static constexpr float TILT_THRESHOLD_DEG = 27.0f; // Angle threshold for fused tilt detection + static constexpr float NEUTRAL_THRESHOLD_DEG = 15.0f; + static constexpr unsigned long COOLDOWN_MS = 600; // Minimum ms between triggers + static constexpr unsigned long POLL_INTERVAL_MS = 50; // 20 Hz polling + static constexpr float FILTER_ALPHA = 0.25f; // Smoothing factor for axis stabilization + static constexpr unsigned long FILTER_WARMUP_MS = 300; // Stabilization warmup after first sample + + static constexpr uint8_t TILT_I2C_ADDR = 0x6A; + static constexpr uint8_t TILT_I2C_ADDR_ALT = 0x6B; + static constexpr uint8_t TILT_WHO_AM_I_VALUE = 0x05; + static constexpr uint8_t REG_WHO_AM_I = 0x00; + static constexpr uint8_t REG_CTRL1 = 0x02; + static constexpr uint8_t REG_CTRL2 = 0x03; + static constexpr uint8_t REG_CTRL3 = 0x04; + static constexpr uint8_t REG_CTRL7 = 0x08; + static constexpr uint8_t REG_AX_L = 0x35; + static constexpr uint8_t REG_GYRO_X_L = 0x3B; + static constexpr uint8_t REG_GYRO_Y_L = 0x3D; + static constexpr uint8_t REG_GYRO_Z_L = 0x3F; + + static constexpr uint8_t CTRL2_2G_125HZ = 0x06; + static constexpr uint8_t CTRL3_512DPS_125HZ = 0x45; + static constexpr uint8_t CTRL7_ACCEL_GYRO_EN = 0x03; + + struct KalmanFilter { + KalmanFilter() { + Q_angle = 0.001f; + Q_bias = 0.003f; + R_measure = 0.03f; + angle = 0.0f; + bias = 0.0f; + P[0][0] = 0.0f; + P[0][1] = 0.0f; + P[1][0] = 0.0f; + P[1][1] = 0.0f; + } + + float update(float newAngle, float newRate, float dt) { + float rate = newRate - bias; + angle += dt * rate; + + P[0][0] += dt * (dt * P[1][1] - P[0][1] - P[1][0] + Q_angle); + P[0][1] -= dt * P[1][1]; + P[1][0] -= dt * P[1][1]; + P[1][1] += Q_bias * dt; + + float S = P[0][0] + R_measure; + float K0 = P[0][0] / S; + float K1 = P[1][0] / S; + + float y = newAngle - angle; + angle += K0 * y; + bias += K1 * y; + + float P00_temp = P[0][0]; + float P01_temp = P[0][1]; + + P[0][0] -= K0 * P00_temp; + P[0][1] -= K0 * P01_temp; + P[1][0] -= K1 * P00_temp; + P[1][1] -= K1 * P01_temp; + + return angle; + } + + void setAngle(float a) { angle = a; } + + private: + float Q_angle; + float Q_bias; + float R_measure; + float angle; + float bias; + float P[2][2]; + }; + + KalmanFilter _kalmanRoll; + KalmanFilter _kalmanPitch; + + bool writeReg(uint8_t reg, uint8_t val) const; + bool readReg(uint8_t reg, uint8_t* val) const; + bool readReg16LE(uint8_t reg, int16_t* val) const; + bool readAccel(float& ax, float& ay, float& az) const; + bool readAccelGyro(float& ax, float& ay, float& az, float& gx, float& gy, float& gz) const; + + public: + void begin(); + void deepSleep(); + void update(bool enabled, uint8_t mode, uint8_t orientation); + + bool wasTiltedForward(); + bool wasTiltedBack(); + bool hadActivity(); + + bool isAvailable() const { return _available; } + void clearPendingEvents(); +}; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index a3de775b..f2045831 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -137,6 +137,14 @@ class CrossPointSettings { // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; + // Action mapped to each tilt gesture direction. + enum TILT_GESTURE_ACTION { + TILT_ACT_NONE = 0, + TILT_ACT_NEXT_PAGE = 1, + TILT_ACT_PREV_PAGE = 2, + TILT_GESTURE_ACTION_COUNT + }; + // Text darkness for AA glyph rendering (forwarded to GfxRenderer::setTextDarkness) enum TEXT_DARKNESS { DARKNESS_NORMAL = 0, // true 4-level AA @@ -257,6 +265,14 @@ class CrossPointSettings { uint8_t imageRendering = IMAGES_DISPLAY; // Dithering mode for decoded images (EPUB/JPG/PNG) uint8_t imageDithering = IMAGE_DITHER_BAYER; + // Tilt-based page turning (X3 only — requires QMI8658 IMU) + uint8_t tiltPageTurn = 0; + // Tilt input processing mode: 0 = raw accel, 1 = smoothed accel, 2 = gyro/accelerometer fusion + uint8_t tiltStabilization = 0; + // Action when the computed tilt value crosses the positive threshold. + uint8_t tiltPositiveAction = TILT_ACT_NEXT_PAGE; + // Action when the computed tilt value crosses the negative threshold. + uint8_t tiltNegativeAction = TILT_ACT_PREV_PAGE; // Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled) uint8_t syntheticTocFallback = 1; // Default bionic reading in EPUB pages when no per-book override is set (1 = enabled, 0 = disabled) diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 1d3702a6..16ff9987 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -180,8 +180,9 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) { JsonDocument doc; + const auto settings = getSettingsList(); - for (const auto& info : getSettingsList()) { + for (const auto& info : settings) { if (!info.key) continue; // Dynamic entries (KOReader etc.) are stored in their own files — skip. if (!info.valuePtr && !info.stringOffset) continue; @@ -258,8 +259,9 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* migrateMissingStatusSetting("statusBarItemsPosition", s.statusBarItemsPosition, "statusBarItemsPosition", CrossPointSettings::STATUS_BAR_ITEMS_BOTTOM, CrossPointSettings::STATUS_BAR_ITEMS_POSITION_COUNT); + const auto settings = getSettingsList(); - for (const auto& info : getSettingsList()) { + for (const auto& info : settings) { if (!info.key) continue; // Dynamic entries (KOReader etc.) are stored in their own files — skip. if (!info.valuePtr && !info.stringOffset) continue; diff --git a/src/SettingsList.h b/src/SettingsList.h index 495ebfd7..02f7417b 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -1,7 +1,10 @@ #pragma once +#include #include +#include +#include #include #include "CrossPointSettings.h" @@ -25,6 +28,7 @@ // works inside a submenu exactly as it does in the parent tab. // Add with .withSubmenu(StrId::STR_MY_SUBMENU). // key — JSON property name used by the web settings API (nullptr = device-only). +// deviceTarget — hardware visibility (BOTH by default; override with .withDeviceTarget()). // // ACTION-type entries and entries without a key are device-only and are added directly // in SettingsActivity::onEnter(), not here. @@ -222,6 +226,24 @@ inline const std::vector list = { SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongPower, {StrId::STR_BTN_DEF_SLEEP}, "btnLongPower", StrId::STR_CAT_CONTROLS) .withSubmenu(StrId::STR_BTN_POWER), + SettingInfo::Toggle(StrId::STR_TILT_PAGE_TURN, &CrossPointSettings::tiltPageTurn, "tiltPageTurn", + StrId::STR_CAT_CONTROLS) + .withSubcategory(StrId::STR_TILT_PAGE_TURN) + .withDeviceTarget(SettingDeviceTarget::X3), + SettingInfo::Enum(StrId::STR_TILT_STABILIZATION, &CrossPointSettings::tiltStabilization, + {StrId::STR_TILT_MODE_RAW, StrId::STR_TILT_MODE_SMOOTH, StrId::STR_TILT_MODE_KALMAN}, + "tiltStabilization", StrId::STR_CAT_CONTROLS) + .withDeviceTarget(SettingDeviceTarget::X3), + SettingInfo::Enum(StrId::STR_DIR_RIGHT, &CrossPointSettings::tiltPositiveAction, + {StrId::STR_NONE_OPT, StrId::STR_NEXT_PAGE, StrId::STR_PREV_PAGE}, "tiltPositiveAction", + StrId::STR_CAT_CONTROLS) + .withSubcategory(StrId::STR_TILT_PAGE_TURN) + .withDeviceTarget(SettingDeviceTarget::X3), + SettingInfo::Enum(StrId::STR_DIR_LEFT, &CrossPointSettings::tiltNegativeAction, + {StrId::STR_NONE_OPT, StrId::STR_NEXT_PAGE, StrId::STR_PREV_PAGE}, "tiltNegativeAction", + StrId::STR_CAT_CONTROLS) + .withSubcategory(StrId::STR_TILT_PAGE_TURN) + .withDeviceTarget(SettingDeviceTarget::X3), #undef BTN_ACT_OPTIONS @@ -322,4 +344,19 @@ inline const std::vector list = { }; } // namespace SettingsListDetail -inline const std::vector& getSettingsList() { return SettingsListDetail::list; } +inline std::vector getSettingsList() { + std::vector settings = SettingsListDetail::list; + const bool isX3 = gpio.deviceIsX3(); + settings.erase(std::remove_if(settings.begin(), settings.end(), + [isX3](const SettingInfo& setting) { + if (setting.deviceTarget == SettingDeviceTarget::BOTH) { + return false; + } + if (setting.deviceTarget == SettingDeviceTarget::X3) { + return !isX3; + } + return isX3; + }), + settings.end()); + return settings; +} diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 851d5079..d50eb97e 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -94,18 +95,36 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { // Also suppress immediate page-turns if a double-click action is configured for the button, // because the button event system delays short events until the double-click window expires. using BA = CrossPointSettings::BUTTON_ACTION; - const bool prev = (SETTINGS.btnShortPageBack == BA::BTN_DEFAULT && - !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::PageBack) && - input.wasReleased(MappedInputManager::Button::PageBack)) || - (SETTINGS.btnShortLeft == BA::BTN_DEFAULT && - !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::Left) && - input.wasReleased(MappedInputManager::Button::Left)); - const bool next = (SETTINGS.btnShortPageForward == BA::BTN_DEFAULT && - !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::PageForward) && - input.wasReleased(MappedInputManager::Button::PageForward)) || - (SETTINGS.btnShortRight == BA::BTN_DEFAULT && - !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::Right) && - input.wasReleased(MappedInputManager::Button::Right)); + using TA = CrossPointSettings::TILT_GESTURE_ACTION; + const bool tiltNegative = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); + const bool tiltPositive = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); + bool tiltPrev = false; + bool tiltNext = false; + auto applyTiltAction = [&](uint8_t action) { + if (action == TA::TILT_ACT_NEXT_PAGE) { + tiltNext = true; + } else if (action == TA::TILT_ACT_PREV_PAGE) { + tiltPrev = true; + } + }; + if (tiltPositive) { + applyTiltAction(SETTINGS.tiltPositiveAction); + } + if (tiltNegative) { + applyTiltAction(SETTINGS.tiltNegativeAction); + } + const bool prev = tiltPrev || ((SETTINGS.btnShortPageBack == BA::BTN_DEFAULT && + !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::PageBack) && + input.wasReleased(MappedInputManager::Button::PageBack)) || + (SETTINGS.btnShortLeft == BA::BTN_DEFAULT && + !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::Left) && + input.wasReleased(MappedInputManager::Button::Left))); + const bool next = tiltNext || ((SETTINGS.btnShortPageForward == BA::BTN_DEFAULT && + !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::PageForward) && + input.wasReleased(MappedInputManager::Button::PageForward)) || + (SETTINGS.btnShortRight == BA::BTN_DEFAULT && + !globalButtonEvents().hasDoubleAction(MappedInputManager::Button::Right) && + input.wasReleased(MappedInputManager::Button::Right))); return {prev, next}; } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index de670db3..3cc1abb5 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -196,6 +196,25 @@ void TxtReaderActivity::loop() { return; } } + + auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput); + if (!prevTriggered && !nextTriggered) { + return; + } + + if (prevTriggered) { + if (currentPage > 0) { + currentPage--; + requestUpdate(); + } + } else if (nextTriggered) { + if (currentPage < totalPages - 1) { + currentPage++; + requestUpdate(); + } else { + finish(); + } + } } void TxtReaderActivity::initializeReader() { diff --git a/src/activities/settings/ButtonActionsOverviewActivity.cpp b/src/activities/settings/ButtonActionsOverviewActivity.cpp index b281e266..8ecb6331 100644 --- a/src/activities/settings/ButtonActionsOverviewActivity.cpp +++ b/src/activities/settings/ButtonActionsOverviewActivity.cpp @@ -30,18 +30,13 @@ constexpr std::array kButtonRows = {{ }}; // Find the SettingInfo for a given (button submenu, press kind) pair in the shared settings list. -const SettingInfo* findEntry(StrId submenu, StrId pressKind) { - const auto& list = getSettingsList(); +std::string cellValue(StrId submenu, StrId pressKind) { + const auto list = getSettingsList(); auto it = std::find_if(list.begin(), list.end(), [submenu, pressKind](const SettingInfo& s) { return s.submenu == submenu && s.nameId == pressKind && s.category == StrId::STR_CAT_CONTROLS; }); - return it != list.end() ? &*it : nullptr; -} - -std::string cellValue(StrId submenu, StrId pressKind) { - const SettingInfo* s = findEntry(submenu, pressKind); - if (!s) return {}; - return s->getDisplayValue(); + if (it == list.end()) return {}; + return it->getDisplayValue(); } } // namespace diff --git a/src/activities/settings/SettingInfo.h b/src/activities/settings/SettingInfo.h index bc7a4f70..3ca8d3bd 100644 --- a/src/activities/settings/SettingInfo.h +++ b/src/activities/settings/SettingInfo.h @@ -11,6 +11,8 @@ enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING }; +enum class SettingDeviceTarget { BOTH, X3, X4 }; + enum class SettingAction { None, RemapFrontButtons, @@ -56,6 +58,7 @@ struct SettingInfo { const char* key = nullptr; // JSON API key (nullptr for ACTION types) StrId category = StrId::STR_NONE_OPT; // Category for web UI grouping bool obfuscated = false; // Save/load via base64 obfuscation (passwords) + SettingDeviceTarget deviceTarget = SettingDeviceTarget::BOTH; // Direct char[] string fields (for settings stored in CrossPointSettings) size_t stringOffset = 0; @@ -234,6 +237,13 @@ struct SettingInfo { return *this; } + // Restrict visibility of this setting to a specific hardware target. + // Default is BOTH when this method is not used. + SettingInfo& withDeviceTarget(SettingDeviceTarget target) { + deviceTarget = target; + return *this; + } + // For internal use by SettingsActivity: placeholder entry that launches the submenu. static SettingInfo SubmenuEntry(StrId titleId) { SettingInfo s; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 2e129416..7044b2b5 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -54,7 +54,7 @@ void SettingsActivity::onEnter() { } vec.push_back(s); }; - auto addToMoved = [](std::vector& vec, StrId& lastSub, SettingInfo&& s) { + auto addToMoved = [](std::vector& vec, StrId& lastSub, SettingInfo s) { if (s.subcategory != StrId::STR_NONE_OPT && s.subcategory != lastSub) { vec.push_back(SettingInfo::Separator(s.subcategory)); lastSub = s.subcategory; diff --git a/src/main.cpp b/src/main.cpp index 0a96b8d0..aacae41c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -141,6 +142,7 @@ void enterDeepSleep() { APP_STATE.saveToFile(); activityManager.goToSleep(); + halTiltSensor.deepSleep(); display.deepSleep(); LOG_DBG("MAIN", "Entering deep sleep (powerBtn isPressed=%d, rawPin=%d)", gpio.isPressed(HalGPIO::BTN_POWER), @@ -200,6 +202,7 @@ void setup() { HalSystem::begin(); gpio.begin(); powerManager.begin(); + halTiltSensor.begin(); gpio_deep_sleep_hold_dis(); // Release deep sleep GPIO hold state from previous sleep cycle const auto wakeupReason = gpio.getWakeupReason(); @@ -331,6 +334,7 @@ void loop() { gpio.update(); buttonEventManager.update(); HalClock::updatePeriodic(); + halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.tiltStabilization, SETTINGS.orientation); renderer.setFadingFix(SETTINGS.fadingFix); renderer.setTextDarkness(SETTINGS.textDarkness); @@ -362,7 +366,8 @@ void loop() { // Check for any user activity (button press or release) or active background work static unsigned long lastActivityTime = millis(); - if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || activityManager.preventAutoSleep()) { + if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() || + activityManager.preventAutoSleep()) { lastActivityTime = millis(); // Reset inactivity timer powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index b7b27c68..0350d967 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1232,7 +1232,7 @@ void CrossPointWebServer::handleSettingsPage() const { } void CrossPointWebServer::handleGetSettings() const { - const auto& settings = getSettingsList(); + const auto settings = getSettingsList(); String result; result.reserve(4096); @@ -1351,7 +1351,7 @@ void CrossPointWebServer::handlePostSettings() { return; } - const auto& settings = getSettingsList(); + const auto settings = getSettingsList(); int applied = 0; for (const auto& s : settings) {