/* ESP32-S2/S3 Gotek Controller — RFID-tagged, Pin-controlled, mTLS backend Features: - Minimal web UI (/setup): Wi‑Fi SSID/password + backend URL (https) - Mutual TLS (client certificate) for backend communication - LOAD_PIN high => read RFID tag (64-bit hex), GET /api/volume?id=, mount USB MSC (RW), pulse JA BTN2 (SELECT) - LOAD_PIN low => pulse JA BTN3 (EJECT), unmount, POST /api/volume?id= (save modified volume; server extracts image) - /api/remount => force re-fetch (reads tag if present), mount, and SELECT - /api/status => JSON (load pin, state, usb status/blocks, wifi, backend, lastId, lastNote) - TinyUSB MSC, LittleFS for /stick.img and /config.json Requirements: - Board: ESP32-S2 or ESP32-S3 (native USB) - Arduino-ESP32 core >= 3.0.0 - LittleFS enabled in partition scheme - Wire JA BTN2 (SELECT) and BTN3 (EJECT) via NPN/optos (active-low) - RFID reader TX -> ESP32 RX pin (RFID_RX_PIN), level shift if reader is 5V Security: - Replace SERVER_CA_PEM, CLIENT_CRT_PEM, CLIENT_KEY_PEM with your actual CA, device certificate and private key. */ #include #include #include #include #include #include #include #include "tusb.h" // ---------------- GPIO Configuration ---------------- static const int LOAD_PIN = 21; // Input: HIGH = load/mount, LOW = eject/save static const int PIN_SELECT_JA = 4; // Output to Gotek JA BTN2 (SELECT) (active-low via NPN/opto) static const int PIN_EJECT_JA = 5; // Output to Gotek JA BTN3 (EJECT) (active-low via NPN/opto) static const int RFID_RX_PIN = 17; // RX pin for RFID UART reader (reader TX -> this pin) static const long RFID_BAUD = 9600; // ---------------- Timings ---------------- static const int PRESS_MS = 60; static const int GAP_MS = 120; static const uint16_t BYTES_PER_SECTOR = 512; static const size_t MAX_VOLUME_BYTES = 8 * 1024 * 1024; // adjust to your partition size // ---------------- Files ---------------- static const char* PATH_CFG = "/config.json"; static const char* PATH_VOL = "/stick.img"; // ---------------- mTLS Material (PLACEHOLDERS — replace with your real PEMs) ---------------- static const char SERVER_CA_PEM[] PROGMEM = R"(-----BEGIN CERTIFICATE----- ...your CA that signed the BACKEND SERVER certificate... -----END CERTIFICATE-----)"; static const char CLIENT_CRT_PEM[] PROGMEM = R"(-----BEGIN CERTIFICATE----- ...device client certificate... -----END CERTIFICATE-----)"; static const char CLIENT_KEY_PEM[] PROGMEM = R"(-----BEGIN PRIVATE KEY----- ...device private key... -----END PRIVATE KEY-----)"; // ---------------- State / Config ---------------- struct AppCfg { String ssid, pass, backendUrl; } CFG; WebServer server(80); File mscFile; volatile bool mscReady = false; uint32_t mscBlocks = 0; enum class LoadState { UNLOADED, LOADED }; LoadState state = LoadState::UNLOADED; static String lastNote; static String lastId; // cache last RFID ID used (hex64) HardwareSerial RFID(1); // UART1 for the RFID reader // ---------------- Helpers ---------------- static inline void line_set(int pin, bool high){ digitalWrite(pin, high?HIGH:LOW); } static void pulse(int pin){ line_set(pin,false); delay(PRESS_MS); line_set(pin,true); delay(GAP_MS); } static inline bool load_pin(){ return digitalRead(LOAD_PIN)==HIGH; } static const char* wifi_state() { wl_status_t s = WiFi.status(); switch (s) { case WL_CONNECTED: return "connected"; case WL_IDLE_STATUS: return "idle"; case WL_NO_SSID_AVAIL: return "no_ssid"; case WL_CONNECT_FAILED: return "connect_failed"; case WL_CONNECTION_LOST: return "lost"; case WL_DISCONNECTED: return "disconnected"; default: return "unknown"; } } // ---------------- USB MSC (TinyUSB) ---------------- //extern "C" { //int32_t tud_msc_read10_cb (uint8_t, uint32_t lba, uint32_t off, void* buf, uint32_t len){ // if(!mscFile) return -1; uint64_t a=(uint64_t)lba*BYTES_PER_SECTOR + off; // if(!mscFile.seek(a)) return -1; return (int32_t)mscFile.read((uint8_t*)buf, len); // } //int32_t tud_msc_write10_cb(uint8_t, uint32_t lba, uint32_t off, uint8_t const* buf, uint32_t len){ // if(!mscFile) return -1; uint64_t a=(uint64_t)lba*BYTES_PER_SECTOR + off; // if(!mscFile.seek(a)) return -1; return (int32_t)mscFile.write((const uint8_t*)buf, len); // } //bool tud_msc_is_writable_cb(uint8_t){ return true; } //bool tud_msc_test_unit_ready_cb(uint8_t){ return mscReady && mscFile; } //void tud_msc_capacity_cb(uint8_t, uint32_t* bc, uint16_t* bs){ *bc=mscBlocks; *bs=BYTES_PER_SECTOR; } //bool tud_msc_start_stop_cb(uint8_t, uint8_t, bool start, bool load_eject) //{ // mscReady = (start && !load_eject && mscFile); return true; //} //} static void usb_init() { tinyusb_config_t cfg={}; tusb_init(&cfg); mscReady=false; } static bool vol_open(){ if(mscFile) mscFile.close(); if(!LittleFS.exists(PATH_VOL)){ mscBlocks=0; return false; } // open RW; if fails, RO mscFile = LittleFS.open(PATH_VOL, "r+"); if(!mscFile){ mscFile = LittleFS.open(PATH_VOL, "r"); if(!mscFile) return false; } uint64_t sz=mscFile.size(); if(sz0; f.close(); return ok; } // ---------------- Wi‑Fi ---------------- static void wifi_connect_or_ap(){ if(CFG.ssid.length()){ WiFi.mode(WIFI_STA); WiFi.begin(CFG.ssid.c_str(), CFG.pass.c_str()); unsigned long t0=millis(); while(WiFi.status()!=WL_CONNECTED && millis()-t0<12000) delay(200); } if(WiFi.status()==WL_CONNECTED){ if(MDNS.begin("gotek")) MDNS.addService("http","tcp",80); } else { WiFi.mode(WIFI_AP); WiFi.softAP("GotekSetup","flashfloppy"); } } // ---------------- RFID (read tag -> uint64) ---------------- HardwareSerial RFID(1); static bool rfid_read_tag(uint64_t &outId, uint32_t timeout_ms=3000){ uint32_t t0 = millis(); String line; line.reserve(24); while(millis()-t0 < timeout_ms){ while(RFID.available()){ int c = RFID.read(); if (c == '\r' || c == '\n') { // parse hex out of line String hex; hex.reserve(16); for (size_t i=0;i 16){ line = ""; break; } // discard invalid uint64_t v = 0; for (size_t i=0;i 32) line.remove(0, line.length()-32); } } delay(2); } return false; } static String hex64(uint64_t v){ char buf[17]; char* p = &buf[16]; *p = '\0'; if (v == 0){ buf[0]='0'; buf[1]='\0'; return String(buf); } while (v && p > buf){ uint8_t nib = v & 0xF; *--p = (nib < 10) ? ('0'+nib) : ('a'+(nib-10)); v >>= 4; } return String(p); } // ---------------- Backend I/O (HTTPS mTLS) ---------------- static const char SERVER_CA_PEM[] PROGMEM = R"(-----BEGIN CERTIFICATE----- ...your CA that signed the BACKEND SERVER certificate... -----END CERTIFICATE-----)"; static const char CLIENT_CRT_PEM[] PROGMEM = R"(-----BEGIN CERTIFICATE----- ...device client certificate... -----END CERTIFICATE-----)"; static const char CLIENT_KEY_PEM[] PROGMEM = R"(-----BEGIN PRIVATE KEY----- ...device private key... -----END PRIVATE KEY-----)"; static bool https_fetch_volume(const String& id){ if(CFG.backendUrl.isEmpty() || id.isEmpty()){ lastNote="no backend/id"; return false; } String url = CFG.backendUrl; if(url.endsWith("/")) url.remove(url.length()-1); url += "/api/volume?id="; url += id; WiFiClientSecure client; client.setCACert(SERVER_CA_PEM); client.setCertificate(CLIENT_CRT_PEM); client.setPrivateKey(CLIENT_KEY_PEM); HTTPClient https; if(!https.begin(client, url)){ lastNote="begin fail"; return false; } int code=https.GET(); if(code!=HTTP_CODE_OK){ https.end(); lastNote="HTTP "+String(code); return false; } int len=https.getSize(); if(len>0 && (size_t)len>MAX_VOLUME_BYTES){ https.end(); lastNote="too big"; return false; } File out=LittleFS.open(PATH_VOL,"w"); if(!out){ https.end(); lastNote="open vol fail"; return false; } WiFiClient* s=https.getStreamPtr(); size_t total=0; uint8_t buf[4096]; while(https.connected()){ size_t avail=s->available(); if(!avail){ if(!https.connected()) break; delay(5); continue; } size_t n = avail>sizeof(buf)?sizeof(buf):avail; int got=s->readBytes((char*)buf,n); if(got<=0) break; if(total+got>MAX_VOLUME_BYTES){ out.close(); LittleFS.remove(PATH_VOL); https.end(); lastNote="overflow"; return false; } out.write(buf,got); total+=got; } out.close(); https.end(); lastNote="downloaded"; return true; } static bool https_post_volume(const String& id){ if(!LittleFS.exists(PATH_VOL) || id.isEmpty()){ lastNote="no vol/id"; return false; } String url = CFG.backendUrl; if(url.endsWith("/")) url.remove(url.length()-1); url += "/api/volume?id="; url += id; WiFiClientSecure client; //client.setCACert(SERVER_CA_PEM); //client.setCertificate(CLIENT_CRT_PEM); //client.setPrivateKey(CLIENT_KEY_PEM); HTTPClient https; if(!https.begin(client, url)){ lastNote="begin fail"; return false; } https.addHeader("Content-Type","application/octet-stream"); File in = LittleFS.open(PATH_VOL,"r"); if(!in){ https.end(); lastNote="open vol fail"; return false; } int code = https.sendRequest("POST",&in,in.size()); in.close(); https.end(); lastNote = (code==HTTP_CODE_OK || code==HTTP_CODE_ACCEPTED) ? "uploaded OK" : ("upload fail "+String(code)); return (code==HTTP_CODE_OK || code==HTTP_CODE_ACCEPTED); } // ---------------- Web UI (/setup, /api/config, /api/reboot, /api/status, /api/remount) ---------------- static String html_setup(){ String h; h.reserve(2600); h += F("" "Gotek Wi‑Fi Setup" "

Wi‑Fi & Backend

" "" "" "" "
" ""); return h; } static void h_setup(){ server.send(200,"text/html; charset=utf-8", html_setup()); } static void h_cfg(){ if(!server.hasArg("plain")){ server.send(400,"application/json","{\"error\":\"no body\"}"); return; } DynamicJsonDocument d(1024); if(deserializeJson(d,server.arg("plain"))){ server.send(400,"application/json","{\"error\":\"bad json\"}"); return; } CFG.ssid=(const char*)(d["ssid"]|""); CFG.pass=(const char*)(d["pass"]|""); CFG.backendUrl=(const char*)(d["backendUrl"]|""); bool ok=cfg_save(); server.send(ok?200:500,"application/json", ok?"{\"ok\":true}":"{\"error\":\"save failed\"}"); } static void h_reboot(){ server.send(200,"application/json","{\"ok\":true}"); delay(200); ESP.restart(); } static void h_status(){ char ipbuf[32] = {0}; if (WiFi.status()==WL_CONNECTED) { IPAddress ip = WiFi.localIP(); snprintf(ipbuf, sizeof(ipbuf), "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); } String s = "{"; s += "\"load_pin\":"; s += (load_pin() ? "true":"false"); s += ","; s += "\"state\":\""; s += (state==LoadState::LOADED? "loaded":"unloaded"); s += "\","; s += "\"usb_ready\":"; s += (mscReady? "true":"false"); s += ","; s += "\"blocks\":"; s += String(mscBlocks); s += ","; s += "\"backend\":\""; s += CFG.backendUrl; s += "\","; s += "\"wifi\":{\"status\":\""; s += wifi_state(); s += "\",\"rssi\":"; s += (WiFi.status()==WL_CONNECTED ? String(WiFi.RSSI()) : "null"); s += ",\"ip\":\""; s += ipbuf; s += "\"},"; s += "\"lastId\":\""; s += lastId; s += "\","; s += "\"note\":\""; s += lastNote; s += "\"}"; server.send(200,"application/json",s); } // Force fetch + mount + SELECT (reads tag if available) static void h_remount(){ set_ready(false); if(mscFile) mscFile.flush(); if(mscFile) mscFile.close(); String idParam = ""; uint64_t tag=0; if (rfid_read_tag(tag, 2000)) idParam = hex64(tag); else if (lastId.length()) idParam = lastId; bool ok = (idParam.length() && https_fetch_volume(idParam) && vol_open()); if(ok){ lastId = idParam; set_ready(true); pulse(PIN_SELECT_JA); state = LoadState::LOADED; server.send(200,"application/json","{\"ok\":true}"); } else { server.send(500,"application/json","{\"error\":\"fetch/mount failed\"}"); } } // ---------------- State transitions ---------------- static void enter_loaded(){ // Read tag uint64_t tag=0; //if(!rfid_read_tag(tag, 3000)){ lastNote="no tag"; return; } String id = hex64(tag); if(!https_fetch_volume(id)) return; if(!vol_open()) return; lastId = id; set_ready(true); pulse(PIN_SELECT_JA); // SELECT state = LoadState::LOADED; } static void enter_unloaded(){ pulse(PIN_EJECT_JA); // EJECT set_ready(false); if(mscFile) mscFile.flush(); if(mscFile) mscFile.close(); if(lastId.length()) https_post_volume(lastId); state = LoadState::UNLOADED; } // ---------------- Setup / Loop ---------------- void setup(){ Serial.begin(115200); LittleFS.begin(true); pinMode(LOAD_PIN, INPUT); // use external pulldown if needed pinMode(PIN_SELECT_JA, OUTPUT); digitalWrite(PIN_SELECT_JA, HIGH); // idle-high pinMode(PIN_EJECT_JA, OUTPUT); digitalWrite(PIN_EJECT_JA, HIGH); // RFID UART (RX only) RFID.begin(RFID_BAUD, SERIAL_8N1, RFID_RX_PIN, -1); cfg_load(); wifi_connect_or_ap(); usb_init(); // initial state by pin if(load_pin()) enter_loaded(); else enter_unloaded(); // HTTP routes server.on("/", HTTP_GET, h_setup); server.on("/setup", HTTP_GET, h_setup); server.on("/api/config", HTTP_POST, h_cfg); server.on("/api/reboot", HTTP_POST, h_reboot); server.on("/api/status", HTTP_GET, h_status); server.on("/api/remount", HTTP_POST, h_remount); server.begin(); if(MDNS.begin("gotek")) MDNS.addService("http","tcp",80); } void loop(){ server.handleClient(); tud_task(); // Debounced edge detection on LOAD pin static bool last = load_pin(); bool now = load_pin(); if(now != last){ delay(30); now = load_pin(); if(now != last){ last = now; if(now && state != LoadState::LOADED) enter_loaded(); else if(!now && state != LoadState::UNLOADED) enter_unloaded(); } } }