react-native-ble-mesh 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +288 -172
  2. package/docs/IOS-BACKGROUND-BLE.md +231 -0
  3. package/docs/OPTIMIZATION.md +70 -0
  4. package/docs/SPEC-v2.1.md +308 -0
  5. package/package.json +1 -1
  6. package/src/MeshNetwork.js +659 -465
  7. package/src/constants/index.js +1 -0
  8. package/src/crypto/AutoCrypto.js +79 -0
  9. package/src/crypto/CryptoProvider.js +99 -0
  10. package/src/crypto/index.js +15 -63
  11. package/src/crypto/providers/ExpoCryptoProvider.js +125 -0
  12. package/src/crypto/providers/QuickCryptoProvider.js +134 -0
  13. package/src/crypto/providers/TweetNaClProvider.js +124 -0
  14. package/src/crypto/providers/index.js +11 -0
  15. package/src/errors/MeshError.js +2 -1
  16. package/src/expo/withBLEMesh.js +102 -0
  17. package/src/hooks/useMesh.js +30 -9
  18. package/src/hooks/useMessages.js +2 -0
  19. package/src/index.js +23 -8
  20. package/src/mesh/dedup/DedupManager.js +36 -10
  21. package/src/mesh/fragment/Assembler.js +5 -0
  22. package/src/mesh/index.js +1 -1
  23. package/src/mesh/monitor/ConnectionQuality.js +408 -0
  24. package/src/mesh/monitor/NetworkMonitor.js +327 -316
  25. package/src/mesh/monitor/index.js +7 -3
  26. package/src/mesh/peer/PeerManager.js +6 -1
  27. package/src/mesh/router/MessageRouter.js +26 -15
  28. package/src/mesh/router/RouteTable.js +7 -1
  29. package/src/mesh/store/StoreAndForwardManager.js +295 -297
  30. package/src/mesh/store/index.js +1 -1
  31. package/src/service/BatteryOptimizer.js +282 -278
  32. package/src/service/EmergencyManager.js +224 -214
  33. package/src/service/HandshakeManager.js +167 -13
  34. package/src/service/MeshService.js +72 -6
  35. package/src/service/SessionManager.js +77 -2
  36. package/src/service/audio/AudioManager.js +8 -2
  37. package/src/service/file/FileAssembler.js +106 -0
  38. package/src/service/file/FileChunker.js +79 -0
  39. package/src/service/file/FileManager.js +307 -0
  40. package/src/service/file/FileMessage.js +122 -0
  41. package/src/service/file/index.js +15 -0
  42. package/src/service/text/broadcast/BroadcastManager.js +16 -0
  43. package/src/transport/BLETransport.js +131 -9
  44. package/src/transport/MockTransport.js +1 -1
  45. package/src/transport/MultiTransport.js +305 -0
  46. package/src/transport/WiFiDirectTransport.js +295 -0
  47. package/src/transport/adapters/NodeBLEAdapter.js +34 -0
  48. package/src/transport/adapters/RNBLEAdapter.js +56 -1
  49. package/src/transport/index.js +6 -0
  50. package/src/utils/compression.js +291 -291
  51. package/src/crypto/aead.js +0 -189
  52. package/src/crypto/chacha20.js +0 -181
  53. package/src/crypto/hkdf.js +0 -187
  54. package/src/crypto/hmac.js +0 -143
  55. package/src/crypto/keys/KeyManager.js +0 -271
  56. package/src/crypto/keys/KeyPair.js +0 -216
  57. package/src/crypto/keys/SecureStorage.js +0 -219
  58. package/src/crypto/keys/index.js +0 -32
  59. package/src/crypto/noise/handshake.js +0 -410
  60. package/src/crypto/noise/index.js +0 -27
  61. package/src/crypto/noise/session.js +0 -253
  62. package/src/crypto/noise/state.js +0 -268
  63. package/src/crypto/poly1305.js +0 -113
  64. package/src/crypto/sha256.js +0 -240
  65. package/src/crypto/x25519.js +0 -154
@@ -0,0 +1,307 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview File transfer manager for mesh network
5
+ * @module service/file/FileManager
6
+ */
7
+
8
+ const EventEmitter = require('../../utils/EventEmitter');
9
+ const FileChunker = require('./FileChunker');
10
+ const FileAssembler = require('./FileAssembler');
11
+ const { FileMessage, FILE_TRANSFER_STATE } = require('./FileMessage');
12
+
13
+ /**
14
+ * Default file transfer configuration
15
+ * @constant {Object}
16
+ */
17
+ const DEFAULT_CONFIG = Object.freeze({
18
+ chunkSize: 4096,
19
+ maxFileSize: 10 * 1024 * 1024, // 10MB
20
+ transferTimeoutMs: 5 * 60 * 1000, // 5 minutes
21
+ maxConcurrentTransfers: 5
22
+ });
23
+
24
+ /**
25
+ * Manages file transfers over the mesh network.
26
+ * Handles chunking, reassembly, progress tracking, and timeouts.
27
+ *
28
+ * @class FileManager
29
+ * @extends EventEmitter
30
+ *
31
+ * @fires FileManager#sendProgress - When send progress updates
32
+ * @fires FileManager#receiveProgress - When receive progress updates
33
+ * @fires FileManager#fileReceived - When a complete file is received
34
+ * @fires FileManager#transferFailed - When a transfer fails
35
+ * @fires FileManager#transferCancelled - When a transfer is cancelled
36
+ */
37
+ class FileManager extends EventEmitter {
38
+ /**
39
+ * @param {Object} [config={}]
40
+ */
41
+ constructor(config = {}) {
42
+ super();
43
+ this._config = { ...DEFAULT_CONFIG, ...config };
44
+ this._chunker = new FileChunker({
45
+ chunkSize: this._config.chunkSize,
46
+ maxFileSize: this._config.maxFileSize
47
+ });
48
+
49
+ /** @type {Map<string, Object>} Active outgoing transfers */
50
+ this._outgoing = new Map();
51
+ /** @type {Map<string, Object>} Active incoming transfers */
52
+ this._incoming = new Map();
53
+ /** @type {Map<string, NodeJS.Timeout>} Transfer timeouts */
54
+ this._timeouts = new Map();
55
+ }
56
+
57
+ /**
58
+ * Prepares a file for sending. Returns the transfer object with chunks.
59
+ * The caller (MeshNetwork) is responsible for actually sending chunks via transport.
60
+ *
61
+ * @param {string} peerId - Target peer ID
62
+ * @param {Object} fileInfo - File information
63
+ * @param {Uint8Array} fileInfo.data - File data
64
+ * @param {string} fileInfo.name - File name
65
+ * @param {string} [fileInfo.mimeType='application/octet-stream'] - MIME type
66
+ * @returns {Object} Transfer object with id, offer, and chunks
67
+ */
68
+ prepareSend(peerId, fileInfo) {
69
+ if (this._outgoing.size >= this._config.maxConcurrentTransfers) {
70
+ throw new Error('Max concurrent transfers reached');
71
+ }
72
+
73
+ const transferId = `ft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
74
+ const chunks = this._chunker.chunk(fileInfo.data, transferId);
75
+
76
+ const fileMeta = new FileMessage({
77
+ id: transferId,
78
+ name: fileInfo.name,
79
+ mimeType: fileInfo.mimeType || 'application/octet-stream',
80
+ size: fileInfo.data.length,
81
+ totalChunks: chunks.length,
82
+ chunkSize: this._chunker.chunkSize
83
+ });
84
+
85
+ const transfer = {
86
+ id: transferId,
87
+ peerId,
88
+ meta: fileMeta,
89
+ chunks,
90
+ sentChunks: 0,
91
+ state: FILE_TRANSFER_STATE.PENDING
92
+ };
93
+
94
+ this._outgoing.set(transferId, transfer);
95
+ this._setTransferTimeout(transferId);
96
+
97
+ return {
98
+ id: transferId,
99
+ offer: fileMeta.toOffer(),
100
+ chunks,
101
+ totalChunks: chunks.length
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Marks a chunk as sent and emits progress
107
+ * @param {string} transferId - Transfer ID
108
+ * @param {number} chunkIndex - Chunk index that was sent
109
+ */
110
+ markChunkSent(transferId, _chunkIndex) {
111
+ const transfer = this._outgoing.get(transferId);
112
+ if (!transfer) { return; }
113
+
114
+ transfer.sentChunks++;
115
+ if (!transfer.meta.startedAt) {
116
+ transfer.meta.startedAt = Date.now();
117
+ }
118
+ transfer.state = FILE_TRANSFER_STATE.TRANSFERRING;
119
+
120
+ const progress = Math.round((transfer.sentChunks / transfer.chunks.length) * 100);
121
+ this.emit('sendProgress', {
122
+ transferId,
123
+ peerId: transfer.peerId,
124
+ name: transfer.meta.name,
125
+ percent: progress
126
+ });
127
+
128
+ if (transfer.sentChunks >= transfer.chunks.length) {
129
+ transfer.state = FILE_TRANSFER_STATE.COMPLETE;
130
+ transfer.meta.completedAt = Date.now();
131
+ this._clearTransferTimeout(transferId);
132
+ this._outgoing.delete(transferId);
133
+ this.emit('sendComplete', {
134
+ transferId,
135
+ peerId: transfer.peerId,
136
+ name: transfer.meta.name,
137
+ elapsedMs: transfer.meta.elapsedMs
138
+ });
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Handles an incoming file offer
144
+ * @param {Object} offer - File offer metadata
145
+ * @param {string} senderId - Sender peer ID
146
+ * @returns {string} Transfer ID
147
+ */
148
+ handleOffer(offer, senderId) {
149
+ if (this._incoming.size >= this._config.maxConcurrentTransfers) {
150
+ throw new Error('Max concurrent incoming transfers reached');
151
+ }
152
+
153
+ // Validate offer fields
154
+ if (!offer || !offer.id || !offer.name) {
155
+ throw new Error('Invalid file offer: missing id or name');
156
+ }
157
+ if (typeof offer.totalChunks !== 'number' || offer.totalChunks <= 0) {
158
+ throw new Error('Invalid file offer: invalid totalChunks');
159
+ }
160
+ if (typeof offer.size !== 'number' || offer.size <= 0) {
161
+ throw new Error('Invalid file offer: invalid size');
162
+ }
163
+ if (offer.size > this._config.maxFileSize) {
164
+ throw new Error(`File too large: ${offer.size} bytes exceeds ${this._config.maxFileSize} byte limit`);
165
+ }
166
+
167
+ const fileMeta = FileMessage.fromOffer(offer, senderId);
168
+ const assembler = new FileAssembler(offer.id, offer.totalChunks, offer.size);
169
+
170
+ this._incoming.set(offer.id, {
171
+ meta: fileMeta,
172
+ assembler,
173
+ senderId
174
+ });
175
+
176
+ this._setTransferTimeout(offer.id);
177
+ return offer.id;
178
+ }
179
+
180
+ /**
181
+ * Handles an incoming file chunk
182
+ * @param {string} transferId - Transfer ID
183
+ * @param {number} index - Chunk index
184
+ * @param {Uint8Array} data - Chunk data
185
+ */
186
+ handleChunk(transferId, index, data) {
187
+ const transfer = this._incoming.get(transferId);
188
+ if (!transfer) { return; }
189
+
190
+ if (!transfer.meta.startedAt) {
191
+ transfer.meta.startedAt = Date.now();
192
+ }
193
+ transfer.meta.state = FILE_TRANSFER_STATE.TRANSFERRING;
194
+
195
+ const isNew = transfer.assembler.addChunk(index, data);
196
+ if (!isNew) { return; }
197
+
198
+ transfer.meta.receivedChunks = transfer.assembler.receivedChunks;
199
+
200
+ this.emit('receiveProgress', {
201
+ transferId,
202
+ from: transfer.senderId,
203
+ name: transfer.meta.name,
204
+ percent: transfer.assembler.progress
205
+ });
206
+
207
+ if (transfer.assembler.isComplete()) {
208
+ const fileData = transfer.assembler.assemble();
209
+ transfer.meta.state = FILE_TRANSFER_STATE.COMPLETE;
210
+ transfer.meta.completedAt = Date.now();
211
+ this._clearTransferTimeout(transferId);
212
+ this._incoming.delete(transferId);
213
+
214
+ this.emit('fileReceived', {
215
+ transferId,
216
+ from: transfer.senderId,
217
+ file: {
218
+ name: transfer.meta.name,
219
+ mimeType: transfer.meta.mimeType,
220
+ size: transfer.meta.size,
221
+ data: fileData
222
+ },
223
+ elapsedMs: transfer.meta.elapsedMs
224
+ });
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Cancels a transfer (incoming or outgoing)
230
+ * @param {string} transferId - Transfer ID
231
+ */
232
+ cancelTransfer(transferId) {
233
+ this._clearTransferTimeout(transferId);
234
+
235
+ if (this._outgoing.has(transferId)) {
236
+ const transfer = this._outgoing.get(transferId);
237
+ transfer.state = FILE_TRANSFER_STATE.CANCELLED;
238
+ this._outgoing.delete(transferId);
239
+ this.emit('transferCancelled', { transferId, direction: 'outgoing' });
240
+ }
241
+
242
+ if (this._incoming.has(transferId)) {
243
+ const transfer = this._incoming.get(transferId);
244
+ transfer.meta.state = FILE_TRANSFER_STATE.CANCELLED;
245
+ transfer.assembler.clear();
246
+ this._incoming.delete(transferId);
247
+ this.emit('transferCancelled', { transferId, direction: 'incoming' });
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Gets active transfers
253
+ * @returns {Object} { outgoing: [], incoming: [] }
254
+ */
255
+ getActiveTransfers() {
256
+ return {
257
+ outgoing: Array.from(this._outgoing.values()).map(t => ({
258
+ id: t.id, peerId: t.peerId, name: t.meta.name,
259
+ progress: Math.round((t.sentChunks / t.chunks.length) * 100),
260
+ state: t.state
261
+ })),
262
+ incoming: Array.from(this._incoming.values()).map(t => ({
263
+ id: t.meta.id, from: t.senderId, name: t.meta.name,
264
+ progress: t.assembler.progress,
265
+ state: t.meta.state
266
+ }))
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Destroys and cleans up
272
+ */
273
+ destroy() {
274
+ for (const id of this._timeouts.keys()) {
275
+ this._clearTransferTimeout(id);
276
+ }
277
+ this._outgoing.clear();
278
+ for (const t of this._incoming.values()) {
279
+ t.assembler.clear();
280
+ }
281
+ this._incoming.clear();
282
+ this.removeAllListeners();
283
+ }
284
+
285
+ /** @private */
286
+ _setTransferTimeout(transferId) {
287
+ const timer = setTimeout(() => {
288
+ this.cancelTransfer(transferId);
289
+ this.emit('transferFailed', {
290
+ transferId,
291
+ reason: 'timeout'
292
+ });
293
+ }, this._config.transferTimeoutMs);
294
+ this._timeouts.set(transferId, timer);
295
+ }
296
+
297
+ /** @private */
298
+ _clearTransferTimeout(transferId) {
299
+ const timer = this._timeouts.get(transferId);
300
+ if (timer) {
301
+ clearTimeout(timer);
302
+ this._timeouts.delete(transferId);
303
+ }
304
+ }
305
+ }
306
+
307
+ module.exports = FileManager;
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview File message metadata
5
+ * @module service/file/FileMessage
6
+ */
7
+
8
+ /**
9
+ * File transfer message types
10
+ * @constant {Object}
11
+ */
12
+ const FILE_MESSAGE_TYPE = Object.freeze({
13
+ /** Initial file offer with metadata */
14
+ OFFER: 'file:offer',
15
+ /** File chunk data */
16
+ CHUNK: 'file:chunk',
17
+ /** Transfer complete acknowledgment */
18
+ COMPLETE: 'file:complete',
19
+ /** Transfer cancelled */
20
+ CANCEL: 'file:cancel'
21
+ });
22
+
23
+ /**
24
+ * File transfer states
25
+ * @constant {Object}
26
+ */
27
+ const FILE_TRANSFER_STATE = Object.freeze({
28
+ PENDING: 'pending',
29
+ TRANSFERRING: 'transferring',
30
+ COMPLETE: 'complete',
31
+ FAILED: 'failed',
32
+ CANCELLED: 'cancelled'
33
+ });
34
+
35
+ /**
36
+ * Represents a file being transferred over the mesh.
37
+ * @class FileMessage
38
+ */
39
+ class FileMessage {
40
+ /**
41
+ * @param {Object} options
42
+ * @param {string} options.id - Transfer ID
43
+ * @param {string} options.name - File name
44
+ * @param {string} options.mimeType - MIME type
45
+ * @param {number} options.size - Total size in bytes
46
+ * @param {number} options.totalChunks - Total number of chunks
47
+ * @param {number} [options.chunkSize=4096] - Chunk size in bytes
48
+ * @param {string} [options.senderId] - Sender peer ID
49
+ */
50
+ constructor(options) {
51
+ this.id = options.id;
52
+ this.name = options.name;
53
+ this.mimeType = options.mimeType || 'application/octet-stream';
54
+ this.size = options.size;
55
+ this.totalChunks = options.totalChunks;
56
+ this.chunkSize = options.chunkSize || 4096;
57
+ this.senderId = options.senderId || null;
58
+ this.receivedChunks = 0;
59
+ this.state = FILE_TRANSFER_STATE.PENDING;
60
+ this.startedAt = null;
61
+ this.completedAt = null;
62
+ }
63
+
64
+ /**
65
+ * Gets transfer progress as percentage
66
+ * @returns {number} 0-100
67
+ */
68
+ get progress() {
69
+ if (this.totalChunks === 0) { return 100; }
70
+ return Math.round((this.receivedChunks / this.totalChunks) * 100);
71
+ }
72
+
73
+ /**
74
+ * Gets elapsed transfer time in ms
75
+ * @returns {number}
76
+ */
77
+ get elapsedMs() {
78
+ if (!this.startedAt) { return 0; }
79
+ const end = this.completedAt || Date.now();
80
+ return end - this.startedAt;
81
+ }
82
+
83
+ /**
84
+ * Serializes the file offer metadata
85
+ * @returns {Object}
86
+ */
87
+ toOffer() {
88
+ return {
89
+ type: FILE_MESSAGE_TYPE.OFFER,
90
+ id: this.id,
91
+ name: this.name,
92
+ mimeType: this.mimeType,
93
+ size: this.size,
94
+ totalChunks: this.totalChunks,
95
+ chunkSize: this.chunkSize
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Creates a FileMessage from an offer
101
+ * @param {Object} offer
102
+ * @param {string} senderId
103
+ * @returns {FileMessage}
104
+ */
105
+ static fromOffer(offer, senderId) {
106
+ return new FileMessage({
107
+ id: offer.id,
108
+ name: offer.name,
109
+ mimeType: offer.mimeType,
110
+ size: offer.size,
111
+ totalChunks: offer.totalChunks,
112
+ chunkSize: offer.chunkSize,
113
+ senderId
114
+ });
115
+ }
116
+ }
117
+
118
+ module.exports = {
119
+ FileMessage,
120
+ FILE_MESSAGE_TYPE,
121
+ FILE_TRANSFER_STATE
122
+ };
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ const FileManager = require('./FileManager');
4
+ const FileChunker = require('./FileChunker');
5
+ const FileAssembler = require('./FileAssembler');
6
+ const { FileMessage, FILE_MESSAGE_TYPE, FILE_TRANSFER_STATE } = require('./FileMessage');
7
+
8
+ module.exports = {
9
+ FileManager,
10
+ FileChunker,
11
+ FileAssembler,
12
+ FileMessage,
13
+ FILE_MESSAGE_TYPE,
14
+ FILE_TRANSFER_STATE
15
+ };
@@ -45,6 +45,17 @@ class BroadcastManager extends EventEmitter {
45
45
  this._senderId = null;
46
46
  /** @private */
47
47
  this._sendCallback = null;
48
+ /** @private */
49
+ this._cleanupTimer = null;
50
+
51
+ // Auto-cleanup every 5 minutes
52
+ this._cleanupTimer = setInterval(() => {
53
+ this.cleanup();
54
+ }, 5 * 60 * 1000);
55
+
56
+ if (this._cleanupTimer && typeof this._cleanupTimer.unref === 'function') {
57
+ this._cleanupTimer.unref();
58
+ }
48
59
  }
49
60
 
50
61
  /**
@@ -160,6 +171,11 @@ class BroadcastManager extends EventEmitter {
160
171
  clear() {
161
172
  this._recentBroadcasts = [];
162
173
  this._seenMessageIds.clear();
174
+
175
+ if (this._cleanupTimer) {
176
+ clearInterval(this._cleanupTimer);
177
+ this._cleanupTimer = null;
178
+ }
163
179
  }
164
180
 
165
181
  /**
@@ -30,6 +30,7 @@ class BLETransport extends Transport {
30
30
  * @param {string} [options.powerMode='BALANCED'] - Power mode
31
31
  * @param {number} [options.maxPeers=8] - Maximum peers
32
32
  * @param {number} [options.connectTimeoutMs=10000] - Connection timeout
33
+ * @param {number} [options.mtu=23] - Default BLE MTU
33
34
  */
34
35
  constructor(adapter, options = {}) {
35
36
  super(options);
@@ -59,6 +60,13 @@ class BLETransport extends Transport {
59
60
  */
60
61
  this._connectTimeoutMs = options.connectTimeoutMs || 10000;
61
62
 
63
+ /**
64
+ * BLE MTU (Maximum Transmission Unit)
65
+ * @type {number}
66
+ * @private
67
+ */
68
+ this._mtu = options.mtu || 23; // Default BLE MTU
69
+
62
70
  /**
63
71
  * Whether scanning is active
64
72
  * @type {boolean}
@@ -66,6 +74,20 @@ class BLETransport extends Transport {
66
74
  */
67
75
  this._isScanning = false;
68
76
 
77
+ /**
78
+ * Per-peer write queues for serializing BLE writes
79
+ * @type {Map<string, Array>}
80
+ * @private
81
+ */
82
+ this._writeQueue = new Map();
83
+
84
+ /**
85
+ * Per-peer write locks
86
+ * @type {Map<string, boolean>}
87
+ * @private
88
+ */
89
+ this._writing = new Map();
90
+
69
91
  /**
70
92
  * Bound event handlers for cleanup
71
93
  * @type {Object}
@@ -86,6 +108,14 @@ class BLETransport extends Transport {
86
108
  return this._isScanning;
87
109
  }
88
110
 
111
+ /**
112
+ * Gets the current BLE MTU
113
+ * @returns {number} Current MTU in bytes
114
+ */
115
+ get mtu() {
116
+ return this._mtu;
117
+ }
118
+
89
119
  /**
90
120
  * Starts the BLE transport
91
121
  * @returns {Promise<void>}
@@ -107,7 +137,18 @@ class BLETransport extends Transport {
107
137
  }
108
138
 
109
139
  this._adapter.onStateChange(this._handlers.onStateChange);
140
+
141
+ // Register disconnect callback if adapter supports it
142
+ if (typeof this._adapter.onDeviceDisconnected === 'function') {
143
+ this._adapter.onDeviceDisconnected((peerId) => {
144
+ this._handleDeviceDisconnected(peerId);
145
+ });
146
+ }
147
+
110
148
  this._setState(Transport.STATE.RUNNING);
149
+
150
+ // Auto-start scanning for peers
151
+ await this.startScanning();
111
152
  } catch (error) {
112
153
  this._setState(Transport.STATE.ERROR);
113
154
  throw error;
@@ -191,11 +232,28 @@ class BLETransport extends Transport {
191
232
  }
192
233
 
193
234
  try {
235
+ let timeoutId;
236
+ const timeoutPromise = new Promise((_, reject) => {
237
+ timeoutId = setTimeout(() => reject(new Error('Connection timeout')), this._connectTimeoutMs);
238
+ });
194
239
  const device = await Promise.race([
195
- this._adapter.connect(peerId),
196
- this._createTimeout(this._connectTimeoutMs, 'Connection timeout')
240
+ this._adapter.connect(peerId).then(d => { clearTimeout(timeoutId); return d; }),
241
+ timeoutPromise
197
242
  ]);
198
243
 
244
+ // Negotiate MTU for larger payloads
245
+ let negotiatedMtu = this._mtu;
246
+ try {
247
+ if (typeof this._adapter.requestMTU === 'function') {
248
+ const mtu = await this._adapter.requestMTU(peerId, 512);
249
+ if (mtu) {
250
+ negotiatedMtu = mtu;
251
+ }
252
+ }
253
+ } catch (mtuError) {
254
+ // MTU negotiation failure is non-fatal, continue with default MTU
255
+ }
256
+
199
257
  // Subscribe to notifications
200
258
  await this._adapter.subscribe(
201
259
  peerId,
@@ -207,7 +265,8 @@ class BLETransport extends Transport {
207
265
  const connectionInfo = {
208
266
  peerId,
209
267
  device,
210
- connectedAt: Date.now()
268
+ connectedAt: Date.now(),
269
+ mtu: negotiatedMtu
211
270
  };
212
271
 
213
272
  this._peers.set(peerId, connectionInfo);
@@ -254,12 +313,21 @@ class BLETransport extends Transport {
254
313
  throw ConnectionError.fromCode('E207', peerId);
255
314
  }
256
315
 
257
- await this._adapter.write(
258
- peerId,
259
- BLE_SERVICE_UUID,
260
- BLE_CHARACTERISTIC_TX,
261
- data
262
- );
316
+ const peerInfo = this._peers.get(peerId);
317
+ const mtu = peerInfo.mtu || this._mtu || 23;
318
+ const chunkSize = Math.max(mtu - 3, 20); // ATT header overhead, minimum 20
319
+
320
+ if (data.length <= chunkSize) {
321
+ // Single write
322
+ await this._queuedWrite(peerId, data);
323
+ return;
324
+ }
325
+
326
+ // Chunk data for BLE MTU compliance
327
+ for (let offset = 0; offset < data.length; offset += chunkSize) {
328
+ const chunk = data.slice(offset, Math.min(offset + chunkSize, data.length));
329
+ await this._queuedWrite(peerId, chunk);
330
+ }
263
331
  }
264
332
 
265
333
  /**
@@ -326,6 +394,15 @@ class BLETransport extends Transport {
326
394
  _handleDeviceDisconnected(peerId) {
327
395
  if (this._peers.has(peerId)) {
328
396
  this._peers.delete(peerId);
397
+
398
+ // Clean up write queue
399
+ const queue = this._writeQueue.get(peerId);
400
+ if (queue) {
401
+ queue.forEach(({ reject }) => reject(new Error('Peer disconnected')));
402
+ this._writeQueue.delete(peerId);
403
+ }
404
+ this._writing.delete(peerId);
405
+
329
406
  this.emit('peerDisconnected', { peerId, reason: 'connection_lost' });
330
407
  }
331
408
  }
@@ -352,6 +429,51 @@ class BLETransport extends Transport {
352
429
  setTimeout(() => reject(new Error(message)), ms);
353
430
  });
354
431
  }
432
+
433
+ /**
434
+ * Queues a write operation to serialize BLE writes
435
+ * @param {string} peerId - Target peer ID
436
+ * @param {Uint8Array} data - Data to write
437
+ * @returns {Promise<void>}
438
+ * @private
439
+ */
440
+ async _queuedWrite(peerId, data) {
441
+ if (!this._writeQueue.has(peerId)) {
442
+ this._writeQueue.set(peerId, []);
443
+ }
444
+
445
+ return new Promise((resolve, reject) => {
446
+ this._writeQueue.get(peerId).push({ data, resolve, reject });
447
+ this._processWriteQueue(peerId);
448
+ });
449
+ }
450
+
451
+ /**
452
+ * Processes the write queue for a peer
453
+ * @param {string} peerId - Target peer ID
454
+ * @returns {Promise<void>}
455
+ * @private
456
+ */
457
+ async _processWriteQueue(peerId) {
458
+ if (this._writing.get(peerId)) { return; } // Already processing
459
+
460
+ const queue = this._writeQueue.get(peerId);
461
+ if (!queue || queue.length === 0) { return; }
462
+
463
+ this._writing.set(peerId, true);
464
+
465
+ while (queue.length > 0) {
466
+ const { data, resolve, reject } = queue.shift();
467
+ try {
468
+ await this._adapter.write(peerId, BLE_SERVICE_UUID, BLE_CHARACTERISTIC_TX, data);
469
+ resolve();
470
+ } catch (err) {
471
+ reject(err);
472
+ }
473
+ }
474
+
475
+ this._writing.set(peerId, false);
476
+ }
355
477
  }
356
478
 
357
479
  module.exports = BLETransport;
@@ -50,7 +50,7 @@ class MockTransport extends Transport {
50
50
  * @type {string|null}
51
51
  * @private
52
52
  */
53
- this._localPeerId = options.localPeerId || null;
53
+ this._localPeerId = options.localPeerId || `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
54
54
  }
55
55
 
56
56
  /**