/ scripts / calibre-simulator.mjs
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();