From f13b2682181ef489bb03a230acfd0e6abc53d6df Mon Sep 17 00:00:00 2001 From: eMad Date: Fri, 22 May 2026 19:09:21 -0400 Subject: [PATCH 1/6] sleep after 15min of inactivity after reboot --- include/BLE_Common.h | 2 ++ src/BLE_Common.cpp | 22 +++++++++++++++++----- src/Main.cpp | 11 +++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/include/BLE_Common.h b/include/BLE_Common.h index 0a881942..b68cf951 100644 --- a/include/BLE_Common.h +++ b/include/BLE_Common.h @@ -70,6 +70,8 @@ void setupBLE(); extern TaskHandle_t BLEClientTask; // ***********************Common********************************** void BLECommunications(); +void setLEDAllowedUntil(uint32_t t); +void extendLEDWindow(); // Check if a BLE device supports any of our supported services bool isDeviceSupported(const NimBLEAdvertisedDevice* advertisedDevice, const String& deviceName = ""); diff --git a/src/BLE_Common.cpp b/src/BLE_Common.cpp index 95e610fd..c0149085 100644 --- a/src/BLE_Common.cpp +++ b/src/BLE_Common.cpp @@ -10,11 +10,17 @@ #include "BLE_Common.h" #include "Constants.h" +#include #include #include #include #include +static uint32_t ledAllowedUntil = 15UL * 60UL * 1000UL; + +void setLEDAllowedUntil(uint32_t t) { ledAllowedUntil = t; } +void extendLEDWindow() { ledAllowedUntil = millis() + 15UL * 60UL * 1000UL; } + /** * @brief Retrieves the BLE service information for a given advertised device and device name. * @@ -142,14 +148,20 @@ void BLECommunications() { } } - // blink if no client connected - if (spinBLEServer.connectedClientCount() == 0) { - if ((millis() / 500) % 2 == 0) { - digitalWrite(LED_PIN, LOW); + static int _lastClientCount = -1; + int currentCount = spinBLEServer.connectedClientCount(); + if (currentCount != _lastClientCount) { + _lastClientCount = currentCount; + extendLEDWindow(); + } + + if (millis() < ledAllowedUntil) { + if (currentCount == 0) { + digitalWrite(LED_PIN, (millis() / 500) % 2 == 0 ? LOW : HIGH); } else { digitalWrite(LED_PIN, HIGH); } } else { - digitalWrite(LED_PIN, HIGH); + digitalWrite(LED_PIN, LOW); } } diff --git a/src/Main.cpp b/src/Main.cpp index 4bec052c..fb6d13fa 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -10,6 +10,7 @@ #include "SS2KLog.h" #include "esp_system.h" #include +#include #include #include #include "ERG_Mode.h" @@ -125,6 +126,14 @@ extern "C" void app_main() { digitalWrite(currentBoard.stepPin, LOW); digitalWrite(LED_PIN, LOW); + // LED quiet mode: allow 15 min on power-on or manual reboot; inhibit after inactivity reboot + { + bool inhibit = false; + {Preferences p; p.begin("led_ctrl", true); inhibit = p.getBool("inhibit", false); p.end();} + {Preferences p; p.begin("led_ctrl", false); p.putBool("inhibit", false); p.end();} + setLEDAllowedUntil((esp_reset_reason() == ESP_RST_POWERON || !inhibit) ? 15UL * 60UL * 1000UL : 0UL); + } + ss2k->setupTMCStepperDriver(); SS2K_LOG(MAIN_LOG_TAG, "Setting up cpu Tasks"); @@ -295,6 +304,7 @@ void SS2K::maintenanceLoop(void* pvParameters) { if (((millis() - rebootTimer) > 1800000)) { // Timer expired SS2K_LOG(MAIN_LOG_TAG, "Rebooting due to inactivity."); + {Preferences p; p.begin("led_ctrl", false); p.putBool("inhibit", true); p.end();} ss2k->rebootFlag = true; logHandler.writeLogs(); webSocketAppender.Loop(); @@ -306,6 +316,7 @@ void SS2K::maintenanceLoop(void* pvParameters) { _oldWatts = rtConfig->watts.getValue(); _oldTargetIncline = rtConfig->getTargetIncline(); rebootTimer = millis(); + extendLEDWindow(); } #ifdef DEBUG_STACK From 6d68fef8e82785b19bffb81b2d6dde80814c98f2 Mon Sep 17 00:00:00 2001 From: eMad Date: Fri, 22 May 2026 20:28:05 -0400 Subject: [PATCH 2/6] changelog --- CHANGELOG.md | 1 + dependencies.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ccc7bc6..6a197598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Adjusted BLE advertising to present SmartSpin2k as a cycling power sensor for improved Garmin discovery and pairing. - Updated BLE setup to use a public device address for Garmin compatibility. +- added 15 minute timeout to blue status LED - if not used, then it shuts off. ### Hardware diff --git a/dependencies.lock b/dependencies.lock index f84838b8..6f1aafb5 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -96,6 +96,6 @@ direct_dependencies: - espressif/network_provisioning - idf - joltwallet/littlefs -manifest_hash: df9baa925ebc2a028ee0e349be53f2169bb7ac38b2cb9c63b3ebf69123c43ef8 +manifest_hash: e7ce7a886dfdb3cc5cd59acbdb4ff333c0a91c6e4de55b9fd6323d866c7cc691 target: esp32 version: 2.0.0 From 63548ca9e416279febf760e4969389cbea2893b7 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 23 May 2026 00:39:36 +0000 Subject: [PATCH 3/6] Update changelog for version 26.5.4 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a197598..327aa862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +### Changed + +### Hardware + + +## [26.5.4] + +### Added + ### Changed - Adjusted BLE advertising to present SmartSpin2k as a cycling power sensor for improved Garmin discovery and pairing. - Updated BLE setup to use a public device address for Garmin compatibility. From 22579c0a50fa8d996393dcf0fc3b54dddf49ca3d Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Tue, 26 May 2026 10:15:37 -0500 Subject: [PATCH 4/6] Added constants for namespace and keys and unwrapped the setup for readability. --- include/BLE_Common.h | 2 ++ src/BLE_Common.cpp | 4 ++-- src/Main.cpp | 28 +++++++++++++++++++++++----- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/include/BLE_Common.h b/include/BLE_Common.h index b68cf951..e2425de5 100644 --- a/include/BLE_Common.h +++ b/include/BLE_Common.h @@ -69,6 +69,8 @@ using BLEServices::SUPPORTED_SERVICES; void setupBLE(); extern TaskHandle_t BLEClientTask; // ***********************Common********************************** +inline constexpr uint32_t kLedAllowedWindowMs = 15UL * 60UL * 1000UL; + void BLECommunications(); void setLEDAllowedUntil(uint32_t t); void extendLEDWindow(); diff --git a/src/BLE_Common.cpp b/src/BLE_Common.cpp index c0149085..93137430 100644 --- a/src/BLE_Common.cpp +++ b/src/BLE_Common.cpp @@ -16,10 +16,10 @@ #include #include -static uint32_t ledAllowedUntil = 15UL * 60UL * 1000UL; +static uint32_t ledAllowedUntil = kLedAllowedWindowMs; void setLEDAllowedUntil(uint32_t t) { ledAllowedUntil = t; } -void extendLEDWindow() { ledAllowedUntil = millis() + 15UL * 60UL * 1000UL; } +void extendLEDWindow() { ledAllowedUntil = millis() + kLedAllowedWindowMs; } /** * @brief Retrieves the BLE service information for a given advertised device and device name. diff --git a/src/Main.cpp b/src/Main.cpp index fb6d13fa..d596cece 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -44,6 +44,11 @@ SS2K* ss2k = new SS2K; userParameters* userConfig = new userParameters; RuntimeParameters* rtConfig = new RuntimeParameters; +namespace { +constexpr char kLedControlNamespace[] = "led_ctrl"; +constexpr char kLedInhibitKey[] = "inhibit"; +} // namespace + ///////////// Log Appender ///////////// UdpAppender udpAppender; WebSocketAppender webSocketAppender; @@ -128,10 +133,17 @@ extern "C" void app_main() { // LED quiet mode: allow 15 min on power-on or manual reboot; inhibit after inactivity reboot { - bool inhibit = false; - {Preferences p; p.begin("led_ctrl", true); inhibit = p.getBool("inhibit", false); p.end();} - {Preferences p; p.begin("led_ctrl", false); p.putBool("inhibit", false); p.end();} - setLEDAllowedUntil((esp_reset_reason() == ESP_RST_POWERON || !inhibit) ? 15UL * 60UL * 1000UL : 0UL); + Preferences ledPreferences; + + ledPreferences.begin(kLedControlNamespace, true); + bool inhibit = ledPreferences.getBool(kLedInhibitKey, false); + ledPreferences.end(); + + ledPreferences.begin(kLedControlNamespace, false); + ledPreferences.putBool(kLedInhibitKey, false); + ledPreferences.end(); + + setLEDAllowedUntil((esp_reset_reason() == ESP_RST_POWERON || !inhibit) ? kLedAllowedWindowMs : 0UL); } ss2k->setupTMCStepperDriver(); @@ -304,7 +316,13 @@ void SS2K::maintenanceLoop(void* pvParameters) { if (((millis() - rebootTimer) > 1800000)) { // Timer expired SS2K_LOG(MAIN_LOG_TAG, "Rebooting due to inactivity."); - {Preferences p; p.begin("led_ctrl", false); p.putBool("inhibit", true); p.end();} + { + Preferences ledPreferences; + // Inhibit the startup LED window after inactivity reboot; app_main clears this flag after reading it. + ledPreferences.begin(kLedControlNamespace, false); + ledPreferences.putBool(kLedInhibitKey, true); + ledPreferences.end(); + } ss2k->rebootFlag = true; logHandler.writeLogs(); webSocketAppender.Loop(); From cc06e1372531246f8a772065cba728a8ec2e4352 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 27 May 2026 12:25:09 -0500 Subject: [PATCH 5/6] Add agent notes for SmartSpin2k firmware --- AGENTS.md | 578 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d030c0f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,578 @@ +# SmartSpin2k Agent Notes + +These notes are for coding agents working in this repository. They are intentionally focused on firmware/software behavior and ignore the physical design files in `Hardware/` or `hardware/`. + +## Project Shape + +SmartSpin2k is ESP32 firmware for converting a spin bike into a BLE smart trainer. It: + +- Reads local shifter buttons and optional Peloton aux serial data. +- Acts as a BLE client for power meters, cadence sensors, heart-rate monitors, FTMS trainers, HID remotes, Echelon, Flywheel, and Peloton-like data. +- Acts as a BLE server exposing cycling power, cycling speed/cadence, heart rate, FTMS, device info, firmware update, and SmartSpin2k custom characteristics. +- Serves a web UI from `data/` over WiFi/LittleFS. +- Runs a DirCon TCP bridge for BLE-like service discovery, reads, writes, and notifications over WiFi. +- Drives a TMC2209/FastAccelStepper motor to change resistance. + +Primary software directories: + +- `src/`: main firmware modules. +- `include/`: headers, settings, UUIDs, BLE data structures, board pins. +- `lib/SS2K/`: core sensor parsing library used by firmware and native tests. +- `lib/ArduinoCompat/`: native-test compatibility shims. +- `test/`: Unity tests for native PlatformIO environment. +- `data/`: web interface assets served by the firmware. +- `.github/copilot-instructions.md`: older agent/build notes that may still be useful. + +## Build And Test + +PlatformIO is the expected entry point. + +- Build firmware: `pio run --environment release` +- Build filesystem: `pio run --target buildfs` +- Run native tests: `pio test --environment native` +- Static analysis: `pio check -e debug` +- Pre-commit checks: `pre-commit run --all-files` + +Important timing/network notes: + +- First PlatformIO builds/tests may download ESP32 platforms and toolchains. They can take 15-45 minutes for firmware builds and 5-15 minutes for native tests. +- In restricted environments, PlatformIO can fail on network downloads. If that happens, report it rather than trying to fake validation. +- The firmware itself cannot be fully run without ESP32 hardware, BLE devices, and a stepper driver. + +Native tests cover sensor parsing, BLE device-name stability logic, power-buffer behavior, and power-table lookup/fill/save flows. When changing: + +- `src/Power_Table.cpp` or `src/PowerTable_Helpers.cpp`, run `pio test -e native`. +- `lib/SS2K/src/sensors/*`, run the native tests for sensor parsing. +- BLE/FTMS code, build firmware and reason carefully about characteristic payload formats; many paths need hardware/manual validation. + +Formatting: + +- `.clang-format` is based on Google style, keeps include order, uses 180 column limit, and aligns consecutive macros/assignments. +- Existing C++ is mixed Arduino/ESP-IDF style. Prefer local patterns over broad refactors. + +## Global State Model + +Most runtime state is global and initialized in `src/Main.cpp`: + +- `SS2K* ss2k`: top-level controller state and stepper/shifter behavior. +- `userParameters* userConfig`: persisted user settings loaded from LittleFS config. +- `RuntimeParameters* rtConfig`: live measurements, targets, mode, limits, and transient state. +- `ErgMode* ergMode`: ERG controller. +- `PowerTable* powerTable`: learned watts/cadence/position table. +- `SpinBLEClient spinBLEClient`: BLE client manager and connected sensor slots. +- `SpinBLEServer spinBLEServer`: BLE server manager and server write queue. +- `Board currentBoard`: selected at boot by analog hardware revision voltage. +- `UdpAppender`, `WebSocketAppender`, `BleAppender`: log sinks registered with `LogHandler`. + +Because the firmware uses FreeRTOS tasks and callbacks, treat these globals as shared state. Many setters have side effects through timestamps, saved config, BLE notifications, or stepper behavior. + +## Main Runtime Loop + +Boot entry is `app_main()` in `src/Main.cpp`. + +Boot sequence: + +1. Initialize Arduino/Serial. +2. Detect hardware revision using `REV_PIN` and `boards.rev1/rev2`. +3. Start stepper serial and optional aux serial for Peloton. +4. Mount LittleFS. +5. Load and re-save `userConfig`. +6. Start WiFi and run firmware update check. +7. Configure GPIO pins. +8. Initialize LED quiet-mode preference. +9. Configure TMC/FastAccelStepper via `SS2K::setupTMCStepperDriver()`. +10. Register log appenders. +11. Start BLE via `setupBLE()`. +12. Start web server and DirCon. +13. Create `SS2K::maintenanceLoop` task. + +`SS2K::maintenanceLoop()` is the main cooperative loop. It roughly does: + +- Every `BLE_NOTIFY_DELAY`: `BLECommunications()`, flush logs, websocket loop. +- If not updating and not in spindown: `ss2k->moveStepper()`, `ss2k->FTMSModeShiftModifier()`, `ergMode->runERG()`. +- Periodically poll Peloton aux serial via `txSerial()`. +- Always handle local shifter button state. +- Notify changed custom-characteristic values via `BLE_ss2kCustomCharacteristic::parseNemit()`. +- Update HTTP clients and DirCon. +- Slow stepper near Peloton resistance limits when unhomed. +- Handle reboot/default-reset/save flags. +- Every roughly 6 seconds, log status and reboot after 30 minutes of inactivity. + +Do not introduce long blocking work into `maintenanceLoop()` unless the existing code already does so for a hardware procedure like homing. + +## Core Data Structures + +### `Measurement` + +Defined in `include/SmartSpin_parameters.h`. + +Fields: + +- `simulate`: whether this measurement is simulated rather than real sensor data. +- `value`: current measured/simulated value. +- `target`: requested target value. +- `min`, `max`: bounds used mostly for resistance ranges. +- `timestamp`: updated by `setSimulate()`, `setValue()`, and `setTarget()`. + +Used for `rtConfig->watts`, `hr`, `cad`, `batt`, and `resistance`. + +Be careful: timestamp equality is used by ERG code to skip already processed watt samples. If adding setters or bypassing setters, update behavior can silently break. + +### `RuntimeParameters` + +Live state. Key fields: + +- `targetIncline`: internal target used by ERG, resistance mode, and simulation mode before becoming `ss2k->targetPosition`. +- `simulatedSpeed`: optional speed from sensors or custom characteristic. +- `FTMSMode`: current mode/opcode from FTMS control point. +- `shifterPosition`: logical gear/shift count. +- `homed`: whether reliable min/max stepper positions are known. +- `minStep`, `maxStep`: movement limits for stepper target clamping. +- `minResistance`, `maxResistance`: external/real resistance bounds. +- `simTargetWatts`: custom-characteristic target-watts simulation flag. +- `bleLogEnabled`: allows BLE log access through custom characteristic. + +`rtConfig` is the live truth for sensor measurements and control modes. BLE server notifications read from it. Stepper and ERG code write to it. + +### `userParameters` + +Persisted user config. Key fields: + +- Firmware/device/WiFi: update URL, device name, auto update, SSID/password. +- Stepper tuning: `shiftStep`, `stepperPower`, `stepperSpeed`, `stepperDir`, `stealthChop`, `homingSensitivity`. +- Control tuning: `inclineMultiplier`, `powerCorrectionFactor`, `ERGSensitivity`. +- ERG/power table bounds: `minWatts`, `maxWatts`, `pTab4Pwr`, `hMin`, `hMax`. +- BLE preferences: connected power meter, heart monitor, remote, found devices. +- Logging: UDP logging enabled. + +Persistence lives in `src/SmartSpin_parameters.cpp` with JSON serialization to `configFILENAME` in LittleFS. For new config fields, update defaults, JSON output, save, load, and custom characteristic handling if the app needs it. + +### `SS2K` + +Top-level controller in `include/Main.h`. + +Private state: + +- Button debounce and current button states. +- `lastShifterPosition`: previous logical shift position for delta detection. +- Scan delay state. +- `targetPosition` and `currentPosition`: actual stepper positions. + +Public flags: + +- `stepperIsRunning` +- `externalControl`: bypasses normal target computation in `moveStepper()`. +- `syncMode`: forces current stepper position to match target. +- `pelotonIsConnected` +- `rebootFlag`, `saveFlag`, `resetDefaultsFlag`, `resetPowerTableFlag`, `isUpdating` + +Important functions: + +- `maintenanceLoop()`: main task. +- `moveStepper()`: computes/clamps/sends stepper target. +- `_resistanceMove()`: converts resistance target into stepper target or fallback ERG target. +- `FTMSModeShiftModifier()`: remaps shifter changes depending on FTMS mode. +- `handleShiftButtons()`: debounced local button input. +- `goHome()`, `_findEndStop()`, `_findFTMSHome()`: homing procedures. +- `setupTMCStepperDriver()`, `updateStepperPower()`, `updateStealthChop()`, `updateStepperSpeed()`: motor driver configuration. +- `txSerial()`, `rxSerial()`, `pelotonConnected()`: Peloton aux serial integration. + +## Control Flow: Sensors To Stepper + +The common path is: + +1. BLE client receives notification in `notifyCB()` (`src/BLE_Client.cpp`). +2. Notification is enqueued into the matching `SpinBLEAdvertisedDevice`. +3. `SpinBLEClient::postConnect()`/client task drains queued data and calls `collectAndSet()`. +4. `collectAndSet()` (`src/SensorCollector.cpp`) uses `SensorDataFactory` to decode bytes. +5. Decoded values update `rtConfig` measurements unless simulation or config rules say to ignore them. +6. `ergMode->runERG()` may update `rtConfig->targetIncline`. +7. `SS2K::moveStepper()` turns mode/state into `ss2k->targetPosition` and calls `stepper->moveTo()`. +8. BLE server services read `rtConfig` and notify connected apps. + +Important gates in `collectAndSet()`: + +- Heart rate zeros are ignored until 10 consecutive zero-ish readings. +- Cadence accepts 1-249 RPM, otherwise sets cadence to 0. +- Power is multiplied by `userConfig->getPowerCorrectionFactor()` and accepted from 1-2999 W. +- Peloton cadence/power can be ignored when an external BLE power meter is configured. +- If `userConfig->getPTab4Pwr()` is true, real sensor power does not overwrite watts because watts are derived from the power table. +- IC Bike resistance is blacklisted due to non-standard behavior. +- Real resistance clears `rtConfig->resistance.simulate`. + +## BLE Client + +Primary files: `include/BLE_Common.h`, `src/BLE_Client.cpp`, `src/BLE_Common.cpp`. + +`SpinBLEClient` owns: + +- Connection flags: `connectedPM`, `connectedHRM`, `connectedCD`, `connectedCT`, `connectedSpeed`, `connectedRemote`. +- Scan flag: `doScan`. +- CSC cumulative values reused by server-side CSC/Cycling Power notifications. +- `myBLEDevices[NUM_BLE_DEVICES]`: slots for connected/discovered devices. + +`SpinBLEAdvertisedDevice` stores: + +- `advertisedDevice`, `peerAddress`, `uniqueName`. +- `connectedClientID`, service/characteristic UUIDs. +- Type flags: HRM, PM, CSC, CT, remote. +- `doConnect`, `isPostConnected`, `lastDataUpdateTime`. +- FreeRTOS queue for notification payloads. + +Key functions: + +- `SpinBLEClient::start()`: creates BLE client task and configures scanning. +- `ScanCallbacks::onResult()`: filters supported devices, updates `foundDevices`, sets slots to connect when user config matches. +- `SpinBLEClient::connectToServer()`: creates fresh NimBLE client, connects, sets slot state, removes duplicates. +- `subscribeToAllNotifications()`: subscribes to notify/indicate characteristics for supported services. +- `SpinBLEClient::postConnect()`: completes service subscriptions, reads FTMS resistance range, starts FTMS training where needed, drains notification queues. +- `SpinBLEClient::checkBLEReconnect()`: sets `doScan` when configured devices are missing. +- `SpinBLEClient::adevName2UniqueName()`: stable names for saved device preferences. Public/static random addresses get address suffix; private random addresses prefer manufacturer-data suffix or base name. + +`BLEServices::SUPPORTED_SERVICES` maps service UUIDs to the characteristic UUIDs this firmware expects. If adding sensor support, update this list, `SensorDataFactory`, and tests if parsing is involved. + +## Sensor Parsing Library + +Primary files: `lib/SS2K/include/sensors/*`, `lib/SS2K/src/sensors/*`. + +`SensorData` is the abstract interface: + +- Capability flags: `hasHeartRate()`, `hasCadence()`, `hasPower()`, `hasSpeed()`, `hasResistance()`. +- Getters return real values or sentinel values (`INT_MIN`, `nanf("")`) when absent. +- `decode(uint8_t* data, size_t length)` stores parsed fields. + +`SensorDataFactory` caches parser instances by `(characteristicUUID, uniqueName)` so stateful parsers keep previous cumulative counters. It returns: + +- `CyclePowerData` for Cycling Power Measurement. +- `HeartRateData` for Heart Rate. +- `FitnessMachineIndoorBikeData` for FTMS Indoor Bike Data. +- `FlywheelData` for Flywheel UART. +- `EchelonData` for Echelon data. +- `PelotonData` for Peloton aux serial payloads. +- `CscSensorData` for Cycling Speed/Cadence. +- `NullData` for unknown characteristic UUIDs. + +Stateful parser caution: + +- Cycling power and CSC cadence/speed require previous cumulative revolution/event-time state. Do not replace cached objects with one-shot parsing unless you account for that. + +## BLE Server And FTMS + +Primary files: `src/BLE_Server.cpp`, `src/BLE_Fitness_Machine_Service.cpp`, service-specific `src/BLE_*_Service.cpp`. + +`startBLEServer()` creates the NimBLE server and starts services: + +- Cycling Speed/Cadence +- Cycling Power +- Heart +- Fitness Machine +- SmartSpin2k custom characteristic +- Device Information +- BLE firmware update + +Zwift/OpenBikeControl services exist but are currently commented out in regular BLE advertising/setup; DirCon and the source files still matter. + +`SpinBLEServer::update()` refreshes wheel/crank revolution counters, then calls service `update()` methods. The FTMS service also processes pending writes. + +`MyCharacteristicCallbacks::onWrite()` queues FTMS control-point writes in `spinBLEServer.writeCache`. `BLE_Fitness_Machine_Service::processFTMSWrite()` consumes that queue. + +FTMS control point behavior: + +- `RequestControl`: success, clears watt target and simulated target watts. +- `Reset`: success, status reset/idle. +- `SetTargetInclination`: sets FTMS mode and `rtConfig->targetIncline`. +- `SetTargetResistanceLevel`: sets FTMS mode and `rtConfig->resistance.target`, clamping to min/max. +- `SetTargetPower`: sets FTMS mode, sets `rtConfig->watts.target`, optionally forwards corrected target to a connected FTMS trainer. +- `SetIndoorBikeSimulationParameters`: sets simulation mode incline and forwards to connected FTMS device. +- `SpinDownControl`: sets `spinBLEServer.spinDownFlag = 2`; BLE client task later calls `ss2k->goHome(true)` once cadence is present. +- `StartOrResume`, `StopOrPause`, `SetTargetedCadence`: update FTMS status/training status. + +`BLE_Fitness_Machine_Service::update()` sends Indoor Bike Data from `rtConfig` and notifies DirCon. It computes speed from `rtConfig->simulatedSpeed` if available, otherwise from server power-based speed estimate. If resistance is not recently real, it simulates resistance from stepper position. + +## Custom SmartSpin2k Characteristic + +Primary files: `include/BLE_Custom_Characteristic.h`, `src/BLE_Custom_Characteristic.cpp`, `CustomCharacteristic.md`. + +Protocol: + +- `cc_read` reads a variable. +- `cc_write` writes a variable. +- Responses generally start with `cc_success` or `cc_error`, followed by the variable id and bytes/string. + +The giant switch in `BLE_ss2kCustomCharacteristic::process()` maps variable IDs to `userConfig`, `rtConfig`, and `ss2k` fields. Examples: + +- Firmware URL, device name, WiFi SSID/password. +- Simulated watts/cadence/heart rate/speed. +- Simulate flags. +- FTMS mode and target watts. +- Shift step, shifter position, min/max brake watts. +- Stepper power/speed/direction/stealthChop. +- External control and sync mode. +- Save/reboot/reset flags. +- Power table row transfer. +- Homing min/max/sensitivity. +- `pTab4Pwr`, UDP logging, BLE logging. + +`parseNemit()` compares current config/runtime values against static old copies and sends one notification per call for changed values. Some changes, like `hMin`/`hMax`, trigger `userConfig->saveToLittleFS()`. Turning `pTab4Pwr` on sets `spinBLEServer.spinDownFlag = 1` to trigger homing. + +When adding a custom characteristic variable: + +1. Add/confirm the ID in `include/BLE_Custom_Characteristic.h`. +2. Update `process()` read/write behavior. +3. Update `parseNemit()` if clients need change notifications. +4. Update `CustomCharacteristic.md`. +5. Preserve byte order conventions used nearby. + +## Stepper And Resistance Control + +Primary files: `include/Stepper.h`, `src/Stepper.cpp`. + +Globals: + +- `HardwareSerial stepperSerial(2)` +- `TMC2209Stepper driver` +- `FastAccelStepperEngine engine` +- `FastAccelStepper* stepper` + +`SS2K::moveStepper()` is the key function: + +- Updates `ss2k->stepperIsRunning` and `ss2k->currentPosition`. +- If `externalControl` is false: + - ERG mode (`SetTargetPower`): `targetPosition = rtConfig->targetIncline`, with optional guardrails to avoid moving opposite the watt error. + - Resistance mode (`SetTargetResistanceLevel`): calls `_resistanceMove()`. + - Simulation mode: target is `shifterPosition * shiftStep + targetIncline * inclineMultiplier`. +- If `syncMode`, stops movement and sets current stepper position to target. +- Applies Peloton/resistance safety nudges and min/max step clamps. +- Calls `stepper->moveTo(targetPosition)`. +- Enables outputs only when cadence is present; otherwise auto-enable is used. +- Detects runtime stepper direction changes and updates the direction pin after current motion stops. + +`_resistanceMove()` has two modes: + +- Simulated resistance: maps 0-100 percent target resistance into known min/max step range. If no reliable range exists, it falls back by setting `watts.target` and switching to ERG mode. +- Real resistance: compares `resistance.target` and `resistance.value`, then increments `targetIncline` using larger moves for big errors and smaller moves for near-target corrections. + +Homing: + +- `goHome(false)` finds minimum/home only. Startup can use this. +- `goHome(true)` performs a full spindown/homing and saves `hMin/hMax`. +- If a real FTMS resistance-reporting device is connected, `_findFTMSHome()` homes by driving to reported min/max resistance. +- Otherwise `_findEndStop()` uses TMC StallGuard, with repeated taps and drift detection. +- Homing aborts when shifter position changes. +- Homing changes driver current/speed/StealthChop and must restore normal driver setup. + +Stepper safety: + +- Homed devices clamp to `rtConfig->minStep/maxStep`. +- Unhomed devices use provisional defaults unless power-table/resistance updates refine limits. +- Do not bypass `moveStepper()` target clamping for ordinary control paths. + +## ERG Mode + +Primary files: `include/ERG_Mode.h`, `src/ERG_Mode.cpp`. + +`ErgMode::runERG()` is called from the main maintenance loop. It: + +- Waits for delayed stepper movement after large power-table seeks. +- Saves power table after delayed `saveFlag`. +- Loads power table once per session. +- Adds live power/cadence/position samples to the power table when cadence exists and `pTab4Pwr` is false. +- Calls `computeErg()` when FTMS mode is target power and a power meter or simulation is active. +- Periodically updates stepper min/max from power table. +- Handles power-table reset flag. +- If `pTab4Pwr` is true, estimates watts from cadence and current stepper position using `PowerTable::lookupWatts()`. + +`computeErg()`: + +- Stops ERG and switches back to simulation mode 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. +- Falls back to `_inSetpointState()` proportional control. +- Writes the new target to `rtConfig->targetIncline`. + +`_setPointChangeState()`: + +- Chooses increasing/decreasing mode. +- Looks up a position near the target watts/cadence with a PID window offset. +- Rejects table results that move the wrong way or become negative while homed. +- Adds delay based on step distance and configured stepper speed. + +`_inSetpointState()`: + +- Uses proportional-only control with `ERGSensitivity`. +- Scales gain by watt error size. +- Caps movement by stepper speed and `ERG_MODE_DELAY`. + +## Power Table + +Primary files: `include/Power_Table.h`, `include/PowerTable_Helpers.h`, `src/Power_Table.cpp`, `src/PowerTable_Helpers.cpp`. + +Purpose: + +- Learn mapping between watts, cadence, and stepper target position. +- Use that mapping for ERG setpoint jumps and optional power estimation (`pTab4Pwr`). +- Infer min/max stepper limits when not using homing or real resistance feedback. + +Data structures: + +- `PowerEntry`: raw sample with watts, cadence, target position, resistance, reading count. +- `PowerBuffer`: fixed `POWER_SAMPLES` sample buffer used before committing a table entry. +- `TableEntry`: compact stored table cell: `int16_t targetPosition`, `int8_t readings`. +- `PTData`: `POWERTABLE_CAD_SIZE` x `POWERTABLE_WATT_SIZE` table. +- `ResistanceModel`: regression model predicting position from watts/cadence and inverse watts from position/cadence. +- `PTHelpers`: indexing, lookup, cleaning, interpolation, monotonic enforcement, and model fitting. + +Table dimensions/constants are in `include/settings.h`: + +- Watts increment: `POWERTABLE_WATT_INCREMENT`. +- Cadence starts at `MINIMUM_TABLE_CAD`, increment `POWERTABLE_CAD_INCREMENT`. +- Positions are divided by `TABLE_DIVISOR` when stored to save memory. +- `INT16_MIN` marks empty table positions. +- `readings == 1` means inferred/low-confidence; human/real readings are generally `2+`. + +Flow: + +1. `PowerTable::processPowerValue()` accepts sane cadence/watts and stable position samples. +2. A full `PowerBuffer` is averaged in `PowerTable::newEntry()`. +3. `PTHelpers::calculateIndex()` maps watts/cadence to table indexes. +4. `PTHelpers::enterData()` averages the cell, rejects monotonic violations, fills gaps, runs PAVA-style monotonic correction, fits `ResistanceModel`, and fills inferred cells. +5. `lookup()` uses the model to predict a target position and multiplies by `TABLE_DIVISOR`. +6. `lookupWatts()` inverses the model for `pTab4Pwr`. + +Persistence: + +- `_manageSaveState()` loads/saves `POWER_TABLE_FILENAME`. +- Saving/loading requires `rtConfig->homed`. +- File format starts with `TABLE_VERSION`, saved reading quality, and saved homed state, then table entries. +- `_save()` refuses to save with no valid readings. +- `reset()` clears table, resets homing settings, and rewrites/attempts save. + +Caution: + +- `ResistanceModel::fit()` uses only entries with `readings >= 2`. +- Inferred cells can improve lookup smoothness but should not be treated as real measurements. +- Power-table and homing logic are tightly linked now. Loading/saving without homing is intentionally blocked. + +## DirCon + +Primary files: `include/DirConManager.h`, `src/DirConManager.cpp`, `include/DirConMessage.h`, `src/DirConMessage.cpp`. + +DirCon exposes BLE-like services over TCP: + +- Starts a WiFi server on `DIRCON_TCP_PORT`. +- Publishes MDNS service and BLE service UUID TXT records. +- Handles discover-services, discover-characteristics, read, write, enable-notifications, and unsolicited notification messages. +- Services register with `DirConManager::registerService()`. +- FTMS registers a write handler in `BLE_Fitness_Machine_Service::setupService()` so DirCon writes to the FTMS control point run the same control logic as BLE writes. +- BLE server updates call `DirConManager::notifyCharacteristic()` so TCP clients receive corresponding updates. + +DirCon uses static buffers and fixed client/subscription arrays. Be cautious with dynamic allocation and payload sizes. + +## HTTP/Web UI + +Primary files: `src/HTTP_Server_Basic.cpp`, `include/HTTP_Server_Basic.h`, `data/*`. + +Responsibilities: + +- Start/stop WiFi (`startWifi()`, `stopWifi()`). +- Serve LittleFS web assets and built-in OTA pages. +- Firmware update flow through `HTTP_Server::FirmwareUpdate()`. +- Settings JSON/API behavior through `settingsProcessor()`. +- Periodic web client update through `webClientUpdate()`. +- BLE scanner page support. + +Web UI changes usually need matching firmware handlers when adding settings. Config fields are not automatically exposed unless `settingsProcessor()` and/or the custom BLE characteristic know about them. + +## Logging + +Primary files: `src/SS2KLog.cpp`, `include/SS2KLog.h`, `src/*Appender.cpp`. + +Use `SS2K_LOG*` macros rather than raw `Serial.printf` unless matching nearby code or during very low-level diagnostics. Log sinks: + +- Serial/log handler. +- UDP when enabled. +- WebSocket. +- BLE custom logging through `BleAppender`. + +`DEBUG_BLE_TX_RX`, `CUSTOM_CHAR_DEBUG`, `DEBUG_POWERTABLE`, `DEBUG_DIRCON_MESSAGES`, and `DEBUG_STACK` in `settings.h` unlock extra diagnostics. Be aware that verbose BLE/Peloton logging can be noisy or timing-sensitive. + +## Configuration Constants + +`include/settings.h` is central. It contains: + +- Firmware update URLs and LittleFS filenames. +- Device/WiFi defaults. +- Stepper power, speed, acceleration, travel, and driver tuning. +- ERG constants and compile-time feature flags. +- Power-table sizes, increments, and quality constants. +- Hardware pin assignments for board revisions. +- BLE timing, reconnect, stack, and buffer sizes. +- Peloton aux serial constants. +- Homing thresholds and sensitivity defaults. + +When changing behavior, prefer adjusting named constants instead of scattering magic numbers. + +## Common Change Patterns + +Adding a new sensor parser: + +1. Add `SensorData` subclass in `lib/SS2K/include/sensors` and `lib/SS2K/src/sensors`. +2. Add service/characteristic UUIDs to `include/Constants.h` or `include/BLE_Definitions.h` as appropriate. +3. Add the service/characteristic to `BLEServices::SUPPORTED_SERVICES`. +4. Add factory branch in `SensorDataFactory::getSensorData()`. +5. Add native tests for parser behavior. +6. Make sure `collectAndSet()` accepts or intentionally ignores the new values. + +Adding a persisted setting: + +1. Add field/getter/setter/default in `userParameters`. +2. Update `setDefaults()`, `returnJSON()`, `saveToLittleFS()`, `loadFromLittleFS()`. +3. Add custom characteristic read/write/notify if the app controls it. +4. Add HTTP settings support if the web UI controls it. +5. Consider whether a live change needs immediate side effects, like updating stepper speed/power. + +Changing ERG or resistance behavior: + +1. Trace the relevant FTMS opcode in `processFTMSWrite()`. +2. Follow how `rtConfig` fields are updated. +3. Check `FTMSModeShiftModifier()` for shifter remapping side effects. +4. Check `ErgMode::runERG()` and `computeErg()`. +5. Check final clamps in `SS2K::moveStepper()`. +6. Run native tests and note any hardware validation needed. + +Changing BLE server characteristics: + +1. Update the service setup. +2. Update callback/write handling. +3. Update DirCon registration/notification if TCP clients should see it. +4. Update `CustomCharacteristic.md` or relevant docs. +5. Keep BLE packet sizes and little-endian encoding consistent. + +## Known Pitfalls + +- `Hardware/` is physical design material and should be ignored for firmware/software analysis unless explicitly requested. +- Some paths and folder names differ by case (`Hardware` vs `hardware`); use both excludes in searches. +- `compile_commands.json`, `.pio/`, `managed_components/`, map files, and generated logs are large/noisy. +- `SensorDataFactory` intentionally caches parser objects per unique device/characteristic. +- `Measurement` timestamps matter for ERG deduplication. +- `PowerTable` stores positions divided by `TABLE_DIVISOR`; lookup returns full-scale positions. +- `PowerTable` persistence requires homing. +- `SpinBLEAdvertisedDevice::reset()` updates global connected flags before clearing local flags. +- BLE address randomization is handled specially in `adevName2UniqueName()`; saved names depend on this behavior. +- `spinBLEServer.writeCache` is shared by BLE writes and DirCon writes. +- `spinDownFlag` is a state machine trigger, not just a bool: `1` means home/startup-ish, `2+` means full spindown/homing. +- `externalControl` bypasses normal target calculation but final state can still be affected by sync/clamping code. +- Many BLE and motor changes cannot be fully validated without hardware. + +## Search Tips + +Useful commands: + +- List software files: `rg --files -g '!Hardware/**' -g '!hardware/**'` +- Find symbols: `rg -n "symbolName" src include lib/SS2K test` +- Find BLE UUID use: `rg -n "UUID|SERVICE|CHARACTERISTIC" include src lib/SS2K` +- Find config variables: `rg -n "getName|setName|BLE_name|jsonKey" include src data` +- Find FTMS behavior: `rg -n "FitnessMachineControlPointProcedure|SetTargetPower|SetIndoorBikeSimulationParameters" src include` + +Prefer `rg` over slower recursive tools. From 87c740ed65bb7dceb010ba5770730c292f302f42 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 27 May 2026 14:32:29 -0500 Subject: [PATCH 6/6] Simplified logic further. Moved flag from NVS to RTC memory. --- AGENTS.md | 7 +++- dependencies.lock | 2 +- include/BLE_Common.h | 4 -- include/Main.h | 4 ++ src/BLE_Common.cpp | 21 ---------- src/Main.cpp | 91 ++++++++++++++++++++++++++++++-------------- 6 files changed, 73 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d030c0f4..210867f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ These notes are for coding agents working in this repository. They are intentionally focused on firmware/software behavior and ignore the physical design files in `Hardware/` or `hardware/`. +Keep this file current while doing code work when it would help future agents: prune notes that become wrong, and add important discoveries. Many tasks will not need an `AGENTS.md` edit. Keep it concise and project-essential rather than comprehensive. + ## Project Shape SmartSpin2k is ESP32 firmware for converting a spin bike into a BLE smart trainer. It: @@ -79,7 +81,7 @@ Boot sequence: 5. Load and re-save `userConfig`. 6. Start WiFi and run firmware update check. 7. Configure GPIO pins. -8. Initialize LED quiet-mode preference. +8. Initialize LED state; commanded-reboot quiet mode uses RTC memory so true power cycles still show startup blink behavior. 9. Configure TMC/FastAccelStepper via `SS2K::setupTMCStepperDriver()`. 10. Register log appenders. 11. Start BLE via `setupBLE()`. @@ -94,6 +96,7 @@ Boot sequence: - Always handle local shifter button state. - Notify changed custom-characteristic values via `BLE_ss2kCustomCharacteristic::parseNemit()`. - Update HTTP clients and DirCon. +- Update LED status/diagnostics. - Slow stepper near Peloton resistance limits when unhomed. - Handle reboot/default-reset/save flags. - Every roughly 6 seconds, log status and reboot after 30 minutes of inactivity. @@ -176,6 +179,7 @@ Important functions: - `goHome()`, `_findEndStop()`, `_findFTMSHome()`: homing procedures. - `setupTMCStepperDriver()`, `updateStepperPower()`, `updateStealthChop()`, `updateStepperSpeed()`: motor driver configuration. - `txSerial()`, `rxSerial()`, `pelotonConnected()`: Peloton aux serial integration. +- `setLEDEnabled()`, `updateLED()`: main LED state and diagnostic blink/pulse behavior. ## Control Flow: Sensors To Stepper @@ -554,6 +558,7 @@ Changing BLE server characteristics: - `Hardware/` is physical design material and should be ignored for firmware/software analysis unless explicitly requested. - Some paths and folder names differ by case (`Hardware` vs `hardware`); use both excludes in searches. - `compile_commands.json`, `.pio/`, `managed_components/`, map files, and generated logs are large/noisy. +- LED behavior is owned by `SS2K`/`src/Main.cpp`, not BLE common code. Its reboot inhibit flag is `RTC_DATA_ATTR`, which should survive `ESP.restart()` but clear on power loss. - `SensorDataFactory` intentionally caches parser objects per unique device/characteristic. - `Measurement` timestamps matter for ERG deduplication. - `PowerTable` stores positions divided by `TABLE_DIVISOR`; lookup returns full-scale positions. diff --git a/dependencies.lock b/dependencies.lock index 6f1aafb5..f84838b8 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -96,6 +96,6 @@ direct_dependencies: - espressif/network_provisioning - idf - joltwallet/littlefs -manifest_hash: e7ce7a886dfdb3cc5cd59acbdb4ff333c0a91c6e4de55b9fd6323d866c7cc691 +manifest_hash: df9baa925ebc2a028ee0e349be53f2169bb7ac38b2cb9c63b3ebf69123c43ef8 target: esp32 version: 2.0.0 diff --git a/include/BLE_Common.h b/include/BLE_Common.h index e2425de5..0a881942 100644 --- a/include/BLE_Common.h +++ b/include/BLE_Common.h @@ -69,11 +69,7 @@ using BLEServices::SUPPORTED_SERVICES; void setupBLE(); extern TaskHandle_t BLEClientTask; // ***********************Common********************************** -inline constexpr uint32_t kLedAllowedWindowMs = 15UL * 60UL * 1000UL; - void BLECommunications(); -void setLEDAllowedUntil(uint32_t t); -void extendLEDWindow(); // Check if a BLE device supports any of our supported services bool isDeviceSupported(const NimBLEAdvertisedDevice* advertisedDevice, const String& deviceName = ""); diff --git a/include/Main.h b/include/Main.h index cd01e6c2..09a23b6f 100644 --- a/include/Main.h +++ b/include/Main.h @@ -35,6 +35,7 @@ class SS2K { unsigned long int scanDelayStart; int32_t targetPosition; int32_t currentPosition; + bool ledEnabled; void handleShiftButtons(); public: @@ -81,12 +82,15 @@ class SS2K { void txSerial(); bool pelotonConnected(); void goHome(bool bothDirections = false); + void setLEDEnabled(bool enabled); + void updateLED(); SS2K() { upButtonState = RELEASED; downButtonState = RELEASED; targetPosition = 0; currentPosition = 0; + ledEnabled = false; stepperIsRunning = false; externalControl = false; syncMode = false; diff --git a/src/BLE_Common.cpp b/src/BLE_Common.cpp index 93137430..bdafd1c0 100644 --- a/src/BLE_Common.cpp +++ b/src/BLE_Common.cpp @@ -16,11 +16,6 @@ #include #include -static uint32_t ledAllowedUntil = kLedAllowedWindowMs; - -void setLEDAllowedUntil(uint32_t t) { ledAllowedUntil = t; } -void extendLEDWindow() { ledAllowedUntil = millis() + kLedAllowedWindowMs; } - /** * @brief Retrieves the BLE service information for a given advertised device and device name. * @@ -148,20 +143,4 @@ void BLECommunications() { } } - static int _lastClientCount = -1; - int currentCount = spinBLEServer.connectedClientCount(); - if (currentCount != _lastClientCount) { - _lastClientCount = currentCount; - extendLEDWindow(); - } - - if (millis() < ledAllowedUntil) { - if (currentCount == 0) { - digitalWrite(LED_PIN, (millis() / 500) % 2 == 0 ? LOW : HIGH); - } else { - digitalWrite(LED_PIN, HIGH); - } - } else { - digitalWrite(LED_PIN, LOW); - } } diff --git a/src/Main.cpp b/src/Main.cpp index d596cece..b22e7950 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -8,9 +8,7 @@ #include "Main.h" #include "Stepper.h" #include "SS2KLog.h" -#include "esp_system.h" #include -#include #include #include #include "ERG_Mode.h" @@ -44,10 +42,25 @@ SS2K* ss2k = new SS2K; userParameters* userConfig = new userParameters; RuntimeParameters* rtConfig = new RuntimeParameters; +#ifndef UNIT_TEST namespace { -constexpr char kLedControlNamespace[] = "led_ctrl"; -constexpr char kLedInhibitKey[] = "inhibit"; +RTC_NOINIT_ATTR bool inhibitLedOnBoot = false; + + // RTC memory survives commanded ESP.restart() calls but not power loss. A set + // flag means the previous reboot was intentional, so consume it and keep the + // LED quiet until activity turns it back on. +bool shouldStartWithLedEnabled() { + bool inhibitLed = inhibitLedOnBoot; + inhibitLedOnBoot = false; + return !inhibitLed; +} + +// Set a flag in RTC memory to keep the LED off on the next boot. +void keepLedOffAfterReboot() { + inhibitLedOnBoot = true; +} } // namespace +#endif ///////////// Log Appender ///////////// UdpAppender udpAppender; @@ -130,21 +143,7 @@ extern "C" void app_main() { digitalWrite(currentBoard.dirPin, LOW); digitalWrite(currentBoard.stepPin, LOW); digitalWrite(LED_PIN, LOW); - - // LED quiet mode: allow 15 min on power-on or manual reboot; inhibit after inactivity reboot - { - Preferences ledPreferences; - - ledPreferences.begin(kLedControlNamespace, true); - bool inhibit = ledPreferences.getBool(kLedInhibitKey, false); - ledPreferences.end(); - - ledPreferences.begin(kLedControlNamespace, false); - ledPreferences.putBool(kLedInhibitKey, false); - ledPreferences.end(); - - setLEDAllowedUntil((esp_reset_reason() == ESP_RST_POWERON || !inhibit) ? kLedAllowedWindowMs : 0UL); - } + ss2k->setLEDEnabled(shouldStartWithLedEnabled()); ss2k->setupTMCStepperDriver(); @@ -153,7 +152,7 @@ extern "C" void app_main() { // disableCore0WDT(); // Disable the watchdog timer on core 0 (so long stepper // moves don't cause problems) - digitalWrite(LED_PIN, HIGH); + digitalWrite(LED_PIN, LOW); // Configure and Initialize Logger logHandler.addAppender(&webSocketAppender); logHandler.addAppender(&udpAppender); @@ -181,7 +180,7 @@ extern "C" void app_main() { #endif ss2k->resetIfShiftersHeld(); - digitalWrite(LED_PIN, HIGH); + digitalWrite(LED_PIN, LOW); xTaskCreatePinnedToCore(SS2K::maintenanceLoop, /* Task function. */ "maintenanceLoopFunction", /* name of task. */ @@ -242,6 +241,7 @@ void SS2K::maintenanceLoop(void* pvParameters) { httpServer.webClientUpdate(); // Update DirCon protocol DirConManager::update(); + ss2k->updateLED(); // If we're in ERG mode, modify shift commands to inc/dec the target watts instead. // If we have a resistance bike attached, slow down when we're close to the limits. @@ -316,13 +316,7 @@ void SS2K::maintenanceLoop(void* pvParameters) { if (((millis() - rebootTimer) > 1800000)) { // Timer expired SS2K_LOG(MAIN_LOG_TAG, "Rebooting due to inactivity."); - { - Preferences ledPreferences; - // Inhibit the startup LED window after inactivity reboot; app_main clears this flag after reading it. - ledPreferences.begin(kLedControlNamespace, false); - ledPreferences.putBool(kLedInhibitKey, true); - ledPreferences.end(); - } + keepLedOffAfterReboot(); ss2k->rebootFlag = true; logHandler.writeLogs(); webSocketAppender.Loop(); @@ -334,7 +328,7 @@ void SS2K::maintenanceLoop(void* pvParameters) { _oldWatts = rtConfig->watts.getValue(); _oldTargetIncline = rtConfig->getTargetIncline(); rebootTimer = millis(); - extendLEDWindow(); + ss2k->setLEDEnabled(true); } #ifdef DEBUG_STACK @@ -358,9 +352,48 @@ void SS2K::maintenanceLoop(void* pvParameters) { #endif // UNIT_TEST +void SS2K::setLEDEnabled(bool enabled) { + ledEnabled = enabled; + if (!enabled) { + digitalWrite(LED_PIN, LOW); + } +} + +void SS2K::updateLED() { + if (!ledEnabled) { + digitalWrite(LED_PIN, LOW); + return; + } + + int currentCount = spinBLEServer.connectedClientCount(); + if (currentCount == 0) { + // No app/client connected yet: simple idle blink. + digitalWrite(LED_PIN, (millis() / 500) % 2 == 0 ? LOW : HIGH); + return; + } + + // Connected: stay mostly on, then count clients as brief off-pulses. + constexpr unsigned long pulseOffTime = 200; + constexpr unsigned long pulseGap = 300; + constexpr unsigned long countGap = 5000; + unsigned long pulsePeriod = pulseOffTime + pulseGap; + unsigned long cycleDuration = currentCount * pulsePeriod + countGap; + unsigned long cyclePosition = millis() % cycleDuration; + + // After the diagnostic pulses, return to solid-on connected status. + if (cyclePosition >= currentCount * pulsePeriod) { + digitalWrite(LED_PIN, HIGH); + return; + } + + // Each pulse starts with a short off dip, followed by on-time between dips. + digitalWrite(LED_PIN, (cyclePosition % pulsePeriod) < pulseOffTime ? LOW : HIGH); +} + void SS2K::FTMSModeShiftModifier() { int shiftDelta = rtConfig->getShifterPosition() - ss2k->lastShifterPosition; if (shiftDelta) { // Shift detected + ss2k->setLEDEnabled(true); // When Zwift virtual shifting is active, forward shifts to Zwift // instead of handling them internally. Zwift sends gear changes // back via the custom trainer protocol which we already handle.