Missing initial files
This commit is contained in:
parent
36c7aa081b
commit
4432d3252f
391
Frontend/ESP32_Firmware.ino
Normal file
391
Frontend/ESP32_Firmware.ino
Normal file
@ -0,0 +1,391 @@
|
||||
/*
|
||||
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=<id>, mount USB MSC (RW), pulse JA BTN2 (SELECT)
|
||||
- LOAD_PIN low => pulse JA BTN3 (EJECT), unmount, POST /api/volume?id=<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 <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <ESPmDNS.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <FS.h>
|
||||
#include <LittleFS.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include "esp_tinyusb.h"
|
||||
#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(sz<BYTES_PER_SECTOR){ mscFile.close(); return false; }
|
||||
mscBlocks = (uint32_t)(sz / BYTES_PER_SECTOR);
|
||||
return true;
|
||||
}
|
||||
static void set_ready(bool r){ mscReady = r && mscFile; }
|
||||
|
||||
// ---------------- Config I/O ----------------
|
||||
static bool cfg_load(){
|
||||
if(!LittleFS.exists(PATH_CFG)) return false;
|
||||
File f=LittleFS.open(PATH_CFG,"r"); if(!f) return false;
|
||||
DynamicJsonDocument d(1024); if(deserializeJson(d,f)){ f.close(); return false; } f.close();
|
||||
CFG.ssid=(const char*)(d["ssid"]|""); CFG.pass=(const char*)(d["pass"]|""); CFG.backendUrl=(const char*)(d["backendUrl"]|"");
|
||||
return true;
|
||||
}
|
||||
static bool cfg_save(){
|
||||
DynamicJsonDocument d(1024); d["ssid"]=CFG.ssid; d["pass"]=CFG.pass; d["backendUrl"]=CFG.backendUrl;
|
||||
File f=LittleFS.open(PATH_CFG,"w"); if(!f) return false; bool ok=serializeJson(d,f)>0; 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<line.length();++i){
|
||||
char ch = line[i];
|
||||
if (isxdigit((unsigned char)ch)) hex += (char)toupper(ch);
|
||||
}
|
||||
if (hex.length() == 0 || hex.length() > 16){ line = ""; break; } // discard invalid
|
||||
uint64_t v = 0;
|
||||
for (size_t i=0;i<hex.length();++i){
|
||||
char h = hex[i]; uint8_t nib = (h <= '9') ? (h - '0') : (10 + (h - 'A'));
|
||||
v = (v << 4) | nib;
|
||||
}
|
||||
outId = v; return true;
|
||||
} else {
|
||||
line += (char)c;
|
||||
if (line.length() > 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("<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
|
||||
"<title>Gotek Wi‑Fi Setup</title><style>body{font-family:system-ui;margin:2rem;max-width:640px}"
|
||||
"label{display:block;margin:.75rem 0 .25rem}input,button{width:100%;padding:.6rem;border:1px solid #ccc;border-radius:10px}"
|
||||
".row{display:flex;gap:.75rem;margin-top:.75rem}.row>*{flex:1}</style>"
|
||||
"<h1>Wi‑Fi & Backend</h1>"
|
||||
"<label>SSID<input id=s value='"); h+=CFG.ssid; h+=F("'></label>"
|
||||
"<label>Password<input id=p type=password value='"); h+=CFG.pass; h+=F("'></label>"
|
||||
"<label>Backend URL (https://host:port)<input id=u value='"); h+=CFG.backendUrl; h+=F("'></label>"
|
||||
"<div class=row><button id=save>Save</button><button id=reboot>Reboot</button></div>"
|
||||
"<script>save.onclick=async()=>{const b=JSON.stringify({ssid:s.value,pass:p.value,backendUrl:u.value});"
|
||||
"const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:b});"
|
||||
"alert((await r.json()).ok?'Saved':'Save failed');};"
|
||||
"reboot.onclick=async()=>{await fetch('/api/reboot',{method:'POST'});};</script>");
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user