From 307b92739e322193062db1481d159a3fa12d04de Mon Sep 17 00:00:00 2001 From: srgg Date: Tue, 27 Oct 2026 06:27:07 -0600 Subject: [PATCH 1/5] Show a hidden service to connected centrals and announce the change addService() on a service hidden with removeService(svc, false) only flagged it for the next GATT reset, which start() runs only with no central connected, and the only Service Changed went out after that reset. A connected central could not get the service back and never heard about the hide or the show. Hidden services now stay registered across a reset, addService() makes one visible in place, and hide and show indicate Service Changed for that service's handle range. --- src/NimBLEServer.cpp | 62 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/NimBLEServer.cpp b/src/NimBLEServer.cpp index 2e54f088..18abb32f 100644 --- a/src/NimBLEServer.cpp +++ b/src/NimBLEServer.cpp @@ -195,6 +195,24 @@ void NimBLEServer::sendServiceChangedIndication() const { ble_svc_gatt_changed(0x0001, 0xffff); } +/** + * @brief Indicate a change to the handle range of one registered service only, so a client + * rediscovers that service and keeps the rest of its table. + * @details NimBLE allocates handles sequentially in registration order, so a service runs from its + * own handle to the one before the next registered service; the last one runs to the table's end. + */ +static void indicateServiceRangeChanged(const std::vector& svcVec, const NimBLEService* pSvc) { + const uint16_t start = pSvc->getHandle(); + uint16_t end = 0xffff; + for (const auto* pOther : svcVec) { + const uint16_t handle = pOther->getHandle(); + if (handle > start && handle - 1 < end) { + end = handle - 1; + } + } + ble_svc_gatt_changed(start, end); +} + /** * @brief Callback for GATT registration events, * used to obtain the assigned handles for services, characteristics, and descriptors. @@ -209,7 +227,7 @@ void NimBLEServer::gattRegisterCallback(ble_gatt_register_ctxt* ctxt, void* arg) NimBLEUUID uuid(ctxt->svc.svc_def->uuid); args->pSvc = nullptr; for (auto pSvc : NimBLEDevice::getServer()->m_svcVec) { - if (!pSvc->getRemoved() && pSvc->m_handle == 0 && pSvc->getUUID() == uuid) { + if (pSvc->getRemoved() != NIMBLE_ATT_REMOVE_DELETE && pSvc->m_handle == 0 && pSvc->getUUID() == uuid) { pSvc->m_handle = ctxt->svc.handle; NIMBLE_LOGD(LOG_TAG, "Service registered: %s, handle=%d", uuid.toString().c_str(), ctxt->svc.handle); // Set the arg to the service so we know that the following @@ -295,6 +313,13 @@ bool NimBLEServer::start() { return false; } + // resetGATT() registered the hidden services too; hide them again now that they have handles. + for (const auto& svc : m_svcVec) { + if (svc->getRemoved() == NIMBLE_ATT_REMOVE_HIDE) { + ble_gatts_svc_set_visibility(svc->getHandle(), 0); + } + } + # if MYNEWT_VAL(NIMBLE_CPP_LOG_LEVEL) >= 4 ble_gatts_show_local(); @@ -838,8 +863,9 @@ void NimBLEServer::resetCallbacks() { * available and can be re-added in the future. If desired a removed but not deleted service can * be deleted later by calling this method with deleteSvc set to true. * - * @note The service will not be removed from the database until all open connections are closed - * as it requires resetting the GATT server. In the interim the service will have it's visibility disabled. + * @note A service removed but not deleted stays registered with its visibility disabled, through any + * GATT reset, so addService() can show it again while clients are connected. A deleted service + * leaves the database at the next reset, which waits until all open connections are closed. * * @note Advertising will need to be restarted by the user after calling this as we must stop * advertising in order to remove the service. @@ -860,6 +886,8 @@ void NimBLEServer::removeService(NimBLEService* service, bool deleteSvc) { break; } } + // A hidden service is still registered; only a reset drops it from the database. + setServiceChanged(); } return; @@ -871,7 +899,11 @@ void NimBLEServer::removeService(NimBLEService* service, bool deleteSvc) { } service->setRemoved(deleteSvc ? NIMBLE_ATT_REMOVE_DELETE : NIMBLE_ATT_REMOVE_HIDE); - setServiceChanged(); + if (deleteSvc) { + setServiceChanged(); + } else { + indicateServiceRangeChanged(m_svcVec, service); + } # if !MYNEWT_VAL(BLE_EXT_ADV) && MYNEWT_VAL(BLE_ROLE_BROADCASTER) NimBLEDevice::getAdvertising()->removeServiceUUID(service->getUUID()); # endif @@ -897,6 +929,14 @@ void NimBLEServer::addService(NimBLEService* service) { return; } + // Hidden but still registered: shown in place, no reset needed. + if (service->getRemoved() == NIMBLE_ATT_REMOVE_HIDE && service->getHandle() != 0 && + ble_gatts_svc_set_visibility(service->getHandle(), 1) == 0) { + service->setRemoved(0); + indicateServiceRangeChanged(m_svcVec, service); + return; + } + service->setRemoved(0); setServiceChanged(); } // addService @@ -904,8 +944,8 @@ void NimBLEServer::addService(NimBLEService* service) { /** * @brief Resets the GATT server, used when services are added/removed after initialization. * @return True if successful. - * @details This will reset the GATT server and re-register all services, characteristics, and - * descriptors that have not been removed. Services, characteristics, and descriptors that have been + * @details This will reset the GATT server and re-register every service that has not been deleted, + * hidden ones included; start() hides those again. Characteristics and descriptors that have been * removed but not deleted will be skipped and have their handles cleared, and those that have been * deleted will be removed from the server's service vector. */ @@ -950,11 +990,11 @@ bool NimBLEServer::resetGATT() { ++chrIt; } - if (pSvc->getRemoved() == 0) { - if (!pSvc->start_internal()) { - NIMBLE_LOGE(LOG_TAG, "Failed to start service: %s", pSvc->getUUID().toString().c_str()); - return false; - } + // A hidden service stays registered, so addService() can show it again while a client is + // connected — a reset, which needs none connected, is the only way to register one. + if (!pSvc->start_internal()) { + NIMBLE_LOGE(LOG_TAG, "Failed to start service: %s", pSvc->getUUID().toString().c_str()); + return false; } pSvc->m_handle = 0; From 35cf4571895bf7620134b5ae3bb9358b5fe2b219 Mon Sep 17 00:00:00 2001 From: srgg Date: Tue, 27 Oct 2026 13:05:24 -0600 Subject: [PATCH 2/5] Send full-range Service Changed and defer deleting a hidden service ble_svc_gatt_changed() keeps one pending range, so a second hide or show before the first indication went out overwrote the first range and the central never rediscovered that service. Hide and show now indicate the full range, as sendServiceChangedIndication() does. A hidden service now survives a reset, so deleting it freed a definition the stack still pointed to. It is now marked for deletion and freed by resetGATT(). addService() shows a hidden service in place only when no reset is pending, since that reset may still change its database. --- src/NimBLEServer.cpp | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/src/NimBLEServer.cpp b/src/NimBLEServer.cpp index 18abb32f..05da0a4b 100644 --- a/src/NimBLEServer.cpp +++ b/src/NimBLEServer.cpp @@ -195,24 +195,6 @@ void NimBLEServer::sendServiceChangedIndication() const { ble_svc_gatt_changed(0x0001, 0xffff); } -/** - * @brief Indicate a change to the handle range of one registered service only, so a client - * rediscovers that service and keeps the rest of its table. - * @details NimBLE allocates handles sequentially in registration order, so a service runs from its - * own handle to the one before the next registered service; the last one runs to the table's end. - */ -static void indicateServiceRangeChanged(const std::vector& svcVec, const NimBLEService* pSvc) { - const uint16_t start = pSvc->getHandle(); - uint16_t end = 0xffff; - for (const auto* pOther : svcVec) { - const uint16_t handle = pOther->getHandle(); - if (handle > start && handle - 1 < end) { - end = handle - 1; - } - } - ble_svc_gatt_changed(start, end); -} - /** * @brief Callback for GATT registration events, * used to obtain the assigned handles for services, characteristics, and descriptors. @@ -879,14 +861,9 @@ void NimBLEServer::removeService(NimBLEService* service, bool deleteSvc) { // Otherwise, ignore the call and return. if (service->getRemoved() > 0) { if (deleteSvc) { - for (auto it = m_svcVec.begin(); it != m_svcVec.end(); ++it) { - if ((*it) == service) { - delete *it; - m_svcVec.erase(it); - break; - } - } - // A hidden service is still registered; only a reset drops it from the database. + // A hidden service is still registered and the stack keeps a pointer to its definition, + // so resetGATT() frees it once the reset has dropped it from the database. + service->setRemoved(NIMBLE_ATT_REMOVE_DELETE); setServiceChanged(); } @@ -902,7 +879,7 @@ void NimBLEServer::removeService(NimBLEService* service, bool deleteSvc) { if (deleteSvc) { setServiceChanged(); } else { - indicateServiceRangeChanged(m_svcVec, service); + sendServiceChangedIndication(); } # if !MYNEWT_VAL(BLE_EXT_ADV) && MYNEWT_VAL(BLE_ROLE_BROADCASTER) NimBLEDevice::getAdvertising()->removeServiceUUID(service->getUUID()); @@ -929,11 +906,12 @@ void NimBLEServer::addService(NimBLEService* service) { return; } - // Hidden but still registered: shown in place, no reset needed. - if (service->getRemoved() == NIMBLE_ATT_REMOVE_HIDE && service->getHandle() != 0 && + // Hidden but still registered: shown in place, no reset needed. A pending reset may still change + // the database under it, so the service waits for that reset instead. + if (service->getRemoved() == NIMBLE_ATT_REMOVE_HIDE && service->getHandle() != 0 && !m_svcChanged && ble_gatts_svc_set_visibility(service->getHandle(), 1) == 0) { service->setRemoved(0); - indicateServiceRangeChanged(m_svcVec, service); + sendServiceChangedIndication(); return; } From de52f033431e582d99aa385e1ad3d39b3dca465f Mon Sep 17 00:00:00 2001 From: srgg Date: Tue, 27 Oct 2026 14:05:56 -0600 Subject: [PATCH 3/5] Stop resetGATT when ble_gatts_reset refuses ble_gatts_reset() refuses with BLE_HS_EBUSY while an advertising, discovery or connection procedure is active, and leaves every attribute registered. resetGATT() ignored that and freed the services marked for deletion, which the stack still references. It now returns false before freeing anything; the services stay marked changed, so the next start() retries. --- src/NimBLEServer.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/NimBLEServer.cpp b/src/NimBLEServer.cpp index 05da0a4b..508a4778 100644 --- a/src/NimBLEServer.cpp +++ b/src/NimBLEServer.cpp @@ -932,7 +932,13 @@ bool NimBLEServer::resetGATT() { NimBLEDevice::stopAdvertising(); # endif - ble_gatts_reset(); + // A refused reset leaves every attribute registered, so nothing may be freed below; the next + // start() retries, since services stay marked changed. + int rc = ble_gatts_reset(); + if (rc != 0) { + NIMBLE_LOGE(LOG_TAG, "ble_gatts_reset; rc=%d, %s", rc, NimBLEUtils::returnCodeToString(rc)); + return false; + } ble_svc_gap_init(); ble_svc_gatt_init(); From 4a11d6544455143f5266ae69b40b0fac5fb53739 Mon Sep 17 00:00:00 2001 From: srgg Date: Wed, 28 Oct 2026 22:23:10 -0600 Subject: [PATCH 4/5] Re-send Service Changed to a client as it subscribes Service Changed reaches only the clients connected when it is sent; NimBLE keeps a missed indication for bonded peers alone. A client that disconnects as it goes out keeps the table it last discovered, and CoreBluetooth serves that cached table to its next connection: after a service was shown in place, the first new link saw the old table, 3 of 3 runs. The same holds for the indication start() sends after a GATT reset, with no client connected. Once any Service Changed has been sent, each client that enables it with a CCCD write gets the indication again. A bonded client's restored subscription is left to the host, which sends its pending indication itself. --- src/NimBLEServer.cpp | 24 ++++++++++++++++++++++++ src/NimBLEServer.h | 1 + 2 files changed, 25 insertions(+) diff --git a/src/NimBLEServer.cpp b/src/NimBLEServer.cpp index 508a4778..a3877fb6 100644 --- a/src/NimBLEServer.cpp +++ b/src/NimBLEServer.cpp @@ -53,6 +53,7 @@ struct gattRegisterCallbackArgs { NimBLEServer::NimBLEServer() : m_gattsStarted{false}, m_svcChanged{false}, + m_svcChangedSent{false}, # if !MYNEWT_VAL(BLE_EXT_ADV) m_advertiseOnDisconnect{false}, # endif @@ -192,6 +193,7 @@ void NimBLEServer::setServiceChanged() { * @details This should be called when services are added, removed or modified after the server has been started. */ void NimBLEServer::sendServiceChangedIndication() const { + m_svcChangedSent = true; ble_svc_gatt_changed(0x0001, 0xffff); } @@ -566,6 +568,28 @@ int NimBLEServer::handleGapEvent(ble_gap_event* event, void* arg) { uint8_t subVal = event->subscribe.cur_notify + (event->subscribe.cur_indicate << 1); NIMBLE_LOGI(LOG_TAG, "subscribe event; attr_handle=%d, subscribed: %d", event->subscribe.attr_handle, subVal); + static const ble_uuid16_t gattSvcUuid = BLE_UUID16_INIT(BLE_GATT_SVC_UUID16); + static const ble_uuid16_t svcChangedUuid = BLE_UUID16_INIT(BLE_SVC_GATT_CHR_SERVICE_CHANGED_UUID16); + uint16_t svcChangedHandle = 0; + if (ble_gatts_find_chr(&gattSvcUuid.u, &svcChangedUuid.u, nullptr, &svcChangedHandle) == 0 && + svcChangedHandle == event->subscribe.attr_handle) { + // GATT database changes can leave clients with stale attribute caches. + // NimBLE sends Service Changed indications to connected clients but preserves + // pending indications across disconnections only for bonded clients. + // + // Because unbonded clients cannot be recognized across connections, once any + // Service Changed has been sent the server indicates it to every client that + // enables it. A server may indicate only after the client enables it in the CCCD, + // so this is the earliest opportunity, and it stops a caching client + // (CoreBluetooth) from serving a stale table. A subscription restored for a bonded + // client is skipped: the host sends that client's pending indication itself. + if (pServer->m_svcChangedSent && event->subscribe.cur_indicate && + event->subscribe.reason == BLE_GAP_SUBSCRIBE_REASON_WRITE) { + ble_gatts_indicate(event->subscribe.conn_handle, svcChangedHandle); + } + break; + } + auto pChar = pServer->getCharacteristicByHandle(event->subscribe.attr_handle); if (!pChar) { NIMBLE_LOGE(LOG_TAG, "subscribe event; attr_handle=%d, not found", event->subscribe.attr_handle); diff --git a/src/NimBLEServer.h b/src/NimBLEServer.h index 1260750c..ad872d93 100644 --- a/src/NimBLEServer.h +++ b/src/NimBLEServer.h @@ -130,6 +130,7 @@ class NimBLEServer { bool m_gattsStarted : 1; bool m_svcChanged : 1; + mutable bool m_svcChangedSent : 1; # if !MYNEWT_VAL(BLE_EXT_ADV) && MYNEWT_VAL(BLE_ROLE_BROADCASTER) bool m_advertiseOnDisconnect : 1; # endif From 91b49f8c2b51f4fbdf6bf6e46ea06e3c04d9c271 Mon Sep 17 00:00:00 2001 From: srgg Date: Sun, 1 Nov 2026 15:38:33 -0700 Subject: [PATCH 5/5] Indicate Service Changed when a visible service is deleted removeService(svc, true) hid the service at once but indicated nothing: the indication came from start() after the reset, and the reset waits until no client is connected, so a connected client kept the deleted service in its table. The delete now indicates as a hide does, and still marks the reset that frees the service. --- src/NimBLEServer.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NimBLEServer.cpp b/src/NimBLEServer.cpp index a3877fb6..db473c2d 100644 --- a/src/NimBLEServer.cpp +++ b/src/NimBLEServer.cpp @@ -902,9 +902,8 @@ void NimBLEServer::removeService(NimBLEService* service, bool deleteSvc) { service->setRemoved(deleteSvc ? NIMBLE_ATT_REMOVE_DELETE : NIMBLE_ATT_REMOVE_HIDE); if (deleteSvc) { setServiceChanged(); - } else { - sendServiceChangedIndication(); } + sendServiceChangedIndication(); # if !MYNEWT_VAL(BLE_EXT_ADV) && MYNEWT_VAL(BLE_ROLE_BROADCASTER) NimBLEDevice::getAdvertising()->removeServiceUUID(service->getUUID()); # endif