Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ Primary files: `include/ERG_Mode.h`, `src/ERG_Mode.cpp`.

`computeErg()`:

- Stops ERG and switches back to simulation mode if cadence is below `MIN_ERG_CADENCE`.
- Keeps ERG active and lowers its target to `userConfig->minWatts` if cadence is below `MIN_ERG_CADENCE`.
- Raises target to `userConfig->minWatts` when apps request too little.
- Skips if the same watt timestamp/target was already processed or current watts are negative.
- For large setpoint changes, tries `_setPointChangeState()` using the power table when homed.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added new BLE firmware update protocol.

### Changed
- Truncate only the BLE-advertised device name when needed so the SmartSpin2k service UUID remains present in the legacy scan response.
- Keep ERG mode active and lower its target to the configured minimum brake watts when cadence falls below the ERG threshold.
- Prevent table-assisted ERG target changes from indefinitely blocking PID control when power approaches or settles near the new target.

### Hardware

Expand Down
5 changes: 0 additions & 5 deletions include/ERG_Mode.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,10 @@ class ErgMode {
void _writeLog(float currentIncline, float newIncline, int currentSetPoint, int newSetPoint, int currentWatts, int newWatts, int currentCadence, int newCadence);

private:
bool engineStopped = false;

int mode = Mode::MAINTAIN;
Measurement prevWatts;
Measurement prevCadence;

// check if user is spinning, reset incline if user stops spinning
bool _userIsSpinning(int cadence, float incline);

// calculate incline if setpoint (from Zwift) changes
int32_t _setPointChangeState();

Expand Down
14 changes: 7 additions & 7 deletions include/settings.h
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ const char* const DEFAULT_PASSWORD = "password";
// Use internal ERG control on external FTMS Trainer.
// #define INTERNAL_ERG_4EXT_FTMS

// Minimum cadence where ERG mode stops.
#define MIN_ERG_CADENCE 30
// Cadence at or below which ERG lowers its target to the configured minimum brake watts.
#define MIN_ERG_CADENCE 20

// Default minimum ERG target while the stepper is unhomed.
// Homed operation uses the known stepper travel limits instead.
Expand Down Expand Up @@ -160,7 +160,7 @@ const char* const DEFAULT_PASSWORD = "password";

// BLE Device Generic Names
constexpr const char* NONE = "none";
constexpr const char* ANY = "any";
constexpr const char* ANY = "any";

// Name of default Power Meter. any connects to anything, none connects to
// nothing.
Expand Down Expand Up @@ -253,10 +253,10 @@ constexpr const char* ANY = "any";
// Limit power table size to save memory
#define TABLE_DIVISOR 10.0f

//Max distance a failed neighbor can be horizontally from target position
// Max distance a failed neighbor can be horizontally from target position
#define HORIZONTAL_NEIGHBOR_RANGE 0.6f
//Max distance a failed neighbor can be vertically from target position

// Max distance a failed neighbor can be vertically from target position
#define VERTICAL_NEIGHBOR_RANGE 0.8f

// Temperature of the ESP32 at which to start reducing the power output of the stepper motor driver.
Expand Down Expand Up @@ -287,6 +287,7 @@ constexpr const char* ANY = "any";
#define HOMING_TAP_TOLERANCE 150
#define HOMING_RECOVERY_BACKOFF_MULT 3
#define HOMING_MAX_SENSITIVITY 100
#define SHIFTER_MIDDLE_POSITION 8

// BLE automatic reconnect interval in milliseconds.
#define BLE_RECONNECT_SCAN_INTERVAL 8000
Expand Down Expand Up @@ -325,4 +326,3 @@ constexpr const char* ANY = "any";

// uncomment to enable bench testing of ptab4pwr
// #define TEST_PTAB4PWR

2 changes: 1 addition & 1 deletion src/BLE_Client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ void bleClientTask(void* pvParameters) {
} else { // Startup Homing
ss2k->goHome(false);
}
rtConfig->setShifterPosition(8); // Reset to middle position
rtConfig->setShifterPosition(SHIFTER_MIDDLE_POSITION); // Reset to middle position
spinBLEServer.spinDownFlag = 0;
}
}
Expand Down
26 changes: 25 additions & 1 deletion src/BLE_Server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
#include <WiFi.h>
#include <host/ble_gatt.h>
#include <cmath>
#include <cstring>
#include <limits>
#include <string>
#include "BLE_Cycling_Speed_Cadence.h"
#include "BLE_Cycling_Power_Service.h"
#include "BLE_Heart_Service.h"
Expand Down Expand Up @@ -43,6 +45,22 @@ BLE_OpenBikeControl_Service openBikeControlService;

namespace {
constexpr uint8_t SMARTSPIN2K_IP_ADVERTISEMENT_VERSION = 1;
// Leaves room for the 128-bit SmartSpin2k service UUID in the 31-byte scan response.
constexpr size_t BLE_ADVERTISED_NAME_MAX_SIZE = 11;

std::string bleAdvertisementName(const char* deviceName) {
std::string name = deviceName;
if (name.size() <= BLE_ADVERTISED_NAME_MAX_SIZE) {
return name;
}

size_t length = BLE_ADVERTISED_NAME_MAX_SIZE;
while (length > 0 && (static_cast<uint8_t>(name[length]) & 0xc0) == 0x80) {
--length;
}
name.resize(length);
return name;
}

void addIpAddressToAdvertisement(NimBLEAdvertising* advertising) {
IPAddress ipAddress = WiFi.status() == WL_CONNECTED ? WiFi.localIP() : WiFi.softAPIP();
Expand Down Expand Up @@ -103,7 +121,13 @@ void startBLEServer() {
// Keep the name and 128-bit SmartSpin2k UUID in the scan response. The primary
// advertisement uses the space previously occupied by the duplicate name for the IP address.
addIpAddressToAdvertisement(pAdvertising);
oScanResponseData.setName(userConfig->getDeviceName());
const std::string advertisedName = bleAdvertisementName(userConfig->getDeviceName());
if (advertisedName.size() < std::strlen(userConfig->getDeviceName())) {
oScanResponseData.setShortName(advertisedName);
SS2K_LOGW(BLE_SERVER_LOG_TAG, "BLE device name shortened to '%s' to fit scan response", advertisedName.c_str());
} else {
oScanResponseData.setName(advertisedName);
}
oScanResponseData.setCompleteServices(SMARTSPIN2K_SERVICE_UUID);
pAdvertising->setScanResponseData(oScanResponseData);

Expand Down
79 changes: 39 additions & 40 deletions src/ERG_Mode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ double scheduledErgGain(double sensitivity, int operatingWatts, int cadence, boo

double gain = fallbackErgGain(sensitivity, operatingWatts);
// Sparse linear fits are useful for lookup, but not stable enough to schedule ERG gain from their slope.
if (powerTable->ptHelpers.resistanceModel.getIsValid() && powerTable->ptHelpers.resistanceModel.getIsQuadratic() && lowerPosition != RETURN_ERROR && upperPosition != RETURN_ERROR &&
upperPosition > lowerPosition) {
if (powerTable->ptHelpers.resistanceModel.getIsValid() && powerTable->ptHelpers.resistanceModel.getIsQuadratic() && lowerPosition != RETURN_ERROR &&
upperPosition != RETURN_ERROR && upperPosition > lowerPosition) {
const double localStepsPerWatt = static_cast<double>(upperPosition - lowerPosition) / static_cast<double>(upperWatts - lowerWatts);
gain = localStepsPerWatt * sensitivity / ERG_SLOPE_CONTROL_DIVISOR;
usedPowerTable = true;
Expand All @@ -69,32 +69,36 @@ double clampErgGain(double gain, double sensitivity) {
} // namespace

void ErgMode::runERG() {
static ErgMode ergMode;
static PowerBuffer powerBuffer;
static bool hasConnectedPowerMeter = false;
static bool simulationRunning = false;
static int loopCounter = 0;

if (mode == Mode::INCREASING) {
if (rtConfig->watts.getValue() > rtConfig->watts.getTarget()) { // Resume PID control
ergTimer = 0;
mode = Mode::MAINTAIN;
SS2K_LOG(ERG_MODE_LOG_TAG, "ERG increasing target reached.");
} else if (rtConfig->watts.getValue() >= this->prevWatts.getValue()) {
// power is still increasing, wait longer
return;
}
} else if (mode == Mode::DECREASING) {
if (rtConfig->watts.getValue() < rtConfig->watts.getTarget()) // Resume PID control
{
ergTimer = 0;
mode = Mode::MAINTAIN;
SS2K_LOG(ERG_MODE_LOG_TAG, "ERG decreasing target reached.");
} else if (rtConfig->watts.getValue() <= this->prevWatts.getValue()) {
// power is still decreasing, wait longer
return;
static int lastSetPoint = 0;

if (rtConfig->getFTMSMode() == FitnessMachineControlPointProcedure::SetTargetPower && rtConfig->cad.getValue() <= MIN_ERG_CADENCE) {
if (rtConfig->watts.getTarget() != userConfig->getMinWatts()) {
SS2K_LOG(ERG_MODE_LOG_TAG, "Cadence below ERG minimum; lowering target to %dw", userConfig->getMinWatts());
lastSetPoint = rtConfig->watts.getTarget();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the deferred ERG target across low-cadence shifts

When the rider shifts while cadence is below the threshold, FTMSModeShiftModifier() applies the shift to the temporary minimum target; on the next loop this assignment replaces the original saved setpoint. For example, a 200 W target falls back to 50 W, one shift raises the public target to 60 W, and this stores 60 W so recovery resumes at 60 W rather than the expected 210 W. Low-cadence ERG shifts need to update the deferred target instead of overwriting it from the temporary fallback value.

AGENTS.md reference: AGENTS.md:L558-L564

Useful? React with 👍 / 👎.

rtConfig->watts.setTarget(userConfig->getMinWatts());
mode = Mode::MAINTAIN;
isDelayed = false;
ergTimer = 0;
}
} else if (lastSetPoint != 0 && rtConfig->getFTMSMode() == FitnessMachineControlPointProcedure::SetTargetPower && rtConfig->cad.getValue() > MIN_ERG_CADENCE) {
SS2K_LOG(ERG_MODE_LOG_TAG, "Cadence above ERG minimum; restoring target to %dw", lastSetPoint);
rtConfig->watts.setTarget(lastSetPoint);
lastSetPoint = 0;
}

const bool reachedIncreasingTarget = mode == Mode::INCREASING && rtConfig->watts.getValue() >= rtConfig->watts.getTarget();
const bool reachedDecreasingTarget = mode == Mode::DECREASING && rtConfig->watts.getValue() <= rtConfig->watts.getTarget();
if (reachedIncreasingTarget || reachedDecreasingTarget) {
SS2K_LOG(ERG_MODE_LOG_TAG, "ERG setpoint reached; resuming PID control");
mode = Mode::MAINTAIN;
isDelayed = false;
ergTimer = 0;
}

if (isDelayed && (ss2k->getCurrentPosition() == ss2k->getTargetPosition())) {
SS2K_LOG(ERG_MODE_LOG_TAG, "ERG delay cleared, %dw, tgt %dw, pos %d, tgt %d", rtConfig->watts.getValue(), rtConfig->watts.getTarget(), ss2k->getCurrentPosition(),
ss2k->getTargetPosition());
Expand All @@ -109,6 +113,11 @@ void ErgMode::runERG() {
isDelayed = false;
}

if (mode != Mode::MAINTAIN) {
SS2K_LOG(ERG_MODE_LOG_TAG, "ERG setpoint seek complete; resuming PID control");
mode = Mode::MAINTAIN;
}

// reset the timer.
ergTimer = millis() + ERG_MODE_DELAY;

Expand All @@ -130,7 +139,7 @@ void ErgMode::runERG() {
powerTable->_manageSaveState();
}

if (rtConfig->cad.getValue()) {
if (rtConfig->cad.getValue() > MIN_ERG_CADENCE / 2) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Command lower resistance before filtering stopped cadence

When a cadence sensor reports 0–10 RPM at a stop, this gate skips computeErg(), so the new fallback changes only watts.target and never updates targetIncline; the stepper therefore remains at the previous high-load position. If the first resumed sample is already above 20 RPM, lines 87–90 restore the old watt target before any low-load position is commanded, leaving the rider in the ERG “black hole” this change is intended to prevent. Apply the low-cadence position adjustment even for stopped-cadence samples rather than limiting it to the 11–19 RPM window.

AGENTS.md reference: AGENTS.md:L405-L412

Useful? React with 👍 / 👎.

hasConnectedPowerMeter = spinBLEClient.connectedPM;
simulationRunning = rtConfig->watts.getTarget();
if (!simulationRunning) {
Expand All @@ -144,7 +153,7 @@ void ErgMode::runERG() {

// compute ERG
if ((rtConfig->getFTMSMode() == FitnessMachineControlPointProcedure::SetTargetPower) && (hasConnectedPowerMeter || simulationRunning)) {
ergMode.computeErg();
this->computeErg();
}

// Set Min and Max Stepper positions
Expand Down Expand Up @@ -200,12 +209,6 @@ void ErgMode::runERG() {
void ErgMode::computeErg() {
int32_t result = RETURN_ERROR;

bool isUserSpinning = this->_userIsSpinning(rtConfig->cad.getValue(), ss2k->getCurrentPosition());
if (!isUserSpinning) {
SS2K_LOG(ERG_MODE_LOG_TAG, "ERG Mode but no User Spin");
return;
}

// Without known travel limits, keep ERG above the configured minimum bike watts.
// Once homed, moveStepper() clamps the commanded position to the known min/max step range instead.
if (!rtConfig->getHomed() && rtConfig->watts.getTarget() < userConfig->getMinWatts()) {
Expand All @@ -230,6 +233,12 @@ void ErgMode::computeErg() {
result = _inSetpointState();
}
#endif

// Avoid ERG Black hole
if (rtConfig->cad.getValue() < MIN_ERG_CADENCE && rtConfig->getHomed()) {
SS2K_LOG(ERG_MODE_LOG_TAG, "Cadence below ERG minimum");
result = userConfig->getShiftStep() * SHIFTER_MIDDLE_POSITION;
}
_updateValues(result);
}

Expand Down Expand Up @@ -355,16 +364,6 @@ void ErgMode::_updateValues(float newIncline) {
this->prevCadence = rtConfig->cad;
}

bool ErgMode::_userIsSpinning(int cadence, float incline) {
if (cadence <= MIN_ERG_CADENCE) {
rtConfig->setFTMSMode(FitnessMachineControlPointProcedure::SetIndoorBikeSimulationParameters);
rtConfig->setTargetIncline(1.0f);
return false; // Cadence too low, nothing to do here
}
this->engineStopped = false;
return true;
}

void ErgMode::_writeLog(float currentIncline, float newIncline, int currentSetPoint, int newSetPoint, int currentWatts, int newWatts, int currentCadence, int newCadence) {
SS2K_LOGW(ERG_MODE_LOG_CSV_TAG, "%d;%.2f;%.2f;%d;%d;%d;%d;%d", currentIncline, newIncline, currentSetPoint, newSetPoint, currentWatts, newWatts, currentCadence, newCadence);
}
Loading