Bug #22 fix — Suspend radio before flash I/O to prevent permanent SPI hang
=============================================================================
On nRF52, SoftDevice flash operations halt the CPU.  If the SX1262 has an
active SPI transaction at that moment, the SPI bus is left corrupted and
the radio never recovers (permanent hang until reboot).

This patch:
  1. Adds Radio::suspendRadio() — puts SX1262 into standby via idle()
  2. Adds Dispatcher::isRadioBusy() — exposes in-flight TX state
  3. Defers acl.save() while radio is busy (outbound queued OR mid-TX)
  4. Calls suspendRadio() before flash I/O when radio is idle
  5. Caps deferrals to 50 retries (10s) to guarantee eventual save
  6. Radio restarts automatically on next recvRaw() call

Applied to: simple_repeater, simple_room_server, simple_sensor

--- a/src/Dispatcher.h
+++ b/src/Dispatcher.h
@@ -69,6 +69,12 @@ public:
 
   virtual bool isInRecvMode() const = 0;
 
+  /**
+   * \brief  Put radio into standby.  Next recvRaw() call will restart receive.
+   *         Used to quiesce the SPI bus before flash I/O on nRF52.
+  */
+  virtual void suspendRadio() { }
+
   /**
    * \returns  true if the radio is currently mid-receive of a packet.
   */
@@ -185,6 +191,9 @@ public:
 
   // helper methods
   bool millisHasNowPassed(unsigned long timestamp) const;
   unsigned long futureMillis(int millis_from_now) const;
+
+  /// True when a packet is mid-transmit (dequeued, SPI active).
+  bool isRadioBusy() const { return outbound != NULL; }
 
 private:
   bool tryParsePacket(Packet* pkt, const uint8_t* raw, int len);
--- a/src/helpers/radiolib/RadioLibWrappers.h
+++ b/src/helpers/radiolib/RadioLibWrappers.h
@@ -29,6 +29,7 @@ public:
   bool isSendComplete() override;
   void onSendFinished() override;
   bool isInRecvMode() const override;
+  void suspendRadio() override { idle(); }
   bool isChannelActive();
 
   bool isReceiving() override {
--- a/examples/simple_repeater/MyMesh.cpp
+++ b/examples/simple_repeater/MyMesh.cpp
@@ -1291,8 +1297,17 @@ void MyMesh::loop() {
 
   // is pending dirty contacts write needed?
   if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
-    acl.save(_fs);
-    dirty_contacts_expiry = 0;
+    if (_mgr->getOutboundTotal() > 0 || isRadioBusy()) {
+      // Radio is active on SPI — defer to avoid flash/SPI conflict on nRF52
+      dirty_contacts_expiry = futureMillis(200);
+      if (++dirty_contacts_defer_count > 50) {  // cap at ~10s to guarantee save
+        _radio->suspendRadio();
+        acl.save(_fs);
+        dirty_contacts_expiry = 0;
+        dirty_contacts_defer_count = 0;
+      }
+    } else {
+      _radio->suspendRadio();
+      acl.save(_fs);
+      dirty_contacts_expiry = 0;
+      dirty_contacts_defer_count = 0;
+    }
   }
 
   // update uptime
--- a/examples/simple_repeater/MyMesh.h
+++ b/examples/simple_repeater/MyMesh.h
@@ -105,6 +105,7 @@
   unsigned long dirty_contacts_expiry;
+  uint8_t dirty_contacts_defer_count;
--- a/examples/simple_room_server/MyMesh.cpp
+++ b/examples/simple_room_server/MyMesh.cpp
@@ -1011,8 +1017,17 @@ void MyMesh::loop() {
 
   // is pending dirty contacts write needed?
   if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
-    acl.save(_fs, MyMesh::saveFilter);
-    dirty_contacts_expiry = 0;
+    if (_mgr->getOutboundTotal() > 0 || isRadioBusy()) {
+      dirty_contacts_expiry = futureMillis(200);
+      if (++dirty_contacts_defer_count > 50) {
+        _radio->suspendRadio();
+        acl.save(_fs, MyMesh::saveFilter);
+        dirty_contacts_expiry = 0;
+        dirty_contacts_defer_count = 0;
+      }
+    } else {
+      _radio->suspendRadio();
+      acl.save(_fs, MyMesh::saveFilter);
+      dirty_contacts_expiry = 0;
+      dirty_contacts_defer_count = 0;
+    }
   }
 
   // TODO: periodically check for OLD/inactive entries in known_clients[], and evict
--- a/examples/simple_room_server/MyMesh.h
+++ b/examples/simple_room_server/MyMesh.h
@@ -103,6 +103,7 @@
   unsigned long dirty_contacts_expiry;
+  uint8_t dirty_contacts_defer_count;
--- a/examples/simple_sensor/SensorMesh.cpp
+++ b/examples/simple_sensor/SensorMesh.cpp
@@ -968,8 +974,17 @@ void SensorMesh::loop() {
 
   // is there are pending dirty contacts write needed?
   if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
-    acl.save(_fs);
-    dirty_contacts_expiry = 0;
+    if (_mgr->getOutboundTotal() > 0 || isRadioBusy()) {
+      dirty_contacts_expiry = futureMillis(200);
+      if (++dirty_contacts_defer_count > 50) {
+        _radio->suspendRadio();
+        acl.save(_fs);
+        dirty_contacts_expiry = 0;
+        dirty_contacts_defer_count = 0;
+      }
+    } else {
+      _radio->suspendRadio();
+      acl.save(_fs);
+      dirty_contacts_expiry = 0;
+      dirty_contacts_defer_count = 0;
+    }
   }
 }
