The Infinite Flash Drive — แฟลชไดรฟ์ความจุไม่จำกัดจาก ESP32-S3
ทำแฟลชไดรฟ์จากบอร์ด ESP32-S3 ใน 3 ระดับ: ไดรฟ์ USB ธรรมดา → ลากไฟล์ใส่แล้วขึ้นคลาวด์เอง → ไดรฟ์ 512MB ที่ไม่มีไฟล์อยู่ในบอร์ดเลย แต่สตรีมทุกไบต์มาจาก server
ในหน้านี้
บอร์ด ESP32-S3 ตัวเล็กๆ ตัวเดียว เสียบเข้าคอมแล้วกลายเป็นแฟลชไดรฟ์ได้จริง และถ้าเขียนโค้ดให้ฉลาดขึ้นอีกนิด มันจะแอบอัปโหลดไฟล์ขึ้นคลาวด์เอง จนถึงขั้นสุดท้ายที่คอมเห็นไดรฟ์ 512MB เต็มไปด้วยไฟล์ ทั้งที่ในบอร์ดไม่มีข้อมูลไฟล์พวกนั้นอยู่เลยสักไบต์
โปรเจกต์นี้แบ่งเป็น 3 ระดับ ทำทีละขั้นได้ แต่ละระดับใช้บอร์ดตัวเดิม เปลี่ยนแค่โค้ด
| ระดับ | คอมเห็นอะไร | ความลับอยู่ตรงไหน |
|---|---|---|
| 1 · Basic USB | ไดรฟ์ ~960KB ก๊อปไฟล์เข้าออกได้ | ใช้ flash ของบอร์ดเป็นที่เก็บจริง |
| 2 · Cloud-Sync | ไดรฟ์เหมือนเดิม แต่ไฟล์เด้งขึ้น server เอง | บอร์ดรอให้ก๊อปเสร็จ แล้ว POST ไฟล์ขึ้นเน็ต |
| 3 · Infinite | ไดรฟ์ ~512MB ไฟล์เต็ม | บอร์ดปั้นตารางไฟล์ปลอม แล้วสตรีมของจริงจาก server |
อุปกรณ์ที่ต้องใช้
0/4 ชิ้น- ×1
ต้องเป็นรุ่น S3 เท่านั้น ESP32 รุ่นธรรมดาและ C3 ทำ USB Mass Storage แบบนี้ไม่ได้ แนะนำบอร์ดที่มีพอร์ต USB-C 2 ช่อง จะดู Serial log ไปพร้อมกับใช้ไดรฟ์ได้
- ×1
สายที่ชาร์จได้อย่างเดียวจะไม่ขึ้นไดรฟ์ ถ้าเสียบแล้วเงียบให้ลองเปลี่ยนสายก่อน
- ×1
ใช้รัน server ในระดับ 2 และ 3 (ระดับ 1 ไม่ต้องใช้)
- ×1 วง
บอร์ดกับคอมที่รัน server ต้องต่อ WiFi วงเดียวกัน
ตั้งค่า Arduino IDE ก่อนเริ่ม
ต้องใช้ ESP32 Arduino core v3.x ขึ้นไป (Boards Manager → esp32 by Espressif) แล้วตั้งค่าบอร์ดตามนี้
| หัวข้อ | ค่าที่ต้องเลือก |
|---|---|
| Board | ESP32S3 Dev Module |
| USB CDC On Boot | Enabled (ไว้ดู Serial log) |
| USB Mode | USB-OTG (TinyUSB) |
| Partition Scheme | Default 4MB with ffat (ระดับ 1–2) |
| Upload Mode | UART0 / Hardware CDC |
ระดับ 1 — แฟลชไดรฟ์ธรรมดา
เปิด level1_basic_usb.ino คอมไพล์และอัปโหลดได้เลย ไม่ต้องแก้อะไร เสียบเข้าคอมแล้วจะขึ้นไดรฟ์ใหม่ราว 960KB ที่ format เป็น FAT เรียบร้อย
หลักการคือเอา flash partition ชื่อ ffat ของบอร์ดมาเป็นที่เก็บข้อมูล แล้วต่อเข้ากับ USB Mass Storage ผ่านชั้น wear-leveling เวลาคอมขออ่านหรือเขียน บอร์ดแค่แปลงตำแหน่ง sector เป็นตำแหน่งบน flash
// -------- callback: คอมขอ "อ่าน" ข้อมูลจากไดรฟ์ --------static int32_t onRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) { // แปลง (lba, offset) เป็น address จริงบน flash แล้วอ่านผ่าน wear-leveling esp_err_t err = wl_read(s_wl, (size_t)lba * s_sectorSize + offset, buffer, bufsize); return (err == ESP_OK) ? (int32_t)bufsize : -1;} // -------- callback: คอมขอ "เขียน" ข้อมูลลงไดรฟ์ --------static int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) { size_t addr = (size_t)lba * s_sectorSize + offset; // flash ต้อง erase ก่อนเขียนเสมอ (MSC เขียนทีละ block ที่ตรงขอบ sector อยู่แล้ว) if (wl_erase_range(s_wl, addr, bufsize) != ESP_OK) return -1; if (wl_write(s_wl, addr, buffer, bufsize) != ESP_OK) return -1; return (int32_t)bufsize;}ระดับ 2 — ลากไฟล์ใส่ แล้วขึ้นคลาวด์เอง
หน้าตาไดรฟ์เหมือนระดับ 1 ทุกอย่าง แต่คราวนี้บอร์ดต่อ WiFi อยู่เบื้องหลัง พอเราลากไฟล์ใส่ บอร์ดจะรอให้คอมหยุดเขียนสักพัก (ถือว่าก๊อปเสร็จ) แล้วอัปโหลดไฟล์ใหม่ขึ้น server ให้เอง ไม่ต้องกดอะไร
ก่อนคอมไพล์ แก้ 4 บรรทัดบนสุดให้ตรงกับเครื่องของเรา
#define WIFI_SSID "ใส่ชื่อ_WiFi_ที่นี่"#define WIFI_PASS "ใส่รหัส_WiFi_ที่นี่"#define SERVER_HOST "192.168.1.100" // IP เครื่องที่รัน node server.js#define SERVER_PORT 3000SERVER_HOST คือ IP ของคอมที่รัน server (ดูวิธีหา IP ในหัวข้อ รัน server) ส่วนจังหวะ "ก๊อปเสร็จแล้ว" ดูจากเวลาที่คอมเขียนครั้งล่าสุด ถ้าเงียบเกิน 1.5 วินาทีก็เริ่ม sync
// ถ้ามีการเขียน และ PC หยุดเขียนเกิน 1.5 วิ = ก๊อปเสร็จ -> sync ขึ้น cloud if (s_dirty && (millis() - s_lastWriteMs > 1500)) { s_dirty = false; if (WiFi.status() == WL_CONNECTED) { Serial.println("[sync] PC หยุดเขียนแล้ว เริ่ม sync ขึ้น cloud..."); syncNewFiles(); } else { Serial.println("[sync] ยังไม่มี WiFi — ข้ามรอบนี้ไปก่อน"); s_dirty = true; // ลองใหม่รอบหน้า } }ระดับ 3 — ไดรฟ์ 512MB ที่ไม่มีไฟล์อยู่จริง
ระดับนี้คือไม้ตาย บอร์ดดึงรายชื่อไฟล์จาก server ตอนเปิดเครื่อง แล้วปั้น "ภาพ" ของไดรฟ์ FAT16 ขนาดราว 512MB ขึ้นมาสดๆ คอมเลยเห็นไฟล์เต็มไดรฟ์ ทั้งที่บอร์ดเก็บแค่รายชื่อ
flowchart LR PC["คอม<br/>เห็นไดรฟ์ 512MB"] -- "USB: ขออ่าน sector" --> ESP["ESP32-S3<br/>ปั้น FAT ปลอม"] ESP -- "WiFi: HTTP Range" --> SRV["Server<br/>ไฟล์จริงอยู่ที่นี่"] SRV -- "ส่งเฉพาะไบต์ที่ขอ" --> ESP ESP -- "ข้อมูล sector" --> PC
มันหลอกคอมได้ด้วย 2 อย่าง
- ตารางไฟล์ปลอม — sector แรกๆ ของไดรฟ์ (boot sector, ตาราง FAT, รายการไฟล์) ถูกคำนวณจากรายชื่อไฟล์บน server ทุกครั้งที่คอมขออ่าน ไม่ได้เก็บไว้ที่ไหนเลย
- สตรีมตอนเปิดไฟล์ — พอคอมขออ่าน sector ในโซนข้อมูล บอร์ดแปลงกลับเป็น "ไฟล์ไหน ไบต์ที่เท่าไหร่" แล้วยิงคำขอ HTTP Range ไปดึงเฉพาะช่วงนั้นจาก server
ขนาดของไดรฟ์ปลอมกำหนดจากค่าพวกนี้ (sector ละ 512 ไบต์, cluster ละ 32KB, 16384 cluster)
static const uint32_t BPS = 512; // bytes ต่อ sectorstatic const uint32_t SPC = 64; // sector ต่อ cluster -> cluster = 32KBstatic const uint32_t CLUSTER = BPS * SPC;// 32768static const uint32_t RSVD = 1; // reserved sector (boot)static const uint32_t NUM_FATS = 1;static const uint32_t FAT_SECS = 65; // ขนาด FAT (รองรับ ~16384 cluster)static const uint32_t ROOT_ENTS = 512; // จำนวน entry ใน root dirstatic const uint32_t ROOT_SECS = (ROOT_ENTS * 32) / BPS; // = 32static const uint32_t DATA_START = RSVD + NUM_FATS * FAT_SECS + ROOT_SECS; // = 98static const uint32_t DATA_CLUS = 16384; // จำนวน cluster ในโซนข้อมูล -> ~512MBstatic const uint32_t TOTAL_SECS = DATA_START + DATA_CLUS * SPC;และนี่คือหัวใจของการสตรีม ขอไบต์ช่วงเดียวจากไฟล์บน server
static bool fetchRange(const char *realname, uint32_t start, uint32_t len, uint8_t *out) { memset(out, 0, len); WiFiClient c; if (!c.connect(SERVER_HOST, SERVER_PORT)) return false; c.printf("GET /file/%s HTTP/1.1\r\n", realname); c.printf("Host: %s\r\n", SERVER_HOST); c.printf("Range: bytes=%u-%u\r\n", start, start + len - 1); c.print("Connection: close\r\n\r\n");รัน server (ใช้ในระดับ 2 และ 3)
server เขียนด้วย Node.js ล้วน ไม่ต้อง npm install อะไรเลย
cd server
node server.jsหา IP ของเครื่องที่รัน server แล้วเอาไปใส่ใน SERVER_HOST
- Windows: พิมพ์
ipconfigแล้วดูบรรทัด IPv4 Address - Mac / Linux: พิมพ์
ifconfigหรือip addrแล้วดู inet ของ WiFi
อยากให้ระดับ 3 มีของโชว์ ให้วางไฟล์ใหญ่ๆ (วิดีโอ, zip) ไว้ในโฟลเดอร์ server/storage/ ก่อนรัน server ตัว server สร้างไฟล์ตัวอย่าง HELLO.TXT และ BIGFILE.TXT ขนาด 5MB ไว้ให้แล้ว
ทดสอบ server โดยไม่ต้องมีบอร์ด
# ดูรายชื่อไฟล์ (ระดับ 3 ใช้ endpoint นี้)
curl http://localhost:3000/files
# อัปโหลดไฟล์แบบเดียวกับระดับ 2
curl -X POST "http://localhost:3000/upload?name=test.txt" --data-binary "hello"
# อ่านแค่ 5 ไบต์แรก ด้วย HTTP Range แบบเดียวกับระดับ 3
curl -r 0-4 http://localhost:3000/file/test.txtข้อจำกัดที่ควรรู้
- ชื่อไฟล์ในระดับ 3 เป็นแบบ 8.3 (เช่น
HELLO.TXT) ชื่อยาวหรือภาษาไทยจะถูกตัดเหลือตัวพิมพ์ใหญ่ 8 ตัว ตั้งชื่อไฟล์ในstorage/ให้สั้นจะสวยกว่า - ความเร็วตอนเปิดไฟล์ขึ้นกับ WiFi ไฟล์ใหญ่มากคอมจะอ่านทีละก้อน แถบโหลดจะวิ่งช้าๆ ซึ่งเป็นหลักฐานว่ามันสตรีมจริง
- ระดับ 1–2 เก็บไฟล์ใน flash จริง ข้อมูลอยู่ข้ามการรีบูต ระดับ 3 ไม่เก็บอะไรเลย
- บาง OS พยายามเขียนไฟล์ระบบ (เช่น
System Volume Information) ลงไดรฟ์ ระดับ 2 กรองทิ้งให้แล้ว ระดับ 3 รับแล้วทิ้งอยู่แล้ว
โค้ดและไฟล์
โหลดทั้งหมด (.zip)ESP32-S3 ทำตัวเป็นแฟลชไดรฟ์ราว 960KB เก็บไฟล์ใน flash ของบอร์ด คอมไพล์ได้เลยไม่ต้องแก้
/* ============================================================ * Infinite Flash Drive — LEVEL 1 : Basic USB Mass Storage * บอร์ด: ESP32-S3 (ต้องเป็น S3 เพราะใช้ Native USB) * ============================================================ * * สิ่งที่คลิปโชว์: * เสียบสาย USB (พอร์ต USB-OTG ของ S3 ตัวที่อยู่ติดกับ UART) * -> คอมขึ้นไดรฟ์ใหม่ ~960KB ที่ format เป็น FAT เรียบร้อย * -> ก๊อปไฟล์เข้า-ออกได้เหมือนแฟลชไดรฟ์จริง (เก็บใน flash ของบอร์ด) * * ---------- Arduino IDE / arduino-cli settings ---------- * Board : "ESP32S3 Dev Module" * USB CDC On Boot : Enabled (ไว้ดู Serial log) * USB Mode : "USB-OTG (TinyUSB)" << สำคัญมาก * Partition Scheme : "Default 4MB with ffat" << ต้องมี partition ffat * ต้องใช้ ESP32 Arduino core v3.x ขึ้นไป * * วิธีคิดของโค้ด: * ใช้ flash partition ชื่อ "ffat" เป็นตัวเก็บข้อมูลจริง * mount เป็น FAT (ผ่าน wear-leveling) ครั้งแรกเพื่อ format ให้สะอาด * แล้วต่อ USB MSC เข้ากับ layer wear-leveling ตัวเดียวกัน * -> คอมอ่าน/เขียนไปที่ flash โดยตรง เห็นเป็น FAT ที่ valid * ============================================================ */ #include "USB.h"#include "USBMSC.h"#include "esp_partition.h"#include "wear_levelling.h"#include "esp_vfs_fat.h" // ---- ค่าคงที่ ----static const char *PARTITION_LABEL = "ffat"; // ต้องตรงกับชื่อใน partition schemestatic const char *MOUNT_POINT = "/disk"; USBMSC msc; // ตัวจัดการ USB Mass Storagewl_handle_t s_wl = WL_INVALID_HANDLE; // handle ของ wear-leveling layeruint32_t s_sectorSize = 0; // ขนาด sector (ปกติ 4096)uint32_t s_sectorCount = 0; // จำนวน sector ทั้งหมด // -------- callback: คอมขอ "อ่าน" ข้อมูลจากไดรฟ์ --------static int32_t onRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) { // แปลง (lba, offset) เป็น address จริงบน flash แล้วอ่านผ่าน wear-leveling esp_err_t err = wl_read(s_wl, (size_t)lba * s_sectorSize + offset, buffer, bufsize); return (err == ESP_OK) ? (int32_t)bufsize : -1;} // -------- callback: คอมขอ "เขียน" ข้อมูลลงไดรฟ์ --------static int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) { size_t addr = (size_t)lba * s_sectorSize + offset; // flash ต้อง erase ก่อนเขียนเสมอ (MSC เขียนทีละ block ที่ตรงขอบ sector อยู่แล้ว) if (wl_erase_range(s_wl, addr, bufsize) != ESP_OK) return -1; if (wl_write(s_wl, addr, buffer, bufsize) != ESP_OK) return -1; return (int32_t)bufsize;} // -------- callback: คอมสั่ง start/stop (เช่นตอน eject) --------static bool onStartStop(uint8_t power_condition, bool start, bool load_eject) { Serial.printf("[MSC] StartStop start=%d eject=%d\n", start, load_eject); return true;} void setup() { Serial.begin(115200); delay(300); Serial.println("\n=== Infinite Flash Drive — LEVEL 1 ==="); // 1) mount FAT ครั้งแรก: ถ้ายังไม่ได้ format จะ format ให้อัตโนมัติ // ทำเพื่อให้แน่ใจว่า flash มี FAT filesystem ที่ valid (คอมจะได้ไม่ขอ format) esp_vfs_fat_mount_config_t mountCfg = { .format_if_mount_failed = true, .max_files = 4, .allocation_unit_size = CONFIG_WL_SECTOR_SIZE, }; esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl( MOUNT_POINT, PARTITION_LABEL, &mountCfg, &s_wl); if (err != ESP_OK) { Serial.printf("!! mount/format ล้มเหลว: %s\n", esp_err_to_name(err)); Serial.println(" ตรวจว่าเลือก Partition Scheme ที่มี ffat แล้วหรือยัง"); return; } // ขนาด sector และจำนวน sector (เอาไว้บอก USB MSC) s_sectorSize = wl_sector_size(s_wl); s_sectorCount = (uint32_t)(wl_size(s_wl) / s_sectorSize); Serial.printf("[FAT] sector=%u bytes, count=%u (≈ %u KB)\n", s_sectorSize, s_sectorCount, (s_sectorSize * s_sectorCount) / 1024); // 2) ต่อ USB Mass Storage เข้ากับ flash เดียวกัน msc.vendorID("QLAB"); // ชื่อโชว์ตอนเสียบ (<=8 ตัว) msc.productID("InfiniteFD"); // (<=16 ตัว) msc.productRevision("1.0"); msc.onRead(onRead); msc.onWrite(onWrite); msc.onStartStop(onStartStop); msc.mediaPresent(true); msc.begin(s_sectorCount, s_sectorSize); // บอกความจุไดรฟ์ให้คอมรู้ USB.begin(); // เริ่มทำงานเป็นอุปกรณ์ USB Serial.println("[USB] พร้อมแล้ว — เสียบเข้าคอมได้เลย ไดรฟ์จะขึ้นมาเอง");} void loop() { // Level 1 ไม่มีอะไรทำในลูป — งานทั้งหมดอยู่ใน callback ของ USB delay(1000);}ลากไฟล์ใส่ไดรฟ์แล้วบอร์ดอัปโหลดขึ้น server ให้เอง แก้ 4 บรรทัดบนสุด (WiFi และ IP ของ server) ก่อนคอมไพล์
/* ============================================================ * Infinite Flash Drive — LEVEL 2 : Cloud-Sync USB * บอร์ด: ESP32-S3 * ============================================================ * * สิ่งที่คลิปโชว์: * เสียบ USB -> ไดรฟ์ขึ้นเหมือน Level 1 * ลากไฟล์ใส่ไดรฟ์ -> บอร์ดแอบเชื่อม WiFi แล้วอัปโหลดไฟล์นั้น * ขึ้น server อัตโนมัติเบื้องหลัง (ดู log ที่ /files ของ server) * * หลักการ: * - ต่อ USB MSC จาก flash partition "ffat" เหมือน Level 1 * - onWrite จับเวลาไว้ พอ PC หยุดเขียนสักพัก (idle) ถือว่าก๊อปเสร็จ * - บอร์ด scan โฟลเดอร์ FAT หาไฟล์ใหม่ แล้ว POST ขึ้น server * * ---------- Board settings (เหมือน Level 1) ---------- * USB Mode = USB-OTG (TinyUSB) , Partition = Default 4MB with ffat * * >>> แก้ 4 บรรทัดด้านล่างก่อนคอมไพล์ <<< * ============================================================ */ #define WIFI_SSID "ใส่ชื่อ_WiFi_ที่นี่"#define WIFI_PASS "ใส่รหัส_WiFi_ที่นี่"#define SERVER_HOST "192.168.1.100" // IP เครื่องที่รัน node server.js#define SERVER_PORT 3000 #include "USB.h"#include "USBMSC.h"#include "wear_levelling.h"#include "esp_vfs_fat.h"#include <WiFi.h>#include <dirent.h>#include <set>#include <string> static const char *PARTITION_LABEL = "ffat";static const char *MOUNT_POINT = "/disk"; USBMSC msc;wl_handle_t s_wl = WL_INVALID_HANDLE;uint32_t s_sectorSize = 0, s_sectorCount = 0; volatile uint32_t s_lastWriteMs = 0; // เวลาเขียนล่าสุดจาก PCvolatile bool s_dirty = false; // มีการเขียนที่ยังไม่ได้ syncstd::set<std::string> s_uploaded; // รายชื่อไฟล์ที่อัปโหลดไปแล้ว (กันซ้ำ) // ---------------- USB MSC callbacks ----------------static int32_t onRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) { return wl_read(s_wl, (size_t)lba * s_sectorSize + offset, buffer, bufsize) == ESP_OK ? (int32_t)bufsize : -1;}static int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) { size_t addr = (size_t)lba * s_sectorSize + offset; if (wl_erase_range(s_wl, addr, bufsize) != ESP_OK) return -1; if (wl_write(s_wl, addr, buffer, bufsize) != ESP_OK) return -1; s_lastWriteMs = millis(); // จับเวลาไว้ เดี๋ยว loop จะเช็ค idle s_dirty = true; return (int32_t)bufsize;} // ---------------- อัปโหลดไฟล์ 1 ไฟล์ขึ้น server (POST /upload) ----------------// ส่งแบบสตรีมทีละก้อน ใช้ RAM น้อย รองรับไฟล์ใหญ่กว่า RAM ได้static bool uploadFile(const char *fullpath, const char *name, size_t size) { WiFiClient client; if (!client.connect(SERVER_HOST, SERVER_PORT)) { Serial.printf(" [upload] ต่อ server ไม่ได้\n"); return false; } // ส่วนหัว HTTP client.printf("POST /upload?name=%s HTTP/1.1\r\n", name); client.printf("Host: %s:%d\r\n", SERVER_HOST, SERVER_PORT); client.print("Content-Type: application/octet-stream\r\n"); client.printf("Content-Length: %u\r\n", (unsigned)size); client.print("Connection: close\r\n\r\n"); // ส่งเนื้อไฟล์ทีละ 1KB FILE *f = fopen(fullpath, "rb"); if (!f) { client.stop(); return false; } uint8_t buf[1024]; size_t sent = 0, n; while ((n = fread(buf, 1, sizeof(buf), f)) > 0) { client.write(buf, n); sent += n; } fclose(f); // อ่าน response สั้นๆ พอให้ server ทำงานจบ uint32_t t0 = millis(); while (client.connected() && millis() - t0 < 3000) { while (client.available()) client.read(); delay(5); } client.stop(); Serial.printf(" [upload] %s (%u bytes) -> OK\n", name, (unsigned)sent); return true;} // ---------------- scan โฟลเดอร์ FAT หาไฟล์ใหม่ แล้วอัปโหลด ----------------static void syncNewFiles() { DIR *dir = opendir(MOUNT_POINT); if (!dir) return; struct dirent *ent; while ((ent = readdir(dir)) != NULL) { if (ent->d_type != DT_REG) continue; // เอาเฉพาะไฟล์ std::string nm = ent->d_name; if (nm.empty() || nm[0] == '.') continue; // ข้ามไฟล์ซ่อน/ระบบ if (nm == "System Volume Information") continue; if (s_uploaded.count(nm)) continue; // อัปไปแล้ว ข้าม std::string full = std::string(MOUNT_POINT) + "/" + nm; struct stat st; if (stat(full.c_str(), &st) != 0) continue; Serial.printf("[sync] เจอไฟล์ใหม่: %s (%ld bytes)\n", nm.c_str(), (long)st.st_size); if (uploadFile(full.c_str(), nm.c_str(), st.st_size)) { s_uploaded.insert(nm); } } closedir(dir);} void setup() { Serial.begin(115200); delay(300); Serial.println("\n=== Infinite Flash Drive — LEVEL 2 (Cloud-Sync) ==="); // 1) mount FAT (format ให้ถ้ายังไม่เคย) + ได้ handle wl ไว้ต่อ USB esp_vfs_fat_mount_config_t cfg = { .format_if_mount_failed = true, .max_files = 4, .allocation_unit_size = CONFIG_WL_SECTOR_SIZE }; if (esp_vfs_fat_spiflash_mount_rw_wl(MOUNT_POINT, PARTITION_LABEL, &cfg, &s_wl) != ESP_OK) { Serial.println("!! mount FAT ล้มเหลว — เช็ค Partition Scheme (ต้องมี ffat)"); return; } s_sectorSize = wl_sector_size(s_wl); s_sectorCount = (uint32_t)(wl_size(s_wl) / s_sectorSize); // 2) ต่อ USB Mass Storage msc.vendorID("QLAB"); msc.productID("CloudSyncFD"); msc.productRevision("2.0"); msc.onRead(onRead); msc.onWrite(onWrite); msc.mediaPresent(true); msc.begin(s_sectorCount, s_sectorSize); USB.begin(); Serial.println("[USB] ไดรฟ์พร้อม — ลากไฟล์ใส่ได้เลย"); // 3) ต่อ WiFi (ทำเบื้องหลัง ไม่บล็อกการใช้ไดรฟ์) WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); Serial.printf("[WiFi] กำลังต่อ %s ...\n", WIFI_SSID);} void loop() { // แจ้งสถานะ WiFi ครั้งเดียวตอนต่อติด static bool wifiReady = false; if (!wifiReady && WiFi.status() == WL_CONNECTED) { wifiReady = true; Serial.printf("[WiFi] ต่อแล้ว IP=%s\n", WiFi.localIP().toString().c_str()); } // ถ้ามีการเขียน และ PC หยุดเขียนเกิน 1.5 วิ = ก๊อปเสร็จ -> sync ขึ้น cloud if (s_dirty && (millis() - s_lastWriteMs > 1500)) { s_dirty = false; if (WiFi.status() == WL_CONNECTED) { Serial.println("[sync] PC หยุดเขียนแล้ว เริ่ม sync ขึ้น cloud..."); syncNewFiles(); } else { Serial.println("[sync] ยังไม่มี WiFi — ข้ามรอบนี้ไปก่อน"); s_dirty = true; // ลองใหม่รอบหน้า } } delay(50);}ไดรฟ์ FAT16 ปลอมขนาดราว 512MB ที่สตรีมไฟล์จาก server ด้วย HTTP Range แก้ 4 บรรทัดบนสุดก่อนคอมไพล์
/* ============================================================ * Infinite Flash Drive — LEVEL 3 : Infinite Storage (สตรีมจาก cloud) * บอร์ด: ESP32-S3 * ============================================================ * * สิ่งที่คลิปโชว์ (ท่าไม้ตาย): * เสียบ USB -> คอมเห็นไดรฟ์ความจุใหญ่มาก (~512MB) พร้อมไฟล์เต็มไปหมด * แต่ในบอร์ด "ไม่มีข้อมูลไฟล์พวกนั้นเลย" — * รายชื่อไฟล์ดึงมาจาก server, พอกดเปิดไฟล์ไหน บอร์ดจะยิง HTTP Range * ไปดึงเฉพาะไบต์ที่คอมขออ่าน สตรีมกลับมาแบบ real-time * เหมือนพก Google Drive เวอร์ชันฮาร์ดแวร์ * * หลักการ: * เราไม่ได้ใช้ flash เก็บ FAT จริง แต่ "สังเคราะห์" ภาพ FAT16 ขึ้นสดๆ * ในหน่วยความจำตอนคอมขออ่านแต่ละ sector: * - sector 0 = Boot sector (BPB) ที่เราปั้นเอง * - FAT region = ตาราง cluster chain ที่คำนวณจากขนาดไฟล์ * - Root dir region = รายการไฟล์ (ชื่อ 8.3 + ขนาด + cluster เริ่มต้น) * - Data region = ไม่มีจริง! map กลับเป็น (ไฟล์, offset) แล้วไปดึงจาก server * * หมายเหตุ: Level 3 นี้เป็น "อ่านอย่างเดียว" — การเขียนจาก PC จะถูกรับแล้วทิ้ง * (ตั้งใจ เพื่อให้ภาพไดรฟ์คงที่ตามรายชื่อไฟล์จาก server) * * ---------- Board settings ---------- * USB Mode = USB-OTG (TinyUSB) (Partition Scheme อะไรก็ได้ ไม่ใช้ flash เก็บ FAT) * * >>> แก้ 4 บรรทัดด้านล่างก่อนคอมไพล์ <<< * ============================================================ */ #define WIFI_SSID "ใส่ชื่อ_WiFi_ที่นี่"#define WIFI_PASS "ใส่รหัส_WiFi_ที่นี่"#define SERVER_HOST "192.168.1.100"#define SERVER_PORT 3000 #include "USB.h"#include "USBMSC.h"#include <WiFi.h>#include <string.h> // ---------------- เรขาคณิตของ FAT16 ที่เรากำหนดเอง ----------------static const uint32_t BPS = 512; // bytes ต่อ sectorstatic const uint32_t SPC = 64; // sector ต่อ cluster -> cluster = 32KBstatic const uint32_t CLUSTER = BPS * SPC;// 32768static const uint32_t RSVD = 1; // reserved sector (boot)static const uint32_t NUM_FATS = 1;static const uint32_t FAT_SECS = 65; // ขนาด FAT (รองรับ ~16384 cluster)static const uint32_t ROOT_ENTS = 512; // จำนวน entry ใน root dirstatic const uint32_t ROOT_SECS = (ROOT_ENTS * 32) / BPS; // = 32static const uint32_t DATA_START = RSVD + NUM_FATS * FAT_SECS + ROOT_SECS; // = 98static const uint32_t DATA_CLUS = 16384; // จำนวน cluster ในโซนข้อมูล -> ~512MBstatic const uint32_t TOTAL_SECS = DATA_START + DATA_CLUS * SPC; // ---------------- ตารางไฟล์ที่ดึงมาจาก server ----------------#define MAX_FILES 32struct VFile { char name83[12]; // ชื่อรูปแบบ 8.3 (ไม่มีจุด, 11 ตัว + \0) char realname[64]; // ชื่อจริงบน server (ไว้ใช้ยิง HTTP) uint32_t size; uint32_t firstClus; // cluster เริ่มต้น uint32_t clusCount; // จำนวน cluster ที่ใช้};static VFile s_files[MAX_FILES];static uint32_t s_fileCount = 0; USBMSC msc; // ============================================================// ส่วนที่ 1 : ดึงรายชื่อไฟล์จาก server แล้วสร้างตาราง + จัด cluster// ============================================================ // แปลงชื่อจริง -> 8.3 uppercase (ตัวอักษรต้องห้ามแทนด้วย _)static void toName83(const char *src, char out[12]) { memset(out, ' ', 11); out[11] = '\0'; // แยกชื่อกับนามสกุล const char *dot = strrchr(src, '.'); int nlen = dot ? (int)(dot - src) : (int)strlen(src); const char *ext = dot ? dot + 1 : ""; for (int i = 0; i < 8 && i < nlen; i++) { char c = src[i]; if (c >= 'a' && c <= 'z') c -= 32; if (c == ' ' || c == '.' ) c = '_'; out[i] = c; } for (int i = 0; i < 3 && ext[i]; i++) { char c = ext[i]; if (c >= 'a' && c <= 'z') c -= 32; out[8 + i] = c; }} // อ่าน 1 บรรทัด (จบด้วย \n) จาก clientstatic int readLine(WiFiClient &c, char *buf, int max) { int i = 0; uint32_t t0 = millis(); while (i < max - 1) { if (c.available()) { char ch = c.read(); if (ch == '\n') break; if (ch != '\r') buf[i++] = ch; t0 = millis(); } else { if (!c.connected() && !c.available()) break; if (millis() - t0 > 3000) break; delay(1); } } buf[i] = '\0'; return i;} // ดึง /files (JSON) แล้ว parse แบบง่ายๆ หา "name" กับ "size"static void fetchFileList() { s_fileCount = 0; WiFiClient c; if (!c.connect(SERVER_HOST, SERVER_PORT)) { Serial.println("[list] ต่อ server ไม่ได้"); return; } c.printf("GET /files HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", SERVER_HOST); // ข้าม header จนเจอบรรทัดว่าง char line[256]; while (readLine(c, line, sizeof(line)) > 0) { /* skip headers */ } // อ่าน body (JSON) ทั้งก้อน String body; uint32_t t0 = millis(); while (c.connected() || c.available()) { while (c.available()) { body += (char)c.read(); t0 = millis(); } if (millis() - t0 > 3000) break; delay(1); } c.stop(); // parse: หา pattern "name":"...." และ "size":ตัวเลข int idx = 0; while (s_fileCount < MAX_FILES) { int np = body.indexOf("\"name\":\"", idx); if (np < 0) break; np += 8; int ne = body.indexOf("\"", np); if (ne < 0) break; String nm = body.substring(np, ne); int sp = body.indexOf("\"size\":", ne); if (sp < 0) break; sp += 7; int se = sp; while (se < (int)body.length() && isDigit(body[se])) se++; uint32_t sz = body.substring(sp, se).toInt(); VFile &f = s_files[s_fileCount]; nm.toCharArray(f.realname, sizeof(f.realname)); toName83(f.realname, f.name83); f.size = sz; s_fileCount++; idx = se; } // จัดสรร cluster ให้แต่ละไฟล์ (ต่อเนื่องกันไปเรื่อยๆ เริ่มที่ cluster 2) uint32_t nextClus = 2; for (uint32_t i = 0; i < s_fileCount; i++) { uint32_t need = (s_files[i].size + CLUSTER - 1) / CLUSTER; if (need == 0) need = 1; // ไฟล์ว่างก็จอง 1 cluster s_files[i].firstClus = nextClus; s_files[i].clusCount = need; nextClus += need; Serial.printf("[list] %-12s %8u bytes clus %u..%u\n", s_files[i].name83, s_files[i].size, s_files[i].firstClus, s_files[i].firstClus + need - 1); } Serial.printf("[list] รวม %u ไฟล์จาก server\n", s_fileCount);} // หาว่ามีไฟล์ไหนเป็นเจ้าของ cluster นี้ (คืน index หรือ -1)static int fileOfCluster(uint32_t clus) { for (uint32_t i = 0; i < s_fileCount; i++) if (clus >= s_files[i].firstClus && clus < s_files[i].firstClus + s_files[i].clusCount) return (int)i; return -1;} // ============================================================// ส่วนที่ 2 : ดึงไบต์จริงจาก server ด้วย HTTP Range// ============================================================// เติม out[0..len) ด้วยไบต์ของไฟล์ realname ตั้งแต่ byte 'start'// ส่วนที่เกินไฟล์จริงจะเติม 0static bool fetchRange(const char *realname, uint32_t start, uint32_t len, uint8_t *out) { memset(out, 0, len); WiFiClient c; if (!c.connect(SERVER_HOST, SERVER_PORT)) return false; c.printf("GET /file/%s HTTP/1.1\r\n", realname); c.printf("Host: %s\r\n", SERVER_HOST); c.printf("Range: bytes=%u-%u\r\n", start, start + len - 1); c.print("Connection: close\r\n\r\n"); // อ่าน status line (คาดหวัง 206 Partial Content หรือ 200) char line[256]; readLine(c, line, sizeof(line)); // ข้าม header ที่เหลือ while (readLine(c, line, sizeof(line)) > 0) { /* skip */ } // อ่าน body เติมลง out uint32_t got = 0, t0 = millis(); while (got < len) { if (c.available()) { int n = c.read(out + got, len - got); if (n > 0) { got += n; t0 = millis(); } } else { if (!c.connected() && !c.available()) break; if (millis() - t0 > 4000) break; delay(1); } } c.stop(); return true;} // ============================================================// ส่วนที่ 3 : สังเคราะห์ภาพ FAT16 ต่อ sector// ============================================================ static void putU16(uint8_t *p, uint16_t v){ p[0]=v; p[1]=v>>8; }static void putU32(uint8_t *p, uint32_t v){ p[0]=v; p[1]=v>>8; p[2]=v>>16; p[3]=v>>24; } // สร้าง boot sector (512 bytes)static void genBoot(uint8_t *b) { memset(b, 0, BPS); b[0]=0xEB; b[1]=0x3C; b[2]=0x90; // jmp memcpy(b+3, "MSDOS5.0", 8); putU16(b+11, BPS); b[13]=SPC; putU16(b+14, RSVD); b[16]=NUM_FATS; putU16(b+17, ROOT_ENTS); putU16(b+19, 0); // totSec16 = 0 (ใช้ 32-bit) b[21]=0xF8; // media putU16(b+22, FAT_SECS); putU16(b+24, 63); // secPerTrack putU16(b+26, 255); // numHeads putU32(b+28, 0); // hidden putU32(b+32, TOTAL_SECS); b[36]=0x80; b[38]=0x29; // drvNum, bootSig putU32(b+39, 0x12345678); // volume ID memcpy(b+43, "INFINITE FD", 11); memcpy(b+54, "FAT16 ", 8); b[510]=0x55; b[511]=0xAA;} // สร้าง FAT sector ที่ index sIdx (0-based ภายใน FAT) — FAT16 = 256 entry/sectorstatic void genFat(uint32_t sIdx, uint8_t *b) { memset(b, 0, BPS); uint32_t base = sIdx * (BPS / 2); // entry แรกของ sector นี้ for (uint32_t k = 0; k < BPS / 2; k++) { uint32_t e = base + k; // หมายเลข entry (= หมายเลข cluster) uint16_t val = 0x0000; // ว่าง if (e == 0) val = 0xFFF8; // reserved else if (e == 1) val = 0xFFFF; else { int fi = fileOfCluster(e); if (fi >= 0) { uint32_t last = s_files[fi].firstClus + s_files[fi].clusCount - 1; val = (e == last) ? 0xFFFF : (uint16_t)(e + 1); // chain } } putU16(b + k * 2, val); }} // สร้าง root-dir sector ที่ index sIdx (16 entry/sector)static void genRoot(uint32_t sIdx, uint8_t *b) { memset(b, 0, BPS); uint32_t base = sIdx * (BPS / 32); // entry แรกของ sector นี้ for (uint32_t k = 0; k < BPS / 32; k++) { uint32_t fi = base + k; if (fi >= s_fileCount) return; uint8_t *e = b + k * 32; memcpy(e, s_files[fi].name83, 11); // ชื่อ 8.3 e[11] = 0x20; // attr = archive putU16(e + 26, (uint16_t)s_files[fi].firstClus); // first cluster (low) putU16(e + 20, 0); // first cluster (high) = 0 ใน FAT16 putU32(e + 28, s_files[fi].size); // ขนาดไฟล์ putU16(e + 22, 0x6000); // เวลาเขียน (mock) putU16(e + 24, 0x5A21); // วันที่เขียน (mock) }} // ============================================================// ส่วนที่ 4 : USB MSC callbacks — หัวใจของ Level 3// ============================================================ // อ่านภาพไดรฟ์ที่ address (แบบ flat byte) ยาว len ลง bufstatic void readImage(uint64_t addr, uint8_t *buf, uint32_t len) { uint32_t done = 0; uint8_t tmp[BPS]; while (done < len) { uint64_t cur = addr + done; uint32_t sector = (uint32_t)(cur / BPS); uint32_t within = (uint32_t)(cur % BPS); if (sector < RSVD) { // ----- boot sector ----- genBoot(tmp); uint32_t n = min(len - done, BPS - within); memcpy(buf + done, tmp + within, n); done += n; } else if (sector < RSVD + NUM_FATS * FAT_SECS) { // ----- FAT region ----- uint32_t fatSec = (sector - RSVD) % FAT_SECS; genFat(fatSec, tmp); uint32_t n = min(len - done, BPS - within); memcpy(buf + done, tmp + within, n); done += n; } else if (sector < DATA_START) { // ----- Root directory ----- uint32_t rootSec = sector - (RSVD + NUM_FATS * FAT_SECS); genRoot(rootSec, tmp); uint32_t n = min(len - done, BPS - within); memcpy(buf + done, tmp + within, n); done += n; } else { // ----- Data region: map -> (ไฟล์, offset) แล้วสตรีมจาก server ----- uint32_t secInData = sector - DATA_START; uint32_t cluster = 2 + secInData / SPC; uint32_t posInClus = (secInData % SPC) * BPS + within; int fi = fileOfCluster(cluster); if (fi < 0) { // ไม่มีไฟล์เป็นเจ้าของ -> ที่ว่าง เติม 0 uint32_t n = min(len - done, BPS - within); memset(buf + done, 0, n); done += n; } else { uint32_t byteInFile = (cluster - s_files[fi].firstClus) * CLUSTER + posInClus; if (byteInFile >= s_files[fi].size) { // เลยท้ายไฟล์ (slack ใน cluster) -> เติม 0 uint32_t n = min(len - done, BPS - within); memset(buf + done, 0, n); done += n; } else { // ดึงไบต์จริงจาก server ให้ยาวที่สุดเท่าที่ยังอยู่ในไฟล์เดียวกัน uint32_t span = min(len - done, s_files[fi].size - byteInFile); fetchRange(s_files[fi].realname, byteInFile, span, buf + done); done += span; } } } }} static int32_t onRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) { readImage((uint64_t)lba * BPS + offset, (uint8_t *)buffer, bufsize); return (int32_t)bufsize;}// Level 3 อ่านอย่างเดียว: รับการเขียนแล้วทิ้ง (กันคอม error)static int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) { return (int32_t)bufsize;} void setup() { Serial.begin(115200); delay(300); Serial.println("\n=== Infinite Flash Drive — LEVEL 3 (Infinite Storage) ==="); // ต่อ WiFi ก่อน แล้วดึงรายชื่อไฟล์มาสร้างตาราง FAT WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); Serial.printf("[WiFi] กำลังต่อ %s ...\n", WIFI_SSID); uint32_t t0 = millis(); while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) delay(200); if (WiFi.status() == WL_CONNECTED) Serial.printf("[WiFi] ต่อแล้ว IP=%s\n", WiFi.localIP().toString().c_str()); else Serial.println("[WiFi] ต่อไม่ติด — ไดรฟ์จะว่างเปล่า"); fetchFileList(); // ต่อ USB Mass Storage โดยแจ้งความจุ ~512MB (แต่ไม่มีของจริงในบอร์ด) msc.vendorID("QLAB"); msc.productID("InfiniteFD"); msc.productRevision("3.0"); msc.onRead(onRead); msc.onWrite(onWrite); msc.mediaPresent(true); msc.begin(TOTAL_SECS, BPS); USB.begin(); Serial.printf("[USB] พร้อม — คอมจะเห็นไดรฟ์ %u MB\n", (unsigned)((uint64_t)TOTAL_SECS * BPS / (1024 * 1024)));} void loop() { delay(1000);}server ของระดับ 2 และ 3 เขียนด้วย Node.js ล้วน ไม่ต้อง npm install รันด้วยคำสั่ง node server.js
// ============================================================// Infinite Flash Drive — Cloud Server (pure Node.js, ไม่ต้อง npm install)// ใช้กับคลิป ESP32-S3 "The Infinite Flash Drive"//// รันด้วย: node server.js// (ใช้แค่โมดูล http + fs ในตัว Node เท่านั้น ไม่มี dependency)//// Endpoints:// GET /health -> เช็คว่า server ยังไม่ตาย// GET /files -> คืน list ไฟล์ [{name,size}] (Level 3 ใช้)// GET /file/<name> -> ดาวน์โหลดไฟล์ รองรับ HTTP Range (Level 3 streaming)// POST /upload?name=<name> -> อัปโหลดไฟล์ (body = ไบต์ไฟล์ดิบ) (Level 2 ใช้)//// ไฟล์ทั้งหมดเก็บในโฟลเดอร์ ./storage// ============================================================ const http = require('http');const fs = require('fs');const path = require('path');const url = require('url'); const PORT = process.env.PORT || 3000;const STORAGE = path.join(__dirname, 'storage'); // สร้างโฟลเดอร์ storage + ไฟล์ตัวอย่าง เพื่อให้ Level 3 มีของโชว์ทันทีif (!fs.existsSync(STORAGE)) fs.mkdirSync(STORAGE, { recursive: true });seedSampleFiles(); function seedSampleFiles() { const hello = path.join(STORAGE, 'HELLO.TXT'); if (!fs.existsSync(hello)) { fs.writeFileSync( hello, 'สวัสดีครับ! ไฟล์นี้ไม่ได้อยู่ในบอร์ด ESP32 เลย\r\n' + 'มันถูกสตรีมมาจาก server แบบ real-time ตอนคุณเปิดอ่าน\r\n' + 'นี่คือ Infinite Flash Drive :)\r\n' ); } // ไฟล์ใหญ่ปลอมๆ ไว้โชว์ว่า "ความจุไม่จริง" — 5 MB ของอักษร A const big = path.join(STORAGE, 'BIGFILE.TXT'); if (!fs.existsSync(big)) { const chunk = Buffer.alloc(1024 * 1024, 0x41); // 1MB ของ 'A' const fd = fs.openSync(big, 'w'); for (let i = 0; i < 5; i++) fs.writeSync(fd, chunk); fs.closeSync(fd); }} function listFiles() { return fs.readdirSync(STORAGE) .filter((n) => fs.statSync(path.join(STORAGE, n)).isFile()) .map((n) => ({ name: n, size: fs.statSync(path.join(STORAGE, n)).size }));} // กัน path หลุดออกนอกโฟลเดอร์ storage (path traversal)function safePath(name) { const clean = path.basename(name || ''); if (!clean || clean === '.' || clean === '..') return null; return path.join(STORAGE, clean);} const server = http.createServer((req, res) => { const parsed = url.parse(req.url, true); const pathname = decodeURIComponent(parsed.pathname); const t = new Date().toISOString().slice(11, 19); console.log(`[${t}] ${req.method} ${pathname}`); // ---- GET /health ---- if (req.method === 'GET' && pathname === '/health') { return json(res, 200, { ok: true, files: listFiles().length }); } // ---- GET /files ---- (Level 3 ดึงรายชื่อไฟล์) if (req.method === 'GET' && pathname === '/files') { return json(res, 200, listFiles()); } // ---- POST /upload?name=xxx ---- (Level 2 push ไฟล์ขึ้นมา) if (req.method === 'POST' && pathname === '/upload') { const name = parsed.query.name; const dest = safePath(name); if (!dest) return json(res, 400, { error: 'missing or bad ?name=' }); const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks); fs.writeFileSync(dest, body); console.log(` ↳ saved ${path.basename(dest)} (${body.length} bytes)`); json(res, 200, { ok: true, name: path.basename(dest), size: body.length }); }); req.on('error', () => json(res, 500, { error: 'upload failed' })); return; } // ---- GET /file/<name> ---- (Level 3 streaming, รองรับ Range) ---- if (req.method === 'GET' && pathname.startsWith('/file/')) { const name = pathname.slice('/file/'.length); const fpath = safePath(name); if (!fpath || !fs.existsSync(fpath)) return json(res, 404, { error: 'not found' }); const size = fs.statSync(fpath).size; const range = req.headers['range']; if (range) { // ตัวอย่าง header: Range: bytes=0-511 const m = /bytes=(\d+)-(\d*)/.exec(range); let start = m ? parseInt(m[1], 10) : 0; let end = m && m[2] ? parseInt(m[2], 10) : size - 1; if (isNaN(start) || start < 0) start = 0; if (isNaN(end) || end >= size) end = size - 1; if (start > end) { res.writeHead(416); return res.end(); } res.writeHead(206, { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes ${start}-${end}/${size}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, }); return fs.createReadStream(fpath, { start, end }).pipe(res); } // ไม่มี Range = ส่งทั้งไฟล์ res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Content-Length': size, }); return fs.createReadStream(fpath).pipe(res); } json(res, 404, { error: 'unknown route' });}); function json(res, code, obj) { const body = JSON.stringify(obj); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(body);} server.listen(PORT, () => { console.log('=================================================='); console.log(' Infinite Flash Drive — Cloud Server'); console.log(` ฟังอยู่ที่พอร์ต ${PORT}`); console.log(` เก็บไฟล์ที่: ${STORAGE}`); console.log(' ---'); console.log(` เทสได้เลย: curl http://localhost:${PORT}/files`); console.log('==================================================');});