bitchat-node 0.1.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.
- package/README.md +223 -0
- package/dist/bin/bitchat.d.ts +7 -0
- package/dist/bin/bitchat.d.ts.map +1 -0
- package/dist/bin/bitchat.js +69 -0
- package/dist/bin/bitchat.js.map +1 -0
- package/dist/client.d.ts +77 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +411 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto/index.d.ts +6 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +6 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/crypto/noise.d.ts +72 -0
- package/dist/crypto/noise.d.ts.map +1 -0
- package/dist/crypto/noise.js +470 -0
- package/dist/crypto/noise.js.map +1 -0
- package/dist/crypto/signing.d.ts +34 -0
- package/dist/crypto/signing.d.ts.map +1 -0
- package/dist/crypto/signing.js +56 -0
- package/dist/crypto/signing.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/mesh/deduplicator.d.ts +48 -0
- package/dist/mesh/deduplicator.d.ts.map +1 -0
- package/dist/mesh/deduplicator.js +107 -0
- package/dist/mesh/deduplicator.js.map +1 -0
- package/dist/mesh/index.d.ts +6 -0
- package/dist/mesh/index.d.ts.map +1 -0
- package/dist/mesh/index.js +6 -0
- package/dist/mesh/index.js.map +1 -0
- package/dist/mesh/router.d.ts +90 -0
- package/dist/mesh/router.d.ts.map +1 -0
- package/dist/mesh/router.js +204 -0
- package/dist/mesh/router.js.map +1 -0
- package/dist/protocol/binary.d.ts +37 -0
- package/dist/protocol/binary.d.ts.map +1 -0
- package/dist/protocol/binary.js +310 -0
- package/dist/protocol/binary.js.map +1 -0
- package/dist/protocol/constants.d.ts +30 -0
- package/dist/protocol/constants.d.ts.map +1 -0
- package/dist/protocol/constants.js +37 -0
- package/dist/protocol/constants.js.map +1 -0
- package/dist/protocol/index.d.ts +8 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +8 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/packets.d.ts +38 -0
- package/dist/protocol/packets.d.ts.map +1 -0
- package/dist/protocol/packets.js +177 -0
- package/dist/protocol/packets.js.map +1 -0
- package/dist/protocol/types.d.ts +134 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/types.js +108 -0
- package/dist/protocol/types.js.map +1 -0
- package/dist/session/index.d.ts +5 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +5 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/manager.d.ts +113 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +371 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/transport/ble.d.ts +92 -0
- package/dist/transport/ble.d.ts.map +1 -0
- package/dist/transport/ble.js +434 -0
- package/dist/transport/ble.js.map +1 -0
- package/dist/transport/index.d.ts +5 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +5 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/server.d.ts +16 -0
- package/dist/ui/server.d.ts.map +1 -0
- package/dist/ui/server.js +510 -0
- package/dist/ui/server.js.map +1 -0
- package/package.json +79 -0
- package/src/bin/bitchat.ts +87 -0
- package/src/client.ts +519 -0
- package/src/crypto/index.ts +22 -0
- package/src/crypto/noise.ts +574 -0
- package/src/crypto/signing.ts +66 -0
- package/src/index.ts +95 -0
- package/src/mesh/deduplicator.ts +129 -0
- package/src/mesh/index.ts +6 -0
- package/src/mesh/router.ts +258 -0
- package/src/protocol/binary.ts +345 -0
- package/src/protocol/constants.ts +43 -0
- package/src/protocol/index.ts +15 -0
- package/src/protocol/packets.ts +223 -0
- package/src/protocol/types.ts +182 -0
- package/src/session/index.ts +9 -0
- package/src/session/manager.ts +476 -0
- package/src/transport/ble.ts +553 -0
- package/src/transport/index.ts +10 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/server.ts +569 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLE Transport
|
|
3
|
+
* Handles Bluetooth Low Energy communication for Bitchat mesh
|
|
4
|
+
*
|
|
5
|
+
* Uses noble for central mode (scanning/connecting) and
|
|
6
|
+
* bleno for peripheral mode (advertising/accepting)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
import type { Link } from '../mesh/router.js';
|
|
11
|
+
import {
|
|
12
|
+
BLE_MAX_MTU,
|
|
13
|
+
CHARACTERISTIC_UUID,
|
|
14
|
+
SERVICE_UUID,
|
|
15
|
+
SERVICE_UUID_TESTNET,
|
|
16
|
+
} from '../protocol/constants.js';
|
|
17
|
+
import type { PeerID } from '../protocol/types.js';
|
|
18
|
+
|
|
19
|
+
// Types for noble
|
|
20
|
+
type NobleModule = typeof import('@abandonware/noble');
|
|
21
|
+
type Peripheral = import('@abandonware/noble').Peripheral;
|
|
22
|
+
type Characteristic = import('@abandonware/noble').Characteristic;
|
|
23
|
+
|
|
24
|
+
// Types for bleno
|
|
25
|
+
type BlenoModule = typeof import('@abandonware/bleno');
|
|
26
|
+
|
|
27
|
+
export interface BLETransportConfig {
|
|
28
|
+
serviceUUID: string;
|
|
29
|
+
characteristicUUID: string;
|
|
30
|
+
testnet: boolean;
|
|
31
|
+
enableCentral: boolean;
|
|
32
|
+
enablePeripheral: boolean;
|
|
33
|
+
scanDuplicates: boolean;
|
|
34
|
+
advertisingName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CONFIG: BLETransportConfig = {
|
|
38
|
+
serviceUUID: SERVICE_UUID,
|
|
39
|
+
characteristicUUID: CHARACTERISTIC_UUID,
|
|
40
|
+
testnet: false,
|
|
41
|
+
enableCentral: true,
|
|
42
|
+
enablePeripheral: true,
|
|
43
|
+
scanDuplicates: true, // Enable to allow reconnection attempts
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
interface ConnectedPeripheral {
|
|
47
|
+
peripheral: Peripheral;
|
|
48
|
+
characteristic?: Characteristic;
|
|
49
|
+
peerID?: PeerID;
|
|
50
|
+
mtu: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BLETransportEvents {
|
|
54
|
+
ready: () => void;
|
|
55
|
+
data: (data: Uint8Array, fromLink: BLELink) => void;
|
|
56
|
+
'link:connected': (link: BLELink) => void;
|
|
57
|
+
'link:disconnected': (linkId: string) => void;
|
|
58
|
+
error: (error: Error, context: string) => void;
|
|
59
|
+
state: (state: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* BLE link implementation
|
|
64
|
+
*/
|
|
65
|
+
export class BLELink implements Link {
|
|
66
|
+
readonly id: string;
|
|
67
|
+
private peripheral: Peripheral;
|
|
68
|
+
private characteristic: Characteristic;
|
|
69
|
+
private _peerID?: PeerID;
|
|
70
|
+
private mtu: number;
|
|
71
|
+
|
|
72
|
+
constructor(peripheral: Peripheral, characteristic: Characteristic, mtu: number = BLE_MAX_MTU) {
|
|
73
|
+
this.id = peripheral.uuid;
|
|
74
|
+
this.peripheral = peripheral;
|
|
75
|
+
this.characteristic = characteristic;
|
|
76
|
+
this.mtu = mtu;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get peerID(): PeerID | undefined {
|
|
80
|
+
return this._peerID;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setPeerID(peerID: PeerID): void {
|
|
84
|
+
this._peerID = peerID;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async send(data: Uint8Array): Promise<void> {
|
|
88
|
+
// Fragment if necessary
|
|
89
|
+
if (data.length > this.mtu) {
|
|
90
|
+
// TODO: Implement fragmentation
|
|
91
|
+
throw new Error('Message too large for MTU, fragmentation not yet implemented');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log('[BLE] Sending', data.length, 'bytes to', this.id);
|
|
95
|
+
console.log('[BLE] First 50 bytes:', Buffer.from(data.slice(0, 50)).toString('hex'));
|
|
96
|
+
console.log('[BLE] Last 20 bytes:', Buffer.from(data.slice(-20)).toString('hex'));
|
|
97
|
+
|
|
98
|
+
const buf = Buffer.from(data);
|
|
99
|
+
console.log('[BLE] Buffer check - is Buffer:', Buffer.isBuffer(buf), 'length:', buf.length);
|
|
100
|
+
|
|
101
|
+
// Try write WITHOUT response (withoutResponse = true)
|
|
102
|
+
// This uses a different BLE write type that might work better
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
this.characteristic.write(buf, true, (error) => {
|
|
105
|
+
if (error) {
|
|
106
|
+
console.error('[BLE] Send error:', error);
|
|
107
|
+
reject(error);
|
|
108
|
+
} else {
|
|
109
|
+
console.log('[BLE] Send success (without response)');
|
|
110
|
+
resolve();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get isConnected(): boolean {
|
|
117
|
+
return this.peripheral.state === 'connected';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* BLE Transport - handles Bluetooth mesh networking
|
|
123
|
+
*/
|
|
124
|
+
export class BLETransport extends EventEmitter {
|
|
125
|
+
private config: BLETransportConfig;
|
|
126
|
+
private noble?: NobleModule;
|
|
127
|
+
private bleno?: BlenoModule;
|
|
128
|
+
private running = false;
|
|
129
|
+
|
|
130
|
+
// Connected peripherals (central mode)
|
|
131
|
+
private peripherals = new Map<string, ConnectedPeripheral>();
|
|
132
|
+
private links = new Map<string, BLELink>();
|
|
133
|
+
|
|
134
|
+
// Subscribed centrals (peripheral mode)
|
|
135
|
+
private subscribedCentrals = new Set<string>();
|
|
136
|
+
private notifyCallback?: (data: Buffer) => void;
|
|
137
|
+
|
|
138
|
+
constructor(config: Partial<BLETransportConfig> = {}) {
|
|
139
|
+
super();
|
|
140
|
+
const baseConfig = { ...DEFAULT_CONFIG, ...config };
|
|
141
|
+
// Use testnet UUID if testnet is true and no explicit serviceUUID was provided
|
|
142
|
+
if (baseConfig.testnet && !config.serviceUUID) {
|
|
143
|
+
baseConfig.serviceUUID = SERVICE_UUID_TESTNET;
|
|
144
|
+
}
|
|
145
|
+
this.config = baseConfig;
|
|
146
|
+
console.log(
|
|
147
|
+
'[BLE] Config: testnet =',
|
|
148
|
+
this.config.testnet,
|
|
149
|
+
', serviceUUID =',
|
|
150
|
+
this.config.serviceUUID
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Start the BLE transport
|
|
156
|
+
*/
|
|
157
|
+
async start(): Promise<void> {
|
|
158
|
+
if (this.running) return;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Initialize central mode (noble)
|
|
162
|
+
if (this.config.enableCentral) {
|
|
163
|
+
await this.initCentral();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Initialize peripheral mode (bleno)
|
|
167
|
+
if (this.config.enablePeripheral) {
|
|
168
|
+
await this.initPeripheral();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.running = true;
|
|
172
|
+
this.emit('ready');
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.emit('error', error as Error, 'start');
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Stop the BLE transport
|
|
181
|
+
*/
|
|
182
|
+
async stop(): Promise<void> {
|
|
183
|
+
if (!this.running) return;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
// Stop scanning
|
|
187
|
+
if (this.noble) {
|
|
188
|
+
await this.noble.stopScanningAsync();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Stop advertising
|
|
192
|
+
if (this.bleno) {
|
|
193
|
+
this.bleno.stopAdvertising();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Disconnect all peripherals
|
|
197
|
+
for (const [, conn] of this.peripherals) {
|
|
198
|
+
try {
|
|
199
|
+
await conn.peripheral.disconnectAsync();
|
|
200
|
+
} catch {
|
|
201
|
+
// Ignore disconnect errors
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.peripherals.clear();
|
|
206
|
+
this.links.clear();
|
|
207
|
+
this.subscribedCentrals.clear();
|
|
208
|
+
this.running = false;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
this.emit('error', error as Error, 'stop');
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get all active links
|
|
217
|
+
*/
|
|
218
|
+
getLinks(): BLELink[] {
|
|
219
|
+
return Array.from(this.links.values());
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get link by ID
|
|
224
|
+
*/
|
|
225
|
+
getLink(id: string): BLELink | undefined {
|
|
226
|
+
return this.links.get(id);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Broadcast data to all connected peers
|
|
231
|
+
*/
|
|
232
|
+
async broadcast(data: Uint8Array): Promise<void> {
|
|
233
|
+
// Send via central mode (noble) links
|
|
234
|
+
const sendPromises = Array.from(this.links.values()).map(async (link) => {
|
|
235
|
+
try {
|
|
236
|
+
await link.send(data);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
this.emit('error', error as Error, `broadcast to ${link.id}`);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Send via peripheral mode (bleno) notifications
|
|
243
|
+
if (this.notifyCallback) {
|
|
244
|
+
try {
|
|
245
|
+
console.log('[BLE] Notifying subscribed centrals,', data.length, 'bytes');
|
|
246
|
+
this.notifyCallback(Buffer.from(data));
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.error('[BLE] Notify error:', error);
|
|
249
|
+
this.emit('error', error as Error, 'notify');
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
console.log('[BLE] No notify callback (no centrals subscribed)');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await Promise.allSettled(sendPromises);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Central Mode (Noble) ---
|
|
259
|
+
|
|
260
|
+
private async initCentral(): Promise<void> {
|
|
261
|
+
console.log('[BLE] Initializing central mode (noble)...');
|
|
262
|
+
const nobleModule = await import('@abandonware/noble');
|
|
263
|
+
this.noble = nobleModule.default;
|
|
264
|
+
console.log('[BLE] Noble imported, state:', (this.noble as any).state);
|
|
265
|
+
|
|
266
|
+
// Wait for powered on
|
|
267
|
+
await this.waitForPoweredOn();
|
|
268
|
+
console.log('[BLE] Noble powered on');
|
|
269
|
+
|
|
270
|
+
// Set up event handlers
|
|
271
|
+
this.noble.on('discover', (peripheral) => this.onDiscover(peripheral));
|
|
272
|
+
this.noble.on('stateChange', (state) => this.emit('state', state));
|
|
273
|
+
|
|
274
|
+
// Start scanning
|
|
275
|
+
console.log('[BLE] Starting scan for service:', this.config.serviceUUID);
|
|
276
|
+
await this.noble.startScanningAsync([this.config.serviceUUID], this.config.scanDuplicates);
|
|
277
|
+
console.log('[BLE] Scanning started');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async waitForPoweredOn(): Promise<void> {
|
|
281
|
+
if (!this.noble) throw new Error('Noble not initialized');
|
|
282
|
+
|
|
283
|
+
const noble = this.noble as any;
|
|
284
|
+
if (noble.state === 'poweredOn') return;
|
|
285
|
+
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
const timeout = setTimeout(() => {
|
|
288
|
+
reject(new Error('Bluetooth adapter timeout'));
|
|
289
|
+
}, 10000);
|
|
290
|
+
|
|
291
|
+
const handler = (state: string) => {
|
|
292
|
+
if (state === 'poweredOn') {
|
|
293
|
+
clearTimeout(timeout);
|
|
294
|
+
noble.removeListener('stateChange', handler);
|
|
295
|
+
resolve();
|
|
296
|
+
} else if (state === 'poweredOff' || state === 'unauthorized') {
|
|
297
|
+
clearTimeout(timeout);
|
|
298
|
+
reject(new Error(`Bluetooth state: ${state}`));
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
noble.on('stateChange', handler);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Track failed connection attempts for retry logic
|
|
307
|
+
private failedConnections = new Map<string, { count: number; lastAttempt: Date }>();
|
|
308
|
+
// Track peripherals currently being connected to (prevent duplicate attempts)
|
|
309
|
+
private connectingPeripherals = new Set<string>();
|
|
310
|
+
|
|
311
|
+
private async onDiscover(peripheral: Peripheral): Promise<void> {
|
|
312
|
+
// Skip verbose logging for repeated discoveries
|
|
313
|
+
const isRepeatedDiscovery =
|
|
314
|
+
this.failedConnections.has(peripheral.uuid) ||
|
|
315
|
+
this.peripherals.has(peripheral.uuid) ||
|
|
316
|
+
this.connectingPeripherals.has(peripheral.uuid);
|
|
317
|
+
if (!isRepeatedDiscovery) {
|
|
318
|
+
console.log(
|
|
319
|
+
'[BLE] Discovered peripheral:',
|
|
320
|
+
peripheral.uuid,
|
|
321
|
+
peripheral.advertisement?.localName
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Skip if already connected
|
|
326
|
+
if (this.peripherals.has(peripheral.uuid)) return;
|
|
327
|
+
|
|
328
|
+
// Skip if currently connecting (prevents duplicate connect attempts)
|
|
329
|
+
if (this.connectingPeripherals.has(peripheral.uuid)) return;
|
|
330
|
+
|
|
331
|
+
// If we have a working peripheral connection (someone subscribed to us),
|
|
332
|
+
// don't spam central connection attempts - mesh protocol doesn't need bidirectional
|
|
333
|
+
if (this.notifyCallback && this.links.size === 0) {
|
|
334
|
+
// We have a working inbound connection, don't need outbound
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Check if we've failed too recently
|
|
339
|
+
const failed = this.failedConnections.get(peripheral.uuid);
|
|
340
|
+
if (failed) {
|
|
341
|
+
const backoffMs = Math.min(60000, 2000 * 2 ** failed.count); // Longer backoff
|
|
342
|
+
const elapsed = Date.now() - failed.lastAttempt.getTime();
|
|
343
|
+
if (elapsed < backoffMs) {
|
|
344
|
+
// Don't log every skip - too noisy
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Mark as connecting to prevent duplicate attempts
|
|
350
|
+
this.connectingPeripherals.add(peripheral.uuid);
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
console.log('[BLE] Connecting to', peripheral.uuid);
|
|
354
|
+
await peripheral.connectAsync();
|
|
355
|
+
console.log('[BLE] Connected to', peripheral.uuid);
|
|
356
|
+
|
|
357
|
+
// Clear tracking state on success
|
|
358
|
+
this.connectingPeripherals.delete(peripheral.uuid);
|
|
359
|
+
this.failedConnections.delete(peripheral.uuid);
|
|
360
|
+
|
|
361
|
+
// Discover services and characteristics
|
|
362
|
+
console.log('[BLE] Discovering services for', peripheral.uuid);
|
|
363
|
+
const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
|
|
364
|
+
[this.config.serviceUUID],
|
|
365
|
+
[this.config.characteristicUUID]
|
|
366
|
+
);
|
|
367
|
+
console.log('[BLE] Found', characteristics.length, 'characteristics');
|
|
368
|
+
|
|
369
|
+
if (characteristics.length === 0) {
|
|
370
|
+
console.log('[BLE] No characteristics found, disconnecting');
|
|
371
|
+
await peripheral.disconnectAsync();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const characteristic = characteristics[0];
|
|
376
|
+
|
|
377
|
+
// Get MTU
|
|
378
|
+
const mtu = peripheral.mtu ?? BLE_MAX_MTU;
|
|
379
|
+
console.log('[BLE] Peripheral MTU:', peripheral.mtu, '(using:', `${mtu})`);
|
|
380
|
+
|
|
381
|
+
// Store connection
|
|
382
|
+
const conn: ConnectedPeripheral = {
|
|
383
|
+
peripheral,
|
|
384
|
+
characteristic,
|
|
385
|
+
mtu,
|
|
386
|
+
};
|
|
387
|
+
this.peripherals.set(peripheral.uuid, conn);
|
|
388
|
+
|
|
389
|
+
// Create link
|
|
390
|
+
const link = new BLELink(peripheral, characteristic, mtu);
|
|
391
|
+
this.links.set(peripheral.uuid, link);
|
|
392
|
+
|
|
393
|
+
// Subscribe to notifications
|
|
394
|
+
await characteristic.subscribeAsync();
|
|
395
|
+
characteristic.on('data', (data: Buffer) => {
|
|
396
|
+
this.emit('data', new Uint8Array(data), link);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Handle disconnect
|
|
400
|
+
peripheral.once('disconnect', () => {
|
|
401
|
+
this.onDisconnect(peripheral.uuid);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
this.emit('link:connected', link);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
// Clear connecting state
|
|
407
|
+
this.connectingPeripherals.delete(peripheral.uuid);
|
|
408
|
+
|
|
409
|
+
const err = error as Error;
|
|
410
|
+
|
|
411
|
+
// Track failure for retry backoff
|
|
412
|
+
const failed = this.failedConnections.get(peripheral.uuid) ?? {
|
|
413
|
+
count: 0,
|
|
414
|
+
lastAttempt: new Date(),
|
|
415
|
+
};
|
|
416
|
+
failed.count++;
|
|
417
|
+
failed.lastAttempt = new Date();
|
|
418
|
+
this.failedConnections.set(peripheral.uuid, failed);
|
|
419
|
+
|
|
420
|
+
// Only log first few failures, then go quiet
|
|
421
|
+
if (failed.count <= 3) {
|
|
422
|
+
console.error(
|
|
423
|
+
'[BLE] Connection failed:',
|
|
424
|
+
err?.message ?? 'unknown error',
|
|
425
|
+
`(attempt ${failed.count})`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Try to disconnect if still connected
|
|
430
|
+
try {
|
|
431
|
+
if (peripheral.state === 'connected' || peripheral.state === 'connecting') {
|
|
432
|
+
await peripheral.disconnectAsync();
|
|
433
|
+
}
|
|
434
|
+
} catch {}
|
|
435
|
+
|
|
436
|
+
// Only emit error for first failure
|
|
437
|
+
if (failed.count === 1) {
|
|
438
|
+
this.emit('error', err ?? new Error('Unknown BLE discover error'), 'discover');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private onDisconnect(peripheralUUID: string): void {
|
|
444
|
+
this.peripherals.delete(peripheralUUID);
|
|
445
|
+
this.links.delete(peripheralUUID);
|
|
446
|
+
this.emit('link:disconnected', peripheralUUID);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// --- Peripheral Mode (Bleno) ---
|
|
450
|
+
|
|
451
|
+
private async initPeripheral(): Promise<void> {
|
|
452
|
+
try {
|
|
453
|
+
const blenoModule = await import('@abandonware/bleno');
|
|
454
|
+
this.bleno = blenoModule.default;
|
|
455
|
+
|
|
456
|
+
// Wait for powered on
|
|
457
|
+
await this.waitForBlenoPoweredOn();
|
|
458
|
+
|
|
459
|
+
// Create characteristic
|
|
460
|
+
const CharacteristicClass = this.bleno.Characteristic;
|
|
461
|
+
const characteristic = new CharacteristicClass({
|
|
462
|
+
uuid: this.config.characteristicUUID.replace(/-/g, ''),
|
|
463
|
+
properties: ['read', 'write', 'writeWithoutResponse', 'notify'],
|
|
464
|
+
onWriteRequest: (
|
|
465
|
+
data: Buffer,
|
|
466
|
+
_offset: number,
|
|
467
|
+
_withoutResponse: boolean,
|
|
468
|
+
callback: (result: number) => void
|
|
469
|
+
) => {
|
|
470
|
+
// Handle incoming data from central
|
|
471
|
+
this.emit('data', new Uint8Array(data), null as any);
|
|
472
|
+
callback(CharacteristicClass.RESULT_SUCCESS);
|
|
473
|
+
},
|
|
474
|
+
onSubscribe: (_maxValueSize: number, updateValueCallback: (data: Buffer) => void) => {
|
|
475
|
+
// Central subscribed to notifications - store callback
|
|
476
|
+
this.notifyCallback = updateValueCallback;
|
|
477
|
+
console.log('Central subscribed to notifications');
|
|
478
|
+
},
|
|
479
|
+
onUnsubscribe: () => {
|
|
480
|
+
// Central unsubscribed
|
|
481
|
+
this.notifyCallback = undefined;
|
|
482
|
+
console.log('Central unsubscribed');
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Create service
|
|
487
|
+
const PrimaryServiceClass = this.bleno.PrimaryService;
|
|
488
|
+
const service = new PrimaryServiceClass({
|
|
489
|
+
uuid: this.config.serviceUUID.replace(/-/g, ''),
|
|
490
|
+
characteristics: [characteristic],
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Set services
|
|
494
|
+
this.bleno.setServices([service]);
|
|
495
|
+
|
|
496
|
+
// Listen for central connection events
|
|
497
|
+
const bleno = this.bleno as any;
|
|
498
|
+
bleno.on('accept', (clientAddress: string) => {
|
|
499
|
+
console.log('[BLE] Central accepted connection from:', clientAddress);
|
|
500
|
+
});
|
|
501
|
+
bleno.on('disconnect', (clientAddress: string) => {
|
|
502
|
+
console.log('[BLE] Central disconnected:', clientAddress);
|
|
503
|
+
});
|
|
504
|
+
bleno.on('servicesSet', (error?: Error) => {
|
|
505
|
+
console.log('[BLE] Services set, error:', error);
|
|
506
|
+
});
|
|
507
|
+
bleno.on('advertisingStart', (error?: Error) => {
|
|
508
|
+
console.log('[BLE] Advertising started, error:', error);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Start advertising
|
|
512
|
+
const name = this.config.advertisingName ?? 'bitchat';
|
|
513
|
+
this.bleno.startAdvertising(name, [this.config.serviceUUID]);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
// Bleno might not be available on all platforms
|
|
516
|
+
console.warn('Bleno (peripheral mode) not available:', error);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private async waitForBlenoPoweredOn(): Promise<void> {
|
|
521
|
+
if (!this.bleno) throw new Error('Bleno not initialized');
|
|
522
|
+
|
|
523
|
+
const bleno = this.bleno as any;
|
|
524
|
+
if (bleno.state === 'poweredOn') return;
|
|
525
|
+
|
|
526
|
+
return new Promise((resolve, reject) => {
|
|
527
|
+
const timeout = setTimeout(() => {
|
|
528
|
+
reject(new Error('Bleno timeout'));
|
|
529
|
+
}, 10000);
|
|
530
|
+
|
|
531
|
+
const handler = (state: string) => {
|
|
532
|
+
if (state === 'poweredOn') {
|
|
533
|
+
clearTimeout(timeout);
|
|
534
|
+
bleno.removeListener('stateChange', handler);
|
|
535
|
+
resolve();
|
|
536
|
+
} else if (state === 'poweredOff' || state === 'unauthorized') {
|
|
537
|
+
clearTimeout(timeout);
|
|
538
|
+
reject(new Error(`Bleno state: ${state}`));
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
bleno.on('stateChange', handler);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Clean up
|
|
548
|
+
*/
|
|
549
|
+
destroy(): void {
|
|
550
|
+
this.stop().catch(() => {});
|
|
551
|
+
this.removeAllListeners();
|
|
552
|
+
}
|
|
553
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { startUIServer, type UIServerConfig } from './server.js';
|