From 6ea9f9688acf2fb8949d52f1d66ba48e3868b099 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Tue, 24 Mar 2026 15:41:57 -0500 Subject: [PATCH 1/2] Add NFC+ UID registration page for plain NFC tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New page at /register/uid lets users register plain NFC tags (NTAG215 etc.) in Spoolman using the tag's UID as identifier. No data is written to the tag — it works as a simple ID badge for a spool. - UIDRegistrationHTML.h: full form matching existing writer page patterns - Read Tag UID button reads UID from scanner via /api/status - Register in Spoolman button creates filament + spool with nfc_id extra - Form fields: material, brand, color, weight, temps, density - NFC+ nav link added to all pages after OpenTag3D - NFC+ Registration card added to landing page Closes #4 --- src/ConfigHTML.h | 1 + src/LandingHTML.h | 7 + src/OpenPrintTagWriterHTML.h | 1 + src/OpenTag3DWriterHTML.h | 1 + src/ReaderHTML.h | 1 + src/TigerTagWriterHTML.h | 1 + src/TroubleshootingHTML.h | 1 + src/UIDRegistrationHTML.h | 409 +++++++++++++++++++++++++++++++++++ src/UpdateHTML.h | 1 + 9 files changed, 423 insertions(+) create mode 100644 src/UIDRegistrationHTML.h diff --git a/src/ConfigHTML.h b/src/ConfigHTML.h index f98d3e5..ddfd7c1 100644 --- a/src/ConfigHTML.h +++ b/src/ConfigHTML.h @@ -49,6 +49,7 @@ const char CONFIG_HTML[] PROGMEM = R"rawliteral( OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config diff --git a/src/LandingHTML.h b/src/LandingHTML.h index bedee06..6a56696 100644 --- a/src/LandingHTML.h +++ b/src/LandingHTML.h @@ -21,6 +21,7 @@ const char LANDING_HTML[] PROGMEM = R"rawliteral( OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config @@ -70,6 +71,12 @@ const char LANDING_HTML[] PROGMEM = R"rawliteral(
Write filament data to NTAG215/216 tags using the OpenTag3D format.
+ +
💳
+
NFC+ Registration
+
Register a plain NFC tag in Spoolman using its UID. No data written to the tag.
+
+
Firmware Update
diff --git a/src/OpenPrintTagWriterHTML.h b/src/OpenPrintTagWriterHTML.h index 758c2ab..ec55c5e 100644 --- a/src/OpenPrintTagWriterHTML.h +++ b/src/OpenPrintTagWriterHTML.h @@ -25,6 +25,7 @@ const char OPENPRINTTAG_WRITER_HTML[] PROGMEM = R"rawliteral(
OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config diff --git a/src/OpenTag3DWriterHTML.h b/src/OpenTag3DWriterHTML.h index d90f250..fe533bf 100644 --- a/src/OpenTag3DWriterHTML.h +++ b/src/OpenTag3DWriterHTML.h @@ -21,6 +21,7 @@ const char OPENTAG3D_WRITER_HTML[] PROGMEM = R"rawliteral( OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config diff --git a/src/ReaderHTML.h b/src/ReaderHTML.h index 68a421f..ac1494e 100644 --- a/src/ReaderHTML.h +++ b/src/ReaderHTML.h @@ -21,6 +21,7 @@ const char READER_HTML[] PROGMEM = R"rawliteral( OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config diff --git a/src/TigerTagWriterHTML.h b/src/TigerTagWriterHTML.h index 35206c0..7ec8312 100644 --- a/src/TigerTagWriterHTML.h +++ b/src/TigerTagWriterHTML.h @@ -21,6 +21,7 @@ const char TIGERTAG_WRITER_HTML[] PROGMEM = R"rawliteral( OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config diff --git a/src/TroubleshootingHTML.h b/src/TroubleshootingHTML.h index 9b32439..52ba332 100644 --- a/src/TroubleshootingHTML.h +++ b/src/TroubleshootingHTML.h @@ -67,6 +67,7 @@ const char TROUBLESHOOTING_HTML[] PROGMEM = R"rawliteral( OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config diff --git a/src/UIDRegistrationHTML.h b/src/UIDRegistrationHTML.h new file mode 100644 index 0000000..6c5706e --- /dev/null +++ b/src/UIDRegistrationHTML.h @@ -0,0 +1,409 @@ +#pragma once + +// UID Registration page served at GET /register/uid +// +// API endpoints available to the page: +// GET /api/status — current tag state JSON (includes uid, spoolman_url) +// (Spoolman API calls are made directly from the browser to the configured Spoolman instance) + +const char UID_REGISTRATION_HTML[] PROGMEM = R"rawliteral( + + + + + + UID Registration — SpoolSense + + + +
+ + +
+
+
+

Register Spool in Spoolman

+

Create a Spoolman spool entry for a plain NFC tag. No data is written to the tag — it's used as a UID identifier only.

+
+
+ +
+
+
Tag UID
+
Place tag & click Read
+
+ +
+
+

Basic

+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
Material name is forced to ALL CAPS.
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
Pick a color or type a hex value.
+
+
+
+ +
+ + + +
+ +
+ + + +
+ +
+ +
This creates a spool entry in Spoolman with the tag's UID as the identifier. No data is written to the tag.
+
+
+
+ + +
+ + + + + +)rawliteral"; diff --git a/src/UpdateHTML.h b/src/UpdateHTML.h index c42a700..42a8ba8 100644 --- a/src/UpdateHTML.h +++ b/src/UpdateHTML.h @@ -85,6 +85,7 @@ const char UPDATE_HTML[] PROGMEM = R"rawliteral( OpenPrintTag TigerTag OpenTag3D + NFC+ Update Troubleshooting Config From 4b77582f9f3d8558df1c4355aab301f30efe5b18 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Tue, 24 Mar 2026 16:33:02 -0500 Subject: [PATCH 2/2] Fix NFC+ registration: add /api/register-uid endpoint, fix CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add handleApiRegisterUid in WebServerManager — scanner proxies all Spoolman API calls (vendor lookup, filament creation, spool creation) to avoid browser CORS issues - Fix Read Tag UID to poll /api/status until tag is detected (500ms interval) - Fix Spoolman URL lookup: read from /api/config not /api/status - Fix filament creation: density and diameter are required by Spoolman, default to 1.24/1.75 when not specified - Fix spool extras: color_hex belongs on filament, not spool extras - Fix nfc_id extra field: Spoolman requires JSON-quoted string values - Add serial debug logging for API payloads --- src/UIDRegistrationHTML.h | 164 +++++++++++++-------------------- src/WebServerManager.cpp | 188 ++++++++++++++++++++++++++++++++++++++ src/WebServerManager.h | 2 + 3 files changed, 252 insertions(+), 102 deletions(-) diff --git a/src/UIDRegistrationHTML.h b/src/UIDRegistrationHTML.h index 6c5706e..9c825f8 100644 --- a/src/UIDRegistrationHTML.h +++ b/src/UIDRegistrationHTML.h @@ -243,23 +243,51 @@ const char UID_REGISTRATION_HTML[] PROGMEM = R"rawliteral( }, 0); }); + var readAbort = null; + readUidBtn.addEventListener('click', function() { - uidValue.textContent = 'Reading\u2026'; - fetch('/api/status') - .then(function(r) { return r.json(); }) - .then(function(d) { - if (d.uid) { - currentUid = d.uid; - uidValue.textContent = d.uid; - } else { - currentUid = ''; - uidValue.textContent = 'No tag detected'; + if (readAbort) { readAbort = true; return; } + readUidBtn.textContent = 'Waiting for tag\u2026'; + readUidBtn.disabled = true; + uidValue.textContent = 'Place tag on scanner\u2026'; + readAbort = false; + var attempts = 0; + var maxAttempts = 60; + + function poll() { + if (readAbort || attempts >= maxAttempts) { + readUidBtn.textContent = 'Read Tag UID'; + readUidBtn.disabled = false; + readAbort = null; + if (attempts >= maxAttempts) { + uidValue.textContent = 'Timed out \u2014 no tag detected'; } - }) - .catch(function() { - currentUid = ''; - uidValue.textContent = 'Error reading scanner'; - }); + return; + } + attempts++; + fetch('/api/status') + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.present && d.uid) { + currentUid = d.uid; + uidValue.textContent = d.uid; + uidValue.style.fontSize = '22px'; + readUidBtn.textContent = 'Read Tag UID'; + readUidBtn.disabled = false; + readAbort = null; + } else { + setTimeout(poll, 500); + } + }) + .catch(function() { + currentUid = ''; + uidValue.textContent = 'Error reading scanner'; + readUidBtn.textContent = 'Read Tag UID'; + readUidBtn.disabled = false; + readAbort = null; + }); + } + poll(); }); async function registerFlow() { @@ -285,105 +313,37 @@ const char UID_REGISTRATION_HTML[] PROGMEM = R"rawliteral( return; } - setResult('Fetching Spoolman URL\u2026', ''); - - var spoolmanUrl = ''; - try { - var statusData = await api('/api/status'); - spoolmanUrl = (statusData.spoolman_url || '').replace(/\/$/, ''); - } catch (e) { - setResult('Could not reach scanner to get Spoolman URL.', 'error'); - return; - } - - if (!spoolmanUrl) { - setResult('Spoolman URL is not configured. Set it in Config.', 'error'); - return; - } - var materialTypeText = materialTypeEl.options[materialTypeEl.selectedIndex].text; var materialName = readString('material_name'); if (materialName) materialName = materialName.toUpperCase(); - var filamentName = (materialName || materialTypeText.toUpperCase()) + ' ' + manufacturer; - - var density = readPositiveNumber('density'); - var diameter = readPositiveNumber('diameter_mm'); - var minPrint = readPositiveNumber('min_print_temp'); - var maxPrint = readPositiveNumber('max_print_temp'); - var minBed = readPositiveNumber('min_bed_temp'); - var maxBed = readPositiveNumber('max_bed_temp'); - - // Step 1: find or create vendor - setResult('Looking up vendor in Spoolman\u2026', ''); - var vendorId = null; - try { - var vendorRes = await fetch(spoolmanUrl + '/api/v1/vendor?name=' + encodeURIComponent(manufacturer)); - if (vendorRes.ok) { - var vendors = await vendorRes.json(); - if (Array.isArray(vendors) && vendors.length > 0) { - vendorId = vendors[0].id; - } - } - } catch (e) { /* vendor lookup optional */ } - // Step 2: create filament - setResult('Creating filament in Spoolman\u2026', ''); - var filamentPayload = { - name: filamentName, - material: materialName || materialTypeText.toUpperCase() + var payload = { + uid: currentUid, + manufacturer: manufacturer, + material: materialName || materialTypeText.toUpperCase(), + material_name: materialName || '', + color: color.replace('#', ''), + initial_weight_g: initialWeight, + remaining_g: remainingWeight, + density: readPositiveNumber('density') || 0, + diameter_mm: readPositiveNumber('diameter_mm') || 1.75, + min_print_temp: readPositiveNumber('min_print_temp') || 0, + max_print_temp: readPositiveNumber('max_print_temp') || 0, + min_bed_temp: readPositiveNumber('min_bed_temp') || 0, + max_bed_temp: readPositiveNumber('max_bed_temp') || 0 }; - if (vendorId !== null) filamentPayload.vendor_id = vendorId; - if (density !== undefined) filamentPayload.density = density; - if (diameter !== undefined) filamentPayload.diameter = diameter; - if (minPrint !== undefined) filamentPayload.settings_extruder_temp = minPrint; - if (maxPrint !== undefined) filamentPayload.settings_extruder_temp_max = maxPrint; - if (minBed !== undefined) filamentPayload.settings_bed_temp = minBed; - if (maxBed !== undefined) filamentPayload.settings_bed_temp_max = maxBed; - filamentPayload.color_hex = color.replace('#', ''); - - var filamentId = null; - try { - var filamentRes = await fetch(spoolmanUrl + '/api/v1/filament', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(filamentPayload) - }); - if (!filamentRes.ok) { - var errText = await filamentRes.text(); - setResult('Failed to create filament: ' + errText, 'error'); - return; - } - var filamentData = await filamentRes.json(); - filamentId = filamentData.id; - } catch (e) { - setResult('Error creating filament: ' + (e.message || e), 'error'); - return; - } - // Step 3: create spool - setResult('Creating spool in Spoolman\u2026', ''); - var spoolPayload = { - filament_id: filamentId, - remaining_weight: remainingWeight, - initial_weight: initialWeight, - extra: { nfc_id: currentUid } - }; + setResult('Registering in Spoolman\u2026', ''); try { - var spoolRes = await fetch(spoolmanUrl + '/api/v1/spool', { + var result = await api('/api/register-uid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spoolPayload) + body: JSON.stringify(payload) }); - if (!spoolRes.ok) { - var spoolErr = await spoolRes.text(); - setResult('Failed to create spool: ' + spoolErr, 'error'); - return; - } - var spoolData = await spoolRes.json(); - setResult('Spool #' + spoolData.id + ' registered successfully. UID: ' + currentUid, 'success'); + setResult('Spool #' + result.spool_id + ' registered successfully. UID: ' + currentUid, 'success'); } catch (e) { - setResult('Error creating spool: ' + (e.message || e), 'error'); + setResult('Registration failed: ' + (e.message || e), 'error'); } } diff --git a/src/WebServerManager.cpp b/src/WebServerManager.cpp index 3958fc7..6b3d95e 100644 --- a/src/WebServerManager.cpp +++ b/src/WebServerManager.cpp @@ -18,6 +18,7 @@ #include "SharedJS.h" #include "ConfigHTML.h" #include "TroubleshootingHTML.h" +#include "UIDRegistrationHTML.h" #include "OpenPrintTagLogo.h" #include "TigerTagLogo.h" #include "UpdateHTML.h" @@ -78,6 +79,7 @@ bool WebServerManager::begin(uint16_t port) { _server.on("/update", HTTP_GET, [this]() { handleUpdatePage(); }); _server.on("/config", HTTP_GET, [this]() { handleConfigPage(); }); _server.on("/troubleshooting", HTTP_GET, [this]() { handleTroubleshootingPage(); }); + _server.on("/register/uid", HTTP_GET, [this]() { handleUIDRegistrationPage(); }); // API _server.on("/api/version", HTTP_GET, [this]() { handleApiVersion(); }); @@ -94,6 +96,7 @@ bool WebServerManager::begin(uint16_t port) { _server.on("/api/format-tag", HTTP_POST, [this]() { handleApiFormatTag(); }); _server.on("/api/write-tigertag", HTTP_POST, [this]() { handleApiWriteTigerTag(); }); _server.on("/api/write-opentag3d", HTTP_POST, [this]() { handleApiWriteOpenTag3D(); }); + _server.on("/api/register-uid", HTTP_POST, [this]() { handleApiRegisterUid(); }); // Allow browser preflight requests (CORS) so the page can be tested // from a local file during development. @@ -186,6 +189,191 @@ void WebServerManager::handleTroubleshootingPage() { _server.send_P(200, "text/html", TROUBLESHOOTING_HTML); } +void WebServerManager::handleUIDRegistrationPage() { + _server.sendHeader("Access-Control-Allow-Origin", "*"); + _server.send_P(200, "text/html", UID_REGISTRATION_HTML); +} + +void WebServerManager::handleApiRegisterUid() { + Serial.println("WebServerManager: POST /api/register-uid received"); + _server.sendHeader("Access-Control-Allow-Origin", "*"); + + StaticJsonDocument<1024> doc; + DeserializationError err = deserializeJson(doc, _server.arg("plain")); + if (err) { + sendError(400, "Invalid JSON"); + return; + } + + const char* uid = doc["uid"] | ""; + const char* manufacturer = doc["manufacturer"] | ""; + const char* material = doc["material"] | "PLA"; + const char* materialName = doc["material_name"] | ""; + const char* color = doc["color"] | "FF0000"; + float initialWeight = doc["initial_weight_g"] | 0.0f; + float remainingWeight = doc["remaining_g"] | 0.0f; + float density = doc["density"] | 0.0f; + float diameter = doc["diameter_mm"] | 1.75f; + int minPrintTemp = doc["min_print_temp"] | 0; + int maxPrintTemp = doc["max_print_temp"] | 0; + int minBedTemp = doc["min_bed_temp"] | 0; + int maxBedTemp = doc["max_bed_temp"] | 0; + + if (strlen(uid) == 0) { + sendError(400, "UID is required"); + return; + } + if (strlen(manufacturer) == 0) { + sendError(400, "Manufacturer is required"); + return; + } + + const char* baseUrl = ConfigurationManager::getInstance().getSpoolmanURL(); + if (!baseUrl || strlen(baseUrl) == 0) { + sendError(500, "Spoolman URL not configured"); + return; + } + + WiFiClient client; + HTTPClient http; + char url[256]; + String response; + int code; + + // --- Step 1: Find or create vendor --- + int vendorId = -1; + snprintf(url, sizeof(url), "%s/api/v1/vendor?name=%s", baseUrl, manufacturer); + http.begin(client, url); + code = http.GET(); + if (code == 200) { + response = http.getString(); + StaticJsonDocument<2048> vendorDoc; + if (!deserializeJson(vendorDoc, response)) { + JsonArray arr = vendorDoc.as(); + for (JsonObject v : arr) { + if (strcasecmp(v["name"] | "", manufacturer) == 0) { + vendorId = v["id"] | -1; + break; + } + } + } + } + http.end(); + + if (vendorId < 0) { + // Create vendor + StaticJsonDocument<128> vendorBody; + vendorBody["name"] = manufacturer; + String vendorJson; + serializeJson(vendorBody, vendorJson); + + snprintf(url, sizeof(url), "%s/api/v1/vendor", baseUrl); + http.begin(client, url); + http.addHeader("Content-Type", "application/json"); + code = http.POST(vendorJson); + if (code == 200 || code == 201) { + response = http.getString(); + StaticJsonDocument<512> vDoc; + if (!deserializeJson(vDoc, response)) { + vendorId = vDoc["id"] | -1; + } + } + http.end(); + } + + // --- Step 2: Create filament --- + StaticJsonDocument<512> filBody; + filBody["name"] = strlen(materialName) > 0 ? materialName : material; + filBody["material"] = material; + if (vendorId > 0) filBody["vendor_id"] = vendorId; + filBody["density"] = density > 0 ? density : 1.24f; + filBody["diameter"] = diameter > 0 ? diameter : 1.75f; + if (strlen(color) > 0) filBody["color_hex"] = color; + + String filJson; + serializeJson(filBody, filJson); + Serial.printf("register-uid: filament payload: %s\n", filJson.c_str()); + + snprintf(url, sizeof(url), "%s/api/v1/filament", baseUrl); + http.begin(client, url); + http.addHeader("Content-Type", "application/json"); + code = http.POST(filJson); + if (code != 200 && code != 201) { + response = http.getString(); + http.end(); + char errMsg[128]; + snprintf(errMsg, sizeof(errMsg), "Failed to create filament (HTTP %d)", code); + sendError(500, errMsg); + return; + } + response = http.getString(); + http.end(); + + int filamentId = -1; + { + StaticJsonDocument<1024> filDoc; + if (!deserializeJson(filDoc, response)) { + filamentId = filDoc["id"] | -1; + } + } + if (filamentId < 0) { + sendError(500, "Failed to parse filament ID from Spoolman response"); + return; + } + + // --- Step 3: Create spool with nfc_id --- + StaticJsonDocument<512> spoolBody; + spoolBody["filament_id"] = filamentId; + if (initialWeight > 0) spoolBody["initial_weight"] = initialWeight; + if (remainingWeight > 0) spoolBody["remaining_weight"] = remainingWeight; + + JsonObject extra = spoolBody.createNestedObject("extra"); + // Spoolman extra fields require JSON-encoded string values (double-quoted) + char quotedUid[128]; + snprintf(quotedUid, sizeof(quotedUid), "\"%s\"", uid); + extra["nfc_id"] = quotedUid; + + String spoolJson; + serializeJson(spoolBody, spoolJson); + Serial.printf("register-uid: spool payload: %s\n", spoolJson.c_str()); + + snprintf(url, sizeof(url), "%s/api/v1/spool", baseUrl); + http.begin(client, url); + http.addHeader("Content-Type", "application/json"); + code = http.POST(spoolJson); + if (code != 200 && code != 201) { + response = http.getString(); + http.end(); + Serial.printf("register-uid: spool creation failed HTTP %d: %s\n", code, response.c_str()); + String errMsg = "Failed to create spool (HTTP " + String(code) + "): " + response; + _server.send(500, "application/json", "{\"error\":\"" + errMsg + "\"}"); + return; + } + response = http.getString(); + http.end(); + + int spoolId = -1; + { + StaticJsonDocument<1024> spoolDoc; + if (!deserializeJson(spoolDoc, response)) { + spoolId = spoolDoc["id"] | -1; + } + } + + // --- Success --- + StaticJsonDocument<256> result; + result["success"] = true; + result["spool_id"] = spoolId; + result["filament_id"] = filamentId; + result["vendor_id"] = vendorId; + result["uid"] = uid; + String resultJson; + serializeJson(result, resultJson); + + _server.send(200, "application/json", resultJson); + Serial.printf("WebServerManager: Registered UID %s as spool %d (filament %d)\n", uid, spoolId, filamentId); +} + // --------------------------------------------------------------------------- // API: Diagnostics // --------------------------------------------------------------------------- diff --git a/src/WebServerManager.h b/src/WebServerManager.h index 19cd9c1..232f844 100644 --- a/src/WebServerManager.h +++ b/src/WebServerManager.h @@ -53,6 +53,8 @@ class WebServerManager { void handleApiPostConfig(); void handleApiDiagnostics(); void handleTroubleshootingPage(); + void handleUIDRegistrationPage(); + void handleApiRegisterUid(); // OTA download state static void otaDownloadTask(void* param);