/* * mtag.cc ESP32 Tag Writer (NTAG213/215/216 via PN532, I2C) — mit Web-UI + Pairing * * Erststart: * 1. ESP spannt WLAN "MTAG-SETUP" auf -> http://192.168.4.1 oeffnen * 2. Heim-WLAN + Setup-Code eintragen (Code unter mtag.cc -> Collectibles * -> Writer API -> "Setup-Code generieren"; 5 Min. gueltig, einmalig) * 3. Geraet holt sich base/user/key einmalig per /api/writer_setup und * speichert alles in NVS — danach laeuft es autark im Heim-WLAN. * * Betrieb: * - Web-UI auf der LAN-IP (steht im Serial): Status, Design-Auswahl * (Dropdown via /api/mchips), Schreib-Log, Re-Pairing. * - Tag auflegen -> UID -> /api/chip -> NDEF-URL schreiben. * * Libs: Adafruit PN532, ArduinoJson (>=6), Preferences/WebServer (ESP32-Core) * Board: ESP32 Dev Module, PN532 auf I2C (SDA=21, SCL=22) * * HTTPS: setInsecure() — fuer ein Writer-Tool ok; wer will, haengt stattdessen * das Root-CA-Cert der Instanz an (WiFiClientSecure::setCACert). */ #include #include #include #include #include #include #include #include Adafruit_PN532 nfc(21, 22); // SDA, SCL (I2C) Preferences prefs; WebServer server(80); String cfgSsid, cfgPass, cfgBase, cfgUser, cfgKey; long cfgKeyUntil = 0; // unix ts, 0 = unbekannt String cfgMchip; // aktives Design (id oder slug), "" = nur registrieren String cfgUrlMode = "token"; // "token" (public_id Hash), "virtual" (UID-frei), "uid" (Hardware-UID) bool cfgLock = false; // true = nach dem Schreiben Lock-Bits setzen (permanent Read-Only!) bool paired = false; String lastLog[6]; int logIdx = 0; // ---------- Helpers ---------------------------------------------------------- void logline(const String &s) { Serial.println(s); lastLog[logIdx % 6] = s; logIdx++; } String urlenc(const String &s) { String out; for (char c : s) { if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out += c; else { char b[4]; sprintf(b, "%%%02X", (uint8_t)c); out += b; } } return out; } // GET mit automatischem http/https-Client. Liefert Body oder "" bei Fehler. String httpGet(const String &url, int *codeOut = nullptr) { HTTPClient http; WiFiClientSecure sec; WiFiClient plain; if (url.startsWith("https")) { sec.setInsecure(); http.begin(sec, url); } else { http.begin(plain, url); } http.setTimeout(8000); int code = http.GET(); if (codeOut) *codeOut = code; String body = (code > 0) ? http.getString() : ""; http.end(); return body; } void loadCfg() { prefs.begin("mtag", true); cfgSsid = prefs.getString("ssid"); cfgPass = prefs.getString("pass"); cfgBase = prefs.getString("base"); cfgUser = prefs.getString("user"); cfgKey = prefs.getString("key"); cfgMchip = prefs.getString("mchip"); cfgUrlMode = prefs.getString("umode", "token"); cfgLock = prefs.getBool("lock", false); cfgKeyUntil = prefs.getLong("kuntil"); prefs.end(); paired = cfgBase.length() && cfgUser.length() && cfgKey.length(); } void saveCfg() { prefs.begin("mtag", false); prefs.putString("ssid", cfgSsid); prefs.putString("pass", cfgPass); prefs.putString("base", cfgBase); prefs.putString("user", cfgUser); prefs.putString("key", cfgKey); prefs.putString("mchip", cfgMchip); prefs.putString("umode", cfgUrlMode); prefs.putBool("lock", cfgLock); prefs.putLong("kuntil", cfgKeyUntil); prefs.end(); } const char *PAGE_HEAD = "" ""; // ---------- Setup-Portal (AP-Modus) ----------------------------------------- void handlePortal() { String h = PAGE_HEAD; h += "

MTAG Writer Setup

" "" "" "" "" "
" "

Code: mtag.cc → Collectibles → Writer API → " "Setup-Code generieren (5 Min. gültig, einmalig).

"; server.send(200, "text/html", h); } void handlePair() { cfgSsid = server.arg("ssid"); cfgPass = server.arg("pass"); String code = server.arg("code"), serv = server.arg("serv"); code.toUpperCase(); code.replace("-", ""); code.trim(); serv.trim(); if (!serv.length()) serv = "https://mtag.cc"; server.send(200, "text/html", String(PAGE_HEAD) + "

Pairing…

Verbinde mit " + cfgSsid + " und hole Credentials. Details im Serial. Diese Seite kann geschlossen werden.

"); WiFi.mode(WIFI_STA); WiFi.begin(cfgSsid.c_str(), cfgPass.c_str()); for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500); if (WiFi.status() != WL_CONNECTED) { logline("pairing: wifi failed"); delay(3000); ESP.restart(); // zurück ins AP-Portal } int code2 = 0; String body = httpGet(serv + "/api/writer_setup?code=" + code, &code2); StaticJsonDocument<512> doc; if (code2 != 200 || deserializeJson(doc, body)) { logline("pairing failed (" + String(code2) + "): " + body.substring(0, 120)); delay(3000); ESP.restart(); } cfgBase = doc["base"].as(); cfgUser = doc["user"].as(); cfgKey = doc["key"].as(); cfgKeyUntil = doc["key_until"] | 0L; cfgMchip = ""; saveCfg(); logline("paired as " + cfgUser + " on " + cfgBase); delay(1000); ESP.restart(); } // ---------- Betriebs-Web-UI (Station-Modus) ---------------------------------- String fetchDesignOptions() { int code = 0; String body = httpGet(cfgBase + "/api/mchips?user=" + urlenc(cfgUser) + "&key=" + urlenc(cfgKey), &code); String opts = ""; if (code != 200) return opts + ""; StaticJsonDocument<8192> doc; if (deserializeJson(doc, body)) return opts; for (JsonObject m : doc["mchips"].as()) { String slug = m["slug"].as(); String name = m["name"].as() + (m["version"].as().length() ? " " + m["version"].as() : ""); opts += ""; } return opts; } String logHtml() { String out; for (int i = 0; i < 6; i++) { String e = lastLog[(logIdx + i) % 6]; if (e.length()) out += e + "
"; } return out.length() ? out : "noch keine Schreibvorgaenge"; } void handleLog() { server.send(200, "text/plain", logHtml()); } void handleStatus() { time_t now = time(nullptr); long remain = (cfgKeyUntil > 0 && now > 1700000000) ? cfgKeyUntil - (long)now : -1; String h = PAGE_HEAD; h += "

MTAG Writer

Paired als " + cfgUser + " @ " + cfgBase + "

"; h += (remain > 0) ? "

API-Key: aktiv, noch " + String(remain / 60) + " min

" : "

API-Key abgelaufen/unbekannt — ggf. neu pairen.

"; h += "
" "" "" "" "" "" "" "
"; if (cfgLock) { h += "

ACHTUNG: Lock-Bit aktiv — beschriebene Chips werden " "permanent schreibgeschuetzt (nicht rueckgaengig machbar)!

"; } h += "

Chip auflegen — UID wird gelesen, URL geschrieben. Fertig.

" "

Log

" + logHtml() + "

" "" "
" "
"; server.send(200, "text/html", h); } void handleSelect() { cfgMchip = server.arg("m"); cfgMchip.trim(); if (server.hasArg("umode")) cfgUrlMode = server.arg("umode"); cfgLock = (server.arg("lock") == "1"); saveCfg(); server.sendHeader("Location", "/"); server.send(302); } void handleReset() { prefs.begin("mtag", false); prefs.clear(); prefs.end(); server.send(200, "text/html", String(PAGE_HEAD) + "

Reset…

"); delay(1000); ESP.restart(); } // ---------- Tag schreiben ---------------------------------------------------- void writeTag(const String &uidHex) { String sendUid = (cfgUrlMode == "virtual") ? "virtual" : uidHex; String umode = (cfgUrlMode == "uid") ? "uid" : "token"; String url = cfgBase + "/api/chip?user=" + urlenc(cfgUser) + "&key=" + urlenc(cfgKey) + "&uid=" + sendUid + "&url_mode=" + umode; if (cfgMchip.length()) url += "&mchip=" + urlenc(cfgMchip); int code = 0; String body = httpGet(url, &code); if (code != 200) { logline("UID " + uidHex + ": lookup " + String(code)); return; } StaticJsonDocument<512> doc; if (deserializeJson(doc, body)) { logline("UID " + uidHex + ": bad json"); return; } String wurl = doc["url"].as(); String st = doc["status"].as(); if (!writeNdefUri(wurl)) { logline("UID " + uidHex + ": WRITE FAILED"); return; } String lockInfo = ""; if (cfgLock) { lockInfo = lockNtag() ? " [LOCKED]" : " [LOCK FAILED]"; } logline("UID " + uidHex + ": " + st + " -> " + wurl + lockInfo); } // NTAG213/215/216 permanent sperren (CC Read-Only + Dynamic & Static Lock Bytes) bool lockNtag() { uint8_t cc[4] = {0xE1, 0x10, 0x12, 0x0F}; if (nfc.mifareultralight_ReadPage(0x03, cc)) { uint8_t dynPage = 0; if (cc[2] == 0x12) dynPage = 0x28; // NTAG213 else if (cc[2] == 0x3E) dynPage = 0x82; // NTAG215 else if (cc[2] == 0x6D) dynPage = 0xE2; // NTAG216 cc[3] = 0x0F; // CC Access: Read-Only nfc.mifareultralight_WritePage(0x03, cc); if (dynPage) { uint8_t dynLock[4] = {0xFF, 0xFF, 0xFF, 0x00}; nfc.mifareultralight_WritePage(dynPage, dynLock); } } uint8_t staticLock[4] = {0x00, 0x00, 0xFF, 0xFF}; return nfc.mifareultralight_WritePage(0x02, staticLock); } // NDEF URI-Record -> NTAG21x Pages (ab Page 0x04), TLV-Wrapper 0x03 ... 0xFE bool writeNdefUri(const String &url) { const char *urlrest = url.c_str(); uint8_t prefix = 0x00; static const char *prefixes[] = { "http://www.", "https://www.", "http://", "https://" }; for (uint8_t p = 1; p <= 4; p++) { if (url.startsWith(prefixes[p - 1])) { prefix = p; urlrest += strlen(prefixes[p - 1]); break; } } uint8_t payLen = 1 + strlen(urlrest); uint8_t recLen = 3 + payLen; uint8_t buf[200]; buf[0] = 0x03; buf[1] = recLen; buf[2] = 0xD1; buf[3] = 0x01; buf[4] = payLen; buf[5] = 0x55; buf[6] = prefix; memcpy(buf + 7, urlrest, strlen(urlrest)); uint8_t end = 7 + strlen(urlrest); buf[end] = 0xFE; uint8_t tlvLen = end + 1; while (tlvLen % 4) buf[tlvLen++] = 0x00; // auf Page-Grenze auffuellen for (uint8_t i = 0; i < tlvLen; i += 4) { if (!nfc.mifareultralight_WritePage(0x04 + i / 4, buf + i)) return false; } return true; } // ---------- setup / loop ----------------------------------------------------- void setup() { Serial.begin(115200); loadCfg(); nfc.begin(); if (!nfc.getFirmwareVersion()) Serial.println("PN532 not found — check wiring"); else nfc.SAMConfig(); if (!paired || !cfgSsid.length()) { WiFi.mode(WIFI_AP); WiFi.softAP("MTAG-SETUP"); server.on("/", handlePortal); server.on("/pair", HTTP_POST, handlePair); server.begin(); Serial.println("Setup-Portal: WLAN 'MTAG-SETUP' -> http://192.168.4.1"); return; } WiFi.mode(WIFI_STA); WiFi.begin(cfgSsid.c_str(), cfgPass.c_str()); for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500); if (WiFi.status() != WL_CONNECTED) { Serial.println("WiFi failed — back to setup portal"); cfgUser = cfgKey = cfgBase = ""; paired = false; delay(500); ESP.restart(); } configTime(0, 0, "pool.ntp.org", "time.nist.gov"); // fuer Key-Restlaufzeit server.on("/", handleStatus); server.on("/select", handleSelect); server.on("/log", handleLog); server.on("/reset", HTTP_POST, handleReset); server.begin(); Serial.print("Web-UI: http://"); Serial.println(WiFi.localIP()); Serial.println("Ready — hold a tag to the reader."); } void loop() { server.handleClient(); if (!paired) { delay(10); return; } uint8_t uid[7]; uint8_t uidLen; if (nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLen, 400)) { char uidHex[15]; for (uint8_t i = 0; i < uidLen; i++) sprintf(uidHex + 2 * i, "%02X", uid[i]); uidHex[uidLen * 2] = 0; writeTag(uidHex); delay(1500); // Doppel-Trigger vermeiden } }