Files
pyxis/patch_nimble.py
T
torlando-agent[bot]andClaude Opus 4.8 36d93f965f fix: announce/sync interval units+defaults, live Settings clock, OTA repair
Settings/clock (user-reported):
- Announce interval: default 1h (was 60s); the field is now MINUTES (label
  "Announce Interval (min):", populate /60, read *60).
- Prop sync interval: default 4h (was 1h); the field is now HOURS (label
  "Prop Sync Interval (hrs):", populate /3600, read *3600); log prints hours.
- Settings clock now ticks live: SettingsScreen::tick() (throttled ~1s) is hooked
  into UIManager::update() like the announce-list tick, so the Time/GPS/system
  readouts refresh while the screen is open instead of being a static snapshot.

OTA repair (was broken two ways; diagnosed via an ultracode workflow):
- patch_nimble.py hardcoded the `tdeck` libdeps path, so building any OTHER env
  (tdeck-ota gets its own libdeps tree) never received the NimBLE patch that
  DEFINES nimble_host_reset_reason -> undefined-reference link error. Made it
  env-aware (env.get("PIOENV",...)), matching the four sibling pre-scripts. Also
  restores 3 NimBLE stability patches that were silently missing from OTA builds.
- ArduinoOTA onStart synchronously called TCPClientInterface::stop() (blocks up to
  30s), overrunning espota's ~10s connect-back window -> "No response from device"
  (phase 2), and its un-fed watchdog tripped reboots. Made onStart non-blocking.
- [env:tdeck-ota] upload_command invoked a non-existent tools/espota.py -> switched
  to upload_protocol=espota.
- Do NOT WiFi.setSleep(false): the ESP32 requires WiFi modem-sleep when WiFi+BT
  coexist (aborts at boot otherwise) -- documented inline.

Verified on device: tdeck-ota links; OTA over WiFi completes 100% (Result: OK) and
the device reboots clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-22 23:28:48 -04:00

161 lines
6.7 KiB
Python

"""
PlatformIO pre-build script: Patch NimBLE stability issues.
Patch 1 — ble_hs.c: Remove assert(0) in BLE_HS_SYNC_STATE_BRINGUP timer handler.
Timer can fire during host re-sync due to a race condition. Harmless — just ignore it.
Patch 2 — NimBLEClient.cpp: Add null checks in PHY update event handler.
If a client is deleted while events are queued, the callback arg becomes a dangling
pointer. Guard against null pClient and null m_pClientCallbacks.
Patch 3 — ble_gap.c: Handle BLE_ERR_CONN_ESTABLISHMENT (574) in conn complete handler.
NimBLE only handles 574 when BLE_PERIODIC_ADV_WITH_RESPONSES is enabled. Without this
patch, 574 falls through to assert(0) and the master GAP state is never cleaned up,
leaving scan/advertise permanently broken. The ESP32-S3 controller returns 574 when
connection establishment fails (peer disappeared, RF interference, etc.).
Patch 4 — NimBLEDevice.cpp: Expose host reset reason via global volatile variable.
NimBLE's onReset callback logs reason to ESP_LOG (serial only). This patch adds a
global volatile int that application code can poll to capture the reason in UDP logs.
"""
Import("env")
import os
NIMBLE_BASE = os.path.join(
env.get("PROJECT_DIR", "."),
# Per-environment libdeps tree (each env gets its own). Was hardcoded to
# "tdeck", so building any OTHER env (e.g. tdeck-ota) patched the wrong tree
# and left nimble_host_reset_reason undefined -> link error. Match the sibling
# pre-scripts (patch_msgpack/filestore/littlefs, sync_file_libdeps).
".pio", "libdeps", env.get("PIOENV", "tdeck"), "NimBLE-Arduino", "src"
)
def apply_patch(filepath, old, new, label):
if not os.path.exists(filepath):
print(f"PATCH: {os.path.basename(filepath)} not found, skipping {label}")
return
with open(filepath, "r") as f:
content = f.read()
if old in content:
content = content.replace(old, new)
with open(filepath, "w") as f:
f.write(content)
print(f"PATCH: {label}")
elif new in content:
print(f"PATCH: {label} (already applied)")
else:
print(f"PATCH: WARNING -- {label}: expected code not found")
# Patch 1: ble_hs.c timer assert
apply_patch(
os.path.join(NIMBLE_BASE, "nimble", "nimble", "host", "src", "ble_hs.c"),
""" case BLE_HS_SYNC_STATE_BRINGUP:
default:
/* The timer should not be set in this state. */
assert(0);
break;""",
""" case BLE_HS_SYNC_STATE_BRINGUP:
default:
/* Timer can fire during bringup due to race with host reset.
* This is harmless — bringup will reschedule when ready. */
break;""",
"ble_hs.c: removed assert(0) in BRINGUP timer handler"
)
# Patch 2: NimBLEClient.cpp PHY update null guard
apply_patch(
os.path.join(NIMBLE_BASE, "NimBLEClient.cpp"),
""" case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: {
NimBLEConnInfo peerInfo;
rc = ble_gap_conn_find(event->phy_updated.conn_handle, &peerInfo.m_desc);
if (rc != 0) {
return BLE_ATT_ERR_INVALID_HANDLE;
}
pClient->m_pClientCallbacks->onPhyUpdate(pClient, event->phy_updated.tx_phy, event->phy_updated.rx_phy);
return 0;
} // BLE_GAP_EVENT_PHY_UPDATE_COMPLETE""",
""" case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: {
if (pClient == nullptr || pClient->m_pClientCallbacks == nullptr) {
return 0;
}
NimBLEConnInfo peerInfo;
rc = ble_gap_conn_find(event->phy_updated.conn_handle, &peerInfo.m_desc);
if (rc != 0) {
return BLE_ATT_ERR_INVALID_HANDLE;
}
pClient->m_pClientCallbacks->onPhyUpdate(pClient, event->phy_updated.tx_phy, event->phy_updated.rx_phy);
return 0;
} // BLE_GAP_EVENT_PHY_UPDATE_COMPLETE""",
"NimBLEClient.cpp: added null guard in PHY update handler"
)
# Patch 3: ble_gap.c — handle BLE_ERR_CONN_ESTABLISHMENT (574) without PAwR
# The ESP32-S3 controller returns error 574 when connection establishment fails.
# NimBLE only handles this in the BLE_PERIODIC_ADV_WITH_RESPONSES path. Without it,
# 574 hits the default case (assert(0) + no cleanup), leaving master GAP state stuck
# in BLE_GAP_OP_M_CONN — permanently blocking scan and advertising.
apply_patch(
os.path.join(NIMBLE_BASE, "nimble", "nimble", "host", "src", "ble_gap.c"),
"""#if MYNEWT_VAL(BLE_PERIODIC_ADV_WITH_RESPONSES)
case BLE_ERR_CONN_ESTABLISHMENT:
if (!v1_evt) {
ble_gap_rx_conn_comp_failed(evt);
}
break;
#endif // MYNEWT_VAL(BLE_PERIODIC_ADV_WITH_RESPONSES)
default:
/* this should never happen, unless controller is broken */
BLE_HS_LOG(INFO, "controller reported invalid error code in conn"
"complete event: %u", evt->status);
assert(0);
break;""",
""" case BLE_ERR_CONN_ESTABLISHMENT:
/* Connection establishment failed (e.g. peer disappeared).
* Clean up master GAP state so scan/advertise can resume.
* Without this, the master state stays stuck in BLE_GAP_OP_M_CONN. */
if (ble_gap_master_in_progress()) {
ble_gap_master_failed(BLE_HS_ECONTROLLER);
}
break;
default:
/* this should never happen, unless controller is broken */
BLE_HS_LOG(INFO, "controller reported invalid error code in conn"
"complete event: %u", evt->status);
if (ble_gap_master_in_progress()) {
ble_gap_master_failed(BLE_HS_ECONTROLLER);
}
break;""",
"ble_gap.c: handle BLE_ERR_CONN_ESTABLISHMENT (574) to prevent stuck GAP state"
)
# Patch 4: NimBLEDevice.cpp — expose host reset reason for application-level logging
# NimBLE's onReset callback logs via NIMBLE_LOGE which only goes to serial UART.
# This patch adds a global volatile int that our BLE loop can poll and log via UDP.
apply_patch(
os.path.join(NIMBLE_BASE, "NimBLEDevice.cpp"),
"""void NimBLEDevice::onReset(int reason) {
if (!m_synced) {
return;
}
m_synced = false;
NIMBLE_LOGE(LOG_TAG, "Host reset; reason=%d, %s", reason, NimBLEUtils::returnCodeToString(reason));
} // onReset""",
"""volatile int nimble_host_reset_reason = 0;
void NimBLEDevice::onReset(int reason) {
if (!m_synced) {
return;
}
m_synced = false;
nimble_host_reset_reason = reason;
NIMBLE_LOGE(LOG_TAG, "Host reset; reason=%d, %s", reason, NimBLEUtils::returnCodeToString(reason));
} // onReset""",
"NimBLEDevice.cpp: expose host reset reason for application-level logging"
)