Skip to content

🐞 [Bug]: iOS app stalls BLE→MQTT forwarding in background; only resumes on foreground (needs CoreBluetooth restoration + background reconnect) #1402

Description

@shinteza

Before submitting

  • I have searched existing issues to make sure this bug hasn't already been reported
  • I have updated to the latest version of the software to verify the issue still exists

Firmware Version

2.7.1

What did you do?

Environment

  • iPhone 14 (latest iOS)
  • Heltec T114 v2 (BLE-only, no Wi‑Fi)
  • Meshtastic firmware 2.7.7
  • Meshtastic iOS app (latest App Store build)
  • MQTT broker: Mosquitto (TLS optional)

Steps

  1. On the node, enabled MQTT uplink on the active channel (default public channel; downlink disabled).
  2. In the iOS app, enabled Client Proxy and configured the MQTT broker (host/port/creds; TLS optional).
  3. Verified in foreground that telemetry/messages from the node arrive on broker topics.
  4. Ensured iOS settings that maximize background continuity:
    • Background App Refresh = ON (Meshtastic)
    • Low Power Mode = OFF
    • Bluetooth = ON
    • Location permission = Always Allow
    • App not force‑quit (left in app switcher).
  5. Sent the app to background and waited ~5–30+ minutes (also tested 60–120).
  6. Watched broker topics throughout the background interval.
  7. Brought the app back to foreground and watched topics again.

Observed outcome

  • Topics update normally while in foreground.
  • After the app sits in background, topics go quiet.
  • Upon foregrounding, updates resume immediately.

Technical context / code pointers (what I looked at)

Goal: Explain why background forwarding stalls and where the code likely needs changes.

1) CoreBluetooth central has no state restoration

  • Expected: initialize the central with a restoration identifier and handle willRestoreState.
  • Current pattern observed (representative):
// BLETransport.swift (representative)
centralManager = CBCentralManager(
    delegate: delegate,
    queue: .global(qos: .utility)
    // ❌ no CBCentralManagerOptionRestoreIdentifierKey
)

// ... later ...
// ❌ Restoration callback exists but is commented or not wired:
/// func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { ... }

2) MQTT client is process-lifetime bound

  • CocoaMQTT autoReconnect only helps while the process is active; iOS suspends background apps.
  • Session settings aren’t background‑friendly:
    • cleanSession = true ⇒ broker discards subscriptions between connects.
    • keepAlive = 60 ⇒ stale links detected slowly in background mode.

3) Background wake sources not leveraged to re‑attach BLE/MQTT

  • App already has location background mode. Use it (and BLE restoration) to trigger an opportunistic reconnect pipeline.
  • Consider a small BGAppRefreshTask nudge.

Proposed fix (surgical and opt‑in)

Add an opt‑in setting: Background Gateway (beta). When ON, enable the following behavior.

A) Enable CoreBluetooth state preservation & restoration

// BLETransport.swift
private let kCentralRestoreID = "com.meshtastic.central"

centralManager = CBCentralManager(
    delegate: delegate,
    queue: .global(qos: .utility),
    options: [CBCentralManagerOptionRestoreIdentifierKey: kCentralRestoreID]
)

func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
    os_log("BLE willRestoreState: %{public}@", log: bleLog, type: .info, String(describing: dict))
    let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
    if peripherals.isEmpty {
        central.scanForPeripherals(
            withServices: [meshServiceUUID],
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )
    } else {
        peripherals.forEach { central.connect($0, options: nil) }
    }
    BackgroundGateway.shared.resumeIfNeeded() // see C)
}

B) Make MQTT more resilient in background

// MqttClientProxyManager.swift
func configureForBackgroundGateway(_ enabled: Bool) {
    if enabled {
        mqtt.cleanSession = false        // persist subs across reconnects
        mqtt.keepAlive = 30              // detect stale faster (battery tradeoff)
    } else {
        mqtt.cleanSession = true
        mqtt.keepAlive = 60
    }
    mqtt.autoReconnect = true
}

func reconnectIfNeeded() {
    guard backgroundGatewayEnabled else { return }
    guard mqtt.connState != .connected && mqtt.connState != .connecting else { return }
    mqtt.connect()
}

// On successful connect ACK:
func mqtt(_ mqtt: CocoaMQTT, didConnectAck ack: CocoaMQTTConnAck) {
    // (Re)subscribe to topics needed by Client Proxy
    topicsToSubscribe.forEach { mqtt.subscribe($0, qos: .qos1) }
}

C) Opportunistic wake handlers (BLE restore, location, BG refresh)

// BackgroundGatewayCoordinator.swift
import BackgroundTasks

enum BGID {
    static let refresh = "com.meshtastic.gateway.refresh"
}

func registerBackgroundTasks() {
    BGTaskScheduler.shared.register(forTaskWithIdentifier: BGID.refresh, using: nil) { task in
        BackgroundGateway.shared.reconnectIfNeeded { ok in
            task.setTaskCompleted(success: ok)
            BackgroundGateway.shared.scheduleNextRefresh()
        }
    }
}

func scheduleNextRefresh() {
    let req = BGAppRefreshTaskRequest(identifier: BGID.refresh)
    req.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
    try? BGTaskScheduler.shared.submit(req)
}

// LocationsHandler.swift
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    BackgroundGateway.shared.reconnectIfNeeded() // opportunistic nudge
}

D) Info.plist additions

  • UIBackgroundModes: add bluetooth-central, location, fetch
  • BGTaskSchedulerPermittedIdentifiers: ["com.meshtastic.gateway.refresh"]

E) Logging

// OSLog categories
let bleLog = OSLog(subsystem: "com.meshtastic", category: "BLE")
let mqttLog = OSLog(subsystem: "com.meshtastic", category: "MQTT")
let bgLog  = OSLog(subsystem: "com.meshtastic", category: "Background")

os_log("Attempting MQTT reconnect (bg)", log: mqttLog, type: .info)

Why opt‑in? Battery/OS‑throttling tradeoffs; keep default behavior unchanged for most users.

Expected Behavior

  • With Client Proxy enabled and Background Gateway (beta) toggled ON, the app should, within iOS limits, best‑effort keep gatewaying in background:
    • iOS delivers CoreBluetooth restoration callbacks; the app re‑attaches to the node (scan/known peripherals) automatically.
    • On any background wake source (BLE restoration, location update, BG app refresh), the app attempts MQTT reconnect and re‑subscribe without user foregrounding.
    • MQTT session parameters favor background resilience:
      • cleanSession = false (retain subs across reconnects)
      • keepAlive ≈ 30s (faster stale detection in bg)
      • small backoff on repeated failures.

Acceptance (practical)

  • After 30–60+ minutes in background, forwarding resumes automatically on the next system wake (no manual foregrounding).
  • Reconnect latency after a wake ≤ ~30s.
  • When the feature is OFF (default), no behavior/battery regressions.

Code‑level expectations (what the app should do)

CoreBluetooth restoration path

// central init has a restoration ID
CBCentralManagerOptionRestoreIdentifierKey = "com.meshtastic.central"

// restoration delegate is implemented and wired
func centralManager(_ c: CBCentralManager, willRestoreState dict: [String : Any]) {
    // resume scan/connect and then nudge MQTT
    BackgroundGateway.shared.resumeIfNeeded()
}

MQTT reconnection path on wakes

// On BLE restore / location update / BG refresh:
BackgroundGateway.shared.reconnectIfNeeded()

// Reconnect will connect + re‑subscribe when not connected:
mqtt.cleanSession = false
mqtt.keepAlive = 30
mqtt.connect()

Info.plist / background config

  • UIBackgroundModes: bluetooth-central, location, fetch
  • BGTaskSchedulerPermittedIdentifiers: com.meshtastic.gateway.refresh

Logging

  • OSLog for BLE/MQTT/Background to verify restoration/reconnect flows in Console and correlate with broker timestamps.

Current Behavior

  • After the app remains in background for ~5–30+ minutes (sometimes longer), BLE→MQTT forwarding stops; broker topics are quiet.
  • Bringing the app to the foreground immediately resumes forwarding.
  • This reproduces consistently with:
    • Background App Refresh: ON
    • Low Power Mode: OFF
    • Location: Always Allow
    • App not force‑quit (remains in switcher)
  • Broker logs show a gap matching the background interval, ending at the moment of foreground.

Evidence in code (why this likely happens)

  1. No CoreBluetooth state restoration
// CBCentralManager created without a restoration identifier
centralManager = CBCentralManager(
    delegate: delegate,
    queue: .global(qos: .utility)
)
// Restoration delegate exists but commented/unwired:
// func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { ... }
  1. MQTT session tied to process lifetime
// Typical setup
mqtt.autoReconnect = true
mqtt.keepAlive = 60
mqtt.cleanSession = true // ❌ loses subs across reconnects
// -> Once iOS suspends the process, reconnects don't happen until foreground.
  1. Wake sources not used to re‑attach
  • App has location background capability; not used to nudge BLE/MQTT.
  • No BGTaskScheduler to opportunistically refresh in background.

Proposed fix sketch (minimal, opt‑in)

CoreBluetooth restoration

centralManager = CBCentralManager(
    delegate: delegate,
    queue: .global(qos: .utility),
    options: [CBCentralManagerOptionRestoreIdentifierKey: "com.meshtastic.central"]
)

func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
    // reconnect known peripherals or resume scan
    BackgroundGateway.shared.resumeIfNeeded()
}

MQTT resilience

mqtt.cleanSession = false
mqtt.keepAlive = 30
func reconnectIfNeeded() {
    guard mqtt.connState != .connected && mqtt.connState != .connecting else { return }
    mqtt.connect()
}

Background nudges

// BGTaskScheduler + location updates call:
BackgroundGateway.shared.reconnectIfNeeded()

Info.plist

  • Add fetch to UIBackgroundModes
  • Add com.meshtastic.gateway.refresh to BGTaskSchedulerPermittedIdentifiers

Logging

  • OSLog categories for BLE/MQTT/Background; verify restoration/reconnects against broker timestamps.

Participation

  • I am willing to submit a pull request for this issue.

Additional comments

No response

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions