From a846384954989622235acb247f301424cff97329 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Tue, 4 Aug 2026 15:06:18 -0500 Subject: [PATCH 01/29] initial commit --- .github/workflows/build.yml | 14 +- .gitignore | 3 + AGENTS.md | 8 +- CMakeLists.txt | 8 +- CustomCharacteristic.md | 9 +- data_s3/bluetoothscanner.html | 177 ++++++++++++ data_s3/btsimulator.html | 293 ++++++++++++++++++++ data_s3/develop.html | 90 +++++++ data_s3/favicon.ico | Bin 0 -> 1406 bytes data_s3/index.html | 118 ++++++++ data_s3/list.json | 1 + data_s3/settings.html | 403 ++++++++++++++++++++++++++++ data_s3/shift.html | 85 ++++++ data_s3/status.html | 240 +++++++++++++++++ data_s3/streamfit.html | 85 ++++++ data_s3/style.css | 141 ++++++++++ dependencies.lock | 2 +- include/BLE_Custom_Characteristic.h | 1 + include/Builtin_Pages.h | 3 +- include/Main.h | 1 + include/boards.h | 21 +- include/settings.h | 55 +++- partitions_esp32s3_16mb.csv | 9 + platformio.ini | 54 +++- scripts/pre_build_cleanup.py | 6 + sdkconfig.s3.defaults | 22 ++ src/BLE_Custom_Characteristic.cpp | 8 + src/HTTP_Server_Basic.cpp | 17 +- src/Main.cpp | 18 ++ src/Stepper.cpp | 4 + 30 files changed, 1873 insertions(+), 23 deletions(-) create mode 100644 data_s3/bluetoothscanner.html create mode 100644 data_s3/btsimulator.html create mode 100644 data_s3/develop.html create mode 100644 data_s3/favicon.ico create mode 100644 data_s3/index.html create mode 100644 data_s3/list.json create mode 100644 data_s3/settings.html create mode 100644 data_s3/shift.html create mode 100644 data_s3/status.html create mode 100644 data_s3/streamfit.html create mode 100644 data_s3/style.css create mode 100644 partitions_esp32s3_16mb.csv create mode 100644 sdkconfig.s3.defaults diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec1245d8..4973d42a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,16 +54,26 @@ jobs: - name: Install PlatformIO run: python -m pip install platformio - name: Pre-install PlatformIO packages (release env) - run: platformio pkg install -e release + run: | + platformio pkg install -e release + platformio pkg install -e S3release - name: Check pre-commit hooks uses: pre-commit/action@v3.0.1 - name: Build firmware run: platformio run -e release - name: Build filesystem run: platformio run -e release --target buildfs + - name: Build S3 firmware + run: platformio run -e S3release + - name: Build S3 filesystem + run: platformio run -e S3release --target buildfs - name: Create artifacts run: | - 7z a SmartSpin2kFirmware-${{ steps.date.outputs.date }}.bin.zip ./.pio/build/release/*.bin + cp ./.pio/build/release/firmware.bin firmware.bin + cp ./.pio/build/release/littlefs.bin littlefs.bin + cp ./.pio/build/S3release/firmware.bin S3firmware.bin + cp ./.pio/build/S3release/littlefs.bin S3littlefs.bin + 7z a SmartSpin2kFirmware-${{ steps.date.outputs.date }}.bin.zip firmware.bin littlefs.bin S3firmware.bin S3littlefs.bin - name: Archive uses: actions/upload-artifact@v5 with: diff --git a/.gitignore b/.gitignore index aee06ddc..8cff471f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ C/Users/ /build /managed_components dependencies.lock +dependencies.lock.* +sdkconfig.S3release +sdkconfig.S3debug .vscode/settings.json *.map graphify-out/ diff --git a/AGENTS.md b/AGENTS.md index 210867f4..e3cf6b07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,8 @@ Primary software directories: - `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. +- `data/`: web interface assets for classic ESP32 filesystem images. +- `data_s3/`: ESP32-S3 filesystem assets; initially mirrors `data/` but may grow independently. - `.github/copilot-instructions.md`: older agent/build notes that may still be useful. ## Build And Test @@ -30,11 +31,16 @@ Primary software directories: PlatformIO is the expected entry point. - Build firmware: `pio run --environment release` +- Build ESP32-S3 firmware: `pio run --environment S3release` - 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` +Codex environment constraint: + +- Do not run PlatformIO firmware or filesystem builds from the Codex environment. The Windows Xtensa toolchain can hang and leave orphaned compiler processes here. Make the requested changes, run non-build checks where useful, and clearly leave PlatformIO build validation for the user to run manually. + 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a5f975e..8a5fca34 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,13 @@ cmake_minimum_required(VERSION 3.16.0) include($ENV{IDF_PATH}/tools/cmake/project.cmake) +if(IDF_TARGET STREQUAL "esp32s3") + set(SDKCONFIG_DEFAULTS "sdkconfig.defaults;sdkconfig.s3.defaults") +endif() +# Keep component-manager locks target-specific so ESP32 and ESP32-S3 builds can +# coexist without rewriting one another's dependency target. +idf_build_set_property(DEPENDENCIES_LOCK dependencies.lock.${IDF_TARGET}) project(SmartSpin2k) include_directories( ${PROJECT_SOURCE_DIR}/include ${PROJECT_SOURCE_DIR}/src -) \ No newline at end of file +) diff --git a/CustomCharacteristic.md b/CustomCharacteristic.md index f6493477..c28ad540 100644 --- a/CustomCharacteristic.md +++ b/CustomCharacteristic.md @@ -77,6 +77,7 @@ From BLE_common.h |BLE_externalControl |0x1A |bool |01 disables internal calculation of targetPosition.| |BLE_syncMode |0x1B |bool |01 stops motor movement for external calibration | |BLE_UDPLogging |0x2E |bool |Enable/disable UDP log streaming | +|BLE_hardwareVersion |0x2F |str |Read-only detected hardware revision | |BLE_BLELogging |0x30 |bool/str|Write: enable/disable BLE log streaming. Read: returns last log message| *syncMode will disable the movement of the stepper motor by forcing stepperPosition = targetPosition prior to the motor control. While this mode is enabled, it allows the client to set parameters like incline and shifterPosition without moving the motor from it's current position. Once the parameters are set, this mode should be turned back off and SS2K will resume normal operation. @@ -84,4 +85,10 @@ From BLE_common.h This characteristic also notifies when a shift is preformed or the button is pressed. -See code for more references/info in BLE_Server.cpp starting on line 534 \ No newline at end of file +See code for more references/info in BLE_Server.cpp starting on line 534 + +Hardware-version example: + +- Client writes: `0x01, 0x2F` +- An ESP32-S3 board indicates: `0x80, 0x2F`, followed by the ASCII bytes for `Revision Three (ESP32-S3)`. +- Writes to `0x2F` return `cc_error` because the detected hardware revision is read-only. diff --git a/data_s3/bluetoothscanner.html b/data_s3/bluetoothscanner.html new file mode 100644 index 00000000..4ea0e487 --- /dev/null +++ b/data_s3/bluetoothscanner.html @@ -0,0 +1,177 @@ + + + + + + SmartSpin2k Bluetooth Scanner + + + +
+
+ +
+ +
+ +

Bluetooth Devices

+ +
+
+

Connected Devices

+
+
+ Power Meter + loading +
+
+ Heart Monitor + loading +
+
+ Remote + loading +
+
+
+ +
+
+

Device Selection

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ +
+
+ +

+ Scan for new devices without saving current selections.
+ Tip: Hold both shifters for 3 seconds to scan/connect at any time. +

+
+
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/data_s3/btsimulator.html b/data_s3/btsimulator.html new file mode 100644 index 00000000..01c342d7 --- /dev/null +++ b/data_s3/btsimulator.html @@ -0,0 +1,293 @@ + + + + + + SmartSpin2k BLE Simulator + + + + +
+
+ +
+ +
+ +

BLE Simulator

+ +
+ +
+
+

Simulate Heart Rate

+ +
+
+
+
+ 40BPM +
+
+ + + +
+
+
+
+ + +
+
+

Simulate Power Output

+ +
+
+
+
+ 0Watts +
+
+ + + +
+
+
+ Auto Update + +
+
+
+ + +
+
+

Simulate Cadence

+ +
+
+
+
+ 0RPM +
+
+ + + +
+
+
+ Auto Update + +
+
+
+ + +
+
+

Simulate ERG Mode

+ +
+
+
+
+ 0Watts +
+
+ + + +
+
+
+ Target Watts Enabled + +
+
+
+
+
+
+ + + + diff --git a/data_s3/develop.html b/data_s3/develop.html new file mode 100644 index 00000000..b1b16c15 --- /dev/null +++ b/data_s3/develop.html @@ -0,0 +1,90 @@ + + + + + + SmartSpin2k Developer Tools + + + +
+
+ +
+ +
+

Developer Tools

+ + + + +
+
+ + diff --git a/data_s3/favicon.ico b/data_s3/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d09e94780af0f06613b4b9e8a49b06631a391ae6 GIT binary patch literal 1406 zcmeH{%TE(g6vn@yQWO=dDAwYGqXMO!;vltxFJKU~fbL9OXf)9Vaf8G*-5Obtx^tl} z-6_i2g*(xOd!y?vX!;^%rYUV_+Rlup?WX!C81CeE?{`kVd*|Gf%m4yE4hMMNiASwK z7XUZIDfBS4e0>OwL7So`n^e!n02d>)BJ0!c>n`jE@zpr~8Oq_Z$h6N;ihk|dPNI{Mvxs2Vz| z)he7$7oIP_K{OhLAPDe}46~;UuPEa8<|ZPM2v*lVLen(pOeT|oX&HF`ZWRN)L8wX& z4{qge`G+jeH9*5-hpi-&e_MN+UvbfaL%x$^df&Z=pMAUpI8aiQX zO3Wrd-u5cAoA@ybdt}zdHs039{cDHqCS4#?2Sfo zMElRM3gB!b=K1Jk+rae238M3!danNWBXV%5wfL`l@n;WfgIQ;u0pFQrHVI69uxMdy SHs&p`at}~BF2#Ti-unlY=fxTT literal 0 HcmV?d00001 diff --git a/data_s3/index.html b/data_s3/index.html new file mode 100644 index 00000000..4c832341 --- /dev/null +++ b/data_s3/index.html @@ -0,0 +1,118 @@ + + + + + + SmartSpin2k + + + + + + + + diff --git a/data_s3/list.json b/data_s3/list.json new file mode 100644 index 00000000..fcb36ff5 --- /dev/null +++ b/data_s3/list.json @@ -0,0 +1 @@ +["bluetoothscanner.html", "btsimulator.html", "favicon.ico", "index.html", "settings.html", "shift.html", "status.html", "style.css", "streamfit.html", "develop.html"] \ No newline at end of file diff --git a/data_s3/settings.html b/data_s3/settings.html new file mode 100644 index 00000000..836446cd --- /dev/null +++ b/data_s3/settings.html @@ -0,0 +1,403 @@ + + + + + + SmartSpin2k Settings + + + +
+
+ +
+ +
+
Loading
+

Settings

+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + Reboot Device +
+ + +

Reset to Defaults?

+

This will delete all current settings. This action cannot be undone.

+
+ + +
+
+
+ + +
+ + + diff --git a/data_s3/shift.html b/data_s3/shift.html new file mode 100644 index 00000000..2e105621 --- /dev/null +++ b/data_s3/shift.html @@ -0,0 +1,85 @@ + + + + + + SmartSpin2k Web Shifter + + + +
+
+ +
+ +
+ +

Web Shifter

+ +
+
+ + +
+ + +
+ + +
+
+
+
+ + + + diff --git a/data_s3/status.html b/data_s3/status.html new file mode 100644 index 00000000..ae883a61 --- /dev/null +++ b/data_s3/status.html @@ -0,0 +1,240 @@ + + + + + + + SmartSpin2k Status + + + + +
+
+ +
+ +
+

System Status

+ +
+
+
+ + +
+
+

Debug Console

+ +
+
Loading
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/data_s3/streamfit.html b/data_s3/streamfit.html new file mode 100644 index 00000000..45466e99 --- /dev/null +++ b/data_s3/streamfit.html @@ -0,0 +1,85 @@ + + + + + + SmartSpin2k StreamFit + + + +
+
+ +
+ +
+

StreamFit

+ +
+
+

Upload a .fit file to simulate a recorded workout.

+

Requires internet connection for file processing.

+
+ +
+ + +
REQUIRES INTERNET CONNECTION
+
+ +
+
+
❤️
+
Heart Rate
+
--
+
BPM
+
+ +
+
+
Power
+
--
+
W
+
+ +
+
🔄
+
Cadence
+
--
+
RPM
+
+
+ +
+ +
+
+ + +
+
+ + + + + diff --git a/data_s3/style.css b/data_s3/style.css new file mode 100644 index 00000000..54caee98 --- /dev/null +++ b/data_s3/style.css @@ -0,0 +1,141 @@ +:root{--primary:#2a9df4;--bg-dark:#03245c;--bg-main:#1167b1;--bg-overlay:rgba(3,37,76,.6);--bg-item:rgba(0,0,0,.2);--text:#fff;--text-secondary:rgba(255,255,255,.7);--border:rgba(255,255,255,.1);--shadow-sm:0 2px 4px rgba(0,0,0,.1);--shadow-md:0 8px 16px rgba(0,0,0,.2);--shadow-lg:0 12px 24px rgba(0,0,0,.25);--radius-sm:4px;--radius-md:8px;--radius-lg:12px} +html{font-family:system-ui,-apple-system,sans-serif;line-height:1.4;background:var(--bg-dark);color:var(--text);-webkit-text-size-adjust:100%} +body{margin:0;min-height:100vh;background:var(--bg-main)} +h1,h2{color:var(--primary);margin:0 0 1rem} +h1{font-size:2rem;font-weight:700} +h2{font-size:1.2rem;font-weight:600} +p{margin:.5rem 0;line-height:1.6} +a{color:var(--text);text-decoration:none;transition:color .2s} +a:hover,.dev-tool-card:hover .card-arrow{color:var(--primary)} +.page-container{max-width:1200px;margin:0 auto;padding:1rem} +header,nav{display:flex;justify-content:space-between;padding:1rem 0} +header{align-items:center} +nav{align-items:center;width:100%} +.brand{font-size:2.5rem;font-weight:700;text-align:center;text-shadow:0 4px 8px rgba(0,0,0,.5);margin:1rem 0;width:100%;letter-spacing:1px;color:var(--text)} +nav a{color:var(--text);padding:.5rem} +nav a:hover{text-decoration:underline} +.dev-tools-grid,.metrics-grid{display:grid;gap:1.5rem} +.dev-tools-grid{padding:2rem 0;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))} +.dev-tool-card,.metric-card,.status-group,.shifter-container,.debug-section,.scan-section,.upload-container,.device-status{background:var(--bg-overlay);border-radius:var(--radius-lg);padding:1.5rem;border:1px solid var(--border)} +.dev-tool-card,.shifter-container{transition:.3s;box-shadow:var(--shadow-md);text-decoration:none;display:block} +.device-status{background:rgba(3,37,76,.8)} +.status-group,.device-status,.debug-section,.scan-section,.shifter-container,.upload-container{margin-bottom:1.5rem} +.dev-tool-card:hover,.shifter-container:hover{background:rgba(42,157,244,.15);border-color:rgba(42,157,244,.5);transform:translateY(-2px);box-shadow:var(--shadow-lg)} +.device-status h2,.status-group h2{margin:0 0 1.5rem;color:var(--primary);font-size:1.2rem;border-bottom:1px solid rgba(42,157,244,.3);padding-bottom:.5rem} +.status-grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(250px,1fr))} +.status-item{background:var(--bg-item);padding:1rem;border-radius:var(--radius-md);display:flex;flex-direction:column;gap:.5rem;font-weight:500;color:var(--primary)} +.follow-toggle,.metric-label{color:var(--text-secondary);font-size:.9rem;margin-bottom:.5rem;text-transform:uppercase;letter-spacing:.5px} +.follow-toggle{display:block} +.debug-console,.gear-display{box-sizing:border-box;width:100%} +.debug-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem} +.debug-header h2{margin:0;color:var(--primary);font-size:1.2rem} +.follow-toggle{display:flex;align-items:center;gap:.5rem} +.debug-console{margin:0;padding:1rem;background-color:#000;background-image:radial-gradient(rgba(0,150,0,.75),#000 120%);height:40vh;resize:both;border:1px solid var(--border);border-radius:var(--radius-md);color:var(--text);font-family:Inconsolata,monospace;font-size:1.1rem;line-height:1.4;overflow:auto;text-shadow:0 0 4px rgba(200,200,200,.5)} +.shifter-container{max-width:600px;margin:2rem auto;padding:2rem;transition:.3s} +.shift-controls{display:flex;flex-direction:column;gap:2rem;align-items:center} +.shift-button{width:100%;height:80px;border:none;border-radius:var(--radius-md);color:var(--text);font-size:1.2rem;font-weight:600;cursor:pointer;transition:.2s;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;padding:1rem;background:var(--bg-overlay)} +.shift-button:active,.shift-button.active,.scan-button:active{transform:translateY(1px);box-shadow:var(--shadow-sm)} +.shift-up{background:linear-gradient(to bottom,#2ecc71,#27ae60)} +.shift-up:hover{background:linear-gradient(to bottom,#27ae60,#219a52)} +.shift-down{background:linear-gradient(to bottom,#e74c3c,#c0392b)} +.shift-down:hover{background:linear-gradient(to bottom,#c0392b,#a93224)} +.shift-arrow{font-size:1.5rem;line-height:1} +.shift-label,.metric-label{text-transform:uppercase;letter-spacing:.5px} +.shift-label{font-size:1rem;letter-spacing:1px} +.gear-display{background:var(--bg-item);padding:2rem;border-radius:var(--radius-md);text-align:center} +.gear-label{display:block;font-size:1.2rem;margin-bottom:1rem;color:rgba(255,255,255,.9)} +.gear-value{font-size:2.5rem;font-weight:700;color:var(--primary);background:0 0;border:none;width:100%;padding:0;text-align:center} +.input-group,.toggle-group,.slider-group{display:flex;padding:1rem;background:var(--bg-item);border-radius:var(--radius-sm)} +.input-group{align-items:center;gap:1rem;margin-bottom:1.5rem;padding:2em} +.toggle-group{justify-content:space-between;align-items:center} +.slider-group{align-items:center;gap:0.5rem;min-height:60px;justify-content:center;padding:0.75rem;width:auto;box-sizing:border-box} +.number-input,.device-select{padding:.75rem;border:1px solid rgba(255,255,255,.2);border-radius:var(--radius-sm);background:rgba(255,255,255,.1);color:var(--text);font-size:1.1rem;transition:.3s} +.number-input{width:120px;padding:0;text-align:center} +.number-input:focus,.device-select:focus{outline:0;border-color:var(--primary);box-shadow:0 0 0 3px rgba(42,157,244,.4)} +.number-input::-webkit-inner-spin-button,.number-input::-webkit-outer-spin-button{opacity:1} +.unit,.value-display{font-weight:500;color:var(--primary)} +.unit{min-width:40px} +.settings-grid{display:grid;gap:2rem;padding:1rem 0;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));max-width:100%;margin:0 auto} +.settings-section{background:rgba(255,255,255,.1);border-radius:var(--radius-md);padding:1.5rem;margin-bottom:.5rem} +.setting-group{margin-bottom:1.5rem;background:var(--bg-overlay);border-radius:6px;padding:0.75rem;box-shadow:var(--shadow-sm);border:1px solid var(--border);overflow:hidden;box-sizing:border-box} +.setting-group input[type="password"],.setting-group input[type="text"]{width:100%;padding:.75rem;background:rgba(255,255,255,.219);border:1px solid rgba(255,255,255,.2);border-radius:var(--radius-sm);color:var(--text);font-size:1rem;box-sizing:border-box} +.slider-container{flex:1;display:flex;flex-direction:column;align-items:center;gap:.25rem;width:100%;max-width:260px;margin:0 auto;box-sizing:border-box} +.slider-container input[type="range"]{width:100%;max-width:100%} +.value-display{display:flex;align-items:center;justify-content:center;margin-bottom:0.5rem} +.value-display span{margin-left:10px} +.adjust-button{width:36px;height:36px;border:none;background:#03254c;color:var(--text);border-radius:50%;cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;transition:background .2s;flex-shrink:0} +.adjust-button:hover,input:checked+.slider{background:var(--primary)} +.button-group{display:flex;gap:1rem;margin-top:2rem;justify-content:flex-end} +.primary-button,.secondary-button,.warning-button{padding:.75rem 1.5rem;border:none;border-radius:var(--radius-sm);font-weight:500;cursor:pointer;transition:.2s} +.primary-button{background:var(--primary);color:var(--text)} +.primary-button:hover{background:#1b8fe3} +.secondary-button,.status-message.info{background:rgba(255,255,255,.1);color:var(--text)} +.secondary-button:hover{background:rgba(255,255,255,.2)} +.warning-button,.status-message.error{background:#dc3545;color:var(--text)} +.warning-button:hover{background:#c82333} +.button-container{text-align:center;margin-top:2rem} +.device-select{width:100%;font-size:1rem;cursor:pointer} +.device-select:hover{background:rgba(255,255,255,.15)} +.device-select option{background:var(--bg-dark);color:var(--text);padding:.5rem} +.scan-section{margin-top:3rem;text-align:center} +.scan-button{display:flex;align-items:center;justify-content:center;gap:1rem;width:100%;max-width:300px;margin:0 auto;padding:1rem;background:linear-gradient(to right,var(--primary),#1b8fe3);border:none;border-radius:var(--radius-md);color:var(--text);font-size:1.1rem;font-weight:600;cursor:pointer;transition:.3s;box-shadow:0 4px 6px rgba(0,0,0,.1)} +.scan-button:hover{transform:translateY(-2px);box-shadow:0 6px 12px rgba(0,0,0,.15);background:linear-gradient(to right,#1b8fe3,#0d7ac9)} +.scan-icon{font-size:1.5rem;animation:2s linear infinite spin;display:inline-block} +.scan-note{margin-top:1.5rem;font-size:.9rem;color:rgba(255,255,255,.8);line-height:1.6} +.scan-note em{color:var(--primary);font-style:normal} +.file-upload{display:flex;flex-direction:column;align-items:center;gap:1rem;padding:1rem;border:2px dashed rgba(255,255,255,.2);border-radius:var(--radius-md);cursor:pointer;transition:.3s} +.file-upload:hover{border-color:var(--primary);background:rgba(42,157,244,.1)} +.upload-icon{font-size:2.5rem;color:var(--primary)} +.file-input,.switch input{display:none} +.metrics-grid{grid-template-columns:repeat(auto-fit,minmax(100px,.5fr));margin:.5rem} +.metric-card{padding:.5rem;text-align:center} +.metric-icon{font-size:2rem;margin-bottom:.5rem} +.metric-value{font-size:2rem;font-weight:600;color:var(--primary);margin-bottom:.25rem} +.metric-unit{font-size:.9rem;color:var(--text-secondary)} +.dev-tool-card{color:var(--text);grid-template-columns:auto 1fr auto;align-items:start} +.tool-icon,.info-box{background:rgba(42,157,244,.1)} +.tool-icon{font-size:2rem;width:3rem;height:3rem;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-lg)} +.info-box{border-left:4px solid var(--primary);padding:1.5rem;margin-bottom:2rem;border-radius:0 4px 4px 0} +.tool-content{flex:1} +.tool-features{list-style:none;padding:0;margin:0;font-size:.9rem;color:var(--text-secondary)} +.tool-features li{margin-bottom:.5rem;display:flex;align-items:center} +.tool-features li:before{content:"•";color:var(--primary);margin-right:.5rem;font-size:1.2em} +.card-arrow{font-size:1.5rem;color:rgba(255,255,255,.3);transition:transform .2s} +.dev-tool-card:hover .card-arrow{transform:translateX(4px)} +.switch,.tooltip{position:relative} +.switch{display:inline-block;width:60px;height:34px} +.switch .slider{position:absolute;inset:0;background:#03254c;border-radius:34px;transition:.4s} +.switch .slider:before{position:absolute;content:"";height:26px;width:26px;left:4px;bottom:4px;background:#fff;border-radius:50%;transition:.4s} +input:checked+.slider:before{transform:translateX(26px)} +input:focus+.slider{box-shadow:0 0 0 3px rgba(42,157,244,.4)} +.tooltip{border-bottom:1px dotted rgba(255,255,255,.3);cursor:help} +.tooltip .tooltiptext{visibility:hidden;width:200px;background:#03254c;color:var(--text);text-align:center;border-radius:6px;padding:.5rem;position:absolute;z-index:1;bottom:125%;left:50%;transform:translateX(-50%);opacity:0;transition:opacity .3s} +.tooltip:hover .tooltiptext{visibility:visible;opacity:1} +.status-message{padding:1rem;margin:1rem 0;border-radius:var(--radius-sm);animation:.3s fadeIn} +.status-message.success{background:#28a745;color:var(--text)} +.note{font-style:italic;color:rgba(255,255,255,.8);font-size:.9rem;margin-top:1rem} +.watermark{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) rotate(45deg);font-size:5rem;color:rgba(255,255,255,.2);pointer-events:none;z-index:0} +footer{margin-top:2rem;padding:1rem 0;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} +.dev-links{display:flex;align-items:center;gap:1rem} +.dev-link{color:var(--text-secondary);font-size:.9rem} +.separator{color:rgba(255,255,255,.3)} +@keyframes fadeIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}} +@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}} +@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(220,53,69,.4)}70%{box-shadow:0 0 0 10px rgba(220,53,69,0)}100%{box-shadow:0 0 0 0 rgba(220,53,69,0)}} +@media (max-width:768px){ +.input-group,.button-group,.value-display,.dev-links{flex-direction:column} +.status-grid,.metrics-grid,.dev-tools-grid{grid-template-columns:1fr} +.number-input,.scan-button{width:100%} +.settings-section{padding:1rem} +.adjust-button{min-width:32px} +.slider-container{min-width:0} +.input-group{align-items:stretch} +.unit{text-align:right} +.shift-button{height:100px} +.status-group,.metric-card{padding:1.25rem} +.value-display{gap:.5rem;text-align:center} +.auto-update{justify-content:center} +.debug-console{height:50vh} +.dev-links{gap:.5rem} +.separator{display:none} +} 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_Custom_Characteristic.h b/include/BLE_Custom_Characteristic.h index 4c1eff5e..df62b040 100644 --- a/include/BLE_Custom_Characteristic.h +++ b/include/BLE_Custom_Characteristic.h @@ -63,6 +63,7 @@ const uint8_t BLE_hMax = 0x2B; // Maximum homing value const uint8_t BLE_homingSensitivity = 0x2C; // Homing sensitivity value const uint8_t BLE_pTab4Pwr = 0x2D; // Use power values for power table const uint8_t BLE_UDPLogging = 0x2E; // Enable or disable UDP logging +const uint8_t BLE_hardwareVersion = 0x2F; // Read-only string identifying the detected hardware revision const uint8_t BLE_BLELogging = 0x30; // Enable or disable BLE logging class BLE_ss2kCustomCharacteristic { diff --git a/include/Builtin_Pages.h b/include/Builtin_Pages.h index 38481cbb..0d446f17 100644 --- a/include/Builtin_Pages.h +++ b/include/Builtin_Pages.h @@ -8,6 +8,7 @@ #pragma once #include +#include "settings.h" String OTAStyle = ""; /* Login page */ String OTALoginIndex = - "
" - "

Firmware Update Login

" - " " - " " - "" + "" + "
" + "

SmartSpin2k

Firmware Update
" + "" + "" + "" + "" "" + "
" "" + OTAStyle; String noIndexHTML = "" - "" - "" - "

The webserver files need to be updated.

" - "Please enter the credentials for your network or upload a new filesystem image using the link below." + "" + "
" + "

SmartSpin2k

Setup Required
" + "

Web files are missing. Connect to Wi-Fi for automatic recovery or upload a filesystem image.

" "
" - "

" - "" - "

" - "" - "" + "" + "" + "" "
" "
" - "" + "" "
" - "

Update Firmware

" - "" - " " + + "

Upload firmware or filesystem

" + "
" + OTAStyle; /* Server Index Page */ String OTAServerIndex = - "" - "
" - "
SmartSpin2k OTA
" + "" + "
" + "

SmartSpin2k

Firmware Update
" "
" - "" - "" + "" + "" "
" - "
" - "
0%
" - "
" - "
Valid files are " FW_BINFILE " or " FS_BINFILE "
" - "
" - "" + "
" + "
Valid files: " FW_BINFILE " or " FS_BINFILE "
" + "
" + "" ""; + "" + + OTAStyle; diff --git a/include/SmartSpin_parameters.h b/include/SmartSpin_parameters.h index 85e6099c..bb58bc8f 100644 --- a/include/SmartSpin_parameters.h +++ b/include/SmartSpin_parameters.h @@ -142,7 +142,7 @@ class userParameters { int32_t hMin = INT32_MIN; int32_t hMax = INT32_MIN; bool FTMSControlPointWrite = false; - int homingSensitivity = DEFAULT_HOMING_SENSITIVITY; // Use default from settings.h + int homingSensitivity = DEFAULT_HOMING_SENSITIVITY; String ssid; String password; String connectedPowerMeter = CONNECTED_POWER_METER; diff --git a/include/Stepper.h b/include/Stepper.h index 18c6a4ba..dfc728bc 100644 --- a/include/Stepper.h +++ b/include/Stepper.h @@ -14,22 +14,11 @@ constexpr int LOG_INTERVAL = 1000; -#define HOME_TIMEOUT 30000 -#define HOMING_SG_SAMPLE_COUNT 24 -#define HOMING_SG_MIN_SAMPLE_MARGIN 10 -#define HOMING_SG_MAX_THRESHOLD_DRIFT 30 -#define HOMING_TAP_MAX_ATTEMPTS 7 -#define HOMING_TAP_REQUIRED_STABLE 3 -#define HOMING_TAP_TOLERANCE 150 -#define HOMING_RECOVERY_BACKOFF_MULT 3 -#define HOMING_MAX_SENSITIVITY 100 - struct HomingSgBaseline { int threshold; int sensitivity; }; extern HardwareSerial stepperSerial; -extern TMC2209Stepper driver; extern FastAccelStepperEngine engine; extern FastAccelStepper* stepper; diff --git a/include/boards.h b/include/boards.h index 6fb6ad9a..eee640c2 100644 --- a/include/boards.h +++ b/include/boards.h @@ -7,7 +7,6 @@ #pragma once -#include "settings.h" #ifndef UNIT_TEST #include #else @@ -18,6 +17,8 @@ class Board { public: String name; int versionVoltage; + int versionTolerance; + int revisionPin; int shiftUpPin; int shiftDownPin; int enablePin; @@ -27,7 +28,11 @@ class Board { int stepperSerialRxPin; int auxSerialTxPin; int auxSerialRxPin; + int ledPin; int pwrScaler; + float rSense; + bool homingSupported; + float homingSensitivityScaler; }; class Boards { @@ -41,45 +46,63 @@ class Boards { Boards() { #if defined(SMARTSPIN2K_S3) - rev3.name = r3_NAME; - rev3.versionVoltage = r3_VERSION_VOLTAGE; - rev3.shiftUpPin = r3_SHIFT_UP_PIN; - rev3.shiftDownPin = r3_SHIFT_DOWN_PIN; - rev3.enablePin = r3_ENABLE_PIN; - rev3.stepPin = r3_STEP_PIN; - rev3.dirPin = r3_DIR_PIN; - rev3.stepperSerialTxPin = r3_STEPPER_SERIAL_TX; - rev3.stepperSerialRxPin = r3_STEPPER_SERIAL_RX; - rev3.auxSerialTxPin = r3_AUX_SERIAL_TX; - rev3.auxSerialRxPin = r3_AUX_SERIAL_RX; - rev3.pwrScaler = r3_PWR_SCALER; + rev3.name = "Revision Three (ESP32-S3)"; + rev3.versionVoltage = 1241; + rev3.versionTolerance = 300; + rev3.revisionPin = 4; + rev3.shiftUpPin = 14; + rev3.shiftDownPin = 13; + rev3.enablePin = 48; + rev3.stepPin = 21; + rev3.dirPin = 47; + rev3.stepperSerialTxPin = 11; + rev3.stepperSerialRxPin = 12; + rev3.auxSerialTxPin = 17; + rev3.auxSerialRxPin = 18; + rev3.ledPin = 2; + rev3.pwrScaler = 12; + rev3.rSense = 0.04f; + rev3.homingSupported = true; + rev3.homingSensitivityScaler = 1.6f; #else // Rev 1 - rev1.name = r1_NAME; - rev1.versionVoltage = r1_VERSION_VOLTAGE; - rev1.shiftUpPin = r1_SHIFT_UP_PIN; - rev1.shiftDownPin = r1_SHIFT_DOWN_PIN; - rev1.enablePin = r1_ENABLE_PIN; - rev1.stepPin = r1_STEP_PIN; - rev1.dirPin = r1_DIR_PIN; - rev1.stepperSerialTxPin = r1_STEPPER_SERIAL_TX; - rev1.stepperSerialRxPin = r1_STEPPER_SERIAL_RX; + rev1.name = "Revision One"; + rev1.versionVoltage = 0; + rev1.versionTolerance = 0; + rev1.revisionPin = 34; + rev1.shiftUpPin = 19; + rev1.shiftDownPin = 18; + rev1.enablePin = 13; + rev1.stepPin = 25; + rev1.dirPin = 33; + rev1.stepperSerialTxPin = 12; + rev1.stepperSerialRxPin = 14; rev1.auxSerialTxPin = 0; rev1.auxSerialRxPin = 0; - rev1.pwrScaler = r1_PWR_SCALER; + rev1.ledPin = 2; + rev1.pwrScaler = 31; + rev1.rSense = 0.08f; + rev1.homingSupported = false; + rev1.homingSensitivityScaler = 1.0f; // Rev 2 - rev2.name = r2_NAME; - rev2.versionVoltage = r2_VERSION_VOLTAGE; - rev2.shiftUpPin = r2_SHIFT_UP_PIN; - rev2.shiftDownPin = r2_SHIFT_DOWN_PIN; - rev2.enablePin = r2_ENABLE_PIN; - rev2.stepPin = r2_STEP_PIN; - rev2.dirPin = r2_DIR_PIN; - rev2.stepperSerialTxPin = r2_STEPPER_SERIAL_TX; - rev2.stepperSerialRxPin = r2_STEPPER_SERIAL_RX; - rev2.auxSerialTxPin = r2_AUX_SERIAL_TX; - rev2.auxSerialRxPin = r2_AUX_SERIAL_RX; - rev2.pwrScaler = r2_PWR_SCALER; + rev2.name = "Revision Two"; + rev2.versionVoltage = 4095; + rev2.versionTolerance = 0; + rev2.revisionPin = 34; + rev2.shiftUpPin = 26; + rev2.shiftDownPin = 32; + rev2.enablePin = 27; + rev2.stepPin = 25; + rev2.dirPin = 33; + rev2.stepperSerialTxPin = 19; + rev2.stepperSerialRxPin = 18; + rev2.auxSerialTxPin = 21; + rev2.auxSerialRxPin = 22; + rev2.ledPin = 2; + rev2.pwrScaler = 12; + rev2.rSense = 0.08f; + rev2.homingSupported = true; + rev2.homingSensitivityScaler = 1.0f; #endif } }; diff --git a/include/settings.h b/include/settings.h index 78a44a53..7c37bfed 100644 --- a/include/settings.h +++ b/include/settings.h @@ -138,119 +138,6 @@ const char* const DEFAULT_PASSWORD = "password"; // Default debounce delay for shifters. Increase if you have false shifts. Decrease if shifting takes too long. #define DEBOUNCE_DELAY 200 -#if defined(SMARTSPIN2K_S3) -// The S3 board moved the hardware-version resistor to GPIO4. -#define REV_PIN 4 -#define BOARD_VERSION_TOLERANCE 300 -#else -// Hardware Revision check pin -#define REV_PIN 34 -#endif - -//////////// Defines for hardware Revision 1 //////////// - -// Board Name -#define r1_NAME "Revision One" - -// ID Voltage on pin 34. Values are 0-4095 (0-3.3v) -#define r1_VERSION_VOLTAGE 0 - -// Hardware pin for Shift Up -#define r1_SHIFT_UP_PIN 19 - -// Hardware pin for Shift Down -#define r1_SHIFT_DOWN_PIN 18 - -// Hardware pin for stepper Enable -#define r1_ENABLE_PIN 13 - -// Hardware pin for stepper step -#define r1_STEP_PIN 25 - -// Hardware pin for stepper dir -#define r1_DIR_PIN 33 - -// TMC2208/TMC2224 SoftwareSerial receive pin -#define r1_STEPPER_SERIAL_RX 14 - -// TMC2208/TMC2224 SoftwareSerial transmit pin -#define r1_STEPPER_SERIAL_TX 12 - -// Reduce current setting by this divisor (0-31) -#define r1_PWR_SCALER 31 -//////////////////////////////////////////////////////// -//////////// Defines for hardware Revision 2 //////////// - -// Board Name -#define r2_NAME "Revision Two" - -// ID Voltage on pin 34. Values are 0-4095 (0-3.3v) -#define r2_VERSION_VOLTAGE 4095 - -// Hardware pin for Shift Up -#define r2_SHIFT_UP_PIN 26 - -// Hardware pin for Shift Down -#define r2_SHIFT_DOWN_PIN 32 - -// Hardware pin for stepper Enable -#define r2_ENABLE_PIN 27 - -// Hardware pin for stepper step -#define r2_STEP_PIN 25 - -// Hardware pin for stepper dir -#define r2_DIR_PIN 33 - -// TMC2209 SoftwareSerial receive pin -#define r2_STEPPER_SERIAL_RX 18 - -// TMC2209 SoftwareSerial transmit pin -#define r2_STEPPER_SERIAL_TX 19 - -// TMC2209 SoftwareSerial receive pin -#define r2_AUX_SERIAL_RX 22 - -// TMC2209 SoftwareSerial transmit pin -#define r2_AUX_SERIAL_TX 21 - -// Reduce current setting by this divisor (0-31) -#define r2_PWR_SCALER 12 -//////////////////////////////////////////////////////// - -#if defined(SMARTSPIN2K_S3) -//////////// Defines for hardware Revision 3 //////////// -#define r3_NAME "Revision Three (ESP32-S3)" - -// 1.0 V on GPIO4 with the 12-bit ADC range (0-4095 for 0-3.3 V). -#define r3_VERSION_VOLTAGE 1241 -#define r3_SHIFT_UP_PIN 13 -#define r3_SHIFT_DOWN_PIN 14 -#define r3_ENABLE_PIN 48 -#define r3_STEP_PIN 21 -#define r3_DIR_PIN 47 -#define r3_STEPPER_SERIAL_RX 12 -#define r3_STEPPER_SERIAL_TX 11 -#define r3_AUX_SERIAL_RX 18 -#define r3_AUX_SERIAL_TX 17 -#define r3_PWR_SCALER 12 -//////////////////////////////////////////////////////// -#endif - -// TMC2208/TMC2224 HardwareSerial port -#define SERIAL_PORT stepperSerial - -// Match to your driver -#if defined(SMARTSPIN2K_S3) -#define R_SENSE 0.04f -#else -#define R_SENSE 0.08f -#endif - -// Hardware pin for indicator LED *note* internal LED on esp32 Dev board is pin -// 2 -#define LED_PIN 2 - // Reconnect tries removed: connections now always instantiate a new NimBLEClient // loop speed for the SmartSpin2k BLE communications @@ -387,9 +274,20 @@ constexpr const char* ANY = "any"; // Interval for polling ble battery updates #define BATTERY_UPDATE_INTERVAL_MILLIS 300000 -// Default homing sensitivity value +// Base homing sensitivity before applying the detected board's scaler. #define DEFAULT_HOMING_SENSITIVITY 50 +// Stepper homing behavior +#define HOME_TIMEOUT 30000 +#define HOMING_SG_SAMPLE_COUNT 24 +#define HOMING_SG_MIN_SAMPLE_MARGIN 10 +#define HOMING_SG_MAX_THRESHOLD_DRIFT 30 +#define HOMING_TAP_MAX_ATTEMPTS 7 +#define HOMING_TAP_REQUIRED_STABLE 3 +#define HOMING_TAP_TOLERANCE 150 +#define HOMING_RECOVERY_BACKOFF_MULT 3 +#define HOMING_MAX_SENSITIVITY 100 + // BLE automatic reconnect interval in milliseconds. #define BLE_RECONNECT_SCAN_INTERVAL 6000 diff --git a/platformio.ini b/platformio.ini index a5d2ce6b..cfc37ef7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,6 +15,7 @@ lib_ldf_mode = chain lib_compat_mode = strict extra_scripts = pre:scripts/pre_build_cleanup.py + post:scripts/name_build_artifacts.py platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip ;platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.10/platform-espressif32.zip ;platform = espressif32 diff --git a/scripts/name_build_artifacts.py b/scripts/name_build_artifacts.py new file mode 100644 index 00000000..87a95c66 --- /dev/null +++ b/scripts/name_build_artifacts.py @@ -0,0 +1,23 @@ +# +# Copyright (C) 2020 Anthony Doud & Joel Baranick +# All rights reserved +# +# SPDX-License-Identifier: GPL-2.0-only +# + +Import("env") + +from pathlib import Path +from shutil import copy2 + + +def add_s3_prefix(source, target, env): + artifact = Path(str(target[0])) + prefixed = artifact.with_name(f"S3{artifact.name}") + copy2(artifact, prefixed) + print(f"[name_build_artifacts] created {prefixed}") + + +if env.subst("$PIOENV") in ("S3release", "S3debug"): + env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", add_s3_prefix) + env.AddPostAction("$BUILD_DIR/littlefs.bin", add_s3_prefix) diff --git a/src/HTTP_Server_Basic.cpp b/src/HTTP_Server_Basic.cpp index 54a8c9fb..f615a951 100644 --- a/src/HTTP_Server_Basic.cpp +++ b/src/HTTP_Server_Basic.cpp @@ -408,7 +408,7 @@ void HTTP_Server::start() { []() { server.sendHeader("Connection", "close"); if (otaUploadRejected) { - server.send(400, "text/plain", "Wrong firmware target or unsupported image filename"); + server.send(400, "text/plain", String("Wrong image filename. Expected ") + FW_BINFILE + " or " + FS_BINFILE + "."); return; } // Check if the Update process reported an error and send the final status. @@ -453,8 +453,6 @@ void HTTP_Server::start() { SS2K_LOG(HTTP_SERVER_LOG_TAG, "Unknown OTA issue on end."); } // The reboot will be triggered in the onComplete handler after the response. - // Setting this to reboot, even if upload fails. - ss2k->rebootFlag = true; } } else if (upload.filename == FS_BINFILE) { if (upload.status == UPLOAD_FILE_START) { diff --git a/src/Main.cpp b/src/Main.cpp index 4354e478..c5352889 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -99,12 +99,18 @@ extern "C" void app_main() { void SS2K::finishSetup() { SS2K_LOG(MAIN_LOG_TAG, "Compiled %s%s", __DATE__, __TIME__); - pinMode(REV_PIN, INPUT); - int actualVoltage = analogRead(REV_PIN); #if defined(SMARTSPIN2K_S3) currentBoard = boards.rev3; - SS2K_LOG(MAIN_LOG_TAG, "Board ID ADC on GPIO%d: %d (expected %d +/- %d)", REV_PIN, actualVoltage, currentBoard.versionVoltage, BOARD_VERSION_TOLERANCE); - if (abs(actualVoltage - currentBoard.versionVoltage) > BOARD_VERSION_TOLERANCE) { +#else + // Revisions one and two share the same hardware-detection pin. + currentBoard = boards.rev1; +#endif + pinMode(currentBoard.revisionPin, INPUT); + int actualVoltage = analogRead(currentBoard.revisionPin); +#if defined(SMARTSPIN2K_S3) + SS2K_LOG(MAIN_LOG_TAG, "Board ID ADC on GPIO%d: %d (expected %d +/- %d)", currentBoard.revisionPin, actualVoltage, currentBoard.versionVoltage, + currentBoard.versionTolerance); + if (abs(actualVoltage - currentBoard.versionVoltage) > currentBoard.versionTolerance) { SS2K_LOG(MAIN_LOG_TAG, "WARNING: Board ID resistor does not match the ESP32-S3 hardware revision"); } #else @@ -167,7 +173,7 @@ void SS2K::finishSetup() { pinMode(currentBoard.shiftUpPin, INPUT_PULLUP); // Push-Button with input Pullup pinMode(currentBoard.shiftDownPin, INPUT_PULLUP); // Push-Button with input Pullup - pinMode(LED_PIN, OUTPUT); + pinMode(currentBoard.ledPin, OUTPUT); pinMode(currentBoard.enablePin, OUTPUT); pinMode(currentBoard.dirPin, OUTPUT); // Stepper Direction Pin pinMode(currentBoard.stepPin, OUTPUT); // Stepper Step Pin @@ -175,7 +181,7 @@ void SS2K::finishSetup() { HIGH); // Should be called a disable Pin - High Disables FETs digitalWrite(currentBoard.dirPin, LOW); digitalWrite(currentBoard.stepPin, LOW); - digitalWrite(LED_PIN, LOW); + digitalWrite(currentBoard.ledPin, LOW); ss2k->setLEDEnabled(shouldStartWithLedEnabled()); ss2k->setupTMCStepperDriver(); @@ -185,7 +191,7 @@ void SS2K::finishSetup() { // disableCore0WDT(); // Disable the watchdog timer on core 0 (so long stepper // moves don't cause problems) - digitalWrite(LED_PIN, LOW); + digitalWrite(currentBoard.ledPin, LOW); // Configure and Initialize Logger logHandler.addAppender(&webSocketAppender); logHandler.addAppender(&udpAppender); @@ -213,7 +219,7 @@ void SS2K::finishSetup() { #endif ss2k->resetIfShiftersHeld(); - digitalWrite(LED_PIN, LOW); + digitalWrite(currentBoard.ledPin, LOW); } void loop() { // Delete this task so we can make one that's more memory efficient. @@ -382,20 +388,20 @@ void SS2K::maintenanceLoop(void* pvParameters) { void SS2K::setLEDEnabled(bool enabled) { ledEnabled = enabled; if (!enabled) { - digitalWrite(LED_PIN, LOW); + digitalWrite(currentBoard.ledPin, LOW); } } void SS2K::updateLED() { if (!ledEnabled) { - digitalWrite(LED_PIN, LOW); + digitalWrite(currentBoard.ledPin, 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); + digitalWrite(currentBoard.ledPin, (millis() / 500) % 2 == 0 ? LOW : HIGH); return; } @@ -409,12 +415,12 @@ void SS2K::updateLED() { // After the diagnostic pulses, return to solid-on connected status. if (cyclePosition >= currentCount * pulsePeriod) { - digitalWrite(LED_PIN, HIGH); + digitalWrite(currentBoard.ledPin, HIGH); return; } // Each pulse starts with a short off dip, followed by on-time between dips. - digitalWrite(LED_PIN, (cyclePosition % pulsePeriod) < pulseOffTime ? LOW : HIGH); + digitalWrite(currentBoard.ledPin, (cyclePosition % pulsePeriod) < pulseOffTime ? LOW : HIGH); } void SS2K::FTMSModeShiftModifier() { @@ -562,9 +568,9 @@ void SS2K::resetIfShiftersHeld() { if ((digitalRead(currentBoard.shiftUpPin) == LOW) && (digitalRead(currentBoard.shiftDownPin) == LOW)) { SS2K_LOG(MAIN_LOG_TAG, "Resetting to defaults via shifter buttons."); for (int x = 0; x < 10; x++) { // blink fast to acknowledge - digitalWrite(LED_PIN, HIGH); + digitalWrite(currentBoard.ledPin, HIGH); delay(200); - digitalWrite(LED_PIN, LOW); + digitalWrite(currentBoard.ledPin, LOW); } for (int i = 0; i < 20; i++) { LittleFS.format(); diff --git a/src/Stepper.cpp b/src/Stepper.cpp index 488324ef..6dbb987d 100644 --- a/src/Stepper.cpp +++ b/src/Stepper.cpp @@ -14,7 +14,8 @@ #include HardwareSerial stepperSerial(2); -TMC2209Stepper driver(&SERIAL_PORT, R_SENSE, 0b00); // Hardware Serial +// Construct after hardware detection so the selected board's sense resistor is used. +static TMC2209Stepper* driver = nullptr; FastAccelStepperEngine engine = FastAccelStepperEngine(); FastAccelStepper* stepper = NULL; @@ -150,6 +151,10 @@ void SS2K::_resistanceMove() { } void SS2K::setupTMCStepperDriver(bool reset) { + if (!driver) { + driver = new TMC2209Stepper(&stepperSerial, currentBoard.rSense, 0b00); + } + // FastAccel setup if (!reset) { engine.init(); @@ -161,15 +166,15 @@ void SS2K::setupTMCStepperDriver(bool reset) { stepper->setAcceleration(STEPPER_ACCELERATION); stepper->setDelayToDisable(65535); // TMC Driver Setup - driver.begin(); + driver->begin(); } - driver.pdn_disable(true); // Use PDN pin to enable UART communication instead of grounding signal - driver.mstep_reg_select(true); // Use register instead of ms1&ms2 pins for microstep selection - driver.microsteps(4); // Set microsteps to 1/4 - driver.iholddelay(5); // Controls the number of clock cycles for motor power down after standstill is detected - driver.TPOWERDOWN(16); // delay until hold current (0-255). 255 = 5.6s, 2 is minimum for StealthChop. - driver.toff(5); // needs >0 for driver enable. 1-15 controls duration of slow decay phase of pwm. + driver->pdn_disable(true); // Use PDN pin to enable UART communication instead of grounding signal + driver->mstep_reg_select(true); // Use register instead of ms1&ms2 pins for microstep selection + driver->microsteps(4); // Set microsteps to 1/4 + driver->iholddelay(5); // Controls the number of clock cycles for motor power down after standstill is detected + driver->TPOWERDOWN(16); // delay until hold current (0-255). 255 = 5.6s, 2 is minimum for StealthChop. + driver->toff(5); // needs >0 for driver enable. 1-15 controls duration of slow decay phase of pwm. this->updateStealthChop(); this->updateStepperSpeed(); this->updateStepperPower(); @@ -178,6 +183,10 @@ void SS2K::setupTMCStepperDriver(bool reset) { static int lastHomingSgThreshold = 0; +static int getScaledHomingSensitivity() { + return round(userConfig->getHomingSensitivity() * currentBoard.homingSensitivityScaler); +} + static HomingSgBaseline getHomingSgBaseline() { int samples[HOMING_SG_SAMPLE_COUNT]; int totalSgResult = 0; @@ -185,10 +194,10 @@ static HomingSgBaseline getHomingSgBaseline() { int maxSampleIndex = 0; for (int i = 0; i < HOMING_SG_SAMPLE_COUNT; i++) { - samples[i] = driver.SG_RESULT(); + samples[i] = driver->SG_RESULT(); if (samples[i] == 0) { delay(30); - samples[i] = driver.SG_RESULT(); + samples[i] = driver->SG_RESULT(); } totalSgResult += samples[i]; if (samples[i] < samples[minSampleIndex]) minSampleIndex = i; @@ -207,9 +216,10 @@ static HomingSgBaseline getHomingSgBaseline() { if (samples[i] > trimmedMax) trimmedMax = samples[i]; } - int threshold = round(trimmedTotal / (float)trimmedCount); - int normalLowDrop = threshold - trimmedMin; - int measuredSensitivity = max(userConfig->getHomingSensitivity(), normalLowDrop + max(userConfig->getHomingSensitivity() / 2, HOMING_SG_MIN_SAMPLE_MARGIN)); + int configuredSensitivity = getScaledHomingSensitivity(); + int threshold = round(trimmedTotal / (float)trimmedCount); + int normalLowDrop = threshold - trimmedMin; + int measuredSensitivity = max(configuredSensitivity, normalLowDrop + max(configuredSensitivity / 2, HOMING_SG_MIN_SAMPLE_MARGIN)); int maxSensitivity = min(HOMING_MAX_SENSITIVITY, max(threshold, 1)); measuredSensitivity = constrain(measuredSensitivity + 10, 1, maxSensitivity); SS2K_LOG(MAIN_LOG_TAG, "Homing SG baseline used %d/%d trimmed samples. Dropped: %d/%d, Spread: %d-%d, measured sensitivity: %d", trimmedCount, HOMING_SG_SAMPLE_COUNT, @@ -223,7 +233,7 @@ static HomingSgBaseline getHomingSgBaseline() { */ bool SS2K::_findEndStop(bool moveForward) { unsigned long timeoutTimer = millis(); - HomingSgBaseline baseline = {0, userConfig->getHomingSensitivity()}; + HomingSgBaseline baseline = {0, getScaledHomingSensitivity()}; // --- SETUP DRIVER FOR SENSORLESS HOMING --- // Use very low power for sensitive stall detection @@ -258,11 +268,11 @@ bool SS2K::_findEndStop(bool moveForward) { return false; } - currentSgResult = driver.SG_RESULT(); + currentSgResult = driver->SG_RESULT(); // if zero detected, wait 10ms and sample again. if (currentSgResult == 0) { delay(10); - currentSgResult = driver.SG_RESULT(); + currentSgResult = driver->SG_RESULT(); } // Periodically log the status for tuning @@ -394,11 +404,7 @@ void SS2K::goHome(bool bothDirections) { } } -#if defined(SMARTSPIN2K_S3) - if (!stepper) { -#else - if (!stepper || currentBoard.name == r1_NAME) { -#endif + if (!stepper || !currentBoard.homingSupported) { SS2K_LOG(MAIN_LOG_TAG, "Homing not supported or stepper not initialized."); fitnessMachineService.spinDown(FitnessMachineStatus::SpinDown_Error); return; @@ -527,29 +533,29 @@ void SS2K::goHome(bool bothDirections) { // Applies current power to driver void SS2K::updateStepperPower(int pwr) { uint16_t rmsPwr = (pwr == 0) ? userConfig->getStepperPower() : pwr; - driver.rms_current(rmsPwr, HOLD_PWR_SCALER); - SS2K_LOG(MAIN_LOG_TAG, "Stepper power is now %d mA (driver setpoint %d mA)", rmsPwr, driver.rms_current()); + driver->rms_current(rmsPwr, HOLD_PWR_SCALER); + SS2K_LOG(MAIN_LOG_TAG, "Stepper power is now %d mA (driver setpoint %d mA)", rmsPwr, driver->rms_current()); } // Applies current StealthChop to driver void SS2K::updateStealthChop(bool coolStepEnabled) { bool stealthChopEnabled = userConfig->getStealthChop(); - driver.en_spreadCycle(!stealthChopEnabled); - driver.pwm_autoscale(stealthChopEnabled); - driver.pwm_autograd(stealthChopEnabled); + driver->en_spreadCycle(!stealthChopEnabled); + driver->pwm_autoscale(stealthChopEnabled); + driver->pwm_autograd(stealthChopEnabled); // Reuse homing sensitivity as CoolStep load tolerance when StealthChop is active. uint8_t coolstepTolerance = (uint8_t)constrain(userConfig->getHomingSensitivity(), 0, 255); if (stealthChopEnabled && coolStepEnabled) { - driver.SGTHRS(coolstepTolerance); - driver.semin(1); // Enable CoolStep - driver.seup(1); - driver.sedn(1); - driver.semax((uint8_t)constrain((coolstepTolerance / 16) + 1, 1, 15)); - driver.seimin(false); + driver->SGTHRS(coolstepTolerance); + driver->semin(1); // Enable CoolStep + driver->seup(1); + driver->sedn(1); + driver->semax((uint8_t)constrain((coolstepTolerance / 16) + 1, 1, 15)); + driver->seimin(false); } else { - driver.semin(0); // Disable CoolStep - driver.SGTHRS(0); + driver->semin(0); // Disable CoolStep + driver->SGTHRS(0); } SS2K_LOG(MAIN_LOG_TAG, "StealthChop:%d CoolStep:%d SGTHRS:%d", stealthChopEnabled, stealthChopEnabled && coolStepEnabled, coolstepTolerance); From 944f48c84649e9de2e0e8022728d1758d113c272 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 5 Aug 2026 13:53:21 -0500 Subject: [PATCH 05/29] Add new HTML, CSS, and Python files for filesystem preparation and compression - Introduced new HTML files: develop.html.gz, index.html.gz, settings.html.gz, shift.html.gz, status.html.gz, streamfit.html.gz, and style.css.gz. - Implemented a Python script (prepare_filesystem.py) to handle the staging and compression of filesystem assets for PlatformIO builds. - The script ensures gzip files are created for specific file types and maintains synchronization with OTA assets. - Added functionality to generate a list.json file containing the staged files for easier management. --- AGENTS.md | 3 + CHANGELOG.md | 4 + data/bluetoothscanner.html.gz | Bin 0 -> 1878 bytes data/btsimulator.html.gz | Bin 0 -> 2407 bytes data/develop.html.gz | Bin 0 -> 946 bytes data/index.html.gz | Bin 0 -> 1149 bytes data/list.json | 2 +- data/settings.html.gz | Bin 0 -> 3698 bytes data/shift.html.gz | Bin 0 -> 1107 bytes data/status.html.gz | Bin 0 -> 2229 bytes data/streamfit.html.gz | Bin 0 -> 1083 bytes data/style.css.gz | Bin 0 -> 3159 bytes data_s3/bluetoothscanner.html.gz | Bin 0 -> 1878 bytes data_s3/btsimulator.html.gz | Bin 0 -> 2407 bytes data_s3/develop.html.gz | Bin 0 -> 946 bytes data_s3/index.html.gz | Bin 0 -> 1149 bytes data_s3/list.json | 2 +- data_s3/settings.html.gz | Bin 0 -> 3698 bytes data_s3/shift.html.gz | Bin 0 -> 1107 bytes data_s3/status.html.gz | Bin 0 -> 2229 bytes data_s3/streamfit.html.gz | Bin 0 -> 1083 bytes data_s3/style.css.gz | Bin 0 -> 3159 bytes platformio.ini | 1 + scripts/prepare_filesystem.py | 85 +++++++++++++ src/HTTP_Server_Basic.cpp | 203 +++++++++++++++++++++++++------ 25 files changed, 259 insertions(+), 41 deletions(-) create mode 100644 data/bluetoothscanner.html.gz create mode 100644 data/btsimulator.html.gz create mode 100644 data/develop.html.gz create mode 100644 data/index.html.gz create mode 100644 data/settings.html.gz create mode 100644 data/shift.html.gz create mode 100644 data/status.html.gz create mode 100644 data/streamfit.html.gz create mode 100644 data/style.css.gz create mode 100644 data_s3/bluetoothscanner.html.gz create mode 100644 data_s3/btsimulator.html.gz create mode 100644 data_s3/develop.html.gz create mode 100644 data_s3/index.html.gz create mode 100644 data_s3/settings.html.gz create mode 100644 data_s3/shift.html.gz create mode 100644 data_s3/status.html.gz create mode 100644 data_s3/streamfit.html.gz create mode 100644 data_s3/style.css.gz create mode 100644 scripts/prepare_filesystem.py diff --git a/AGENTS.md b/AGENTS.md index b2eeb197..f2b5ba0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ PlatformIO is the expected entry point. S3 firmware and filesystem builds retain PlatformIO's canonical artifacts and also create `S3firmware.bin` and `S3littlefs.bin` copies in the environment build directory. +Filesystem builds stage deterministic gzip copies of every HTML/CSS source file under the environment build directory. They also refresh the checked-in `.gz` companions and `list.json` in `data/` or `data_s3/`, which are consumed by repository-based automatic OTA updates. + Codex environment constraint: - Do not run PlatformIO firmware or filesystem builds from the Codex environment. The Windows Xtensa toolchain can hang and leave orphaned compiler processes here. Make the requested changes, run non-build checks where useful, and clearly leave PlatformIO build validation for the user to run manually. @@ -490,6 +492,7 @@ Responsibilities: - Start/stop WiFi (`startWifi()`, `stopWifi()`). - Serve LittleFS web assets and built-in OTA pages. - Firmware update flow through `HTTP_Server::FirmwareUpdate()`. +- Automatic filesystem updates treat remote `list.json` as an allowlist, preserve config/power-table/recovery metadata, and store the installed filesystem release version in NVS. - Settings JSON/API behavior through `settingsProcessor()`. - Periodic web client update through `webClientUpdate()`. - BLE scanner page support. diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c12d5b..e6bcda57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Moved stepper homing tuning constants into `settings.h`. - Restyled the compact built-in recovery, OTA, and login pages and added visible feedback for image validation, upload failures, and reboot completion. - Added S3-prefixed PlatformIO build artifacts and updated the release workflow to package them directly. +- Gzip HTML and CSS assets during LittleFS builds, serve them with the correct encoding, and make automatic filesystem updates binary-safe. +- Automatic filesystem updates now remove files outside the server manifest while preserving settings and power-table data, track the installed filesystem release version, and never replace it with an older release. +- Successful LittleFS uploads from the recovery page now reboot after acknowledging the upload so the replacement filesystem is mounted cleanly. +- Fixed gzip web responses being labeled with duplicate content-encoding headers, which Firefox rejected. ### Hardware - Corrected the ESP32-S3 shift-up and shift-down pin assignments. diff --git a/data/bluetoothscanner.html.gz b/data/bluetoothscanner.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..2561215d1528cea991aae106f9639c20fab71ece GIT binary patch literal 1878 zcmV-c2dVfUiwFP!00002|IJzJj@!l+{$C*PFeQpe0?doF-u22WDX8r&?829Y*G^Fw zK{Fx`rHQE_Gc%ODaP+so`v!f8yiuMcXJ&X8wd=$#8o{v0;kkZu&NqkJ*`J@hdi>^} zSC?e1Hp$|vKh5+vNhn{<9pH{x0UF^qA+rrAO2Rc264cyz`{r-%xzl*U=?3P`mccGd zg>p!kas?bEcPvutc?4S)f@^N>G0qgDi7P{zz^ryN|S_NWHDQ&(T!D(*p`F|m2&Q6 zbOo*smoju=gFy+c1ERRYdrr563P#<8F16DaD2`cg*@`Nba;K6+Sz3g_cmy9jjjB_6 zThj2ot3!8M)*j(9Q7PGIs~h0Cv-suT{~^Z73%s5Aw9mm>sqCTeub5irOD{|}ew5}B z6@CN8%C(iU&YL(lZO?p6sf56K{~rK&!RGUAGQyI?UD&2v=8c@3Y zwb&75y%S*>xKT1XPRHt&CK~*t1i!~(dq=)pCQkuNj=V^*`_tc$i%~1I{y^EhhE1yA z)6=l>UnGIbI>qUSmx#8Ki>*u3kJ5X%?kB-p!%6SA-9mk;+0{F^MN>(e zT$<#7gIV8cdq6>to?q%A&S@!Xk>8Y#(JFJfovgJa3$@%Y|NZ$knqKI*baBlrzrrVCO zHJQr0MpJ*s-ZTG-GNBu{n)KXj;4&5UNwtO~E9TahfMeNHy#J;%68oKKnuR>`tI4&Q z#!QBSWo36Jph!*pWKN16_(PR1Q+GgI>NrE?LZGD_GO_|aR~S^ z2dR*cW48KMk3FqmVo>3FIMC&a&;Ne?>g8Zm@2;oTz=r~4hK&Jaz98kb_k&FNaC9IW zQG6*yxsT22>|$}zeAvA9eR3sIY*XD;IghHYWig>!%J48rpsAq5P1_aGZ@^NKOusNk z1+ySPIy28Knh-r>zIfb@r#m&F>tWm4*Iyf!OyjPX`J4>Woo1#E8Z{jXz)7Vj>*2r(47w=jkA37R z_@J}_XHQ0>J`h_Si`fnf zRKg_X@G^~Hs9&r{efaJBA!)d@MWfLOqQMdO4NCamxgSrCx$kh!XNIuvO0^{RB&0}4 zWC)|azD^VHK!{Wf2bcPbWR@-TN#5$Khgb>F_Ox#oXwi+;dt#~ClzBI(U+8sdHD8JK zy*&xAn963#^$#6X0=IJ8 z=kPas-4Tlc2hXj5n{rxW5+7n1{()ho|@EMf1zpGkw z#o?=9@xv3TeyVMX9UVTqzxWPD-+00pY|T{r=2T|*bZAIZ7h4~T8nV`WV3>Mhs`Ro^4q|mC$|y>EJHiw5GsI3#gYW+IHq}`N*xL6 z8FHIIxnaj?ai|>wt>?$tL5D4M?H93h>;LwQ8XLa9N5(kDR0!G&e5Wnnb?{Br{uyNE Qo4@<~3X^rJVFwxj0G25 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 literal 0 HcmV?d00001 diff --git a/data/develop.html.gz b/data/develop.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..b2655b7886c1f847ec75064f8e4338d561fb5bad GIT binary patch literal 946 zcmV;j15NxNiwFP!00002|HW6!j@vdA-52OTFj`kb%&dw+Hjv3=fC3q0knt4A@>;sK z2va1>hg|Qv>Y_k_qUkD|Ao+>>Pre|ZkV`#WOR*;?R5Xi$Me;rl&pjmXWRI?%KUuuI zeacE-$@#b6Wbv0tQ0rNODrsgA3i@R%tIz|>ORx_8EV*A?r{5L^e*2R(Np5JEaX6Ns^xpiy`C9lgEYEUX@j{0JEI%M|pSrbblOM8t z7(3&%rJe%5=b)R2=>9P+&-a_Mpo(PvS+&aZz3-575<}{vO}e(CILe*#7NJ@RKjg3X zOmnaU$6#_YuyH5XVaT}4i}|(i>{>`zSp`ho#T~r|d>6_ci6BB(#@rtg^gNAI> z1-UP^L3}GVVy~AHrRh4ME1iePL38*I2aZ zQBV?RK=nQj4cPeR5QV?K%XnOy0?7m^k0H9HdFpt6CcLpb81OQvTgC^a$@soa?Cmn% Ub>4JH+J7`Y0h@KeTzU)u00>>(r~m)} literal 0 HcmV?d00001 diff --git a/data/index.html.gz b/data/index.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..9b89458342b12edda8b5c47c626ad2826b3ea3cf GIT binary patch literal 1149 zcmV-@1cLh?iwFP!00002|HW6qj^s2Dy(94tCI_ZlnsjF7gqEZwc4wAd*k#c6uq#f; z9aj=FjuULBo7QTtT#*(ozyWdM%#H8i2jCMZ$4+-T(=#yiqB$jY%2luGRh298JE!Ll zE?UM*9^CSQnv^zi)&+BGf4rkQ{q8h8qFXSxJ?d)m)xj#*y1!Luh_~RW_&?XLA6eTn&uE%aFk0>&NNEcGobtm z*OD#>Rh))`&aKg>D2_fZcuozkWU!V*S?XaF#?<;=6y@9$&3uwpWyGq6Q5E%^3OXQ8 zDzttD(+|0M)Xd|EZV*IhtwR^B;rWdc;cP^U<4);ZQOSbsAQs1K$@Pg|K#-ZxRF%+S zt~lH2m@ar`CN?>O;i)jCYU|Lp@>YbWHei4J_`{#Se;Y^m^=5IOIonFDmnYK~Feft$ z1{C^rGL)#N5Adw21j&Ia(!%C4Cnc2)6=D@f_3%g|wrpoWO`|{$jkgInecI}A%2Q`4 zj&OgM>@&awH@t*3j2)tJ)QIh1+!!7k>yRo{Eis(mydDa>^x4H97z;q}PvI~7@i+58 zt|c$yIfkFOZb3TS1e2Q7=n5#1Dd>H9Jay_*Z?CQBYl)n#w1INl{9O?_7Y(3)rohfA zv3>1J=wCm7_uGDq-tQ2RnS*pKwGvX(5{cD_J(z4xI=0^wt8Hj@mz$IGSdeoMr9|KE zj#Km=L{;xk<@dktN9Cyl0*YMN>tHvLAFu_*8epWoGFm3Np%6lXa?Q!HU!DGpt`u2_>AZDV#VhqoJ@q9woNLjW&ukDOL%W2^Y)PW~^f zX=_b8YeP#9xhj_wZ>M+3tf}pk`#&XIe+;w=Y?k%rLZ@%N&aj8}CfD1}qduq}PXVI# zBk62*@-caY>j9h|`UNPB0S~#%szJuLmBCpOWIk%ooop}_6=iLE-g0Xgs5sj4D~{F^ zZ$ALBP8F|>7s?vx+PZ?T8qmhg6VEALG_9MW2N=`6?&P031++RIrqbmU!PYir*vDzXh z39;HH1EKdfnqT$Jr__1^m_Nz_@GW!FZj27j?Bm|SA!*m+vDfmdOP(c{tXfVeV`mGr z{e)vf#T$HdU@5|57_+`tJEPj^v@h;8ipVoCYDGNJr1ORNbRj$=+#G0vOYkE^8> z3%q>b)Zpy*7(=2KRE!2d@cih%hBrwzhQ@uyZt|{;S^GF7$45s;ZXYwa%f2}B|C-o; P-LLfD#v!j&Tn_*MMLH$> literal 0 HcmV?d00001 diff --git a/data/list.json b/data/list.json index fcb36ff5..d5643c83 100644 --- a/data/list.json +++ b/data/list.json @@ -1 +1 @@ -["bluetoothscanner.html", "btsimulator.html", "favicon.ico", "index.html", "settings.html", "shift.html", "status.html", "style.css", "streamfit.html", "develop.html"] \ No newline at end of file +["bluetoothscanner.html.gz","btsimulator.html.gz","develop.html.gz","favicon.ico","index.html.gz","settings.html.gz","shift.html.gz","status.html.gz","streamfit.html.gz","style.css.gz"] \ No newline at end of file diff --git a/data/settings.html.gz b/data/settings.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..84054d83b183f4fbe0d42f4e0a76b62c471d0eb0 GIT binary patch literal 3698 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 Q*nfuh9|LhOq?k4U0QPSS?*IS* literal 0 HcmV?d00001 diff --git a/data/shift.html.gz b/data/shift.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..afb55840b9c2278a0c5458f2ad2e2eb3db180b9e GIT binary patch literal 1107 zcmV-Z1g!fXiwFP!00002|D9ILZsRr--52OTaI}$(+Ey|}fg&S0Kr)$WikV5!Bn`Tx z#cPX=OsW)R#|X0Rx~qOj*ZrD)NiRt~DBF`_y0Jy-J`T@0B$d2+dv$*M%k>4x^{SYC z{Z(TANkPRj4L}5T1}MXSLXs6|O0t})3UnIW-M*WAA9!yBT|pYGIc!R)bU-pGGzg5` zaHjK=!J20s|9D)m)08i^N#kz~F+xYb^!Q+Y(*u27z6o&AKZCB1Km4>;Y#$ zFbgX>9tFeVO%*q&%c0OVhhV8eXY5^-R1E7-rQ`;E@aJDiY~KENhV6mc{&4y3mfT$* zNK0ae&ZBXcp(Ri?VZ15}y4^!*JCKD`P0KU+7z`2{2!og0y-7Ov}4%8J|Il$7Q| zx)eHU>rSmz#^m^@ZO~>3*j#&w?c+D3Yt$tSi(#~v`=-O(7H({i_Jzod9FbEJHm65O zPMT+9hHwWLAU!WI=BoFLG2v5dgb%#ZQ_7H3ie1+b_Ca__8+l}{qi96Z*>0ACm0Uxg zI3}-;EMaYd1?Vh~!q}odb=xoWxf+uvysmUE8LsX9#cfEQN4?3W88!KUrdFo?lW$71 zY>BtDeH(>to7llgICc;HN)F0Vc3Q+S`3csbH11fJ+9y7@Ri4{*^)AzR3Eo*7N1na- zf<&)8caMDF-aJA;hS7GQ5K^Szw;C#aF2#Z`e>NDaG5PlB$g|+{=a3x8^%1o?s9I?3 z?81J$x%w3PZ0j5ZqNqw_77X9*^YqtBifFVKiz}gfy6n+xqNdFS+u7gG`0Os<{W1tg zr>*CFX%AS&*>#}Lo{Ss(ypkJZ{fjlWG>!(m`CVJIkgM@@U^5(4>N{VkLFcxyd%*Lx ZJqht#bzhYDW#T@b{{;pWb|+&?-boy9ldZGaiJjfq z+HMVzFo~D~R1lP+diC1pe&c>g4*-e;cyXMW?X-Te0G#^`2Zz%quil+p{QScy%H$$H z{_1yA^&jP!r8AE(4|dcZ!3q3AXu2R0qd3E&Aadq?xcGMR+_O#?UXYo$qGY|~LV76X zOcDl=YnsSxmXH;V$wV*4lu=1>J}F|Hli7YnMpBuR?SjX_W73dK1tikY{>DVZoKF=a#; z8300I5mBvR&F~6gLGdKVbH(;8fP=Ctnqo;g^J++d#g!nGCFE16%<1j8&2fA;QNnu; zwU@AsBu5KHbwOC^9sl~Te{YBFlO;YXfDN z+vc@tPa{xiXM(7ldhSBcdQM=`N&es#k(=;b-asW)(vXn?}mXKg^M&i3U|Kw@?4W?Hm-)^O0NYWwn zqLdtRP5@X-oJh;#s@t+T>?{S*T($O|Ym3K&hPoPhtP5kL>xZ(d5t*(4RbTz2ed z4oxuN6*=SSOTFVYaRl5T+BXve3K0l!m5Tv<8kzkE^>NqsY8ut?ZU@s8EJv5J0?LU* zQsZ&7RvjEm)UT|=rw2^pv%A}yA z0N~5(4wDlOnLva^NjXG9UHJsONR7I`x90-Hf&hB*XXOb6aF2Egcxdz)qbkX&HQFBS zk}KhH2{lWGI7v=d@Z*d^A%H9l;2tRGoaR(+;KpDn@y3X=AM4E%y_t-TK1*o9|M{EI z0>j=>`P+$BJf4&xc3U!CSo)xX5xU<+BfUjI%{Ze;LKynxmqr#a%Iof~QLpDgJ1|YLqV1~j!6&&moFxN zy&gsB82RYwzCUV?{uV}KU?o&6z`s!P55QHN-~x=W)3D3k2x>g^t>BCA3eE!9!egZG zoeQ{6Z5x_$xN9WdBw&P_r}s7JwrMhMjs}nbrB1q($kUvtpD#CWlE5O|I7QWO&EUjT zVZe9l@ny~Z`l#t>bTZj!PH-EM^l`=CmdcQ?4gw!;H{T9K8!gng?e|#UPky1BJ9A_E z7Dk(~>WR#4IK(qMa9CCdp(LMFHcBIKt+-BKe|IPO=o{ploGpd!zkPIw{3;ufakf9C z5QeZ}$w@}@B&gVq9#AC}H2EUi(PY48H1XSjv>RV)|K>5WE5Jd)O_a6Z8$veTP^^Ju zQk2B5MsC2jEz^qi9<>^_yZ`UzAEC<*p$HT@D@P%ei&GqDs#vtjzKVFgt72gZ7G86a z_@k1D&1?q1MgQ&*3wnWux_m7=;6W z2toU*7W!!Db}|+-50hch^m$7-y%$1`zL3yoLhnriADrorRf+Edb#vXlCO#vdc?%88 zr_WpR>Ag_uYJz;l-0wNpW&#O%j+n7_|KUm%9Yr`dFP}czAY<~5<(qb9bvlH`Yjy%_ zs|^N(4XN3=khiIZVJZmt=!vpxs9Uptg@2aZB?3Q zmt)aeBB6x%QLGt!cmD2sf7Iv`N@!sML5d{=6H!%W_dEQk+JjLCRw*~og!Z-;%~jz1Z^Ifr^9@K4phL&zPyb_9Zs4d?l$YKwbcpPw~+t8x7=SiX8Lp8T&I zQ1SnNv%lN?xBE^#MCs|*T6tHEUPi43)C_(efnH6B+W70@MUsFc)BxaST^vTy)BBo6 z&STwag&8m8_VD@M^Sv7@CZyQ3uj+b$(YpK8S?~&s7BapD8%%m;AtQ8*zTShs&Wo+4 z3O67xNbAvMor4mr%=1S*=34!OQwF#85V3b^)97N+aO5Cs^b?t%>vd3@d30oxZWQBe zVdw*DKvuH*{MF3`oO~1No~fInUnVQE220_xEHcH_kZ#dbj4jxluvBK?D`&ve@tHHW zve1j1!pm-djJhNzXn(Ksp0<~Es)Uq5v-|qu?U_seBd09zrgL%Z^(o@yOIVFdJHk7R z%-HmTFI9SV4_*_RW)g&V&f=WHKXW!1W!CUTS0?a$=Rw!hCl8+Jd4d?%&rSv{wEBGT zQyoGc6fWC*59Q;-8D+mK-DNFdeJ22A?5){_fROjJI%)rb9wpCp7QU-k& z+ZY|#-%b2kSslZ$sk;+IVcqheP$zOGTl`txbqArXotU-r7ii2Z=*zH}^Gl^#Rc7 zfw4_qJJAX*7fp}_Uy%-)(o&EWzq7Oi0iEzt9}w!V2Ggke3^|ST2iN}q%VQp2fE@q; DMwd^< literal 0 HcmV?d00001 diff --git a/data/streamfit.html.gz b/data/streamfit.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..a0ef3e2764a629ef973daa9755b44e410e041744 GIT binary patch literal 1083 zcmV-B1jPFviwFP!00002|Gie-j^i{GzDMF6Om5ndHVMNOf+R@O?TV3@&d_#*5JJdx zP8-wI&e~2p6}vYp#O)%%6%tw@@euGj`vUAEa2%&;({{?PG~z0;eSW_4`PiSM4<29l z#$Vs`NoE##^8R~~{gRxDS?q!Esu`duehG;dU?^c3RT@m}-HxC5AA371f-WHTmK;`v zRK_Dr3IhTyS3EUYoWhbb@TJF3%ej~n1$pdgv(7$Q6Pxr3&9T}gpHai5@ES|Br8NvLQn(8&o8BhdgtEDBp?j@p z7dTCfBnumL0iyJh*FXJCsw7u9j6!-WL1s+xNf^$!$;wH<2mLYW zT@MER-uUu*uwU`%eZk90ai;y5;(Np%TKSBslkC@Kj{Voa|M|~fFOTwn=u$D_C7%PH zMl!?(zSqxElv%Uy6q8@iQ%b+c)No&DUFfh-V4ty*B6T$c=hp@ z#l?8g)Ul#U;AQLAHpi~Ay_9vUz5M&=jW)vgWaMN5FZOnUF2a!-k5?Q`B`SxyPm%S1 zNpiU2bKX>%L0;6V{8+LQ)!$SQ+q&FJ8HJ4$?_!HIQ@k)lE4D?V=qj*i@P*NWvk_|y zr7?2o9xLDDlL`5&;v_pU>%=RxlB+79zxnXn&jJgDJbOku`(oMobQ`GgE2u&QHO$~? z32Hs65q3Ma6%nU_JJWM-VNAKn-b8zTR{z#NJ6nL=>7K3ls&eo1e*r+9UK_Cs007&S B5OV+k literal 0 HcmV?d00001 diff --git a/data/style.css.gz b/data/style.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..392a5e0fe2caa64309e1c276bb6c15049ce54b6a GIT binary patch literal 3159 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 Qo4@<~3X^rJVFwxj0GTDNA^-pY literal 0 HcmV?d00001 diff --git a/data_s3/btsimulator.html.gz b/data_s3/btsimulator.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..c7cec439671e74d5fb28d8049b5f42beb41ad82d GIT binary patch literal 2407 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 literal 0 HcmV?d00001 diff --git a/data_s3/develop.html.gz b/data_s3/develop.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..178f7af81ae045949280e658d1f1f261ec1b6f1f GIT binary patch literal 946 zcmV;j15NxNiwFP!000023dL8;j@vdA-52OTFj`kb%&dw+Hjv3=fC3q0knt4A@>;sK z2va1>hg|Qv>Y_k_qUkD|Ao+>>Pre|ZkV`#WOR*;?R5Xi$Me;rl&pjmXWRI?%KUuuI zeacE-$@#b6Wbv0tQ0rNODrsgA3i@R%tIz|>ORx_8EV*A?r{5L^e*2R(Np5JEaX6Ns^xpiy`C9lgEYEUX@j{0JEI%M|pSrbblOM8t z7(3&%rJe%5=b)R2=>9P+&-a_Mpo(PvS+&aZz3-575<}{vO}e(CILe*#7NJ@RKjg3X zOmnaU$6#_YuyH5XVaT}4i}|(i>{>`zSp`ho#T~r|d>6_ci6BB(#@rtg^gNAI> z1-UP^L3}GVVy~AHrRh4ME1iePL38*I2aZ zQBV?RK=nQj4cPeR5QV?K%XnOy0?7m^k0H9HdFpt6CcLpb81OQvTgC^a$@soa?Cmn% Ub>4JH+J7`Y0h@KeTzU)u0EcAUvj6}9 literal 0 HcmV?d00001 diff --git a/data_s3/index.html.gz b/data_s3/index.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..63766bfc1ddb66852709e39e79041a3c2f7b9b4c GIT binary patch literal 1149 zcmV-@1cLh?iwFP!000023dL8!j^s2Dy(94tCI_ZlnsjF7gqEZwc4wAd*k#c6uq#f; z9aj=FjuULBo7QTtT#*(ozyWdM%#H8i2jCMZ$4+-T(=#yiqB$jY%2luGRh298JE!Ll zE?UM*9^CSQnv^zi)&+BGf4rkQ{q8h8qFXSxJ?d)m)xj#*y1!Luh_~RW_&?XLA6eTn&uE%aFk0>&NNEcGobtm z*OD#>Rh))`&aKg>D2_fZcuozkWU!V*S?XaF#?<;=6y@9$&3uwpWyGq6Q5E%^3OXQ8 zDzttD(+|0M)Xd|EZV*IhtwR^B;rWdc;cP^U<4);ZQOSbsAQs1K$@Pg|K#-ZxRF%+S zt~lH2m@ar`CN?>O;i)jCYU|Lp@>YbWHei4J_`{#Se;Y^m^=5IOIonFDmnYK~Feft$ z1{C^rGL)#N5Adw21j&Ia(!%C4Cnc2)6=D@f_3%g|wrpoWO`|{$jkgInecI}A%2Q`4 zj&OgM>@&awH@t*3j2)tJ)QIh1+!!7k>yRo{Eis(mydDa>^x4H97z;q}PvI~7@i+58 zt|c$yIfkFOZb3TS1e2Q7=n5#1Dd>H9Jay_*Z?CQBYl)n#w1INl{9O?_7Y(3)rohfA zv3>1J=wCm7_uGDq-tQ2RnS*pKwGvX(5{cD_J(z4xI=0^wt8Hj@mz$IGSdeoMr9|KE zj#Km=L{;xk<@dktN9Cyl0*YMN>tHvLAFu_*8epWoGFm3Np%6lXa?Q!HU!DGpt`u2_>AZDV#VhqoJ@q9woNLjW&ukDOL%W2^Y)PW~^f zX=_b8YeP#9xhj_wZ>M+3tf}pk`#&XIe+;w=Y?k%rLZ@%N&aj8}CfD1}qduq}PXVI# zBk62*@-caY>j9h|`UNPB0S~#%szJuLmBCpOWIk%ooop}_6=iLE-g0Xgs5sj4D~{F^ zZ$ALBP8F|>7s?vx+PZ?T8qmhg6VEALG_9MW2N=`6?&P031++RIrqbmU!PYir*vDzXh z39;HH1EKdfnqT$Jr__1^m_Nz_@GW!FZj27j?Bm|SA!*m+vDfmdOP(c{tXfVeV`mGr z{e)vf#T$HdU@5|57_+`tJEPj^v@h;8ipVoCYDGNJr1ORNbRj$=+#G0vOYkE^8> z3%q>b)Zpy*7(=2KRE!2d@cih%hBrwzhQ@uyZt|{;S^GF7$45s;ZXYwa%f2}B|C-o; P-LLfD#v!j&Tn_*M0s18d literal 0 HcmV?d00001 diff --git a/data_s3/list.json b/data_s3/list.json index fcb36ff5..d5643c83 100644 --- a/data_s3/list.json +++ b/data_s3/list.json @@ -1 +1 @@ -["bluetoothscanner.html", "btsimulator.html", "favicon.ico", "index.html", "settings.html", "shift.html", "status.html", "style.css", "streamfit.html", "develop.html"] \ No newline at end of file +["bluetoothscanner.html.gz","btsimulator.html.gz","develop.html.gz","favicon.ico","index.html.gz","settings.html.gz","shift.html.gz","status.html.gz","streamfit.html.gz","style.css.gz"] \ No newline at end of file diff --git a/data_s3/settings.html.gz b/data_s3/settings.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..007bd7f2395ffc897a4a528677476649db5576b0 GIT binary patch literal 3698 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 Q*nfuh9|LhOq?k4U04pyF`Tzg` literal 0 HcmV?d00001 diff --git a/data_s3/shift.html.gz b/data_s3/shift.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..0a6f70e893b613da5df76672058f1e8f5a0bdc12 GIT binary patch literal 1107 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 D`E*Xv literal 0 HcmV?d00001 diff --git a/data_s3/streamfit.html.gz b/data_s3/streamfit.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..35035468ee610d3aa71ecb637fd6937c62182c5e GIT binary patch literal 1083 zcmV-B1jPFviwFP!000023cXg{j^i{GzDMF6Om5ndHVMNOf+R@O?TV3@&d_#*5JJdx zP8-wI&e~2p6}vYp#O)%%6%tw@@euGj`vUAEa2%&;({{?PG~z0;eSW_4`PiSM4<29l z#$Vs`NoE##^8R~~{gRxDS?q!Esu`duehG;dU?^c3RT@m}-HxC5AA371f-WHTmK;`v zRK_Dr3IhTyS3EUYoWhbb@TJF3%ej~n1$pdgv(7$Q6Pxr3&9T}gpHai5@ES|Br8NvLQn(8&o8BhdgtEDBp?j@p z7dTCfBnumL0iyJh*FXJCsw7u9j6!-WL1s+xNf^$!$;wH<2mLYW zT@MER-uUu*uwU`%eZk90ai;y5;(Np%TKSBslkC@Kj{Voa|M|~fFOTwn=u$D_C7%PH zMl!?(zSqxElv%Uy6q8@iQ%b+c)No&DUFfh-V4ty*B6T$c=hp@ z#l?8g)Ul#U;AQLAHpi~Ay_9vUz5M&=jW)vgWaMN5FZOnUF2a!-k5?Q`B`SxyPm%S1 zNpiU2bKX>%L0;6V{8+LQ)!$SQ+q&FJ8HJ4$?_!HIQ@k)lE4D?V=qj*i@P*NWvk_|y zr7?2o9xLDDlL`5&;v_pU>%=RxlB+79zxnXn&jJgDJbOku`(oMobQ`GgE2u&QHO$~? z32Hs65q3Ma6%nU_JJWM-VNAKn-b8zTR{z#NJ6nL=>7K3ls&eo1e*r+9UK_Cs007s2 B4}Jgu literal 0 HcmV?d00001 diff --git a/data_s3/style.css.gz b/data_s3/style.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..d6e1027602d44a7dcdcfcbac9bc657744152cd80 GIT binary patch literal 3159 zcmV-d45;%TiwFP!000023awh}j-$#E{vRptfM&GPSO+Y?#-0I1%HQ(_iK6qP4UJ6( z1LESDS+r7~C6AUTN%e)MZ}_r%l+0Pc-PP4qUtPQ7wyyip^P09eqxLI)4$)hdZxhcu z9K8&+@A0$03Aa(ozMN4}#m_;o+aCh{L|<`xJfN42yV<+@ox9$x*sn#8&+I4P zCI4H8_<2%`c*E7XaF5gFH_tQb17B>X!GG8vBy5R9jbsXZi_rAiwb)uTpUk*9IUs=0j2N9 zwjQc1zM%F+=xCLsby>IkBatYu_<;}aMbG71e8(Qj{ylU+$oIbiKZiQ|8bRepP^h1r zi%Kly`-^ zDRB=(JhD5w9}t(JO|}Kcrsqixl-X$oe}#B4uwv z5#%K~q6UD*>1*@GY5kCGjC=#m&M>Myqox+F|RrA}>lr_rK;ge%veNaY1B#y$x)?3t(ig0J zQr@Ag(|5k2eY0xxdZp~Cl2)ePkg`Q9y5PAl199|yeYT)V@;nxT9M}2|wxJtDdpF!} zA?QUyW6W5L5io#CIIr7tOrHo=|M}uU#9A#%8&qwG(m91HGU3B0#{vckYsfG>KWBQ9 zjKGh<|3+YL8Z4wJc(Ix=S>+-73?s!pId$SftrkOmSSgPAn(YwyD*m4Q$IFVA!6|0E zQO-?A9DyTV3PyQdmY}e{J|0Uf+(?NIxHSCdoPx<3S-+9>RH!l@=$sinL(||kMIF|? z_&TE5kIKtvF-&4kzyYWlSUqCYdVJ7GfmnPe)C4p>wUEW;^T_u=@U+B2%`k;o&I`!k zz7NyO5}-UXCb{tD5Rd5h&fRo*EN(n1g&{ znpmlch(8wukcGL^ANW6N%tqLmv$ma_w9&w>4&zG+ex~V}@h6e;H>LUUwi2W- zs2uR^n#~%mq-}$0c2zw?k?5 zaUq%p7U8OXCS=i8W`2A$eV`ze_q)K2&z6?@E<57I7kr|SJw#|Sjj4skB5QU;xTuEn z0p6%&=JExX;Fm?M%E=k=+=Nrp4Lhf;YHqv)hGpSRE`&x`XqWhI*= z0`_sNhn{$&*jGy>-9Y9bK(Yzn*-ZI_T#hi$r>q=LUAUsEsP8GJ$m1>y1cuasV@(`Z z|HVAzP(e|$rp7M3*1hEbnL84|F*$C1?lQqIf+UxUrBIK=ztG!Aq<$E2DM~j?;f0sd z<1J9#aK&0n!cxdct8#<2x`+q*B0CwK!oU`@h+E0DNNi6Q(U&!S1zcL(~6H?p+ElGh3wZ&mhsA+M;@o zePf@z2gm$$dcx^D&VJ(xH8V!p;j;O*yhqPR`kFghop;I_t}=SIz(|V#B04tW%Li&J z+6`k0Qtj7K`?9wRMPiCZWVu!ank+6$$J*G}W}79*b8vXg@rF4&eeb*{cg9_Zju;Zc zydED;No=GSTCJK_BQsU;8!Ta{hSdFJ=_cAnb~aRE7Gb2X;byaua3=N5jguJ-XL19V zWrULN(acR%x-s(}@dVMznCH}u9^r4EY3fHjO51&u_P7x(NxkDu^F@Z4EIslY6(?tRVtly1$15ccpJ8-~tZrcWVuzp`YJ_QRX@|e{jLA6Zv>fQ$ z#Y^PsKsCuctBhs{w{6cFI6#yL$E9fi$-&3lz!9@_`Eb8Z9;ZC_7t+w{hv=-AHSx@b z(iD8Fm4`wxS4&(=yOQ~C^5=_Pa~65MBtGYW^ z+Zp2x3ESwz2Mm!0kCV`!{`a5%K3zpL4Ii=Yzk@Yz21<8+j3Bb)-Zv}R&x!EI?-<>@ zTV#os|9Am~v6x1^lS?<=7tPqze3=%*z~X}EL8C2%CDXx{RJCn+BZxV^sk9N4(E)%r z^f)Q14)?KdtLjvY5sHed-*)B#ld8;hgz}yy7DG~4PGvVdP9~9XywFF9D{{5aEGrF; ztTo-lmsJg9^_7@Odx&!__&}F%QlEL(A%jP2tlfxUdctKR5pkvmVrS!}=!!#8l8zzo z7;(ALy!5Vu+$x+MMc+$W( zJQ>fz{^?_h6QzTy7Gk}+ipj_9isJ}xkl*D7TTr1boXQO&fqTJySuJv1k$Z7 zGV76hHLTgtpceIYJ1z$k829&g{FS!|FvWp3=52i*HB&KaEYi1I{pjoY48cCoU;R)Q zAr%l?Vjj;`?^A-t=grP1kkIHfL)qcc|7Q9|pL@8_ccYEFd!vqVZyQNAp$`ToNZEY{ zS-!P1&I;tb%w_t1_X@HZ^@B^fWv899xDqt1rImK?Oub0z>eof>*)1ft=GmRxvrwDD z#(9Z$qgd None: + """Keep gzip files used by raw-GitHub automatic OTA in sync.""" + output_files = [] + expected_gzip_files = set() + source_files = sorted(path for path in source_dir.rglob("*") if path.is_file()) + for source_file in source_files: + relative_path = source_file.relative_to(source_dir) + if relative_path.as_posix() == "list.json": + continue + if source_file.suffix == ".gz" and source_file.with_suffix("").suffix in COMPRESSED_SUFFIXES: + continue + + if source_file.suffix in COMPRESSED_SUFFIXES: + gzip_file = source_file.with_name(source_file.name + ".gz") + gzip_file.write_bytes(gzip.compress(source_file.read_bytes(), compresslevel=9, mtime=0)) + relative_path = gzip_file.relative_to(source_dir) + expected_gzip_files.add(gzip_file) + output_files.append(relative_path.as_posix()) + + for gzip_file in source_dir.rglob("*.gz"): + if gzip_file.with_suffix("").suffix in COMPRESSED_SUFFIXES and gzip_file not in expected_gzip_files: + gzip_file.unlink() + + (source_dir / "list.json").write_text(json.dumps(output_files, separators=(",", ":")), encoding="utf-8") + + +def stage_filesystem(source_dir: Path, output_dir: Path) -> None: + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True) + + output_files = [] + for source_file in sorted(path for path in source_dir.rglob("*") if path.is_file()): + relative_path = source_file.relative_to(source_dir) + if relative_path.as_posix() == "list.json": + continue + + # Gzip copies checked into data/ are for repository-based automatic OTA. + # Always regenerate filesystem copies from the readable source files. + if source_file.suffix == ".gz" and source_file.with_suffix("").suffix in COMPRESSED_SUFFIXES: + continue + + if source_file.suffix in COMPRESSED_SUFFIXES: + relative_path = Path(relative_path.as_posix() + ".gz") + destination = output_dir / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(gzip.compress(source_file.read_bytes(), compresslevel=9, mtime=0)) + else: + destination = output_dir / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_file, destination) + + output_files.append(relative_path.as_posix()) + + print(f"[prepare_filesystem] staged {len(output_files)} files in {output_dir}") + + +if FILESYSTEM_TARGETS.intersection(COMMAND_LINE_TARGETS): + project_dir = Path(env.subst("$PROJECT_DIR")) + build_dir = Path(env.subst("$BUILD_DIR")) + source_name = "data_s3" if env.subst("$PIOENV") in ("S3release", "S3debug") else "data" + staged_dir = build_dir / "filesystem_data" + source_dir = project_dir / source_name + update_repository_ota_assets(source_dir) + stage_filesystem(source_dir, staged_dir) + env.Replace(PROJECT_DATA_DIR=str(staged_dir)) diff --git a/src/HTTP_Server_Basic.cpp b/src/HTTP_Server_Basic.cpp index f615a951..f621cad7 100644 --- a/src/HTTP_Server_Basic.cpp +++ b/src/HTTP_Server_Basic.cpp @@ -60,9 +60,88 @@ void writeStoredBuildVersion(const String& version) { // NVS guard helpers (one-time recovery per firmware version) namespace { -const char* OTA_REC_NS = "ota_recover"; // namespace -const char* OTA_REC_KEY = "ver"; // key storing last recovered firmware version -bool otaUploadRejected = false; +const char* OTA_REC_NS = "ota_recover"; // namespace +const char* OTA_REC_KEY = "ver"; // key storing last recovered firmware version +const char* OTA_FS_VER_KEY = "fs_ver"; // key storing the installed web-filesystem version +bool otaUploadRejected = false; +bool otaFilesystemUpload = false; + +bool filesystemIndexExists() { + return LittleFS.exists("/index.html.gz") || LittleFS.exists("/index.html"); +} + +String contentTypeForPath(String path) { + if (path.endsWith(".gz")) { + path.remove(path.length() - 3); + } + if (path.endsWith(".html")) return "text/html"; + if (path.endsWith(".css")) return "text/css"; + if (path.endsWith(".js")) return "application/javascript"; + if (path.endsWith(".json")) return "application/json"; + if (path.endsWith(".ico")) return "image/x-icon"; + return "application/octet-stream"; +} + +String getNVSFilesystemVersion() { + Preferences p; + if (!p.begin(OTA_REC_NS, true)) return ""; + String version = p.getString(OTA_FS_VER_KEY, ""); + p.end(); + return version; +} + +bool setNVSFilesystemVersion(const String& version) { + Preferences p; + if (!p.begin(OTA_REC_NS, false)) return false; + size_t length = p.putString(OTA_FS_VER_KEY, version); + p.end(); + return length > 0; +} + +bool manifestContains(const JsonArray& files, const String& filename) { + for (JsonVariantConst entry : files) { + String manifestName = "/" + entry.as(); + if (manifestName == filename) return true; + } + return false; +} + +bool pruneFilesystem(const JsonArray& files) { + bool success = true; + while (true) { + File root = LittleFS.open("/"); + if (!root || !root.isDirectory()) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to inspect filesystem before update"); + return false; + } + + String staleName; + File entry = root.openNextFile(); + while (entry) { + String filename = entry.name(); + if (!filename.startsWith("/")) filename = "/" + filename; + bool preserved = filename == configFILENAME || filename == POWER_TABLE_FILENAME || filename == BUILD_VERSION_FILENAME; + if (!preserved && !manifestContains(files, filename)) { + staleName = filename; + entry.close(); + break; + } + entry = root.openNextFile(); + } + entry.close(); + root.close(); + + if (staleName.isEmpty()) break; + if (LittleFS.remove(staleName)) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Removed stale filesystem file: %s", staleName.c_str()); + } else { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to remove stale filesystem file: %s", staleName.c_str()); + success = false; + break; + } + } + return success; +} String getNVSRecoveryVersion() { Preferences p; @@ -419,8 +498,15 @@ void HTTP_Server::start() { server.send(500, "text/plain", "FAIL"); } else { server.send(200, "text/plain", "OK"); - // It's better to trigger the reboot after successfully notifying the client. - ss2k->rebootFlag = true; + if (otaFilesystemUpload) { + // The filesystem was replaced underneath the running web server. Reboot + // directly after allowing the response to reach the browser. + delay(500); + ESP.restart(); + } else { + // Firmware uploads can use the normal cooperative reboot path. + ss2k->rebootFlag = true; + } } }, // This is the onUpload callback. It handles the file data as it arrives. @@ -429,6 +515,7 @@ void HTTP_Server::start() { HTTPUpload &upload = server.upload(); if (upload.status == UPLOAD_FILE_START) { otaUploadRejected = false; + otaFilesystemUpload = upload.filename == FS_BINFILE; } if (upload.filename == FW_BINFILE) { if (upload.status == UPLOAD_FILE_START) { @@ -536,7 +623,7 @@ void HTTP_Server::handleBTScanner() { } void HTTP_Server::handleIndexFile() { - String filename = "/index.html"; + String filename = LittleFS.exists("/index.html.gz") ? "/index.html.gz" : "/index.html"; if (LittleFS.exists(filename)) { File file = LittleFS.open(filename, FILE_READ); server.streamFile(file, "text/html"); @@ -550,17 +637,15 @@ void HTTP_Server::handleIndexFile() { void HTTP_Server::handleLittleFSFile() { String filename = server.uri(); - int dotPosition = filename.lastIndexOf("."); - String fileType = filename.substring((dotPosition + 1), filename.length()); + if (!LittleFS.exists(filename) && LittleFS.exists(filename + ".gz")) { + filename += ".gz"; + } if (LittleFS.exists(filename)) { File file = LittleFS.open(filename, FILE_READ); - if (fileType == "gz") { - fileType = "html"; // no need to change content type as it's done automatically by .streamfile below VV - } - server.streamFile(file, "text/" + fileType); + server.streamFile(file, contentTypeForPath(filename)); file.close(); SS2K_LOG(HTTP_SERVER_LOG_TAG, "Served %s", filename.c_str()); - } else if (!LittleFS.exists("/index.html")) { + } else if (!filesystemIndexExists()) { SS2K_LOG(HTTP_SERVER_LOG_TAG, "%s not found and no filesystem. Sending builtin index.html", filename.c_str()); handleIndexFile(); } else { @@ -770,16 +855,29 @@ void HTTP_Server::FirmwareUpdate() { http.end(); if (httpCode == HTTP_CODE_OK) { // if version received + const String serverVersion = payload; + const String filesystemVersion = getNVSFilesystemVersion(); bool updateAnyway = false; - if (!LittleFS.exists("/index.html")) { + if (!filesystemIndexExists()) { // force firmware update if index.html is missing // updateAnyway = true; SS2K_LOG(HTTP_SERVER_LOG_TAG, " -index.html not found."); } Version availableVer(payload.c_str()); Version currentVer(FIRMWARE_VERSION); + // Development builds append branch/commit data to the release they follow. + // Treat that suffix as newer when the numeric date components are equal. + bool firmwareIsAhead = currentVer > availableVer || (currentVer == availableVer && serverVersion != FIRMWARE_VERSION); + bool filesystemUpgradeAvailable = !firmwareIsAhead && + (filesystemVersion.isEmpty() || availableVer > Version(filesystemVersion.c_str())); + bool filesystemNeedsUpdate = !filesystemIndexExists() || filesystemUpgradeAvailable; + SS2K_LOG(HTTP_SERVER_LOG_TAG, " - Filesystem version: %s%s", filesystemVersion.isEmpty() ? "not recorded" : filesystemVersion.c_str(), + filesystemNeedsUpdate ? " (update required)" : ""); + if (firmwareIsAhead && filesystemIndexExists()) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, " - Firmware is ahead of the server; not installing older release files"); + } - if (((availableVer > currentVer) && (userConfig->getAutoUpdate())) || (!LittleFS.exists("/index.html"))) { + if (filesystemNeedsUpdate) { //////////////// Update LittleFS////////////// SS2K_LOG(HTTP_SERVER_LOG_TAG, "Updating FileSystem"); http.begin(DATA_UPDATEURL DATA_FILELIST, rootCACertificate); // check version URL @@ -804,8 +902,14 @@ void HTTP_Server::FirmwareUpdate() { } // End HTTP connection after file list download http.end(); + if (httpCode != HTTP_CODE_OK) return; JsonArray files = doc.as(); + if (files.isNull() || (!manifestContains(files, "/index.html.gz") && !manifestContains(files, "/index.html"))) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Filesystem file list is invalid or has no index page"); + return; + } + bool filesystemUpdateSucceeded = pruneFilesystem(files); // iterate through file list and download files individually for (JsonVariant v : files) { String fileName = "/" + v.as(); @@ -815,45 +919,66 @@ void HTTP_Server::FirmwareUpdate() { httpCode = http.GET(); delay(100); if (httpCode == HTTP_CODE_OK) { - payload = http.getString(); - payload.trim(); LittleFS.remove(fileName); File file = LittleFS.open(fileName, FILE_WRITE, true); if (!file) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to create file, %s", fileName); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to create file, %s", fileName.c_str()); + filesystemUpdateSucceeded = false; http.end(); // End HTTP before returning return; } - file.print(payload); + int bytesWritten = http.writeToStream(&file); file.close(); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Created: %s", fileName); - httpServer.internetConnection = true; + if (bytesWritten < 0) { + LittleFS.remove(fileName); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Error writing %s (%d)", fileName.c_str(), bytesWritten); + httpServer.internetConnection = false; + filesystemUpdateSucceeded = false; + } else { + if (fileName.endsWith(".gz")) { + String uncompressedName = fileName.substring(0, fileName.length() - 3); + LittleFS.remove(uncompressedName); + } + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Created: %s (%d bytes)", fileName.c_str(), bytesWritten); + httpServer.internetConnection = true; + } } else { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Error downloading %s %d", fileName, httpCode); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Error downloading %s %d", fileName.c_str(), httpCode); httpServer.internetConnection = false; + filesystemUpdateSucceeded = false; } // End HTTP connection after each file download http.end(); } - //////// Update Firmware ///////// - if (((availableVer > currentVer) || updateAnyway) && (userConfig->getAutoUpdate())) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "New firmware detected!"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Upgrading from %s to %s", FIRMWARE_VERSION, payload.c_str()); - t_httpUpdate_return ret = httpUpdate.update(localClient, userConfig->getFirmwareUpdateURL() + String(FW_BINFILE)); - switch (ret) { - case HTTP_UPDATE_FAILED: - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_FAILED Error %d : %s", httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str()); - break; - - case HTTP_UPDATE_NO_UPDATES: - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_NO_UPDATES"); - break; - - case HTTP_UPDATE_OK: - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_OK"); - break; + if (filesystemUpdateSucceeded && filesystemIndexExists()) { + if (setNVSFilesystemVersion(serverVersion)) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Filesystem updated to version %s", serverVersion.c_str()); + } else { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to save filesystem version %s", serverVersion.c_str()); } + } else { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Filesystem update incomplete; version was not changed"); + } + } + + //////// Update Firmware ///////// + if (((availableVer > currentVer) || updateAnyway) && (userConfig->getAutoUpdate())) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "New firmware detected!"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Upgrading from %s to %s", FIRMWARE_VERSION, serverVersion.c_str()); + t_httpUpdate_return ret = httpUpdate.update(localClient, userConfig->getFirmwareUpdateURL() + String(FW_BINFILE)); + switch (ret) { + case HTTP_UPDATE_FAILED: + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_FAILED Error %d : %s", httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str()); + break; + + case HTTP_UPDATE_NO_UPDATES: + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_NO_UPDATES"); + break; + + case HTTP_UPDATE_OK: + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_OK"); + break; } } else { // don't update SS2K_LOG(HTTP_SERVER_LOG_TAG, " - Current Version: %s", FIRMWARE_VERSION); From dfda0df50816dbfc9c168919c77545dfa7ebb7c5 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 5 Aug 2026 15:39:36 -0500 Subject: [PATCH 06/29] Update resistance handling to blacklist non-Grupetto devices --- src/SensorCollector.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SensorCollector.cpp b/src/SensorCollector.cpp index a18befe3..763b7a5f 100644 --- a/src/SensorCollector.cpp +++ b/src/SensorCollector.cpp @@ -93,7 +93,7 @@ void collectAndSet(NimBLEUUID charUUID, NimBLEUUID serviceUUID, std::string& uni logBufLength += snprintf(logBuf + logBufLength, kLogBufMaxLength - logBufLength, " SD(%.2f)", fmodf(sensorData->getSpeed(), 1000.0)); } - if (sensorData->hasResistance() && !uniqueName.starts_with("IC Bike")) { // Blacklist IC Bike resistance due to non-standard compliance + if (sensorData->hasResistance() && uniqueName.starts_with("Grupetto")) { // Blacklist everything not Grupetto. rtConfig->resistance.setSimulate(false); // Mark as real data if ((ss2k->pelotonIsConnected) && (charUUID != PELOTON_DATA_UUID)) { // Peloton connected but using BLE Power Meter. So skip resistance for UUID's that aren't Peloton. From d5df58b984957253b7533be0bbb954889fcba82e Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 5 Aug 2026 15:42:22 -0500 Subject: [PATCH 07/29] Rename S3 firmware artifacts and update build script to remove unprefixed versions --- AGENTS.md | 2 +- scripts/name_build_artifacts.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2b5ba0f..ab922492 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ PlatformIO is the expected entry point. - Static analysis: `pio check -e debug` - Pre-commit checks: `pre-commit run --all-files` -S3 firmware and filesystem builds retain PlatformIO's canonical artifacts and also create `S3firmware.bin` and `S3littlefs.bin` copies in the environment build directory. +S3 firmware and filesystem builds rename PlatformIO's canonical artifacts to `S3firmware.bin` and `S3littlefs.bin` in the environment build directory; the unprefixed artifacts are removed. Filesystem builds stage deterministic gzip copies of every HTML/CSS source file under the environment build directory. They also refresh the checked-in `.gz` companions and `list.json` in `data/` or `data_s3/`, which are consumed by repository-based automatic OTA updates. diff --git a/scripts/name_build_artifacts.py b/scripts/name_build_artifacts.py index 87a95c66..6e9ae6f3 100644 --- a/scripts/name_build_artifacts.py +++ b/scripts/name_build_artifacts.py @@ -8,16 +8,15 @@ Import("env") from pathlib import Path -from shutil import copy2 -def add_s3_prefix(source, target, env): +def rename_s3_artifact(source, target, env): artifact = Path(str(target[0])) prefixed = artifact.with_name(f"S3{artifact.name}") - copy2(artifact, prefixed) - print(f"[name_build_artifacts] created {prefixed}") + artifact.replace(prefixed) + print(f"[name_build_artifacts] renamed {artifact} to {prefixed}") if env.subst("$PIOENV") in ("S3release", "S3debug"): - env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", add_s3_prefix) - env.AddPostAction("$BUILD_DIR/littlefs.bin", add_s3_prefix) + env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", rename_s3_artifact) + env.AddPostAction("$BUILD_DIR/littlefs.bin", rename_s3_artifact) From 02aab8ba7d15ec92180e27f521a82395088c09c9 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 5 Aug 2026 15:45:10 -0500 Subject: [PATCH 08/29] Add return link to main page in OTA server index and update styles --- include/Builtin_Pages.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/include/Builtin_Pages.h b/include/Builtin_Pages.h index cb6f2283..1be2d2b5 100644 --- a/include/Builtin_Pages.h +++ b/include/Builtin_Pages.h @@ -19,6 +19,7 @@ String OTAStyle = "form{max-width:360px;margin:16px auto;text-align:center}" "input{box-sizing:border-box;width:100%;margin:6px 0;padding:12px;border:1px solid #ffffff33;border-radius:6px;background:#ffffff1c;color:#fff;font-size:15px}" "input::placeholder,.hint{color:#ffffffb3}.btn{background:#2a9df4;color:#fff;cursor:pointer;border:0;font-weight:600}.btn:hover{background:#1b8fe3}.secondary{background:#ffffff1c;border:1px solid #ffffff33}.secondary:hover{background:#ffffff2b}" + ".return-link{box-sizing:border-box;display:none;width:100%;max-width:360px;margin:16px auto 0;padding:12px;border-radius:6px;text-align:center;text-decoration:none}" ".track{height:24px;margin-top:14px;background:#0003;border-radius:8px;overflow:hidden}.fill{display:flex;align-items:center;justify-content:center;width:0;height:100%;background:#2a9df4;color:#fff;transition:width .15s}" ".hint{margin-top:12px;text-align:center;font-size:13px}#status{min-height:20px;margin-top:10px;text-align:center;font-weight:600}a{color:#fff}" ""; @@ -76,12 +77,14 @@ String OTAServerIndex = "
" "
Valid files: " FW_BINFILE " or " FS_BINFILE "
" "
" + "Return to Main Page" "" ""; + "15 seconds."; // spinBLEClient.resetDevices(); spinBLEClient.doScan = true; server.send(200, "text/html", response); @@ -357,7 +357,7 @@ void HTTP_Server::start() { server.on("/reboot.html", []() { SS2K_LOG(HTTP_SERVER_LOG_TAG, "Rebooting from Web Request"); - String response = "Rebooting...."; + String response = "Rebooting...."; server.send(200, "text/html", response); ss2k->rebootFlag = true; }); @@ -805,28 +805,24 @@ void HTTP_Server::settingsProcessor() { if (wasBTUpdate) { // Special BT page update response response += "Selections Saved!"; + "= '/bluetoothscanner.html';\",1000);"; } else if (wasSettingsUpdate) { // Special Settings Page update response response += "Network settings will be applied at next reboot.
Everything " "else is available immediately."; + "setTimeout(\"location.href = '/settings.html';\",1000);"; } else { // Normal response response += "Network settings will be applied at next reboot.
Everything " "else is available immediately."; + "setTimeout(\"location.href = '/index.html';\",1000);"; } SS2K_LOG(HTTP_SERVER_LOG_TAG, "Config Updated From Web"); ss2k->saveFlag = true; if (reboot) { response += "Please wait while your settings are saved and SmartSpin2k reboots."; + "setTimeout(\"location.href = '/bluetoothscanner.html';\",5000);"; server.send(200, "text/html", response); ss2k->rebootFlag = true; } From 3a1794ffed1cddc4f0fb4e75feee8575b42f4340 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Sun, 9 Aug 2026 21:32:38 -0500 Subject: [PATCH 18/29] Enhance inactivity detection logic to reboot after 30 minutes without meaningful pedaling --- src/Main.cpp | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/Main.cpp b/src/Main.cpp index c5352889..d8988e42 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -340,13 +340,19 @@ void SS2K::maintenanceLoop(void* pvParameters) { // Things to do every 6 seconds if ((millis() - intervalTimer2) > 6007) { - // reboot every half hour if not in use. - static int _oldHR = 0; - static int _oldWatts = 0; - static float _oldTargetIncline = 0.0f; - if (_oldHR == rtConfig->hr.getValue() && _oldWatts == rtConfig->watts.getValue() && _oldTargetIncline == rtConfig->getTargetIncline()) { - // Inactivity detected - if (((millis() - rebootTimer) > 1800000)) { + // Reboot after half an hour without meaningful pedaling. Also treat unchanged values as inactive because disconnected servers can leave stale readings behind. + constexpr int inactivityThreshold = 10; + constexpr unsigned long inactivityRebootDelay = 1800000; + static int oldHR = 0; + static int oldWatts = 0; + static int oldCadence = 0; + static float oldTargetIncline = 0.0f; + bool powerAndCadenceAreLow = rtConfig->watts.getValue() < inactivityThreshold && rtConfig->cad.getValue() < inactivityThreshold; + bool readingsAreUnchanged = oldHR == rtConfig->hr.getValue() && oldWatts == rtConfig->watts.getValue() && oldCadence == rtConfig->cad.getValue() && + oldTargetIncline == rtConfig->getTargetIncline(); + bool riderIsInactive = powerAndCadenceAreLow || readingsAreUnchanged; + if (riderIsInactive) { + if ((millis() - rebootTimer) > inactivityRebootDelay) { // Timer expired SS2K_LOG(MAIN_LOG_TAG, "Rebooting due to inactivity."); keepLedOffAfterReboot(); @@ -354,14 +360,14 @@ void SS2K::maintenanceLoop(void* pvParameters) { logHandler.writeLogs(); webSocketAppender.Loop(); } - } else { - // We have activity, update monitored values - _oldHR = rtConfig->hr.getValue(); - _oldWatts = rtConfig->watts.getValue(); - _oldTargetIncline = rtConfig->getTargetIncline(); - rebootTimer = millis(); - ss2k->setLEDEnabled(true); + // Fresh active readings restart the full inactivity window. + oldHR = rtConfig->hr.getValue(); + oldWatts = rtConfig->watts.getValue(); + oldCadence = rtConfig->cad.getValue(); + oldTargetIncline = rtConfig->getTargetIncline(); + rebootTimer = millis(); + ss2k->setLEDEnabled(true); } #ifdef DEBUG_STACK From d659bf7d77bceaf47c8c67ceaf2aad6326ec8575 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Mon, 10 Aug 2026 17:28:46 -0500 Subject: [PATCH 19/29] Add firmware image validation for OTA updates and enhance error handling --- AGENTS.md | 1 + include/FirmwareImageValidation.h | 63 +++++++++++++ src/BLE_Firmware_Update.cpp | 14 +++ src/HTTP_Server_Basic.cpp | 149 ++++++++++++++++++++++++------ 4 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 include/FirmwareImageValidation.h diff --git a/AGENTS.md b/AGENTS.md index d3ea97b9..b2bcc396 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -582,6 +582,7 @@ Changing BLE server characteristics: - `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. +- Firmware OTA paths validate the incoming `esp_image_header_t` chip ID before starting flash writes; filesystem images are intentionally exempt from application-image validation. - Many BLE and motor changes cannot be fully validated without hardware. ## Search Tips diff --git a/include/FirmwareImageValidation.h b/include/FirmwareImageValidation.h new file mode 100644 index 00000000..322d7a7a --- /dev/null +++ b/include/FirmwareImageValidation.h @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved. + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include + +#include +#include + +enum class FirmwareImageHeaderResult { + Valid, + NeedMoreData, + InvalidMagic, + InvalidSegmentCount, + WrongChip, +}; + +struct FirmwareImageHeaderValidation { + FirmwareImageHeaderResult result; + esp_chip_id_t imageChipId; +}; + +inline FirmwareImageHeaderValidation validateFirmwareImageHeader(const uint8_t* data, size_t length) { + if (data == nullptr || length < sizeof(esp_image_header_t)) { + return {FirmwareImageHeaderResult::NeedMoreData, ESP_CHIP_ID_INVALID}; + } + + esp_image_header_t header; + memcpy(&header, data, sizeof(header)); + + if (header.magic != ESP_IMAGE_HEADER_MAGIC) { + return {FirmwareImageHeaderResult::InvalidMagic, header.chip_id}; + } + if (header.segment_count == 0 || header.segment_count > ESP_IMAGE_MAX_SEGMENTS) { + return {FirmwareImageHeaderResult::InvalidSegmentCount, header.chip_id}; + } + if (header.chip_id != static_cast(CONFIG_IDF_FIRMWARE_CHIP_ID)) { + return {FirmwareImageHeaderResult::WrongChip, header.chip_id}; + } + + return {FirmwareImageHeaderResult::Valid, header.chip_id}; +} + +inline const char* firmwareImageHeaderResultName(FirmwareImageHeaderResult result) { + switch (result) { + case FirmwareImageHeaderResult::Valid: + return "valid"; + case FirmwareImageHeaderResult::NeedMoreData: + return "header is incomplete"; + case FirmwareImageHeaderResult::InvalidMagic: + return "invalid image magic"; + case FirmwareImageHeaderResult::InvalidSegmentCount: + return "invalid segment count"; + case FirmwareImageHeaderResult::WrongChip: + return "firmware targets a different chip"; + } + return "unknown validation error"; +} diff --git a/src/BLE_Firmware_Update.cpp b/src/BLE_Firmware_Update.cpp index f92bdb3c..eb43744c 100644 --- a/src/BLE_Firmware_Update.cpp +++ b/src/BLE_Firmware_Update.cpp @@ -8,6 +8,7 @@ #include "Main.h" #include "SS2KLog.h" #include "BLE_Common.h" +#include "FirmwareImageValidation.h" #include #include @@ -47,6 +48,19 @@ class otaCallback : public BLECharacteristicCallbacks { bufferCount++; if (!downloadFlag) { + const FirmwareImageHeaderValidation validation = + validateFirmwareImageHeader(reinterpret_cast(rxData.data()), rxData.length()); + if (validation.result != FirmwareImageHeaderResult::Valid) { + SS2K_LOGE(BLE_OTA_LOG_TAG, "Rejected firmware image: %s (expected chip 0x%04x, image chip 0x%04x)", + firmwareImageHeaderResultName(validation.result), CONFIG_IDF_FIRMWARE_CHIP_ID, static_cast(validation.imageChipId)); + ss2k->isUpdating = false; + downloadFlag = false; + bufferCount = 0; + const uint8_t failureStatus = 0x04; + pTxCharacteristic->notify(&failureStatus, sizeof(failureStatus), connInfo.getConnHandle()); + return; + } + ss2k->isUpdating = true; //----------------------------------------------- // First BLE bytes have arrived diff --git a/src/HTTP_Server_Basic.cpp b/src/HTTP_Server_Basic.cpp index 4d9bdc32..46b1c11e 100644 --- a/src/HTTP_Server_Basic.cpp +++ b/src/HTTP_Server_Basic.cpp @@ -12,6 +12,7 @@ #include "cert.h" #include "SS2KLog.h" #include "DirConManager.h" +#include "FirmwareImageValidation.h" #include #include #include @@ -63,8 +64,59 @@ namespace { const char* OTA_REC_NS = "ota_recover"; // namespace const char* OTA_REC_KEY = "ver"; // key storing last recovered firmware version const char* OTA_FS_VER_KEY = "fs_ver"; // key storing the installed web-filesystem version -bool otaUploadRejected = false; -bool otaFilesystemUpload = false; +bool otaUploadRejected = false; +bool otaFilesystemUpload = false; +bool otaFirmwareUpdateBegun = false; +uint8_t otaFirmwareHeader[sizeof(esp_image_header_t)]; +size_t otaFirmwareHeaderLength = 0; +String otaUploadError; + +void resetFirmwareUploadValidation() { + otaFirmwareUpdateBegun = false; + otaFirmwareHeaderLength = 0; + otaUploadError = ""; +} + +bool beginHttpRequest(HTTPClient& request, NetworkClient& client, const String& url) { + if (request.begin(client, url)) return true; + SS2K_LOGE(HTTP_SERVER_LOG_TAG, "Unable to initialize HTTP request for %s", url.c_str()); + return false; +} + +int beginHttpGet(HTTPClient& request, NetworkClient& client, const String& url) { + if (!beginHttpRequest(request, client, url)) return HTTPC_ERROR_CONNECTION_REFUSED; + return request.GET(); +} + +bool validateRemoteFirmwareHeader(HTTPClient& request, NetworkClient& client, const String& firmwareUrl) { + if (!beginHttpRequest(request, client, firmwareUrl)) return false; + // Some custom update servers may ignore Range and return the entire image. + // Close this connection after reading the header so unread image bytes cannot + // contaminate the subsequent HTTPUpdate request on the shared client. + request.setReuse(false); + request.addHeader("Range", String("bytes=0-") + (sizeof(esp_image_header_t) - 1)); + + const int httpCode = request.GET(); + if (httpCode != HTTP_CODE_OK && httpCode != HTTP_CODE_PARTIAL_CONTENT) { + SS2K_LOGE(HTTP_SERVER_LOG_TAG, "Firmware header request failed for %s: HTTP %d", firmwareUrl.c_str(), httpCode); + request.end(); + return false; + } + + uint8_t headerBytes[sizeof(esp_image_header_t)]; + NetworkClient* stream = request.getStreamPtr(); + const size_t bytesRead = stream->readBytes(headerBytes, sizeof(headerBytes)); + request.end(); + + const FirmwareImageHeaderValidation validation = validateFirmwareImageHeader(headerBytes, bytesRead); + if (validation.result != FirmwareImageHeaderResult::Valid) { + SS2K_LOGE(HTTP_SERVER_LOG_TAG, "Rejected remote firmware image: %s (expected chip 0x%04x, image chip 0x%04x)", + firmwareImageHeaderResultName(validation.result), CONFIG_IDF_FIRMWARE_CHIP_ID, static_cast(validation.imageChipId)); + return false; + } + + return true; +} bool filesystemIndexExists() { return LittleFS.exists("/index.html.gz") || LittleFS.exists("/index.html"); @@ -487,7 +539,8 @@ void HTTP_Server::start() { []() { server.sendHeader("Connection", "close"); if (otaUploadRejected) { - server.send(400, "text/plain", String("Wrong image filename. Expected ") + FW_BINFILE + " or " + FS_BINFILE + "."); + server.send(400, "text/plain", otaUploadError.isEmpty() ? String("Wrong image filename. Expected ") + FW_BINFILE + " or " + FS_BINFILE + "." + : otaUploadError); return; } // Check if the Update process reported an error and send the final status. @@ -525,28 +578,74 @@ void HTTP_Server::start() { []() { HTTPUpload &upload = server.upload(); if (upload.status == UPLOAD_FILE_START) { - otaUploadRejected = false; + otaUploadRejected = false; otaFilesystemUpload = upload.filename == FS_BINFILE; + resetFirmwareUploadValidation(); } if (upload.filename == FW_BINFILE) { if (upload.status == UPLOAD_FILE_START) { ss2k->isUpdating = true; // Set the updating flag to true SS2K_LOG(HTTP_SERVER_LOG_TAG, "Update Start: %s", upload.filename.c_str()); - if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { - Update.printError(Serial); - } } else if (upload.status == UPLOAD_FILE_WRITE) { - /* flashing firmware to ESP*/ - if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { + size_t chunkOffset = 0; + if (!otaFirmwareUpdateBegun && !otaUploadRejected) { + const size_t headerBytesNeeded = sizeof(otaFirmwareHeader) - otaFirmwareHeaderLength; + const size_t headerBytesInChunk = min(headerBytesNeeded, upload.currentSize); + memcpy(otaFirmwareHeader + otaFirmwareHeaderLength, upload.buf, headerBytesInChunk); + otaFirmwareHeaderLength += headerBytesInChunk; + chunkOffset += headerBytesInChunk; + + if (otaFirmwareHeaderLength == sizeof(otaFirmwareHeader)) { + const FirmwareImageHeaderValidation validation = validateFirmwareImageHeader(otaFirmwareHeader, otaFirmwareHeaderLength); + if (validation.result != FirmwareImageHeaderResult::Valid) { + otaUploadRejected = true; + otaUploadError = String("Rejected firmware image: ") + firmwareImageHeaderResultName(validation.result) + "."; + ss2k->isUpdating = false; + SS2K_LOGE(HTTP_SERVER_LOG_TAG, "Rejected uploaded firmware image: %s (expected chip 0x%04x, image chip 0x%04x)", + firmwareImageHeaderResultName(validation.result), CONFIG_IDF_FIRMWARE_CHIP_ID, static_cast(validation.imageChipId)); + return; + } + + if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { + otaUploadRejected = true; + otaUploadError = "Unable to start firmware update."; + ss2k->isUpdating = false; + Update.printError(Serial); + return; + } + otaFirmwareUpdateBegun = true; + + if (Update.write(otaFirmwareHeader, sizeof(otaFirmwareHeader)) != sizeof(otaFirmwareHeader)) { + otaUploadRejected = true; + otaUploadError = "Failed to write firmware image header."; + Update.printError(Serial); + return; + } + } + } + + if (!otaUploadRejected && otaFirmwareUpdateBegun && chunkOffset < upload.currentSize && + Update.write(upload.buf + chunkOffset, upload.currentSize - chunkOffset) != upload.currentSize - chunkOffset) { + otaUploadRejected = true; + otaUploadError = "Firmware upload write failed."; Update.printError(Serial); SS2K_LOG(HTTP_SERVER_LOG_TAG, "Upload Write Failed."); } } else if (upload.status == UPLOAD_FILE_END) { // Finalize the update. The true parameter tells it to flash the remaining buffer. // DO NOT send a response here. - if (Update.end(true)) { + if (otaUploadRejected) { + if (otaFirmwareUpdateBegun) Update.abort(); + ss2k->isUpdating = false; + } else if (!otaFirmwareUpdateBegun) { + otaUploadRejected = true; + if (otaUploadError.isEmpty()) otaUploadError = "Firmware image header is incomplete."; + ss2k->isUpdating = false; + } else if (Update.end(true)) { SS2K_LOG(HTTP_SERVER_LOG_TAG, "Firmware Upload Finished Successfully."); } else { + otaUploadRejected = true; + otaUploadError = "Firmware image validation failed."; Update.printError(Serial); SS2K_LOG(HTTP_SERVER_LOG_TAG, "Unknown OTA issue on end."); } @@ -574,6 +673,7 @@ void HTTP_Server::start() { } else if (upload.filename.endsWith(".bin")) { if (upload.status == UPLOAD_FILE_START) { otaUploadRejected = true; + otaUploadError = String("Wrong image filename. Expected ") + FW_BINFILE + " or " + FS_BINFILE + "."; SS2K_LOG(HTTP_SERVER_LOG_TAG, "Rejected image %s; expected %s or %s", upload.filename.c_str(), FW_BINFILE, FS_BINFILE); } } else { // Handles other file uploads to LittleFS @@ -843,11 +943,7 @@ void HTTP_Server::FirmwareUpdate() { WiFiClientSecure localClient; localClient.setCACert(rootCACertificate); SS2K_LOG(HTTP_SERVER_LOG_TAG, "Checking for newer firmware:"); - http.begin(userConfig->getFirmwareUpdateURL() + String(FW_VERSIONFILE), - rootCACertificate); // check version URL - delay(100); - int httpCode = http.GET(); // get data from version file - delay(100); + int httpCode = beginHttpGet(http, localClient, userConfig->getFirmwareUpdateURL() + String(FW_VERSIONFILE)); String payload; if (httpCode == HTTP_CODE_OK) { // if version received payload = http.getString(); // save received version @@ -886,16 +982,10 @@ void HTTP_Server::FirmwareUpdate() { if (filesystemNeedsUpdate) { //////////////// Update LittleFS////////////// SS2K_LOG(HTTP_SERVER_LOG_TAG, "Updating FileSystem"); - http.begin(DATA_UPDATEURL DATA_FILELIST, rootCACertificate); // check version URL - delay(100); - httpCode = http.GET(); // get data from version file - delay(100); + httpCode = beginHttpGet(http, localClient, DATA_UPDATEURL DATA_FILELIST); JsonDocument doc; if (httpCode == HTTP_CODE_OK) { // if version received - payload = http.getString(); // save received version - payload.trim(); - // Deserialize the JSON document - DeserializationError error = deserializeJson(doc, payload); + DeserializationError error = deserializeJson(doc, http.getStream()); if (error) { SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to read file list"); http.end(); // Make sure to end HTTP before returning @@ -919,11 +1009,7 @@ void HTTP_Server::FirmwareUpdate() { // iterate through file list and download files individually for (JsonVariant v : files) { String fileName = "/" + v.as(); - http.begin(DATA_UPDATEURL + fileName, - rootCACertificate); // check version URL - delay(100); - httpCode = http.GET(); - delay(100); + httpCode = beginHttpGet(http, localClient, DATA_UPDATEURL + fileName); if (httpCode == HTTP_CODE_OK) { LittleFS.remove(fileName); File file = LittleFS.open(fileName, FILE_WRITE, true); @@ -972,7 +1058,12 @@ void HTTP_Server::FirmwareUpdate() { if (((availableVer > currentVer) || updateAnyway) && (userConfig->getAutoUpdate())) { SS2K_LOG(HTTP_SERVER_LOG_TAG, "New firmware detected!"); SS2K_LOG(HTTP_SERVER_LOG_TAG, "Upgrading from %s to %s", FIRMWARE_VERSION, serverVersion.c_str()); - t_httpUpdate_return ret = httpUpdate.update(localClient, userConfig->getFirmwareUpdateURL() + String(FW_BINFILE)); + const String firmwareUrl = userConfig->getFirmwareUpdateURL() + String(FW_BINFILE); + if (!validateRemoteFirmwareHeader(http, localClient, firmwareUrl)) { + SS2K_LOGE(HTTP_SERVER_LOG_TAG, "Firmware update cancelled before flashing because image validation failed"); + return; + } + t_httpUpdate_return ret = httpUpdate.update(localClient, firmwareUrl); switch (ret) { case HTTP_UPDATE_FAILED: SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_FAILED Error %d : %s", httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str()); From 4f7aa5b64dfc0e379e3cc4c2aa4ae63a2228d839 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Mon, 10 Aug 2026 18:15:36 -0500 Subject: [PATCH 20/29] Increase BLE reconnect scan interval and default scan duration --- include/settings.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/settings.h b/include/settings.h index 7c37bfed..e1af9c92 100644 --- a/include/settings.h +++ b/include/settings.h @@ -289,10 +289,10 @@ constexpr const char* ANY = "any"; #define HOMING_MAX_SENSITIVITY 100 // BLE automatic reconnect interval in milliseconds. -#define BLE_RECONNECT_SCAN_INTERVAL 6000 +#define BLE_RECONNECT_SCAN_INTERVAL 8000 // Initial and web scan duration in milliseconds -#define DEFAULT_SCAN_DURATION 4000 +#define DEFAULT_SCAN_DURATION 5000 // Task Stack Sizes // In theory you can subtract whatever is left in the report from DEBUG_STACK for each task From f3bf52bcf6f21f41393dc2fbb48d19176867419d Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Tue, 11 Aug 2026 10:17:10 -0500 Subject: [PATCH 21/29] Implement case-insensitive BLE device identifier matching and add corresponding tests --- AGENTS.md | 1 + include/BLE_Device_Identity.h | 24 ++++++++++++++++++++++++ src/BLE_Client.cpp | 23 ++++++++++++----------- test/test.h | 1 + test/test_adevName2UniqueName.cpp | 11 ++++++++++- test/test_unity.cpp | 3 ++- 6 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 include/BLE_Device_Identity.h diff --git a/AGENTS.md b/AGENTS.md index b2bcc396..1741adfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -577,6 +577,7 @@ Changing BLE server characteristics: - `Measurement` timestamps matter for ERG deduplication. - `PowerTable` stores positions divided by `TABLE_DIVISOR`; lookup returns full-scale positions. - `PowerTable` persistence requires homing. +- Saved BLE device identifiers are matched case-insensitively because NimBLE address formatting has changed between lowercase and uppercase across library versions. - `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. diff --git a/include/BLE_Device_Identity.h b/include/BLE_Device_Identity.h new file mode 100644 index 00000000..a6290fd4 --- /dev/null +++ b/include/BLE_Device_Identity.h @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved. + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +inline char foldBleIdentifierAscii(char value) { + return (value >= 'A' && value <= 'Z') ? static_cast(value + ('a' - 'A')) : value; +} + +inline bool bleDeviceIdentifierEquals(const char* left, const char* right) { + if (left == nullptr || right == nullptr) return left == right; + + while (*left != '\0' && *right != '\0') { + if (foldBleIdentifierAscii(*left) != foldBleIdentifierAscii(*right)) return false; + ++left; + ++right; + } + + return *left == *right; +} diff --git a/src/BLE_Client.cpp b/src/BLE_Client.cpp index 78a951a3..604304e8 100644 --- a/src/BLE_Client.cpp +++ b/src/BLE_Client.cpp @@ -7,6 +7,7 @@ #include "Main.h" #include "BLE_Common.h" +#include "BLE_Device_Identity.h" #include "BLE_Fitness_Machine_Service.h" #include "SS2KLog.h" @@ -416,7 +417,7 @@ void ScanCallbacks::onResult(const NimBLEAdvertisedDevice* advertisedDevice) { bool isDuplicateLocal = false; for (JsonPair kv : devices.as()) { JsonObject obj = kv.value().as(); - if (obj["name"] && obj["name"] == aDevName) { + if (obj["name"] && bleDeviceIdentifierEquals(obj["name"].as(), aDevName.c_str())) { isDuplicateLocal = true; break; } @@ -441,7 +442,7 @@ void ScanCallbacks::onResult(const NimBLEAdvertisedDevice* advertisedDevice) { // check to see if we're already connected to this device for (size_t i = 0; i < NUM_BLE_DEVICES; i++) { if (spinBLEClient.myBLEDevices[i].advertisedDevice != nullptr) { - if (aDevName == String(spinBLEClient.myBLEDevices[i].uniqueName.c_str())) { + if (bleDeviceIdentifierEquals(aDevName.c_str(), spinBLEClient.myBLEDevices[i].uniqueName.c_str())) { SS2K_LOG(BLE_CLIENT_LOG_TAG, "%s already connected on slot %d", aDevName.c_str(), i); return; // Already connected to this device } @@ -452,8 +453,8 @@ void ScanCallbacks::onResult(const NimBLEAdvertisedDevice* advertisedDevice) { if (strcmp(userConfig->getConnectedRemote(), ANY) == 0) { SS2K_LOG(BLE_CLIENT_LOG_TAG, "%s %s%s", aDevName.c_str(), REMOTE, STRING_MATCHED_ANY); } else { - bool nameMatched = (aDevName == userConfig->getConnectedRemote()) ? true : false; - bool addrMatched = strcmp(aDevAddr, userConfig->getConnectedRemote()) == 0; + bool nameMatched = bleDeviceIdentifierEquals(aDevName.c_str(), userConfig->getConnectedRemote()); + bool addrMatched = bleDeviceIdentifierEquals(aDevAddr, userConfig->getConnectedRemote()); if (!nameMatched && !addrMatched || strcmp(userConfig->getConnectedRemote(), NONE) == 0) { SS2K_LOG(BLE_CLIENT_LOG_TAG, "%s %s%s%s", REMOTE, aDevName.c_str(), DIDNT_MATCH_THE_SAVED, userConfig->getConnectedRemote()); return; // Ignore this device; @@ -465,8 +466,8 @@ void ScanCallbacks::onResult(const NimBLEAdvertisedDevice* advertisedDevice) { if (strcmp(userConfig->getConnectedHeartMonitor(), ANY) == 0) { SS2K_LOG(BLE_CLIENT_LOG_TAG, "%s %s%s", aDevName.c_str(), HRM, STRING_MATCHED_ANY); } else { - bool nameMatched = (aDevName == userConfig->getConnectedHeartMonitor()) ? true : false; - bool addrMatched = strcmp(aDevAddr, userConfig->getConnectedHeartMonitor()) == 0; + bool nameMatched = bleDeviceIdentifierEquals(aDevName.c_str(), userConfig->getConnectedHeartMonitor()); + bool addrMatched = bleDeviceIdentifierEquals(aDevAddr, userConfig->getConnectedHeartMonitor()); if (!nameMatched && !addrMatched || strcmp(userConfig->getConnectedHeartMonitor(), NONE) == 0) { SS2K_LOG(BLE_CLIENT_LOG_TAG, "%s %s%s%s", HRM, aDevName.c_str(), DIDNT_MATCH_THE_SAVED, userConfig->getConnectedHeartMonitor()); return; // Ignore this device; @@ -479,7 +480,7 @@ void ScanCallbacks::onResult(const NimBLEAdvertisedDevice* advertisedDevice) { if (strcmp(userConfig->getConnectedPowerMeter(), ANY) == 0) { SS2K_LOG(BLE_CLIENT_LOG_TAG, "%s, %s%s", aDevName.c_str(), PM, STRING_MATCHED_ANY); } else { - bool nameMatched = (aDevName == userConfig->getConnectedPowerMeter()) ? true : false; + bool nameMatched = bleDeviceIdentifierEquals(aDevName.c_str(), userConfig->getConnectedPowerMeter()); if (!nameMatched || strcmp(userConfig->getConnectedPowerMeter(), NONE) == 0) { SS2K_LOG(BLE_CLIENT_LOG_TAG, "%s %s%s%s", PM, aDevName.c_str(), DIDNT_MATCH_THE_SAVED, userConfig->getConnectedPowerMeter()); return; // Ignore this device; @@ -500,7 +501,7 @@ void ScanCallbacks::onResult(const NimBLEAdvertisedDevice* advertisedDevice) { // Check if this is the same device using stable identifier if (!spinBLEClient.myBLEDevices[i].uniqueName.empty()) { // Use unique name comparison for stable identification - deviceMatches = (aDevName == String(spinBLEClient.myBLEDevices[i].uniqueName.c_str())); + deviceMatches = bleDeviceIdentifierEquals(aDevName.c_str(), spinBLEClient.myBLEDevices[i].uniqueName.c_str()); } else { // Fall back to address comparison for backward compatibility deviceMatches = (advertisedDevice->getAddress() == spinBLEClient.myBLEDevices[i].peerAddress); @@ -897,7 +898,7 @@ void SpinBLEClient::checkBLEReconnect() { // Helper lambda to check if a device with the given name is currently connected auto isDeviceConnected = [&](const char* configName) -> bool { for (auto& _BLEd : spinBLEClient.myBLEDevices) { - if (_BLEd.isPostConnected && _BLEd.uniqueName == configName) { + if (_BLEd.isPostConnected && bleDeviceIdentifierEquals(_BLEd.uniqueName.c_str(), configName)) { return true; } } @@ -1075,8 +1076,8 @@ void SpinBLEAdvertisedDevice::set(const NimBLEAdvertisedDevice* device, int id, const bool cfgHrmIsNone = (strcmp(cfgHRM, NONE) == 0); const bool cfgHrmIsAny = (strcmp(cfgHRM, ANY) == 0); const std::string addrStr = device->getAddress().toString(); - const bool hrmNameMatch = (adevName == cfgHRM); - const bool hrmAddrMatch = (addrStr == cfgHRM); + const bool hrmNameMatch = bleDeviceIdentifierEquals(adevName.c_str(), cfgHRM); + const bool hrmAddrMatch = bleDeviceIdentifierEquals(addrStr.c_str(), cfgHRM); // Get all services const std::vector& services = pClient->getServices(true); diff --git a/test/test.h b/test/test.h index 54a45ddc..428a32d0 100644 --- a/test/test.h +++ b/test/test.h @@ -55,4 +55,5 @@ class TestAdevName2UniqueName { static void test_null_device_handling(void); static void test_device_without_name(void); static void test_backward_compatibility(void); + static void test_case_insensitive_device_matching(void); }; diff --git a/test/test_adevName2UniqueName.cpp b/test/test_adevName2UniqueName.cpp index 7816a758..b3124ba3 100644 --- a/test/test_adevName2UniqueName.cpp +++ b/test/test_adevName2UniqueName.cpp @@ -7,6 +7,7 @@ #include #include +#include "BLE_Device_Identity.h" #include "test.h" #include "settings.h" #include @@ -204,4 +205,12 @@ void TestAdevName2UniqueName::test_backward_compatibility() { TEST_ASSERT_EQUAL_STRING("TraditionalDevice 55", devices[0].uniqueName.c_str()); TEST_ASSERT_EQUAL_STRING("AndroidDevice", devices[1].uniqueName.c_str()); TEST_ASSERT_EQUAL_STRING("", devices[2].uniqueName.c_str()); // Empty slot -} \ No newline at end of file +} + +void TestAdevName2UniqueName::test_case_insensitive_device_matching() { + TEST_ASSERT_TRUE(bleDeviceIdentifierEquals("IC BIKE D0", "IC BIKE d0")); + TEST_ASSERT_TRUE(bleDeviceIdentifierEquals("aa:bb:cc:dd:ee:d0", "AA:BB:CC:DD:EE:D0")); + TEST_ASSERT_FALSE(bleDeviceIdentifierEquals("IC BIKE D0", "IC BIKE D1")); + TEST_ASSERT_FALSE(bleDeviceIdentifierEquals("IC BIKE D0", "OTHER BIKE d0")); + TEST_ASSERT_FALSE(bleDeviceIdentifierEquals("IC BIKE D0", nullptr)); +} diff --git a/test/test_unity.cpp b/test/test_unity.cpp index 31f43996..f32de69b 100644 --- a/test/test_unity.cpp +++ b/test/test_unity.cpp @@ -82,6 +82,7 @@ void setup() { RUN_TEST(test.test_null_device_handling); RUN_TEST(test.test_device_without_name); RUN_TEST(test.test_backward_compatibility); + RUN_TEST(test.test_case_insensitive_device_matching); } UNITY_END(); @@ -123,4 +124,4 @@ int main(int argc, char** argv) { setup(); return 0; } -#endif \ No newline at end of file +#endif From f9924a46037244bfe039bbc25bb713648e75390b Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Tue, 11 Aug 2026 17:23:21 -0500 Subject: [PATCH 22/29] Enhance BLE advertisement to include current WiFi IPv4 address and update DirCon service handling for settings snapshots --- AGENTS.md | 4 + CustomCharacteristic.md | 25 +++++- include/BLE_Custom_Characteristic.h | 2 +- include/DirConManager.h | 6 +- src/BLE_Custom_Characteristic.cpp | 122 +++++++++++++++++++++++----- src/BLE_Server.cpp | 28 ++++++- src/DirConManager.cpp | 4 +- 7 files changed, 158 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1741adfc..1f66ced7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -285,6 +285,9 @@ Primary files: `src/BLE_Server.cpp`, `src/BLE_Fitness_Machine_Service.cpp`, serv - Device Information - BLE firmware update +The primary BLE advertisement carries the current WiFi IPv4 address in versioned SmartSpin2k manufacturer data. +The device name and 128-bit SmartSpin2k service UUID are kept in the scan response to stay within the legacy advertisement size limit. + 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. @@ -481,6 +484,7 @@ DirCon exposes BLE-like services over TCP: - 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. +- The SmartSpin2k custom service registers a write handler so its request/response protocol also works over DirCon; changed-value notifications and chunked all-settings snapshots are mirrored over TCP. - 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. diff --git a/CustomCharacteristic.md b/CustomCharacteristic.md index aeba3017..20ee7342 100644 --- a/CustomCharacteristic.md +++ b/CustomCharacteristic.md @@ -5,6 +5,20 @@ Custom Characteristic for userConfig Variable manipulation via BLE SMARTSPIN2K_SERVICE_UUID "77776277-7877-7774-4466-896665500000" SMARTSPIN2K_CHARACTERISTIC_UUID "77776277-7877-7774-4466-896665500001" +The same service and characteristic are published in the DirCon mDNS service. +A DirCon client sends the protocol bytes in a characteristic-write request and receives the custom-characteristic response bytes in that write response. +Subscribed DirCon clients also receive changed-value notifications. + +The primary BLE advertisement includes the current Wi-Fi IPv4 address in manufacturer-specific data. +The device name and SmartSpin2k service UUID remain in the scan response. The payload is: + +| Offset | Size | Meaning | +|--------|------|---------| +| 0 | 2 | Reserved development company identifier `0xFFFF`, little-endian | +| 2 | 2 | ASCII payload marker `SS` | +| 4 | 1 | Payload format version (`0x01`) | +| 5 | 4 | IPv4 address octets in network/display order | + An example follows to read/write 26.3kph to simulatedSpeed: simulatedSpeed is a float and first needs to be converted to int by *10 for transmission, so convert 26.3kph to 263 (multiply by 10) @@ -94,11 +108,14 @@ Hardware-version example: - An ESP32-S3 board indicates: `0x80, 0x2F`, followed by the ASCII bytes for `Revision Three (ESP32-S3)`. - Writes to `0x2F` return `cc_error` because the detected hardware revision is read-only. -All-settings snapshot: +All-settings snapshot (BLE or DirCon): -- Client writes `0x01, 0x31` and subscribes to indications on the custom characteristic. -- The server serializes `userConfig->returnJSON()` once, then sends MTU-sized indications sequentially. Each indication is acknowledged before the next is sent. -- Every indication begins with this seven-byte header: +- Client writes `0x01, 0x31`. BLE clients subscribe to indications on the custom characteristic. + A DirCon client receives the first chunk in the characteristic-write response and is automatically subscribed for the remaining chunks. +- The server serializes `userConfig->returnJSON()` once. + Over BLE, it sends MTU-sized indications sequentially and waits for each acknowledgement before sending the next. +- Over DirCon, chunks use the same framing and arrive as characteristic notifications after the first write-response chunk. +- Every snapshot chunk begins with this seven-byte header: | Offset | Size | Meaning | |--------|------|---------| diff --git a/include/BLE_Custom_Characteristic.h b/include/BLE_Custom_Characteristic.h index 9da6ad11..1673c46d 100644 --- a/include/BLE_Custom_Characteristic.h +++ b/include/BLE_Custom_Characteristic.h @@ -72,7 +72,7 @@ class BLE_ss2kCustomCharacteristic { void setupService(NimBLEServer *pServer); void update(); // Used internally for notify and onWrite Callback. - static void process(std::string rxValue, uint16_t connHandle = BLE_HS_CONN_HANDLE_NONE, uint16_t mtu = 23); + static void process(std::string rxValue, uint16_t connHandle = BLE_HS_CONN_HANDLE_NONE, uint16_t mtu = 23, bool indicateResponse = true); // Custom Characteristic value that needs to be notified static void notify(char _item, int tableRow = -1); // Notify any changed value in userConfig diff --git a/include/DirConManager.h b/include/DirConManager.h index 80b84cc8..6123992a 100644 --- a/include/DirConManager.h +++ b/include/DirConManager.h @@ -18,7 +18,7 @@ #define DIRCON_MDNS_SERVICE_NAME "_wahoo-fitness-tnp" #define DIRCON_MDNS_SERVICE_PROTOCOL "tcp" #define DIRCON_TCP_PORT 8081 -#define DIRCON_MAX_CLIENTS 1 +#define DIRCON_MAX_CLIENTS 3 #define DIRCON_RECEIVE_BUFFER_SIZE 256 #define DIRCON_SEND_BUFFER_SIZE 256 #define DIRCON_MAX_CHARACTERISTICS 20 // maximum number of characteristics to track for subscriptions @@ -54,7 +54,7 @@ class DirConManager { static void registerService(const NimBLEUUID& serviceUuid, DirConWriteHandler writeHandler = nullptr, DirConAdvertiseHandler advertiseHandler = nullptr); // Notify DirCon clients about BLE characteristic changes - static void notifyCharacteristic(const NimBLEUUID& serviceUuid, const NimBLEUUID& characteristicUuid, uint8_t* data, size_t length, bool onlySubscribers = true); + static void notifyCharacteristic(const NimBLEUUID& serviceUuid, const NimBLEUUID& characteristicUuid, const uint8_t* data, size_t length, bool onlySubscribers = true); private: // Service registration @@ -86,7 +86,7 @@ class DirConManager { static bool processDirConMessage(DirConMessage* message, size_t clientIndex); static void sendErrorResponse(uint8_t messageId, uint8_t sequenceNumber, uint8_t errorCode, size_t clientIndex); static void sendResponse(DirConMessage* message, size_t clientIndex); - static void broadcastNotification(const NimBLEUUID& characteristicUuid, uint8_t* data, size_t length, bool onlySubscribers = true); + static void broadcastNotification(const NimBLEUUID& characteristicUuid, const uint8_t* data, size_t length, bool onlySubscribers = true); // Service and characteristic handling static void addBleServiceUuid(const NimBLEUUID& serviceUuid); diff --git a/src/BLE_Custom_Characteristic.cpp b/src/BLE_Custom_Characteristic.cpp index 3b0f52a2..260e6f93 100644 --- a/src/BLE_Custom_Characteristic.cpp +++ b/src/BLE_Custom_Characteristic.cpp @@ -89,6 +89,7 @@ This characteristic allows for reading and writing various user configuration pa #include #include #include "BleAppender.h" +#include "DirConManager.h" #include #include @@ -98,6 +99,7 @@ namespace { constexpr uint8_t SETTINGS_SNAPSHOT_VERSION = 1; constexpr size_t SETTINGS_SNAPSHOT_HEADER_LENGTH = 7; constexpr unsigned long SETTINGS_SNAPSHOT_TIMEOUT_MILLIS = 5000; +constexpr size_t DIRCON_SETTINGS_SNAPSHOT_PAYLOAD_LENGTH = 200; struct SettingsSnapshotTransfer { std::string json; @@ -111,36 +113,44 @@ struct SettingsSnapshotTransfer { }; SettingsSnapshotTransfer settingsSnapshot; +SettingsSnapshotTransfer dirConSettingsSnapshot; void resetSettingsSnapshot() { settingsSnapshot = SettingsSnapshotTransfer(); } -bool sendNextSettingsSnapshotChunk(NimBLECharacteristic* characteristic) { - if (!settingsSnapshot.active || settingsSnapshot.chunk >= settingsSnapshot.chunkCount) return false; - - size_t remaining = settingsSnapshot.json.length() - settingsSnapshot.offset; - size_t payloadSize = std::min(settingsSnapshot.payloadLength, remaining); +std::vector makeSettingsSnapshotChunk(SettingsSnapshotTransfer& snapshot) { + size_t remaining = snapshot.json.length() - snapshot.offset; + size_t payloadSize = std::min(snapshot.payloadLength, remaining); std::vector packet(SETTINGS_SNAPSHOT_HEADER_LENGTH + payloadSize); packet[0] = cc_success; packet[1] = BLE_allSettings; packet[2] = SETTINGS_SNAPSHOT_VERSION; - packet[3] = static_cast(settingsSnapshot.chunk & 0xff); - packet[4] = static_cast(settingsSnapshot.chunk >> 8); - packet[5] = static_cast(settingsSnapshot.chunkCount & 0xff); - packet[6] = static_cast(settingsSnapshot.chunkCount >> 8); - std::copy_n(settingsSnapshot.json.data() + settingsSnapshot.offset, payloadSize, packet.begin() + SETTINGS_SNAPSHOT_HEADER_LENGTH); + packet[3] = static_cast(snapshot.chunk & 0xff); + packet[4] = static_cast(snapshot.chunk >> 8); + packet[5] = static_cast(snapshot.chunkCount & 0xff); + packet[6] = static_cast(snapshot.chunkCount >> 8); + std::copy_n(snapshot.json.data() + snapshot.offset, payloadSize, packet.begin() + SETTINGS_SNAPSHOT_HEADER_LENGTH); + + snapshot.offset += payloadSize; + snapshot.chunk++; + snapshot.lastActivity = millis(); + return packet; +} + +bool sendNextSettingsSnapshotChunk(NimBLECharacteristic* characteristic) { + if (!settingsSnapshot.active || settingsSnapshot.chunk >= settingsSnapshot.chunkCount) return false; + + uint16_t chunkNumber = settingsSnapshot.chunk; + std::vector packet = makeSettingsSnapshotChunk(settingsSnapshot); if (!characteristic->indicate(packet.data(), packet.size(), settingsSnapshot.connHandle)) { - SS2K_LOGE(CUSTOM_CHAR_LOG_TAG, "Failed to send settings snapshot chunk %u/%u", static_cast(settingsSnapshot.chunk + 1), + SS2K_LOGE(CUSTOM_CHAR_LOG_TAG, "Failed to send settings snapshot chunk %u/%u", static_cast(chunkNumber + 1), static_cast(settingsSnapshot.chunkCount)); resetSettingsSnapshot(); return false; } - settingsSnapshot.offset += payloadSize; - settingsSnapshot.chunk++; - settingsSnapshot.lastActivity = millis(); return true; } @@ -193,6 +203,28 @@ void handleSettingsSnapshotStatus(NimBLECharacteristic* characteristic, int code sendNextSettingsSnapshotChunk(characteristic); } + +void startDirConSettingsSnapshot(NimBLECharacteristic* characteristic) { + String json = userConfig->returnJSON(); + size_t chunkCount = (json.length() + DIRCON_SETTINGS_SNAPSHOT_PAYLOAD_LENGTH - 1) / DIRCON_SETTINGS_SNAPSHOT_PAYLOAD_LENGTH; + if (chunkCount == 0 || chunkCount > UINT16_MAX) { + const uint8_t error[] = {cc_error, BLE_allSettings}; + characteristic->setValue(error, sizeof(error)); + return; + } + + dirConSettingsSnapshot = SettingsSnapshotTransfer(); + dirConSettingsSnapshot.json = std::string(json.c_str(), json.length()); + dirConSettingsSnapshot.payloadLength = DIRCON_SETTINGS_SNAPSHOT_PAYLOAD_LENGTH; + dirConSettingsSnapshot.chunkCount = static_cast(chunkCount); + dirConSettingsSnapshot.lastActivity = millis(); + dirConSettingsSnapshot.active = true; + + // The first chunk is returned in the DirCon write response. Remaining chunks + // are sent as notifications from update(), after the client is subscribed. + std::vector packet = makeSettingsSnapshotChunk(dirConSettingsSnapshot); + characteristic->setValue(packet.data(), packet.size()); +} } // namespace void BLE_ss2kCustomCharacteristic::setupService(NimBLEServer *pServer) { @@ -202,9 +234,44 @@ void BLE_ss2kCustomCharacteristic::setupService(NimBLEServer *pServer) { smartSpin2kCharacteristic->setValue(ss2kCustomCharacteristicValue, sizeof(ss2kCustomCharacteristicValue)); smartSpin2kCharacteristic->setCallbacks(new ss2kCustomCharacteristicCallbacks()); pSmartSpin2kService->start(); + + DirConManager::registerService(pSmartSpin2kService->getUUID(), [](NimBLECharacteristic* characteristic, const uint8_t* data, size_t length, DirConWriteResult* result) -> bool { + if (!characteristic->getUUID().equals(SMARTSPIN2K_CHARACTERISTIC_UUID)) return false; + + if (length < 2) { + const uint8_t error[] = {cc_error}; + characteristic->setValue(error, sizeof(error)); + result->updateResponseData = true; + return true; + } + + std::string request(reinterpret_cast(data), length); + BLE_ss2kCustomCharacteristic::process(request, BLE_HS_CONN_HANDLE_NONE, 23, false); + result->updateResponseData = true; + if (length >= 2 && data[0] == cc_read && data[1] == BLE_allSettings) { + result->autoSubscribeUuids[0] = characteristic->getUUID(); + result->autoSubscribeCount = 1; + } + return true; + }); } -void BLE_ss2kCustomCharacteristic::update() {} +void BLE_ss2kCustomCharacteristic::update() { + if (!dirConSettingsSnapshot.active) return; + + if (dirConSettingsSnapshot.chunk >= dirConSettingsSnapshot.chunkCount) { + dirConSettingsSnapshot = SettingsSnapshotTransfer(); + return; + } + + std::vector packet = makeSettingsSnapshotChunk(dirConSettingsSnapshot); + smartSpin2kCharacteristic->setValue(packet.data(), packet.size()); + DirConManager::notifyCharacteristic(pSmartSpin2kService->getUUID(), smartSpin2kCharacteristic->getUUID(), packet.data(), packet.size()); + + if (dirConSettingsSnapshot.chunk >= dirConSettingsSnapshot.chunkCount) { + dirConSettingsSnapshot = SettingsSnapshotTransfer(); + } +} void ss2kCustomCharacteristicCallbacks::onWrite(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) { std::string rxValue = pCharacteristic->getValue(); @@ -232,15 +299,24 @@ void ss2kCustomCharacteristicCallbacks::onStatus(NimBLECharacteristic *pCharacte } void BLE_ss2kCustomCharacteristic::notify(char _item, int tableRow) { + if (settingsSnapshot.active || dirConSettingsSnapshot.active) return; + // regular non power table update std::string returnValue = {cc_read, _item}; if (tableRow > -1) { returnValue += (uint8_t)tableRow; } process(returnValue); + + NimBLEService* service = NimBLEDevice::getServer()->getServiceByUUID(SMARTSPIN2K_SERVICE_UUID); + if (service == nullptr) return; + NimBLECharacteristic* characteristic = service->getCharacteristic(SMARTSPIN2K_CHARACTERISTIC_UUID); + if (characteristic == nullptr) return; + NimBLEAttValue value = characteristic->getValue(); + DirConManager::notifyCharacteristic(service->getUUID(), characteristic->getUUID(), value.data(), value.size()); } -void BLE_ss2kCustomCharacteristic::process(std::string rxValue, uint16_t connHandle, uint16_t mtu) { +void BLE_ss2kCustomCharacteristic::process(std::string rxValue, uint16_t connHandle, uint16_t mtu, bool indicateResponse) { // Find the Characteristic if (rxValue.length() < 2 || NimBLEDevice::getServer()->getServiceByUUID(SMARTSPIN2K_SERVICE_UUID) == nullptr) { return; @@ -248,7 +324,11 @@ void BLE_ss2kCustomCharacteristic::process(std::string rxValue, uint16_t connHan NimBLECharacteristic *pCharacteristic = NimBLEDevice::getServer()->getServiceByUUID(SMARTSPIN2K_SERVICE_UUID)->getCharacteristic(SMARTSPIN2K_CHARACTERISTIC_UUID); if (rxValue[0] == cc_read && static_cast(rxValue[1]) == BLE_allSettings) { - startSettingsSnapshot(pCharacteristic, connHandle, mtu); + if (indicateResponse) { + startSettingsSnapshot(pCharacteristic, connHandle, mtu); + } else { + startDirConSettingsSnapshot(pCharacteristic); + } return; } @@ -268,7 +348,7 @@ void BLE_ss2kCustomCharacteristic::process(std::string rxValue, uint16_t connHan #endif size_t returnLength = rxValue.length(); - uint8_t returnValue[returnLength]; + std::vector returnValue(std::max(returnLength + 4, 6), 0); std::string returnString = ""; returnValue[0] = cc_error; for (size_t i = 1; i < returnLength; i++) { @@ -976,7 +1056,7 @@ void BLE_ss2kCustomCharacteristic::process(std::string rxValue, uint16_t connHan SS2K_LOG(CUSTOM_CHAR_LOG_TAG, "%s", logBuf); #endif if (returnString == "") { - pCharacteristic->setValue(returnValue, returnLength); + pCharacteristic->setValue(returnValue.data(), returnLength); } else { // Need to send a string instead uint8_t returnChar[returnString.length() + 2]; returnChar[0] = cc_success; @@ -987,7 +1067,9 @@ void BLE_ss2kCustomCharacteristic::process(std::string rxValue, uint16_t connHan pCharacteristic->setValue(returnChar, returnString.length() + 2); } - pCharacteristic->indicate(connHandle); + if (indicateResponse) { + pCharacteristic->indicate(connHandle); + } } // iterate through all smartspin user parameters and notify the specific one if changed diff --git a/src/BLE_Server.cpp b/src/BLE_Server.cpp index 883cad81..918fe0cd 100644 --- a/src/BLE_Server.cpp +++ b/src/BLE_Server.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include "BLE_Cycling_Speed_Cadence.h" @@ -38,6 +39,26 @@ BLE_OpenBikeControl_Service openBikeControlService; // BLE_Wattbike_Service wattbikeService; // BLE_SB20_Service sb20Service; +namespace { +constexpr uint8_t SMARTSPIN2K_IP_ADVERTISEMENT_VERSION = 1; + +void addIpAddressToAdvertisement(NimBLEAdvertising* advertising) { + IPAddress ipAddress = WiFi.status() == WL_CONNECTED ? WiFi.localIP() : WiFi.softAPIP(); + const uint8_t manufacturerData[] = { + 0xff, 0xff, // Reserved Bluetooth SIG company identifier for development/testing. + 'S', 'S', // SmartSpin2k payload marker. + SMARTSPIN2K_IP_ADVERTISEMENT_VERSION, + ipAddress[0], ipAddress[1], ipAddress[2], ipAddress[3], + }; + + if (advertising->setManufacturerData(manufacturerData, sizeof(manufacturerData))) { + SS2K_LOG(BLE_SERVER_LOG_TAG, "Advertising WiFi IP address %s", ipAddress.toString().c_str()); + } else { + SS2K_LOGW(BLE_SERVER_LOG_TAG, "Unable to fit WiFi IP address in BLE advertisement data"); + } +} +} // namespace + void startBLEServer() { // Server Setup SS2K_LOG(BLE_SERVER_LOG_TAG, "Starting BLE Server"); @@ -68,9 +89,9 @@ void startBLEServer() { // pAdvertising->addServiceUUID(OPENBIKECONTROL_SERVICE_UUID); // Garmin devices need this in the primary advertisement to recognize the device as a cycling power sensor. pAdvertising->setAppearance(0x0484); // Cycling Power Sensor, per https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ - // Put the device name and SmartSpin2k service UUID in the scan response to avoid - // overflowing the primary ad packet (which already carries manufacturer data + service UUIDs). - pAdvertising->setName(userConfig->getDeviceName()); //Due to cutbacks in other data, adding this to primary advertisement for now as well. + // 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()); oScanResponseData.setCompleteServices(SMARTSPIN2K_SERVICE_UUID); pAdvertising->setScanResponseData(oScanResponseData); @@ -96,6 +117,7 @@ void SpinBLEServer::update() { cyclingPowerService.update(); cyclingSpeedCadenceService.update(); fitnessMachineService.update(); + ss2kCustomCharacteristic.update(); // zwiftService.update(); // OpenBikeControl sends event-driven notifications from shift handlers. // wattbikeService.parseNemit(); // Changed from update() to parseNemit() diff --git a/src/DirConManager.cpp b/src/DirConManager.cpp index 01dcd5f5..1b83f56f 100644 --- a/src/DirConManager.cpp +++ b/src/DirConManager.cpp @@ -521,7 +521,7 @@ void DirConManager::sendResponse(DirConMessage* message, size_t clientIndex) { } } -void DirConManager::notifyCharacteristic(const NimBLEUUID& serviceUuid, const NimBLEUUID& characteristicUuid, uint8_t* data, size_t length, bool onlySubscribers) { +void DirConManager::notifyCharacteristic(const NimBLEUUID& serviceUuid, const NimBLEUUID& characteristicUuid, const uint8_t* data, size_t length, bool onlySubscribers) { if (!started || !connectedClients()) { return; } @@ -535,7 +535,7 @@ void DirConManager::notifyCharacteristic(const NimBLEUUID& serviceUuid, const Ni static SemaphoreHandle_t s_notifyMutex = xSemaphoreCreateMutex(); -void DirConManager::broadcastNotification(const NimBLEUUID& characteristicUuid, uint8_t* data, size_t length, bool onlySubscribers) { +void DirConManager::broadcastNotification(const NimBLEUUID& characteristicUuid, const uint8_t* data, size_t length, bool onlySubscribers) { DirConMessage notification; // stack-allocated, safe per-call notification.Request = false; From 246f8da33364e679a7d8a496b7ecc3dd17c34272 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 12 Aug 2026 22:01:50 -0500 Subject: [PATCH 23/29] Refine tooltip text for minimum brake watts and update related comments for clarity --- data/settings.html | 2 +- data/settings.html.gz | Bin 3698 -> 3733 bytes data_s3/settings.html | 2 +- data_s3/settings.html.gz | Bin 3698 -> 3733 bytes include/settings.h | 4 ++-- src/BLE_Custom_Characteristic.cpp | 1 - src/BLE_Cycling_Power_Service.cpp | 1 - src/BLE_Cycling_Speed_Cadence.cpp | 1 - src/BLE_Device_Information_Service.cpp | 1 - src/BLE_Firmware_Update.cpp | 3 --- src/BLE_Fitness_Machine_Service.cpp | 1 - src/BLE_Heart_Service.cpp | 1 - src/BLE_OpenBikeControl_Service.cpp | 2 -- src/BLE_SB20_Service.cpp | 2 +- src/BLE_Wattbike_Service.cpp | 1 - src/BLE_Zwift_Service.cpp | 3 --- src/ERG_Mode.cpp | 28 +++++++++++++------------ src/Main.cpp | 9 ++++---- 18 files changed, 25 insertions(+), 37 deletions(-) diff --git a/data/settings.html b/data/settings.html index 836446cd..fea8c153 100644 --- a/data/settings.html +++ b/data/settings.html @@ -115,7 +115,7 @@

Reset to Defaults?

type: 'slider', title: 'Min Brake Watts', tooltip: 'Bike power floor', - tooltipText: 'Set the minimum watts until stepper stops. 0 disables check.', + tooltipText: 'Minimum ERG target while unhomed. Homed bikes use the detected travel limit. 0 disables the check.', min: 0, max: 200, step: 5, defaultValue: 0, unit: 'W' diff --git a/data/settings.html.gz b/data/settings.html.gz index 007bd7f2395ffc897a4a528677476649db5576b0..627bf40f6bbf64a17b41b72a287ff77e6b80548b 100644 GIT binary patch literal 3733 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 Q*nfuh9|LhOq?k4U04pyF`Tzg` diff --git a/data_s3/settings.html b/data_s3/settings.html index 836446cd..fea8c153 100644 --- a/data_s3/settings.html +++ b/data_s3/settings.html @@ -115,7 +115,7 @@

Reset to Defaults?

type: 'slider', title: 'Min Brake Watts', tooltip: 'Bike power floor', - tooltipText: 'Set the minimum watts until stepper stops. 0 disables check.', + tooltipText: 'Minimum ERG target while unhomed. Homed bikes use the detected travel limit. 0 disables the check.', min: 0, max: 200, step: 5, defaultValue: 0, unit: 'W' diff --git a/data_s3/settings.html.gz b/data_s3/settings.html.gz index 007bd7f2395ffc897a4a528677476649db5576b0..627bf40f6bbf64a17b41b72a287ff77e6b80548b 100644 GIT binary patch literal 3733 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 Q*nfuh9|LhOq?k4U04pyF`Tzg` diff --git a/include/settings.h b/include/settings.h index e1af9c92..4362d8a0 100644 --- a/include/settings.h +++ b/include/settings.h @@ -104,8 +104,8 @@ const char* const DEFAULT_PASSWORD = "password"; // Minimum cadence where ERG mode stops. #define MIN_ERG_CADENCE 30 -// Default Min Watts to stop stepper. -// This is used to set the lower travel limit for the motor. +// Default minimum ERG target while the stepper is unhomed. +// Homed operation uses the known stepper travel limits instead. #define DEFAULT_MIN_WATTS 50 // Default Max Watts that the brake on the spin bike can absorb from the user. diff --git a/src/BLE_Custom_Characteristic.cpp b/src/BLE_Custom_Characteristic.cpp index 260e6f93..205adf77 100644 --- a/src/BLE_Custom_Characteristic.cpp +++ b/src/BLE_Custom_Characteristic.cpp @@ -233,7 +233,6 @@ void BLE_ss2kCustomCharacteristic::setupService(NimBLEServer *pServer) { pSmartSpin2kService->createCharacteristic(SMARTSPIN2K_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::INDICATE | NIMBLE_PROPERTY::NOTIFY); smartSpin2kCharacteristic->setValue(ss2kCustomCharacteristicValue, sizeof(ss2kCustomCharacteristicValue)); smartSpin2kCharacteristic->setCallbacks(new ss2kCustomCharacteristicCallbacks()); - pSmartSpin2kService->start(); DirConManager::registerService(pSmartSpin2kService->getUUID(), [](NimBLECharacteristic* characteristic, const uint8_t* data, size_t length, DirConWriteResult* result) -> bool { if (!characteristic->getUUID().equals(SMARTSPIN2K_CHARACTERISTIC_UUID)) return false; diff --git a/src/BLE_Cycling_Power_Service.cpp b/src/BLE_Cycling_Power_Service.cpp index 3c84661b..7da01ea8 100644 --- a/src/BLE_Cycling_Power_Service.cpp +++ b/src/BLE_Cycling_Power_Service.cpp @@ -27,7 +27,6 @@ void BLE_Cycling_Power_Service::setupService(NimBLEServer *pServer, MyCharacteri cyclingPowerFeatureCharacteristic->setValue(cpFeature, sizeof(cpFeature)); sensorLocationCharacteristic->setValue(cpsLocation, sizeof(cpsLocation)); cyclingPowerMeasurementCharacteristic->setCallbacks(chrCallbacks); - pPowerMonitor->start(); // Register with DirCon for service discovery DirConManager::registerService(pPowerMonitor->getUUID()); diff --git a/src/BLE_Cycling_Speed_Cadence.cpp b/src/BLE_Cycling_Speed_Cadence.cpp index 18a6d15e..da2a2ce0 100644 --- a/src/BLE_Cycling_Speed_Cadence.cpp +++ b/src/BLE_Cycling_Speed_Cadence.cpp @@ -23,7 +23,6 @@ void BLE_Cycling_Speed_Cadence::setupService(NimBLEServer *pServer, MyCharacteri cscFeature->setValue(cscFeatureBytes, sizeof(cscFeatureBytes)); cscMeasurement->setCallbacks(chrCallbacks); - pCyclingSpeedCadenceService->start(); // Register with DirCon for service discovery DirConManager::registerService(pCyclingSpeedCadenceService->getUUID()); diff --git a/src/BLE_Device_Information_Service.cpp b/src/BLE_Device_Information_Service.cpp index afa783f3..597e0267 100644 --- a/src/BLE_Device_Information_Service.cpp +++ b/src/BLE_Device_Information_Service.cpp @@ -73,5 +73,4 @@ void BLE_Device_Information_Service::setupService(NimBLEServer* pServer) { pSystemIDCharacteristic = pDeviceInformationService->createCharacteristic(SYSTEM_ID_UUID, NIMBLE_PROPERTY::READ); pSystemIDCharacteristic->setValue(systemId.data(), systemId.size()); - pDeviceInformationService->start(); } diff --git a/src/BLE_Firmware_Update.cpp b/src/BLE_Firmware_Update.cpp index eb43744c..b8a7b6a2 100644 --- a/src/BLE_Firmware_Update.cpp +++ b/src/BLE_Firmware_Update.cpp @@ -201,9 +201,6 @@ void BLEFirmwareSetup(NimBLEServer *pServer) { pOtaCharacteristic = pService->createCharacteristic(FIRMWARE_CHARACTERISTIC_OTA_UUID, NIMBLE_PROPERTY::WRITE); pOtaCharacteristic->setCallbacks(new otaCallback()); - // 5. Start the service(s) - pService->start(); - // 6. Start advertising // spinBLEServer.pServer->getAdvertising()->addServiceUUID(pService->getUUID()); diff --git a/src/BLE_Fitness_Machine_Service.cpp b/src/BLE_Fitness_Machine_Service.cpp index 1ac61a5b..f5610394 100644 --- a/src/BLE_Fitness_Machine_Service.cpp +++ b/src/BLE_Fitness_Machine_Service.cpp @@ -54,7 +54,6 @@ void BLE_Fitness_Machine_Service::setupService(NimBLEServer *pServer, MyCharacte fitnessMachineInclinationRange->setValue(ftmsInclinationRange, sizeof(ftmsInclinationRange)); fitnessMachineIndoorBikeData->setCallbacks(chrCallbacks); fitnessMachineControlPoint->setCallbacks(chrCallbacks); - pFitnessMachineService->start(); // Register with DirCon for service discovery and write handling DirConManager::registerService(pFitnessMachineService->getUUID(), [](NimBLECharacteristic *characteristic, const uint8_t *data, size_t length, DirConWriteResult *result) -> bool { diff --git a/src/BLE_Heart_Service.cpp b/src/BLE_Heart_Service.cpp index 5346f7b3..6c9b78d5 100644 --- a/src/BLE_Heart_Service.cpp +++ b/src/BLE_Heart_Service.cpp @@ -17,7 +17,6 @@ void BLE_Heart_Service::setupService(NimBLEServer *pServer, MyCharacteristicCall byte heartRateMeasurement[2] = {0x00, 0x00}; heartRateMeasurementCharacteristic->setValue(heartRateMeasurement, 2); heartRateMeasurementCharacteristic->setCallbacks(chrCallbacks); - pHeartService->start(); // Register with DirCon for service discovery DirConManager::registerService(pHeartService->getUUID()); } diff --git a/src/BLE_OpenBikeControl_Service.cpp b/src/BLE_OpenBikeControl_Service.cpp index 7ed4a39d..e6825a33 100644 --- a/src/BLE_OpenBikeControl_Service.cpp +++ b/src/BLE_OpenBikeControl_Service.cpp @@ -130,8 +130,6 @@ void BLE_OpenBikeControl_Service::setupService(NimBLEServer *pServer) { pOpenBikeControlService->createCharacteristic(OPENBIKECONTROL_APP_INFO_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR); appInformationCharacteristic->setCallbacks(&obcAppInfoCallbacks); - pOpenBikeControlService->start(); - DirConManager::registerService( pOpenBikeControlService->getUUID(), [](NimBLECharacteristic *characteristic, const uint8_t *data, size_t length, DirConWriteResult *result) -> bool { diff --git a/src/BLE_SB20_Service.cpp b/src/BLE_SB20_Service.cpp index c738e26c..ec54ef8e 100644 --- a/src/BLE_SB20_Service.cpp +++ b/src/BLE_SB20_Service.cpp @@ -18,7 +18,7 @@ BLE_SB20_Service::BLE_SB20_Service() : pService(nullptr), pCharacteristic(nullpt void BLE_SB20_Service::begin() { pService = BLEDevice::createServer()->createService(SB20_SERVICE_UUID); pCharacteristic = pService->createCharacteristic(SB20_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); - pService->start(); + SS2K_LOG(SS2K_LOG_TAG, "SB20 Service started\n"); } diff --git a/src/BLE_Wattbike_Service.cpp b/src/BLE_Wattbike_Service.cpp index 46adfdcf..fbcf9c6b 100644 --- a/src/BLE_Wattbike_Service.cpp +++ b/src/BLE_Wattbike_Service.cpp @@ -23,7 +23,6 @@ void BLE_Wattbike_Service::setupService(NimBLEServer *pServer) { wattbikeWriteCharacteristic = pWattbikeService->createCharacteristic(WATTBIKE_WRITE_UUID, NIMBLE_PROPERTY::WRITE); // Start the service - pWattbikeService->start(); spinBLEServer.pServer->getAdvertising()->addServiceUUID(pWattbikeService->getUUID()); } diff --git a/src/BLE_Zwift_Service.cpp b/src/BLE_Zwift_Service.cpp index 822e8a1c..676e5deb 100644 --- a/src/BLE_Zwift_Service.cpp +++ b/src/BLE_Zwift_Service.cpp @@ -151,7 +151,6 @@ void BLE_Zwift_Service::setupService(NimBLEServer* pServer) { NimBLECharacteristic* batteryLevelChar = pBatteryService->createCharacteristic(NimBLEUUID((uint16_t)0x2A19), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); uint8_t batteryLevel = 100; batteryLevelChar->setValue(&batteryLevel, 1); - pBatteryService->start(); // Zwift Custom Service (use 0xFC82 to match advertisement) pZwiftService = pServer->createService(ZWIFT_RIDE_CUSTOM_SERVICE_UUID); @@ -173,8 +172,6 @@ void BLE_Zwift_Service::setupService(NimBLEServer* pServer) { unknownCharacteristic6 = pZwiftService->createCharacteristic(ZWIFT_UNKNOWN_CHARACTERISTIC6_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::INDICATE); - pZwiftService->start(); - // Register with DirCon for service discovery and write handling DirConManager::registerService(pZwiftService->getUUID(), [](NimBLECharacteristic* characteristic, const uint8_t* data, size_t length, DirConWriteResult* result) -> bool { if (characteristic->getUUID().equals(NimBLEUUID(ZWIFT_SYNC_RX_CHARACTERISTIC_UUID))) { diff --git a/src/ERG_Mode.cpp b/src/ERG_Mode.cpp index 63bc86cb..1492626e 100644 --- a/src/ERG_Mode.cpp +++ b/src/ERG_Mode.cpp @@ -28,7 +28,7 @@ constexpr int ERG_HIGH_GAIN_WATTS = 400; constexpr int ERG_MIN_SCHEDULE_WATTS = 30; constexpr double ERG_SLOPE_CONTROL_DIVISOR = 10.0; constexpr double ERG_GAIN_MIN_DIVISOR = 4.0; -constexpr double ERG_GAIN_MAX_MULTIPLIER = 8.0; +constexpr double ERG_GAIN_MAX_MULTIPLIER = 4.0; double fallbackErgGain(double sensitivity, int operatingWatts) { if (operatingWatts < ERG_LOW_GAIN_WATTS) { @@ -203,9 +203,10 @@ void ErgMode::computeErg() { return; } - // set minimum set point to minimum bike watts if app sends set point lower than minimum bike watts. - if (rtConfig->watts.getTarget() < userConfig->getMinWatts()) { - SS2K_LOG(ERG_MODE_LOG_TAG, "ERG Target Below Minumum Value."); + // 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()) { + SS2K_LOG(ERG_MODE_LOG_TAG, "ERG target below minimum value while unhomed."); rtConfig->watts.setTarget(userConfig->getMinWatts()); } @@ -233,7 +234,7 @@ int32_t ErgMode::_setPointChangeState() { mode = (rtConfig->watts.getTarget() > rtConfig->watts.getValue()) ? Mode::INCREASING : Mode::DECREASING; // It's better to undershoot increasing watts and overshoot decreasing watts, so lets set the lookup target to the nearest side of POWERTABLE_WATT_INCREMENT int adjustedWattTarget = (mode == Mode::INCREASING) ? rtConfig->watts.getTarget() - ERG_MODE_PID_WINDOW : rtConfig->watts.getTarget() + ERG_MODE_PID_WINDOW; - int32_t tableResult = powerTable->lookup(adjustedWattTarget, + int32_t tableResult = powerTable->lookup(adjustedWattTarget, (mode == Mode::INCREASING) ? rtConfig->cad.getValue() + POWERTABLE_CAD_INCREMENT : rtConfig->cad.getValue() - POWERTABLE_CAD_INCREMENT); // Sanity check - with homing enabled, we should never have a negative result. If we do, something went wrong. @@ -320,14 +321,6 @@ int32_t ErgMode::_inSetpointState() { // final PID output double PID_output = proportional; - // log proportional every five seconds - static unsigned long lastTime = 0; - if (millis() - lastTime > 5000) { - lastTime = millis(); - SS2K_LOG(ERG_MODE_LOG_TAG, "%dw, Target %dw, Kp: %.3f (%s), Proportional: %f", rtConfig->watts.getValue(), rtConfig->watts.getTarget(), Kp, - usedPowerTable ? "table" : "fallback", proportional); - } - // Cap the change to no more than we can move until the next reading int maxChange = round((long)((userConfig->getStepperSpeed() * ERG_MODE_DELAY)) / 1000.0f); // max change based on stepper speed and delay if (PID_output > maxChange) { @@ -338,6 +331,15 @@ int32_t ErgMode::_inSetpointState() { // Calculate new incline float newIncline = ss2k->getCurrentPosition() + PID_output; + + // log output every five seconds + static unsigned long lastTime = 0; + if (millis() - lastTime > 5000) { + lastTime = millis(); + SS2K_LOG(ERG_MODE_LOG_TAG, "%dw, Target %dw, Kp: %.3f (%s), PID Output: %f, Moving to: %f", rtConfig->watts.getValue(), rtConfig->watts.getTarget(), Kp, usedPowerTable ? "table" : "fallback", + PID_output, newIncline); + } + return newIncline; } diff --git a/src/Main.cpp b/src/Main.cpp index d8988e42..54ed6515 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -462,12 +462,13 @@ void SS2K::FTMSModeShiftModifier() { case FitnessMachineControlPointProcedure::SetTargetPower: // ERG Mode { rtConfig->setShifterPosition(ss2k->lastShifterPosition); // reset shifter position because we're remapping it to ERG target - if ((rtConfig->watts.getTarget() + (shiftDelta * ERG_PER_SHIFT) < userConfig->getMinWatts()) || - (rtConfig->watts.getTarget() + (shiftDelta * ERG_PER_SHIFT) > userConfig->getMaxWatts())) { - SS2K_LOG(MAIN_LOG_TAG, "Shift to %dw blocked", rtConfig->watts.getTarget() + shiftDelta); + const int proposedTarget = rtConfig->watts.getTarget() + (shiftDelta * ERG_PER_SHIFT); + const int minimumTarget = rtConfig->getHomed() ? 0 : userConfig->getMinWatts(); + if (proposedTarget < minimumTarget || proposedTarget > userConfig->getMaxWatts()) { + SS2K_LOG(MAIN_LOG_TAG, "Shift to %dw blocked", proposedTarget); break; } - rtConfig->watts.setTarget(rtConfig->watts.getTarget() + (ERG_PER_SHIFT * shiftDelta)); + rtConfig->watts.setTarget(proposedTarget); SS2K_LOG(MAIN_LOG_TAG, "ERG Shift. New Target: %dw", rtConfig->watts.getTarget()); // Format output for FTMS passthrough #ifndef INTERNAL_ERG_4EXT_FTMS From 312ab071427a17bd6bff57a35e3cb3dc74f1b7ec Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Wed, 12 Aug 2026 22:18:06 -0500 Subject: [PATCH 24/29] Add getIsQuadratic method to ResistanceModel and adjust ERG_LOG_INTERVAL_MS for logging frequency --- include/PowerTable_Helpers.h | 3 ++- src/ERG_Mode.cpp | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/include/PowerTable_Helpers.h b/include/PowerTable_Helpers.h index 0801fb0b..0c743534 100644 --- a/include/PowerTable_Helpers.h +++ b/include/PowerTable_Helpers.h @@ -95,6 +95,7 @@ class ResistanceModel { int16_t predict(double watts, double rpm); int predictWatts(int32_t resistance, float cadence); bool getIsValid() { return isValid; } + bool getIsQuadratic() { return isQuadratic; } }; class PTHelpers { @@ -110,4 +111,4 @@ class PTHelpers { void fillGaps(PTData& ptData); bool fillAllWattColumns(PTData& ptData); bool fillAllCadenceLines(PTData& ptData); -}; \ No newline at end of file +}; diff --git a/src/ERG_Mode.cpp b/src/ERG_Mode.cpp index 1492626e..442d0224 100644 --- a/src/ERG_Mode.cpp +++ b/src/ERG_Mode.cpp @@ -29,6 +29,7 @@ constexpr int ERG_MIN_SCHEDULE_WATTS = 30; constexpr double ERG_SLOPE_CONTROL_DIVISOR = 10.0; constexpr double ERG_GAIN_MIN_DIVISOR = 4.0; constexpr double ERG_GAIN_MAX_MULTIPLIER = 4.0; +constexpr int ERG_LOG_INTERVAL_MS = 2000; double fallbackErgGain(double sensitivity, int operatingWatts) { if (operatingWatts < ERG_LOW_GAIN_WATTS) { @@ -49,7 +50,9 @@ double scheduledErgGain(double sensitivity, int operatingWatts, int cadence, boo const int32_t upperPosition = powerTable->lookup(upperWatts, cadence); double gain = fallbackErgGain(sensitivity, operatingWatts); - if (powerTable->ptHelpers.resistanceModel.getIsValid() && lowerPosition != RETURN_ERROR && upperPosition != RETURN_ERROR && upperPosition > lowerPosition) { + // 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) { const double localStepsPerWatt = static_cast(upperPosition - lowerPosition) / static_cast(upperWatts - lowerWatts); gain = localStepsPerWatt * sensitivity / ERG_SLOPE_CONTROL_DIVISOR; usedPowerTable = true; @@ -334,10 +337,10 @@ int32_t ErgMode::_inSetpointState() { // log output every five seconds static unsigned long lastTime = 0; - if (millis() - lastTime > 5000) { + if (millis() - lastTime > ERG_LOG_INTERVAL_MS) { lastTime = millis(); - SS2K_LOG(ERG_MODE_LOG_TAG, "%dw, Target %dw, Kp: %.3f (%s), PID Output: %f, Moving to: %f", rtConfig->watts.getValue(), rtConfig->watts.getTarget(), Kp, usedPowerTable ? "table" : "fallback", - PID_output, newIncline); + SS2K_LOG(ERG_MODE_LOG_TAG, "%dw, Target %dw, Kp: %.3f (%s), PID Output: %f, Moving to: %f", rtConfig->watts.getValue(), rtConfig->watts.getTarget(), Kp, + usedPowerTable ? "table" : "fallback", PID_output, newIncline); } return newIncline; From 7010cd2c3122be39fcf170423f0877a9c76a31e5 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Thu, 13 Aug 2026 10:24:31 -0500 Subject: [PATCH 25/29] Add utility functions for DIRCON message handling and improve logging in sendResponse --- src/DirConManager.cpp | 90 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/src/DirConManager.cpp b/src/DirConManager.cpp index 1b83f56f..c2d38561 100644 --- a/src/DirConManager.cpp +++ b/src/DirConManager.cpp @@ -29,6 +29,78 @@ size_t DirConManager::registeredServiceCount = 0; static char uuidListBuffer[128] = ""; static size_t uuidListLength = 0; +namespace { + +const char* dirConMessageName(uint8_t identifier) { + switch (identifier) { + case DIRCON_MSGID_DISCOVER_SERVICES: + return "services"; + case DIRCON_MSGID_DISCOVER_CHARACTERISTICS: + return "chars"; + case DIRCON_MSGID_READ_CHARACTERISTIC: + return "read"; + case DIRCON_MSGID_WRITE_CHARACTERISTIC: + return "write"; + case DIRCON_MSGID_ENABLE_CHARACTERISTIC_NOTIFICATIONS: + return "subscribe"; + default: + return "unknown"; + } +} + +const char* dirConResponseName(uint8_t responseCode) { + switch (responseCode) { + case DIRCON_RESPCODE_SUCCESS_REQUEST: + return "ok"; + case DIRCON_RESPCODE_UNKNOWN_MESSAGE_TYPE: + return "bad-msg"; + case DIRCON_RESPCODE_UNEXPECTED_ERROR: + return "failed"; + case DIRCON_RESPCODE_SERVICE_NOT_FOUND: + return "no-svc"; + case DIRCON_RESPCODE_CHARACTERISTIC_NOT_FOUND: + return "no-char"; + case DIRCON_RESPCODE_CHARACTERISTIC_OPERATION_NOT_SUPPORTED: + return "unsupported"; + case DIRCON_RESPCODE_CHARACTERISTIC_WRITE_FAILED: + return "write-fail"; + case DIRCON_RESPCODE_UNKNOWN_PROTOCOL: + return "bad-proto"; + default: + return "error"; + } +} + +std::string compactDirConUuid(const NimBLEUUID& uuid) { + if (uuid.equals(FITNESSMACHINECONTROLPOINT_UUID)) { + return "ftms-cp"; + } + if (uuid.equals(SMARTSPIN2K_CHARACTERISTIC_UUID)) { + return "ss2k"; + } + + std::string value = uuid.toString(); + if (value.length() <= 8) { + return value; + } + return value.substr(0, 4) + ".." + value.substr(value.length() - 4); +} + +void formatPayloadPreview(const std::vector& data, char* output, size_t outputSize) { + constexpr size_t kPreviewBytes = 4; + const size_t bytesToLog = std::min(data.size(), kPreviewBytes); + size_t offset = 0; + + for (size_t i = 0; i < bytesToLog && offset + 2 < outputSize; i++) { + offset += snprintf(output + offset, outputSize - offset, "%02X", data[i]); + } + if (data.size() > bytesToLog && offset + 3 < outputSize) { + snprintf(output + offset, outputSize - offset, "..."); + } +} + +} // namespace + bool DirConManager::start() { if (started) { return true; @@ -504,8 +576,6 @@ void DirConManager::sendResponse(DirConMessage* message, size_t clientIndex) { return; } - SS2K_LOG(DIRCON_LOG_TAG, "Sending response message type 0x%02X to client %d", message->Identifier, clientIndex); - if (message->Identifier == DIRCON_MSGID_DISCOVER_SERVICES) { SS2K_LOG(DIRCON_LOG_TAG, "Discover services response contains %d UUIDs", message->AdditionalUUIDs.size()); for (size_t i = 0; i < message->AdditionalUUIDs.size(); i++) { @@ -515,6 +585,22 @@ void DirConManager::sendResponse(DirConMessage* message, size_t clientIndex) { std::vector* encodedMessage = message->encode(lastSequenceNumber[clientIndex]); if (encodedMessage != nullptr && encodedMessage->size() > 0) { + const char* result = dirConResponseName(message->ResponseCode); + if (message->Identifier == DIRCON_MSGID_DISCOVER_SERVICES) { + SS2K_LOG(DIRCON_LOG_TAG, "TX services c%u s%u %s %uU/%uB", static_cast(clientIndex), static_cast(message->SequenceNumber), result, + static_cast(message->AdditionalUUIDs.size()), static_cast(encodedMessage->size())); + } else if (message->Identifier == DIRCON_MSGID_DISCOVER_CHARACTERISTICS) { + const std::string uuid = compactDirConUuid(message->UUID); + SS2K_LOG(DIRCON_LOG_TAG, "TX chars c%u s%u %s %s %uU/%uB", static_cast(clientIndex), static_cast(message->SequenceNumber), uuid.c_str(), result, + static_cast(message->AdditionalUUIDs.size()), static_cast(encodedMessage->size())); + } else { + const std::string uuid = compactDirConUuid(message->UUID); + char payloadPreview[12] = ""; + formatPayloadPreview(message->AdditionalData, payloadPreview, sizeof(payloadPreview)); + SS2K_LOG(DIRCON_LOG_TAG, "TX %s c%u s%u %s %s %uB%s%s", dirConMessageName(message->Identifier), static_cast(clientIndex), + static_cast(message->SequenceNumber), uuid.c_str(), result, static_cast(message->AdditionalData.size()), + message->AdditionalData.empty() ? "" : ":", payloadPreview); + } dirConClients[clientIndex].write(encodedMessage->data(), encodedMessage->size()); } else { SS2K_LOG(DIRCON_LOG_TAG, "Error: No encoded message to send"); From 75305b3b1dbc4ab6866495c34f7667428e7e4bb2 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Thu, 13 Aug 2026 18:28:15 -0500 Subject: [PATCH 26/29] Add TMC2209 OTP hold current programming and verification --- AGENTS.md | 1 + src/Stepper.cpp | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1f66ced7..e5c5263a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -588,6 +588,7 @@ Changing BLE server characteristics: - `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. - Firmware OTA paths validate the incoming `esp_image_header_t` chip ID before starting flash writes; filesystem images are intentionally exempt from application-image validation. +- Initial TMC2209 setup checks `OTP_IHOLD`. If its two-bit field is unprogrammed, firmware irreversibly programs byte 2/bit 5 for the 9% standalone hold-current default; incompatible existing OTP values are never modified. - Many BLE and motor changes cannot be fully validated without hardware. ## Search Tips diff --git a/src/Stepper.cpp b/src/Stepper.cpp index 6dbb987d..1b1009ed 100644 --- a/src/Stepper.cpp +++ b/src/Stepper.cpp @@ -21,6 +21,54 @@ FastAccelStepper* stepper = NULL; extern Board currentBoard; +namespace { + +constexpr uint8_t TMC2209_OTP_IHOLD_SHIFT = 21; +constexpr uint32_t TMC2209_OTP_IHOLD_MASK = 0x03UL << TMC2209_OTP_IHOLD_SHIFT; +constexpr uint32_t TMC2209_OTP_IHOLD_9_PERCENT = 0x01UL << TMC2209_OTP_IHOLD_SHIFT; +constexpr uint16_t TMC2209_OTP_PROGRAM_IHOLD_9 = 0xBD25; // Magic 0xBD, OTP byte 2, bit 5. + +void programTmc2209LowHoldCurrentOtp(TMC2209Stepper* tmcDriver) { + const uint8_t connectionStatus = tmcDriver->test_connection(); + + if (connectionStatus != 0) { + SS2K_LOG(MAIN_LOG_TAG, "Skipping TMC OTP check: UART connection test failed (%u)", static_cast(connectionStatus)); + return; + } + + uint32_t otpRead = tmcDriver->OTP_READ(); + const uint32_t otpIhold = otpRead & TMC2209_OTP_IHOLD_MASK; + if (otpIhold == TMC2209_OTP_IHOLD_9_PERCENT) { + SS2K_LOG(MAIN_LOG_TAG, "TMC OTP hold current is already programmed to 9%%"); + return; + } + if (otpIhold != 0) { + const uint8_t otpIholdSetting = static_cast(otpIhold >> TMC2209_OTP_IHOLD_SHIFT); + SS2K_LOG(MAIN_LOG_TAG, "TMC OTP hold current is already programmed (setting %u); leaving irreversible OTP unchanged", static_cast(otpIholdSetting)); + return; + } + + SS2K_LOG(MAIN_LOG_TAG, "Programming TMC OTP hold current to 9%%"); + tmcDriver->OTP_PROG(TMC2209_OTP_PROGRAM_IHOLD_9); + delay(10); + otpRead = tmcDriver->OTP_READ(); + + if ((otpRead & TMC2209_OTP_IHOLD_MASK) != TMC2209_OTP_IHOLD_9_PERCENT) { + // The datasheet recommends retrying a missing OTP bit with a 100 ms programming time. + tmcDriver->OTP_PROG(TMC2209_OTP_PROGRAM_IHOLD_9); + delay(100); + otpRead = tmcDriver->OTP_READ(); + } + + if ((otpRead & TMC2209_OTP_IHOLD_MASK) == TMC2209_OTP_IHOLD_9_PERCENT) { + SS2K_LOG(MAIN_LOG_TAG, "TMC OTP hold current programmed and verified at 9%%"); + } else { + SS2K_LOG(MAIN_LOG_TAG, "TMC OTP hold-current programming failed verification (OTP_READ=0x%06lX)", static_cast(otpRead & 0xFFFFFFUL)); + } +} + +} // namespace + void SS2K::moveStepper() { static bool _stepperDir = userConfig->getStepperDir(); if (stepper) { @@ -167,6 +215,7 @@ void SS2K::setupTMCStepperDriver(bool reset) { stepper->setDelayToDisable(65535); // TMC Driver Setup driver->begin(); + programTmc2209LowHoldCurrentOtp(driver); } driver->pdn_disable(true); // Use PDN pin to enable UART communication instead of grounding signal From 49a14ea7dd4b9b7e7ea11ff50e4ce99060125b8b Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Thu, 13 Aug 2026 20:19:26 -0500 Subject: [PATCH 27/29] Refactor stepper serial initialization and enhance TMC2209 connection recovery logic --- AGENTS.md | 2 +- include/Stepper.h | 1 + src/Main.cpp | 8 ++------ src/Stepper.cpp | 47 +++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e5c5263a..08572d00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -588,7 +588,7 @@ Changing BLE server characteristics: - `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. - Firmware OTA paths validate the incoming `esp_image_header_t` chip ID before starting flash writes; filesystem images are intentionally exempt from application-image validation. -- Initial TMC2209 setup checks `OTP_IHOLD`. If its two-bit field is unprogrammed, firmware irreversibly programs byte 2/bit 5 for the 9% standalone hold-current default; incompatible existing OTP values are never modified. +- Stepper UART initialization drives TX high for 20 ms before starting hardware UART. Every TMC setup call tests the connection before configuration writes; one failure restarts UART with the same idle-high recovery pulse and aborts setup if the single retry fails. Initial setup also checks `OTP_IHOLD`; if its two-bit field is unprogrammed, firmware irreversibly programs byte 2/bit 5 for the 9% standalone hold-current default, while incompatible existing OTP values are never modified. - Many BLE and motor changes cannot be fully validated without hardware. ## Search Tips diff --git a/include/Stepper.h b/include/Stepper.h index dfc728bc..b3c53ad7 100644 --- a/include/Stepper.h +++ b/include/Stepper.h @@ -20,5 +20,6 @@ struct HomingSgBaseline { }; extern HardwareSerial stepperSerial; +void initializeStepperSerial(bool restart = false); extern FastAccelStepperEngine engine; extern FastAccelStepper* stepper; diff --git a/src/Main.cpp b/src/Main.cpp index 54ed6515..e6393569 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -120,15 +120,11 @@ void SS2K::finishSetup() { currentBoard = boards.rev1; } #endif - SS2K_LOG(MAIN_LOG_TAG, "Current Board Revision is: %s", currentBoard.name); + SS2K_LOG(MAIN_LOG_TAG, "Current Board Revision is: %s", currentBoard.name.c_str()); // initialize Stepper serial port -#if defined(SMARTSPIN2K_S3) - stepperSerial.begin(57600, SERIAL_8N1, currentBoard.stepperSerialRxPin, currentBoard.stepperSerialTxPin); -#else - stepperSerial.begin(57600, SERIAL_8N2, currentBoard.stepperSerialRxPin, currentBoard.stepperSerialTxPin); -#endif + initializeStepperSerial(); // initialize aux serial port (Peloton) if (currentBoard.auxSerialTxPin) { auxSerial.begin(19200, SERIAL_8N1, currentBoard.auxSerialRxPin, currentBoard.auxSerialTxPin, false); diff --git a/src/Stepper.cpp b/src/Stepper.cpp index 1b1009ed..6b31e22c 100644 --- a/src/Stepper.cpp +++ b/src/Stepper.cpp @@ -21,6 +21,21 @@ FastAccelStepper* stepper = NULL; extern Board currentBoard; +void initializeStepperSerial(bool restart) { + if (restart) { + stepperSerial.end(); + } + + // The TMC2209 requires an idle-high interval to reset and resynchronize its + // UART receiver after an incomplete or invalid datagram. Drive TX manually + // before handing the pin to the UART peripheral so boot state is deterministic. + digitalWrite(currentBoard.stepperSerialTxPin, HIGH); + pinMode(currentBoard.stepperSerialTxPin, OUTPUT); + delay(20); + + stepperSerial.begin(57600, SERIAL_8N1, currentBoard.stepperSerialRxPin, currentBoard.stepperSerialTxPin); +} + namespace { constexpr uint8_t TMC2209_OTP_IHOLD_SHIFT = 21; @@ -28,14 +43,26 @@ constexpr uint32_t TMC2209_OTP_IHOLD_MASK = 0x03UL << TMC2209_OTP_IHOLD_SHI constexpr uint32_t TMC2209_OTP_IHOLD_9_PERCENT = 0x01UL << TMC2209_OTP_IHOLD_SHIFT; constexpr uint16_t TMC2209_OTP_PROGRAM_IHOLD_9 = 0xBD25; // Magic 0xBD, OTP byte 2, bit 5. -void programTmc2209LowHoldCurrentOtp(TMC2209Stepper* tmcDriver) { - const uint8_t connectionStatus = tmcDriver->test_connection(); +bool recoverTmc2209Connection(TMC2209Stepper* tmcDriver) { + uint8_t connectionStatus = tmcDriver->test_connection(); + if (connectionStatus == 0) { + return true; + } + + SS2K_LOG(MAIN_LOG_TAG, "TMC UART test failed (%u); forcing idle-high recovery", static_cast(connectionStatus)); + initializeStepperSerial(true); + connectionStatus = tmcDriver->test_connection(); if (connectionStatus != 0) { - SS2K_LOG(MAIN_LOG_TAG, "Skipping TMC OTP check: UART connection test failed (%u)", static_cast(connectionStatus)); - return; + SS2K_LOG(MAIN_LOG_TAG, "TMC UART recovery failed (%u)", static_cast(connectionStatus)); + return false; } + SS2K_LOG(MAIN_LOG_TAG, "TMC UART recovered"); + return true; +} + +void programTmc2209LowHoldCurrentOtp(TMC2209Stepper* tmcDriver) { uint32_t otpRead = tmcDriver->OTP_READ(); const uint32_t otpIhold = otpRead & TMC2209_OTP_IHOLD_MASK; if (otpIhold == TMC2209_OTP_IHOLD_9_PERCENT) { @@ -47,7 +74,7 @@ void programTmc2209LowHoldCurrentOtp(TMC2209Stepper* tmcDriver) { SS2K_LOG(MAIN_LOG_TAG, "TMC OTP hold current is already programmed (setting %u); leaving irreversible OTP unchanged", static_cast(otpIholdSetting)); return; } - + SS2K_LOG(MAIN_LOG_TAG, "Programming TMC OTP hold current to 9%%"); tmcDriver->OTP_PROG(TMC2209_OTP_PROGRAM_IHOLD_9); delay(10); @@ -202,9 +229,17 @@ void SS2K::setupTMCStepperDriver(bool reset) { if (!driver) { driver = new TMC2209Stepper(&stepperSerial, currentBoard.rSense, 0b00); } + const bool initializeFastAccel = !reset || stepper == nullptr; + + // Verify communication before issuing any driver configuration writes. A + // failed recovery leaves the existing hardware state untouched. + if (!recoverTmc2209Connection(driver)) { + SS2K_LOG(MAIN_LOG_TAG, "Skipping TMC driver setup because UART is unavailable"); + return; + } // FastAccel setup - if (!reset) { + if (initializeFastAccel) { engine.init(); stepper = engine.stepperConnectToPin(currentBoard.stepPin); stepper->setDirectionPin(currentBoard.dirPin, userConfig->getStepperDir()); From 9a4080007d6d62c746b2160f84fc813b2b23f126 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Thu, 13 Aug 2026 20:42:12 -0500 Subject: [PATCH 28/29] Enhance TMC2209 connection recovery logic with interface counter validation and improve logging for UART test failures --- AGENTS.md | 2 +- src/Stepper.cpp | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 08572d00..7ecbebbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -588,7 +588,7 @@ Changing BLE server characteristics: - `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. - Firmware OTA paths validate the incoming `esp_image_header_t` chip ID before starting flash writes; filesystem images are intentionally exempt from application-image validation. -- Stepper UART initialization drives TX high for 20 ms before starting hardware UART. Every TMC setup call tests the connection before configuration writes; one failure restarts UART with the same idle-high recovery pulse and aborts setup if the single retry fails. Initial setup also checks `OTP_IHOLD`; if its two-bit field is unprogrammed, firmware irreversibly programs byte 2/bit 5 for the 9% standalone hold-current default, while incompatible existing OTP values are never modified. +- Stepper UART initialization drives TX high for 20 ms before starting hardware UART. TMC connection checks track `IFCNT` across calls to confirm intervening writes were accepted; a failed UART test or unchanged counter restarts UART with one idle-high recovery pulse and aborts the requested setup/power update if that retry fails. Initial setup also checks `OTP_IHOLD`; if its two-bit field is unprogrammed, firmware irreversibly programs byte 2/bit 5 for the 9% standalone hold-current default, while incompatible existing OTP values are never modified. - Many BLE and motor changes cannot be fully validated without hardware. ## Search Tips diff --git a/src/Stepper.cpp b/src/Stepper.cpp index 6b31e22c..bbccf560 100644 --- a/src/Stepper.cpp +++ b/src/Stepper.cpp @@ -44,12 +44,28 @@ constexpr uint32_t TMC2209_OTP_IHOLD_9_PERCENT = 0x01UL << TMC2209_OTP_IHOLD_SHI constexpr uint16_t TMC2209_OTP_PROGRAM_IHOLD_9 = 0xBD25; // Magic 0xBD, OTP byte 2, bit 5. bool recoverTmc2209Connection(TMC2209Stepper* tmcDriver) { + static uint8_t lastInterfaceCounter = 0; + static bool interfaceCounterValid = false; + uint8_t connectionStatus = tmcDriver->test_connection(); if (connectionStatus == 0) { - return true; + const uint8_t interfaceCounter = tmcDriver->IFCNT(); + if (tmcDriver->CRCerror) { + SS2K_LOG(MAIN_LOG_TAG, "TMC IFCNT read failed CRC; forcing idle-high recovery"); + } else if (!interfaceCounterValid) { + lastInterfaceCounter = interfaceCounter; + interfaceCounterValid = true; + return true; + } else if (interfaceCounter != lastInterfaceCounter) { + lastInterfaceCounter = interfaceCounter; + return true; + } else { + SS2K_LOG(MAIN_LOG_TAG, "TMC IFCNT did not increment from %u; forcing idle-high recovery", static_cast(interfaceCounter)); + } + } else { + SS2K_LOG(MAIN_LOG_TAG, "TMC UART test failed (%u); forcing idle-high recovery", static_cast(connectionStatus)); } - SS2K_LOG(MAIN_LOG_TAG, "TMC UART test failed (%u); forcing idle-high recovery", static_cast(connectionStatus)); initializeStepperSerial(true); connectionStatus = tmcDriver->test_connection(); @@ -58,6 +74,13 @@ bool recoverTmc2209Connection(TMC2209Stepper* tmcDriver) { return false; } + lastInterfaceCounter = tmcDriver->IFCNT(); + if (tmcDriver->CRCerror) { + SS2K_LOG(MAIN_LOG_TAG, "TMC UART recovered, but IFCNT read failed CRC"); + interfaceCounterValid = false; + return false; + } + interfaceCounterValid = true; SS2K_LOG(MAIN_LOG_TAG, "TMC UART recovered"); return true; } @@ -616,6 +639,11 @@ void SS2K::goHome(bool bothDirections) { // Applies current power to driver void SS2K::updateStepperPower(int pwr) { + if (driver == nullptr || !recoverTmc2209Connection(driver)) { + SS2K_LOG(MAIN_LOG_TAG, "Skipping stepper power update because TMC UART is unavailable"); + return; + } + uint16_t rmsPwr = (pwr == 0) ? userConfig->getStepperPower() : pwr; driver->rms_current(rmsPwr, HOLD_PWR_SCALER); SS2K_LOG(MAIN_LOG_TAG, "Stepper power is now %d mA (driver setpoint %d mA)", rmsPwr, driver->rms_current()); From c248a2c6d085b53f619042ff2a5a7268925c011b Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Thu, 13 Aug 2026 22:07:33 -0500 Subject: [PATCH 29/29] Update BLE connection parameters and enhance DirCon message parsing error handling --- include/BLE_Common.h | 2 +- src/BLE_Client.cpp | 4 ++-- src/DirConManager.cpp | 3 ++- src/DirConMessage.cpp | 25 +++++++++++++++---------- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/include/BLE_Common.h b/include/BLE_Common.h index 7edfed89..34cbe012 100644 --- a/include/BLE_Common.h +++ b/include/BLE_Common.h @@ -29,7 +29,7 @@ // maxInterval – [in] The maximum connection interval in 1.25ms units. // latency – [in] The number of packets allowed to skip (extends max interval). // timeout – [in] The timeout time in 10ms units before disconnecting. -const uint16_t connectionParams[] = {24, 48, 0, 200}; +const uint16_t connectionParams[] = {24, 48, 0, 500}; // Vector of supported BLE services and their corresponding characteristic UUIDs struct BLEServiceInfo { diff --git a/src/BLE_Client.cpp b/src/BLE_Client.cpp index 604304e8..be616d58 100644 --- a/src/BLE_Client.cpp +++ b/src/BLE_Client.cpp @@ -309,8 +309,8 @@ bool SpinBLEClient::connectToServer() { SS2K_LOG(BLE_CLIENT_LOG_TAG, " - Created new client"); pClient->setClientCallbacks(&myClientCallback, false); pClient->setSelfDelete(true, true); - // Initial connection parameters: 15ms interval, 0 latency, 1000ms timeout (kept from previous logic) - pClient->setConnectionParams(connectionParams[0], connectionParams[1], connectionParams[2], 1000); + // Initial connection parameters: 30-60 ms interval, 0 latency, 5-second supervision timeout. + pClient->setConnectionParams(connectionParams[0], connectionParams[1], connectionParams[2], connectionParams[3]); pClient->setConnectTimeout(10000); // 10 seconds if (!pClient->connect(myDevice, true, false, false)) { return handleFailedClientConnect(); diff --git a/src/DirConManager.cpp b/src/DirConManager.cpp index c2d38561..90313cf4 100644 --- a/src/DirConManager.cpp +++ b/src/DirConManager.cpp @@ -369,7 +369,8 @@ void DirConManager::handleClientData() { size_t parsedBytes = message.parse(receiveBuffer[i] + processedBytes, receiveBufferLength[i] - processedBytes, lastSequenceNumber[i]); if (parsedBytes == 0) { - // Not enough data for a complete message or invalid message + // Keep an incomplete TCP frame buffered until the remaining bytes arrive. + // Complete invalid frames return their frame length so they are discarded. break; } diff --git a/src/DirConMessage.cpp b/src/DirConMessage.cpp index 57d24385..1da1bdf0 100644 --- a/src/DirConMessage.cpp +++ b/src/DirConMessage.cpp @@ -186,6 +186,11 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { return 0; } + // The complete frame is available. If its contents are invalid or its + // identifier is unsupported, return this length after logging the error so + // the caller can discard only this frame and continue parsing the stream. + const size_t frameLength = DIRCON_MESSAGE_HEADER_LENGTH + this->Length; + size_t parsedBytes = 6; switch (this->Identifier) { case DIRCON_MSGID_DISCOVER_SERVICES: @@ -205,7 +210,7 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { } else { SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Length %d isn't a multiple of 16", this->Length); this->Identifier = DIRCON_MSGID_ERROR; - return 0; + return frameLength; } break; @@ -234,7 +239,7 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { } else { SS2K_LOG(DIRCON_LOG_TAG, "Error parsing additional UUIDs and data: Length %d isn't a multiple of 17", (this->Length - 16)); this->Identifier = DIRCON_MSGID_ERROR; - return 0; + return frameLength; } break; @@ -256,7 +261,7 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { } else { SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Length %d < 16", this->Length); this->Identifier = DIRCON_MSGID_ERROR; - return 0; + return frameLength; } break; @@ -274,7 +279,7 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { } else { SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Length %d < 16", this->Length); this->Identifier = DIRCON_MSGID_ERROR; - return 0; + return frameLength; } break; @@ -300,7 +305,7 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { } else { SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Length %d < 16 for enable notifications", this->Length); this->Identifier = DIRCON_MSGID_ERROR; - return 0; + return frameLength; } break; @@ -317,20 +322,20 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { } else { SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Length %d < 16", this->Length); this->Identifier = DIRCON_MSGID_ERROR; - return 0; + return frameLength; } break; default: char hexBuf[kDirConHexLogMaxBytes * 3 + 1]; - const size_t bytesToLog = (len < kDirConHexLogMaxBytes) ? len : kDirConHexLogMaxBytes; + const size_t bytesToLog = (frameLength < kDirConHexLogMaxBytes) ? frameLength : kDirConHexLogMaxBytes; for (size_t i = 0; i < bytesToLog; i++) { snprintf(hexBuf + i * 3, 4, "%02X ", data[i]); } hexBuf[bytesToLog * 3] = '\0'; - SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Unknown identifier %d. Full message (%zu bytes): %s", this->Identifier, len, hexBuf); + SS2K_LOG(DIRCON_LOG_TAG, "Error parsing DirCon message: Unknown identifier %d. Full message (%zu bytes): %s", this->Identifier, frameLength, hexBuf); this->Identifier = DIRCON_MSGID_ERROR; - return 0; + return frameLength; break; } @@ -339,4 +344,4 @@ size_t DirConMessage::parse(uint8_t* data, size_t len, uint8_t sequenceNumber) { bool DirConMessage::isRequest(int sequenceNumber) { return this->ResponseCode == DIRCON_RESPCODE_SUCCESS_REQUEST && (sequenceNumber <= 0 || sequenceNumber != this->SequenceNumber); -} \ No newline at end of file +}