calibre-simulator.mjs
1 #!/usr/bin/env node 2 /** 3 * Calibre Simulator - Simulates Calibre Desktop App 4 * 5 * This simulates the REAL Calibre desktop app protocol: 6 * - Starts TCP server on port 9090 7 * - Listens on UDP discovery ports for device "hello" messages 8 * - Accepts device connections 9 * - Sends books to device 10 * 11 * Protocol flow (matches real Calibre 8.x): 12 * 1. GET_INITIALIZATION_INFO 13 * 2. GET_DEVICE_INFORMATION 14 * 3. SET_CALIBRE_DEVICE_INFO 15 * 4. FREE_SPACE 16 * 5. SET_LIBRARY_INFO 17 * 6. NOOP (no response expected) 18 * 7. SEND_BOOKLISTS (no response expected) 19 * 8. Optional: SEND_BOOK for each book 20 * 21 * Usage: 22 * node calibre-simulator.mjs # Start server, wait for device 23 * node calibre-simulator.mjs -s book.epub # Send book after connect 24 */ 25 26 import dgram from "dgram"; 27 import net from "net"; 28 import fs from "fs"; 29 import path from "path"; 30 import os from "os"; 31 32 const BROADCAST_PORTS = [54982, 48123, 39001, 44044, 59678]; 33 const DEFAULT_PORT = 9090; 34 35 const OPCODES = { 36 OK: 0, 37 SET_CALIBRE_DEVICE_INFO: 1, 38 SET_CALIBRE_DEVICE_NAME: 2, 39 GET_DEVICE_INFORMATION: 3, 40 TOTAL_SPACE: 4, 41 FREE_SPACE: 5, 42 GET_BOOK_COUNT: 6, 43 SEND_BOOKLISTS: 7, 44 SEND_BOOK: 8, 45 GET_INITIALIZATION_INFO: 9, 46 BOOK_DONE: 11, 47 NOOP: 12, 48 DELETE_BOOK: 13, 49 DISPLAY_MESSAGE: 17, 50 CALIBRE_BUSY: 18, 51 SET_LIBRARY_INFO: 19, 52 ERROR: 20, 53 }; 54 55 const OPCODE_NAMES = Object.fromEntries( 56 Object.entries(OPCODES).map(([k, v]) => [v, k]) 57 ); 58 59 class CalibreSimulator { 60 constructor(options = {}) { 61 this.port = options.port || DEFAULT_PORT; 62 this.bookFile = options.bookFile || null; 63 this.tcpServer = null; 64 this.udpSockets = []; 65 this.clientSocket = null; 66 this.recvBuffer = Buffer.alloc(0); 67 this.connected = false; 68 this.deviceInfo = null; 69 this.protocolState = 'idle'; 70 this.deviceStoreUuid = null; 71 } 72 73 async start() { 74 console.log(`\n╔════════════════════════════════════════════════════════════╗`); 75 console.log(`║ Calibre Desktop App Simulator ║`); 76 console.log(`╚════════════════════════════════════════════════════════════╝\n`); 77 78 // Start TCP server 79 await this.startTCPServer(); 80 81 // Start UDP listener 82 await this.startUDPListener(); 83 84 console.log(`\n[STATUS] Calibre is ready!`); 85 console.log(` Waiting for device to connect...\n`); 86 } 87 88 async startTCPServer() { 89 return new Promise((resolve, reject) => { 90 this.tcpServer = net.createServer((socket) => { 91 if (this.clientSocket) { 92 console.log("[TCP] Rejecting additional connection"); 93 socket.end(); 94 return; 95 } 96 97 this.clientSocket = socket; 98 this.recvBuffer = Buffer.alloc(0); 99 100 const clientAddr = `${socket.remoteAddress}:${socket.remotePort}`; 101 console.log(`\n[TCP] ✓ Device connected from ${clientAddr}\n`); 102 this.connected = true; 103 104 // Stop UDP listeners once connected 105 for (const s of this.udpSockets) { 106 try { s.close(); } catch (e) { /* ignore */ } 107 } 108 this.udpSockets = []; 109 110 socket.on("data", (data) => { 111 this.recvBuffer = Buffer.concat([this.recvBuffer, data]); 112 this.processMessages(); 113 }); 114 115 socket.on("close", () => { 116 console.log("\n[TCP] Device disconnected"); 117 this.connected = false; 118 this.clientSocket = null; 119 this.protocolState = 'idle'; 120 }); 121 122 socket.on("error", (err) => { 123 console.error(`[TCP] Error: ${err.message}`); 124 }); 125 126 // Start protocol handshake 127 setTimeout(() => this.startHandshake(), 100); 128 }); 129 130 this.tcpServer.listen(this.port, "0.0.0.0", () => { 131 console.log(`[TCP] Server listening on port ${this.port}`); 132 resolve(); 133 }); 134 135 this.tcpServer.on("error", reject); 136 }); 137 } 138 139 async startUDPListener() { 140 // Listen on broadcast ports for "hello" messages from devices 141 for (const port of BROADCAST_PORTS) { 142 try { 143 const socket = dgram.createSocket({ type: "udp4", reuseAddr: true }); 144 145 socket.on("message", (msg, rinfo) => { 146 const message = msg.toString().trim(); 147 if (message === "hello") { 148 console.log(`[UDP] Received "hello" from ${rinfo.address}:${rinfo.port}`); 149 150 // Respond with Calibre's info 151 const hostname = os.hostname().split(".")[0]; 152 const response = `calibre wireless device client (on ${hostname});0,${this.port}`; 153 154 socket.send(response, rinfo.port, rinfo.address, (err) => { 155 if (err) { 156 console.error(`[UDP] Response error: ${err.message}`); 157 } else { 158 console.log(`[UDP] Sent response: "${response}"`); 159 } 160 }); 161 } 162 }); 163 164 socket.on("error", () => { 165 // Silently ignore bind errors 166 }); 167 168 await new Promise((resolve) => { 169 socket.bind(port, "0.0.0.0", () => { 170 console.log(`[UDP] Listening on port ${port}`); 171 resolve(); 172 }); 173 }); 174 175 this.udpSockets.push(socket); 176 } catch (err) { 177 // Ignore errors for individual ports 178 } 179 } 180 } 181 182 async startHandshake() { 183 console.log("[PROTOCOL] Starting handshake...\n"); 184 this.protocolState = 'init'; 185 186 await this.send(OPCODES.GET_INITIALIZATION_INFO, { 187 calibre_version: [8, 16, 2], 188 serverProtocolVersion: 1, 189 validExtensions: ["epub", "mobi", "pdf", "txt", "azw3", "md", "xtc", "xtch"], 190 passwordChallenge: "", 191 currentLibraryName: "Test Library", 192 currentLibraryUUID: "test-uuid-1234-5678-9abc-def012345678", 193 pubdateFormat: "MMM yyyy", 194 timestampFormat: "dd MMM yyyy", 195 lastModifiedFormat: "dd MMM yyyy", 196 canSupportUpdateBooks: true, 197 canSupportLpathChanges: true, 198 }); 199 } 200 201 async processMessages() { 202 while (this.recvBuffer.length > 0) { 203 let lenEnd = 0; 204 while (lenEnd < this.recvBuffer.length && this.recvBuffer[lenEnd] >= 0x30 && this.recvBuffer[lenEnd] <= 0x39) { 205 lenEnd++; 206 } 207 208 if (lenEnd === 0 || lenEnd >= this.recvBuffer.length) return; 209 210 const msgLen = parseInt(this.recvBuffer.slice(0, lenEnd).toString(), 10); 211 if (this.recvBuffer.length < lenEnd + msgLen) return; 212 213 const msgData = this.recvBuffer.slice(lenEnd, lenEnd + msgLen).toString(); 214 this.recvBuffer = this.recvBuffer.slice(lenEnd + msgLen); 215 216 try { 217 const parsed = JSON.parse(msgData); 218 await this.handleMessage(parsed[0], parsed[1] || {}); 219 } catch (e) { 220 console.error(`[ERROR] Parse error: ${e.message}`); 221 } 222 } 223 } 224 225 async handleMessage(opcode, payload) { 226 const name = OPCODE_NAMES[opcode] || opcode; 227 console.log(`[RECV] ${name}`); 228 229 if (opcode === OPCODES.OK) { 230 // Handle OK responses based on current state 231 switch (this.protocolState) { 232 case 'init': 233 // Response to GET_INITIALIZATION_INFO 234 this.deviceInfo = payload; 235 console.log("\n[DEVICE]"); 236 console.log(` Name: ${payload.deviceName}`); 237 console.log(` Kind: ${payload.deviceKind}`); 238 console.log(` Extensions: ${JSON.stringify(payload.acceptedExtensions)}`); 239 console.log(` Max packet: ${payload.maxBookContentPacketLen}`); 240 this.deviceStoreUuid = payload.device_store_uuid || "unknown"; 241 242 // Check required capabilities 243 const canStreamBooks = payload.canStreamBooks || false; 244 const canStreamMetadata = payload.canStreamMetadata || false; 245 const canReceiveBookBinary = payload.canReceiveBookBinary || false; 246 const canDeleteMultiple = payload.canDeleteMultipleBooks || false; 247 248 console.log(` Capabilities:`); 249 console.log(` canStreamBooks: ${canStreamBooks}`); 250 console.log(` canStreamMetadata: ${canStreamMetadata}`); 251 console.log(` canReceiveBookBinary: ${canReceiveBookBinary}`); 252 console.log(` canDeleteMultipleBooks: ${canDeleteMultiple}`); 253 254 if (!(canStreamBooks && canStreamMetadata && canReceiveBookBinary && canDeleteMultiple)) { 255 console.log("\n[ERROR] ✗ Device capabilities check FAILED!"); 256 console.log(" Real Calibre would reject with: 'The app on your device is too old'"); 257 this.clientSocket.end(); 258 return; 259 } 260 console.log(" ✓ All capabilities OK\n"); 261 262 // Next: GET_DEVICE_INFORMATION 263 this.protocolState = 'devinfo'; 264 await this.send(OPCODES.GET_DEVICE_INFORMATION, {}); 265 break; 266 267 case 'devinfo': 268 // Response to GET_DEVICE_INFORMATION 269 console.log(` → Device version: ${payload.device_version || "unknown"}`); 270 this.protocolState = 'setdevinfo'; 271 await this.send(OPCODES.SET_CALIBRE_DEVICE_INFO, { 272 device_store_uuid: this.deviceStoreUuid, 273 device_name: payload.device_info?.device_name || "Unknown Device", 274 location_code: "main", 275 last_library_uuid: "test-uuid-1234-5678-9abc-def012345678", 276 calibre_version: "8.16.2", 277 date_last_connected: new Date().toISOString(), 278 prefix: "", 279 }); 280 break; 281 282 case 'setdevinfo': 283 // Response to SET_CALIBRE_DEVICE_INFO 284 this.protocolState = 'freespace'; 285 await this.send(OPCODES.FREE_SPACE, {}); 286 break; 287 288 case 'freespace': 289 // Response to FREE_SPACE 290 const gb = (payload.free_space_on_device / 1024 / 1024 / 1024).toFixed(2); 291 console.log(` → Free space: ${gb} GB\n`); 292 293 // Next: SET_LIBRARY_INFO (with fieldMetadata like real Calibre) 294 this.protocolState = 'library'; 295 await this.send(OPCODES.SET_LIBRARY_INFO, { 296 libraryName: "Test Library", 297 libraryUuid: "test-uuid-1234-5678-9abc-def012345678", 298 fieldMetadata: { 299 // Real Calibre sends all field definitions here 300 title: { name: "Title", datatype: "text" }, 301 authors: { name: "Authors", datatype: "text" }, 302 series: { name: "Series", datatype: "series" }, 303 tags: { name: "Tags", datatype: "text" }, 304 }, 305 otherInfo: { id_link_rules: {} }, 306 }); 307 break; 308 309 case 'library': 310 // Response to SET_LIBRARY_INFO 311 console.log(" → Library info acknowledged\n"); 312 313 // Send NOOP with payload (no response expected) 314 await this.send(OPCODES.NOOP, { count: 0 }); 315 316 // Send SEND_BOOKLISTS (no response expected) 317 await this.send(OPCODES.SEND_BOOKLISTS, { 318 count: 0, 319 collections: {}, 320 willStreamMetadata: true, 321 supportsSync: false, 322 }); 323 324 this.protocolState = 'ready'; 325 326 if (this.bookFile && fs.existsSync(this.bookFile)) { 327 this.protocolState = 'sending_book'; 328 await this.sendBook(this.bookFile); 329 } else { 330 console.log("[COMPLETE] Handshake successful!"); 331 if (this.bookFile) { 332 console.log(`[ERROR] Book file not found: ${this.bookFile}`); 333 } 334 } 335 break; 336 337 case 'sending_book': 338 if (payload.willAccept) { 339 console.log(" → Device accepted book, sending data...\n"); 340 await this.sendBookData(); 341 } 342 break; 343 344 case 'sending_data': 345 console.log("\n[COMPLETE] Book transfer complete!"); 346 break; 347 348 default: 349 console.log(" → OK"); 350 } 351 } else if (opcode === OPCODES.BOOK_DONE) { 352 console.log(" → Book transfer confirmed by device\n"); 353 this.protocolState = 'complete'; 354 355 // Send a nice message 356 await this.send(OPCODES.DISPLAY_MESSAGE, { 357 message: "Book transferred successfully!", 358 }); 359 360 console.log("[COMPLETE] All done! ✓\n"); 361 } 362 } 363 364 async sendBook(filePath) { 365 const stats = fs.statSync(filePath); 366 const fileName = path.basename(filePath); 367 const ext = path.extname(filePath); 368 369 this.currentBook = { 370 filePath, 371 fileName, 372 size: stats.size, 373 fd: fs.openSync(filePath, "r"), 374 }; 375 376 console.log(`[SEND_BOOK] "${fileName}" (${stats.size} bytes)\n`); 377 378 await this.send(OPCODES.SEND_BOOK, { 379 lpath: `Calibre/${fileName}`, 380 title: fileName.replace(ext, ""), 381 authors: "Test Author", 382 uuid: `test-${Date.now()}`, 383 length: stats.size, 384 calibre_id: 1, 385 willStreamBooks: true, 386 willStreamBinary: true, 387 wantsSendOkToSendbook: true, 388 canSupportLpathChanges: true, 389 }); 390 } 391 392 async sendBookData() { 393 const { fd, size } = this.currentBook; 394 const CHUNK_SIZE = 4096; 395 const buffer = Buffer.alloc(CHUNK_SIZE); 396 397 let sent = 0; 398 console.log("[TRANSFER] Sending book data...\n"); 399 400 while (sent < size) { 401 const remaining = size - sent; 402 const chunkSize = Math.min(CHUNK_SIZE, remaining); 403 const bytesRead = fs.readSync(fd, buffer, 0, chunkSize, sent); 404 405 await new Promise((resolve, reject) => { 406 this.clientSocket.write(buffer.slice(0, bytesRead), (err) => { 407 if (err) reject(err); 408 else resolve(); 409 }); 410 }); 411 412 sent += bytesRead; 413 const pct = ((sent / size) * 100).toFixed(0); 414 process.stdout.write(`\r[TRANSFER] ${pct}% (${sent}/${size} bytes)`); 415 } 416 417 fs.closeSync(fd); 418 console.log("\n\n[TRANSFER] Data sent, waiting for BOOK_DONE...\n"); 419 this.protocolState = 'sending_data'; 420 } 421 422 async send(opcode, payload) { 423 if (!this.clientSocket || !this.connected) return; 424 425 const name = OPCODE_NAMES[opcode] || opcode; 426 const msg = JSON.stringify([opcode, payload]); 427 const fullMsg = msg.length.toString() + msg; 428 429 console.log(`[SEND] ${name}`); 430 431 return new Promise((resolve, reject) => { 432 this.clientSocket.write(fullMsg, (err) => { 433 if (err) reject(err); 434 else resolve(); 435 }); 436 }); 437 } 438 439 stop() { 440 console.log("\n[STOP] Shutting down..."); 441 for (const s of this.udpSockets) { 442 try { s.close(); } catch (e) { /* ignore */ } 443 } 444 if (this.clientSocket) this.clientSocket.end(); 445 if (this.tcpServer) this.tcpServer.close(); 446 } 447 } 448 449 function getLocalIPs() { 450 const ips = []; 451 const nets = os.networkInterfaces(); 452 for (const name of Object.keys(nets)) { 453 for (const net of nets[name]) { 454 if (net.family === "IPv4" && !net.internal) { 455 ips.push(net.address); 456 } 457 } 458 } 459 return ips; 460 } 461 462 async function main() { 463 const args = process.argv.slice(2); 464 let port = DEFAULT_PORT; 465 let bookFile = null; 466 467 for (let i = 0; i < args.length; i++) { 468 if (args[i] === "--port" || args[i] === "-p") port = parseInt(args[++i], 10); 469 else if (args[i] === "--send" || args[i] === "-s") bookFile = args[++i]; 470 else if (args[i] === "--help" || args[i] === "-h") { 471 console.log(` 472 Calibre Simulator - Simulates Calibre Desktop App 473 474 Usage: 475 node calibre-simulator.mjs [options] 476 477 Options: 478 -p, --port <port> TCP port (default: 9090) 479 -s, --send <file> Send book file after device connects 480 -h, --help Show help 481 482 Examples: 483 node calibre-simulator.mjs # Start server, wait for device 484 node calibre-simulator.mjs -s book.epub # Send book after connect 485 `); 486 process.exit(0); 487 } 488 } 489 490 console.log("Local IPs:", getLocalIPs().join(", ")); 491 492 const calibre = new CalibreSimulator({ port, bookFile }); 493 process.on("SIGINT", () => { calibre.stop(); process.exit(0); }); 494 495 await calibre.start(); 496 while (true) await new Promise(r => setTimeout(r, 1000)); 497 } 498 499 main();