Conversation
Move FTMS control point write logic from DirConManager.cpp into BLE_Fitness_Machine_Service::handleDirConWrite(). Move Zwift sync_rx write logic (including auto-subscription UUIDs) from DirConManager.cpp into BLE_Zwift_Service::handleDirConWrite(). DirConManager now calls each service's handleDirConWrite() generically without referencing any service-specific UUIDs or protocol details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… remove handshake state handling
…into Zwift_shifting
…ncy; adjust spacing and update service UUID references
…e command codes for trainer configuration
There was a problem hiding this comment.
Pull request overview
This PR introduces Zwift virtual shifting support (ZP) by adding a Zwift custom BLE service and an OpenBikeControl BLE service, and extends the DirCon bridge to discover services dynamically and dispatch writes to service-specific handlers.
Changes:
- Add Zwift custom BLE service + protocol handling for handshake, riding data, and virtual shift events.
- Add OpenBikeControl BLE service (button state + haptic/app-info writes) and advertise it over mDNS.
- Refactor DirCon to use a service registration model for discovery, MDNS TXT updates, and write handling callbacks.
Reviewed changes
Copilot reviewed 26 out of 29 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/SS2KLog.cpp | Hardens log formatting/sending and adds hex helpers (now templated). |
| include/SS2KLog.h | Exposes new templated hex helpers. |
| src/Main.cpp | Forwards physical shifter deltas to Zwift/OpenBikeControl services. |
| include/Main.h | Adds accessors for last shifter position to prevent shift echo loops. |
| src/DirConMessage.cpp | Improves unknown-message logging by dumping full message bytes. |
| src/DirConManager.cpp | Adds service registration + write dispatch, improved subscription tracking, OpenBikeControl mDNS advertising, and notification locking. |
| include/DirConManager.h | Adds DirCon service registry/write-handler API and subscription entry struct. |
| src/BLE_Zwift_Service.cpp | New Zwift Ride custom service implementation (handshake, keepalive, gear ratio, shifting). |
| include/BLE_Zwift_Service.h | Public interface for Zwift service + callback class. |
| src/BLE_OpenBikeControl_Service.cpp | New OpenBikeControl BLE service implementation (button state notify, haptic/app-info writes). |
| include/BLE_OpenBikeControl_Service.h | Public interface for OpenBikeControl service + callback classes. |
| src/BLE_Server.cpp | Wires Zwift/OpenBikeControl services into server startup and update loop; adjusts advertising/scan response. |
| src/BLE_Heart_Service.cpp | Switches DirCon integration to registerService(). |
| src/BLE_Fitness_Machine_Service.cpp | Switches DirCon integration to registerService() with a write handler for FTMS CP. |
| src/BLE_Device_Information_Service.cpp | Improves DIS fields (manufacturer/model/hw rev/serial/system id) for interoperability. |
| src/BLE_Cycling_Speed_Cadence.cpp | Switches DirCon integration to registerService(). |
| src/BLE_Cycling_Power_Service.cpp | Switches DirCon integration to registerService(). |
| src/BLE_Custom_Characteristic.cpp | Minor formatting-only change. |
| lib/SS2K/include/Constants.h | Adds UUID constants for Zwift and OpenBikeControl services/characteristics. |
| include/Zwift_Protocol_Messages.h | Adds Zwift protobuf/tag/enum definitions used by the Zwift service. |
| platformio.ini | Adds a pre-build cleanup script; updates NimBLE-Arduino comment link. |
| scripts/pre_build_cleanup.py | New pre-build script to remove stale x509 bundle artifacts and force single-job builds. |
| scripts/pycache/pre_build_cleanup.cpython-313.pyc | Added compiled Python artifact (should not be committed). |
| sdkconfig.release.old | Updates ESP-IDF config snapshot (tooling/build settings). |
| sdkconfig.release | Updates ESP-IDF release config (NimBLE/Arduino selective build settings). |
| include/cert.h | Updates root CA certificate bundle used for GitHub raw fetches. |
| CHANGELOG.md | Adds a new (currently empty) version section. |
| Hardware/V3 - Integrated PCB/PCB/SS2K_KiCad_Files/SmartSpin2k-backups/SmartSpin2k-2023-01-21_082918.zip | Hardware backup ZIP (binary). |
| Hardware/V3 - Integrated PCB/PCB/SS2K_KiCad_Files/SmartSpin2k-backups/SmartSpin2k-2022-10-31_214835.zip | Hardware backup ZIP (binary). |
| if (_messageBufferHandle == NULL) { | ||
| ESP_LOGE(LOG_HANDLER_TAG, "Can not send log message. Message Buffer is NULL"); | ||
| return; | ||
| } |
There was a problem hiding this comment.
LogHandler::writev() returns early when _messageBufferHandle is NULL without releasing _logBufferMutex, which can permanently deadlock logging for the rest of the runtime. Ensure the semaphore is always given on all exit paths after a successful xSemaphoreTake (e.g., give the mutex before returning on the NULL handle path).
| DirConManager::notifyCharacteristic(NimBLEUUID(ZWIFT_RIDE_CUSTOM_SERVICE_UUID), asyncCharacteristic->getUUID(), zData, pos); | ||
| } | ||
|
|
||
| bool BLE_Zwift_Service::isConnected() { |
There was a problem hiding this comment.
BLE_Zwift_Service::isConnected() treats _lastActivityTime == 0 as a recent activity (millis()-0 < timeout), so it will report connected for the first ~10s after boot even with no Zwift client. This can incorrectly route shifter events and keepalives. Consider requiring _lastActivityTime != 0 (and/or tracking actual NimBLE connection state) before returning true.
| bool BLE_Zwift_Service::isConnected() { | |
| bool BLE_Zwift_Service::isConnected() { | |
| if (_lastActivityTime == 0) { | |
| return false; | |
| } |
| // Also update lastShifterPosition so FTMSModeShiftModifier doesn't | ||
| // see this Zwift-driven change as a user shift and echo it back. | ||
| ss2k->setLastShifterPosition(newShifterPos); | ||
| SS2K_LOG(getLogTag(), "Gear %d -> shifter position %d", closestIndex + 1, newShifterPos); |
There was a problem hiding this comment.
applyGearRatio(): closestIndex is already set to i + 1, but the log prints closestIndex + 1, which makes the reported gear off by one. Either keep closestIndex 0-based and add 1 only when presenting, or log closestIndex directly to match the shifter position you apply.
| SS2K_LOG(getLogTag(), "Gear %d -> shifter position %d", closestIndex + 1, newShifterPos); | |
| SS2K_LOG(getLogTag(), "Gear %d -> shifter position %d", closestIndex, newShifterPos); |
| char hexBuf[len * 3 + 1]; | ||
| for (size_t i = 0; i < len; i++) { | ||
| snprintf(hexBuf + i * 3, 4, "%02X ", data[i]); | ||
| } | ||
| hexBuf[len * 3] = '\0'; | ||
| SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Unknown identifier %d. Full message (%d bytes): %s", this->Identifier, len, hexBuf); |
There was a problem hiding this comment.
This uses a variable-length array (char hexBuf[len * 3 + 1];). VLAs are not standard C++ and can fail to compile depending on toolchain flags; they also risk large stack usage when len grows. Prefer a fixed-size buffer based on the protocol max (e.g., DIRCON_RECEIVE_BUFFER_SIZE) or build the hex string incrementally into a std::string.
| char hexBuf[len * 3 + 1]; | |
| for (size_t i = 0; i < len; i++) { | |
| snprintf(hexBuf + i * 3, 4, "%02X ", data[i]); | |
| } | |
| hexBuf[len * 3] = '\0'; | |
| SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Unknown identifier %d. Full message (%d bytes): %s", this->Identifier, len, hexBuf); | |
| char hexBuf[(DIRCON_RECEIVE_BUFFER_SIZE * 3) + 1]; | |
| size_t maxBytesToLog = DIRCON_RECEIVE_BUFFER_SIZE; | |
| size_t bytesToLog = len < maxBytesToLog ? len : maxBytesToLog; | |
| for (size_t i = 0; i < bytesToLog; i++) { | |
| snprintf(hexBuf + i * 3, 4, "%02X ", data[i]); | |
| } | |
| hexBuf[bytesToLog * 3] = '\0'; | |
| if (bytesToLog < len) { | |
| SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Unknown identifier %d. Full message (%d bytes, truncated to %d bytes): %s", this->Identifier, len, bytesToLog, hexBuf); | |
| } else { | |
| SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Unknown identifier %d. Full message (%d bytes): %s", this->Identifier, len, hexBuf); | |
| } |
|
|
||
| void BLE_Zwift_Service::sendShiftDown() { | ||
| SS2K_LOG(getLogTag(), "Sending shift down to Zwift"); | ||
| sendButtonNotification(RideButtonMask::ShiftUpLeft); |
There was a problem hiding this comment.
sendShiftDown() uses RideButtonMask::ShiftUpLeft, which looks like the wrong button bit for a downshift (there is also ShiftDownLeft / ShiftDownRight). This likely sends the incorrect Zwift button event. Use the appropriate ShiftDown* mask for the downshift path.
| sendButtonNotification(RideButtonMask::ShiftUpLeft); | |
| sendButtonNotification(RideButtonMask::ShiftDownLeft); |
| static SemaphoreHandle_t s_notifyMutex = xSemaphoreCreateMutex(); | ||
|
|
||
| void DirConManager::broadcastNotification(const NimBLEUUID& characteristicUuid, uint8_t* data, size_t length, bool onlySubscribers) { |
There was a problem hiding this comment.
s_notifyMutex is created at static initialization time and is used without a null check. If xSemaphoreCreateMutex() fails (or static init ordering is problematic), later xSemaphoreTake() will dereference a null handle and crash. Create the mutex during DirConManager::start() (or lazily on first use) and guard against nullptr before taking/giving it.
| # Keep build deterministic for generated asm artifacts. | ||
| env.SetOption("num_jobs", 1) |
There was a problem hiding this comment.
Setting env.SetOption("num_jobs", 1) forces single-threaded builds for all environments, which can significantly increase build times. If the intent is only to avoid stale x509 bundle artifacts, consider limiting the workaround to deleting the stale files (or gating the single-job setting behind an env flag / only enabling it for affected environments).
| # Keep build deterministic for generated asm artifacts. | |
| env.SetOption("num_jobs", 1) | |
| # Keep the workaround targeted to the affected generated files so | |
| # normal parallel build performance is preserved. |
| void BLE_Zwift_Service::update() { | ||
| unsigned long now = millis(); | ||
| static unsigned long lastadvTime = 0; | ||
| static bool advertiseManufacturerData = true; | ||
| static bool swapAdv = false; |
There was a problem hiding this comment.
BLE_Zwift_Service::update() doesn’t currently gate riding-data notifications behind a real Zwift session/connection, and it doesn’t throttle sends to kZwiftRidingDataIntervalMs (the _lastRidingDataTime member is never used). Add a connection/session check and periodic send logic to avoid spamming BLE/DirCon and wasting CPU.
| int absDelta = abs(shiftDelta); | ||
| if (zwiftService.isConnected()) { | ||
| for (int i = 0; i < absDelta; i++) { | ||
| if (shiftDelta > 0) { | ||
| zwiftService.sendShiftUp(); |
There was a problem hiding this comment.
FTMSModeShiftModifier() forwards shifts by calling sendShiftUp/Down in a loop. Those helpers include a vTaskDelay(50ms) per step, so a multi-gear change can stall this task for hundreds of milliseconds (and longer if multiple services are connected). Consider making shift notifications non-blocking (e.g., queue events to a dedicated task/timer) so control-loop timing isn’t impacted.
Implementing ZP