Skip to content

Commit e825f90

Browse files
committed
fix: retain failed BLE evidence snapshots
1 parent fa4e10b commit e825f90

8 files changed

Lines changed: 574 additions & 29 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ jobs:
3737
run: python3 tools/test_ride_history.py --compiler cc
3838
- name: Test ride recording
3939
run: python3 tools/test_ride_recording.py --compiler cc
40+
- name: Test BLE evidence storage
41+
run: python3 tools/test_ble_evidence.py --compiler cc
4042
- name: Test Ring history storage
4143
run: python3 tools/test_ring_history.py --compiler cc
4244
- name: Test ebook pages
@@ -178,6 +180,22 @@ jobs:
178180
if-no-files-found: warn
179181
retention-days: 14
180182

183+
- name: Retain BLE evidence storage checks
184+
uses: actions/upload-artifact@v4
185+
if: always()
186+
with:
187+
name: ble-evidence-logs
188+
path: |
189+
build/ble-evidence/compile.log
190+
build/ble-evidence/result.log
191+
build/ble-evidence/source.json
192+
build/ble-evidence/ble_evidence.inc
193+
build/ble-evidence/ble_evidence_types.inc
194+
build/ble-evidence/ble_evidence_config.inc
195+
build/ble-evidence/storage_source.inc
196+
if-no-files-found: warn
197+
retention-days: 14
198+
181199
- name: Retain Ring history test evidence
182200
uses: actions/upload-artifact@v4
183201
if: always()

‎ROADMAP.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ Phase 3 software progress has begun:
164164
- [x] MQTT PASTE BYTES prepares a separate 0-128-byte publish draft from Byte Lab, including empty payloads. Explicit Text/Bytes selection preserves the current text field; queued requests retain captured bytes/topic/QoS/retain through later edits and Home. Paste/mode changes never send or log, cleanup rejects stale taps, and profiles/metadata exclude the body. Ten native scenarios cover every length, pending/active ownership, errors and 25 stable cycles. Four actual Byte Lab/SDK/broker round trips cover QoS 0/1/2 and empty data; three private mutations are rejected. The built firmware remains unflashed; physical validation is open.
165165
- [ ] Validate authenticated TLS and WSS brokers, saved-profile reboot/overwrite/delete/corruption recovery, cleartext confirmation/expiry, QoS 0/2, wildcard subscriptions, fragmentation and receive-ring overflow, failed auth/DNS/TLS/timeout paths, SD full/removal, and the second panel family. NVS encryption remains part of the opt-in hardened-device profile.
166166
- [x] BLE GATT Explorer reuses the shared NimBLE scanner but never starts it or connects automatically. It caps results at eight devices, 16 services, and 32 characteristics; requires product-specific BLE profiles to be off; exposes UUIDs, handles, and properties; supports explicit reads and one notification/indication subscription; gates 1-64-byte raw writes behind an unchanged five-second confirmation; and atomically exports the bounded evidence snapshot to SD only after an explicit tap.
167+
- [x] BLE evidence never publishes after a rejected header/row write, retains the named TMP and preserves the first cause through close/error reporting. Creation stops on noncollision directory/probe/open/stream errors and reports `EEXIST` after all ten names are occupied. All 50 native callback/formatter/storage cases pass; the previous callback fails 28 controls, including eight rejected writes incorrectly published. Exact bytes, cache isolation/capacity, admission gates and 25 failure/restart pairs are checked with closed streams/descriptors and balanced modeled locks. Linux CI retains source hashes and logs; physical SD, live BLE, panel and power cuts remain open.
167168
- [x] BLE COPY LATEST VALUE copies a complete 0-64-byte received payload and disarms write confirmation without BLE traffic. It uses the original value handle, rejects truncated or failed buffer copies, and preserves the shared clipboard through disconnect. A read returns one ATT response, not an assembled long attribute. On-device startup tests cover all lengths 0-64, a 65-byte rejection, chained/short buffers, read errors, notify/indicate, stale connections and disconnect. Live GATT copy and evidence-export checks remain open.
168169
- [x] On ST7121 hardware, an explicit scan filled the eight-device cap and found the owned `KICKR CORE 4816`. Read-only discovery reported 12 services and the disclosed 32-characteristic cap; Manufacturer Name returned `WahooFitness`; Indoor Bike Data `0x2AD2` subscribed, delivered an 8-byte notification, and unsubscribed. The published CSV contained eight advertisements, 32 characteristic rows, and the correctly labeled notification row. Home released the live connection, immediate re-entry was idle, System reported `Reset: USB`, and internal heap remained 62 KB free/60 KB minimum.
169170
- [ ] Validate no-target and overflowing scans, connection cancel/failure, pairing/authentication errors, indications and fragmented/over-64-byte values, the raw-write confirmation/expiry path on a disposable target, SD full/removal and retained-temp behavior, interaction with each optional product profile, and the second panel family.

‎docs/architecture.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ Ride history shows totals only after recovery, reading and closing the summary s
9191

9292
Ride recording checks both directories and advances through its 100 same-second names only on CSV/TMP collisions. Other creation errors stop Start; a failed stream conversion closes its descriptor and attempts to remove the newly created empty TMP. Rejected headers/rows and periodic sync failures use one abort path that closes recording ownership, retains the TMP and reports the captured first cause. Tick and Stop also abort on known SD loss before another row or publication. Healthy Stop commits the CSV before rebuilding history, so an index failure leaves the published ride authoritative. Native tests compile the actual lifecycle and history helpers together with storage; UI, BLE data, time and SD boundaries are controlled.
9393

94+
BLE evidence copies its bounded shared cache under the existing lock, then writes that snapshot synchronously. Each directory, final-name probe and file-creation boundary uses a fresh error; only actual CSV/TMP collisions advance the ten-name search. Header and row failures capture the first cause and close the stream without invoking publication. The named TMP remains available for inspection, and cleanup/error reporting cannot replace that cause. Healthy saves use the unchanged storage helper and CSV schema. Native checks compile the callback, its format/status helpers and cache typedefs with modeled BLE/UUID/lock boundaries and real files; no worker or LVGL pool change is introduced.
95+
9496
Ring's saved heart-rate timestamp becomes loaded only after incomplete-tail repair, seek/size checks, the expected bounded tail read and close succeed. Read or close errors leave the checkpoint unchanged and retryable. Its existing health timer retains queued samples and defers history requests until that load succeeds, then filters timestamps against the recovered checkpoint. Known SD unavailability blocks queue processing even when a checkpoint is cached; timeout state still advances before the storage gate. Appends preserve the first error through close and tail repair. Native files and controlled queue/timer/SD boundaries verify this behavior; physical SD, BLE and scheduling remain open.
9597

9698
Ebooks reads up to 8 KiB into its existing cached PSRAM buffer. Page text, current/next byte positions, navigation state and scroll position change only after successful seek, read and close. Failure preserves the confirmed page and positions, reports the first error and allows retry when storage is available. Reader initialization clears both offsets and disables navigation until loading succeeds. Callbacks recheck disabled state and screen pointers; negative positions and additions beyond the platform's `long` range are rejected. Native files and controlled UI/heap/SD boundaries verify the transitions; physical SD and panel behavior remain open.

‎docs/data-formats.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,6 @@ unix_time,event,address,name,rssi,service_uuid,characteristic_uuid,handle,proper
151151

152152
`event` is `advertisement`, `characteristic`, `read`, `notification`, or `indication`. Properties use `R` for read, `W` for write with response, `w` for write without response, `N` for notify, and `I` for indicate. The snapshot is deliberately bounded to eight advertisers, 16 services, 32 characteristics, and 64 displayed value bytes. A trailing ` ...` marks a truncated value; `[unavailable]` means receive-buffer copying failed and no bytes are exported. These markers are not hex bytes. Earlier firmware labeled indications as `notification`. It includes nearby device names and addresses, so saving is explicit and the UI discloses this privacy tradeoff.
153153

154+
BLE evidence creation stops on directory/probe/open/stream errors and retries only occupied CSV/TMP names; exhausting the ten same-second names reports `EEXIST`. A failed stream conversion attempts to remove its newly created empty TMP. Each CSV write must succeed before the publication helper runs. A rejected header or row closes the stream, retains the named TMP and reports its first cause without retrying publication; missing errno falls back to `EIO`. Healthy publication still requires flush, sync and close. Native byte/fault checks cover retained generations and same-second restart; physical SD and power-loss recovery remain hardware checks.
155+
154156
These formats are still pre-1.0. Add fields at the end when possible, never silently change a unit, and document any incompatible migration in release notes.

‎docs/testing.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Python 3 and Node.js are needed for the quick checks. The configuration checks a
2424
| Offline apps, SPI or I2C editor/result flows | [Offline and peripheral UI](#offline-and-peripheral-ui). |
2525
| Serial, saved serial logs or RTU Frames | [Serial and RTU UI](#serial-and-rtu-ui). |
2626
| UART or RS-485 SD log writes | [Serial log writers](#serial-log-writers) and a firmware build. |
27+
| BLE evidence export | [BLE evidence storage](#ble-evidence-storage) and a firmware build. |
2728
| Modbus TCP, NTP, Wake-on-LAN, UDP or HTTP fixtures | [Network fixtures and UI](#network-fixtures-and-ui). |
2829
| MQTT app, packet codec or client protocol | [Lifecycle/storage](#mqtt-lifecycle-and-storage) and [client/broker fixture](#mqtt-client-and-broker-fixture). |
2930
| OTA manifest parsing and update eligibility | [OTA manifests](#ota-manifests) plus a firmware build. |
@@ -211,6 +212,20 @@ UI status/control callbacks, clocks/ticks, SD admission and I/O failures are con
211212

212213
Full serial/function/storage/header/test hashes, generated function/config/storage source and compile/result logs stay under `build/serial-log-write/`. Linux CI retains `serial-log-write-logs`, including available failure diagnostics. These 82 cases are separate from the Windows native total.
213214

215+
## BLE evidence storage
216+
217+
```powershell
218+
python tools/test_ble_evidence.py --compiler 'C:/path/to/clang.exe'
219+
```
220+
221+
This portable fixture compiles the actual BLE save callback, its status/address/property/name helpers, its three cache typedefs and the complete `storage_io.c`. Its 50 cases check exact native CSV/TMP bytes, header and each row failure, directory/probe/open/stream errors, flush/sync/close/publication failures, mixed collisions, all ten occupied names and 25 rejected-header/restart pairs. Every case checks closed streams/descriptors and balanced modeled locks. The previous callback fails 28 controls, including eight rejected writes incorrectly published as CSVs; other controls concern creation/error handling and the retained-file notice.
222+
223+
Healthy cases cover read/notification/indication labels, empty/truncated/unavailable values, CSV name sanitization, missing peers, invalid service indexes and all eight device/16 service/32 characteristic/64 value-byte cache limits. Mutating the shared cache after the snapshot lock exits preserves the originally captured output. Maximum-cache entries repeat controlled identities to exercise capacity; this fixture does not validate live discovery identities.
224+
225+
Cache contents, NimBLE type boundaries and 16-bit UUID conversion, locks, SD admission/error reporting, clocks and I/O faults are controlled. The snapshot is taken through the actual callback; no NimBLE, BLE traffic, real LVGL, SDK mount/error filtering, 32/128-bit UUID conversion or physical FAT/SD/panel/power behavior is executed. UTC fixes filenames, Windows descriptors use binary mode for SDK LF bytes, and cleanup removes only owned files and directories. Failed writes close without a publication retry and retain a named TMP; those bytes can be partial or complete-looking without confirmed physical durability.
226+
227+
Full BLE/function/cache-type/storage/header/test hashes, generated function/type/config/storage source and compile/result logs stay under `build/ble-evidence/`. Linux CI retains `ble-evidence-logs`, including available failure diagnostics. These 50 cases are separate from the Windows native total and live GATT tests.
228+
214229
## CSV viewer storage and UI
215230

216231
```powershell

‎main/ble_tool.c‎

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -963,32 +963,46 @@ static void save_evidence_clicked(lv_event_t *event)
963963
strftime(day, sizeof(day), "%y%m%d", &local);
964964
char directory[32];
965965
snprintf(directory, sizeof(directory), "/sdcard/BLE/%s", day);
966-
if ((mkdir("/sdcard/BLE", 0775) != 0 && errno != EEXIST) ||
967-
(mkdir(directory, 0775) != 0 && errno != EEXIST)) {
968-
int error = errno ? errno : EIO;
969-
if (report_storage_error) report_storage_error(error);
970-
set_status("Evidence directory failed: %s", strerror(error));
971-
return;
966+
const char *folders[] = {"/sdcard/BLE", directory};
967+
for (unsigned i = 0; i < 2; i++) {
968+
errno = 0;
969+
if (mkdir(folders[i], 0775) != 0 && errno != EEXIST) {
970+
int error = errno ? errno : EIO;
971+
if (report_storage_error) report_storage_error(error);
972+
set_status("Evidence directory failed: %s", strerror(error));
973+
return;
974+
}
972975
}
973976

974977
char temporary_path[48] = "";
975978
char final_path[48] = "";
976979
int descriptor = -1;
980+
int create_error = EEXIST;
977981
for (int collision = 0; collision < 10; collision++) {
978982
snprintf(temporary_path, sizeof(temporary_path), "%s/B%02d%02d%02d%d.TMP",
979983
directory, local.tm_hour, local.tm_min, local.tm_sec, collision);
980984
snprintf(final_path, sizeof(final_path), "%s/B%02d%02d%02d%d.CSV",
981985
directory, local.tm_hour, local.tm_min, local.tm_sec, collision);
986+
errno = 0;
982987
if (access(final_path, F_OK) == 0) continue;
988+
if (errno != ENOENT) {
989+
create_error = errno ? errno : EIO;
990+
break;
991+
}
992+
errno = 0;
983993
descriptor = open(temporary_path, O_WRONLY | O_CREAT | O_EXCL, 0664);
984-
if (descriptor >= 0 || errno != EEXIST) break;
994+
if (descriptor >= 0) break;
995+
if (errno != EEXIST) {
996+
create_error = errno ? errno : EIO;
997+
break;
998+
}
985999
}
9861000
if (descriptor < 0) {
987-
int error = errno ? errno : EEXIST;
988-
if (report_storage_error) report_storage_error(error);
989-
set_status("Evidence file could not be created: %s", strerror(error));
1001+
if (report_storage_error) report_storage_error(create_error);
1002+
set_status("Evidence file could not be created: %s", strerror(create_error));
9901003
return;
9911004
}
1005+
errno = 0;
9921006
FILE *file = fdopen(descriptor, "wb");
9931007
if (!file) {
9941008
int error = errno ? errno : EIO;
@@ -1005,17 +1019,20 @@ static void save_evidence_clicked(lv_event_t *event)
10051019
address_text(&device_snapshot[peer_index].address, peer_address);
10061020
csv_text(device_snapshot[peer_index].name, peer_name, sizeof(peer_name));
10071021
}
1008-
bool ok = fprintf(file, "unix_time,event,address,name,rssi,service_uuid,"
1009-
"characteristic_uuid,handle,properties,value_hex\n") >= 0;
1010-
for (size_t i = 0; ok && i < devices_found; i++) {
1022+
errno = 0;
1023+
int error = fprintf(file, "unix_time,event,address,name,rssi,service_uuid,"
1024+
"characteristic_uuid,handle,properties,value_hex\n") < 0 ? (errno ? errno : EIO) : 0;
1025+
for (size_t i = 0; !error && i < devices_found; i++) {
10111026
char address[18];
10121027
char name[33];
10131028
address_text(&device_snapshot[i].address, address);
10141029
csv_text(device_snapshot[i].name, name, sizeof(name));
1015-
ok = fprintf(file, "%lld,advertisement,%s,%s,%d,,,,,\n",
1016-
(long long)now, address, name, device_snapshot[i].rssi) >= 0;
1030+
errno = 0;
1031+
if (fprintf(file, "%lld,advertisement,%s,%s,%d,,,,,\n",
1032+
(long long)now, address, name, device_snapshot[i].rssi) < 0)
1033+
error = errno ? errno : EIO;
10171034
}
1018-
for (size_t i = 0; ok && i < characteristics_found; i++) {
1035+
for (size_t i = 0; !error && i < characteristics_found; i++) {
10191036
if (characteristic_snapshot[i].service_index >= services_found) continue;
10201037
char service_uuid[BLE_UUID_STR_LEN];
10211038
char characteristic_uuid[BLE_UUID_STR_LEN];
@@ -1024,27 +1041,35 @@ static void save_evidence_clicked(lv_event_t *event)
10241041
service_uuid);
10251042
ble_uuid_to_str(&characteristic_snapshot[i].uuid.u, characteristic_uuid);
10261043
properties_text(characteristic_snapshot[i].properties, properties);
1027-
ok = fprintf(file, "%lld,characteristic,%s,%s,,%s,%s,0x%04X,%s,\n",
1028-
(long long)now, peer_address, peer_name, service_uuid,
1029-
characteristic_uuid, characteristic_snapshot[i].val_handle,
1030-
properties) >= 0;
1044+
errno = 0;
1045+
if (fprintf(file, "%lld,characteristic,%s,%s,,%s,%s,0x%04X,%s,\n",
1046+
(long long)now, peer_address, peer_name, service_uuid,
1047+
characteristic_uuid, characteristic_snapshot[i].val_handle,
1048+
properties) < 0)
1049+
error = errno ? errno : EIO;
10311050
}
1032-
if (ok && value_seen) {
1051+
if (!error && value_seen) {
10331052
char value_hex[BLE_TOOL_VALUE_MAX * 3 + 1] = "";
10341053
size_t used = 0;
10351054
for (size_t i = 0; i < value_length && used < sizeof(value_hex); i++)
10361055
used += (size_t)snprintf(value_hex + used, sizeof(value_hex) - used,
10371056
"%s%02X", i ? " " : "", value_snapshot[i]);
1038-
ok = fprintf(file, "%lld,%s,%s,%s,,,,0x%04X,,%s%s\n", (long long)now,
1039-
value_was_indication ? "indication" : value_was_notification ? "notification" : "read",
1040-
peer_address, peer_name, value_handle, value_hex,
1041-
value_copy_failed ? "[unavailable]" : value_total > value_length ? " ..." : "") >= 0;
1057+
errno = 0;
1058+
if (fprintf(file, "%lld,%s,%s,%s,,,,0x%04X,,%s%s\n", (long long)now,
1059+
value_was_indication ? "indication" : value_was_notification ? "notification" : "read",
1060+
peer_address, peer_name, value_handle, value_hex,
1061+
value_copy_failed ? "[unavailable]" : value_total > value_length ? " ..." : "") < 0)
1062+
error = errno ? errno : EIO;
10421063
}
1043-
if (!ok && !errno) errno = EIO;
1044-
if (storage_commit_new_file(&file, temporary_path, final_path) != 0) {
1045-
int error = errno ? errno : EIO;
1064+
if (error) fclose(file);
1065+
else {
1066+
errno = 0;
1067+
if (storage_commit_new_file(&file, temporary_path, final_path) != 0) error = errno ? errno : EIO;
1068+
}
1069+
if (error) {
10461070
if (report_storage_error) report_storage_error(error);
1047-
set_status("Evidence save failed: %s", strerror(error));
1071+
const char *name = strrchr(temporary_path, '/');
1072+
set_status("Evidence not published; %s retained (%s)", name ? name + 1 : "TMP", strerror(error));
10481073
return;
10491074
}
10501075
set_status("Evidence saved to %s", final_path);

0 commit comments

Comments
 (0)