From 665a3b75dbd5dfc390c572d4a8237d1ebbcdf417 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 06:53:08 -0500 Subject: [PATCH 01/16] fix: only re-publish HA discovery when UID changes, legacy cleanup once (#55) Discovery was re-published on every tag state update (10+ MQTT messages per scan). Now tracks lastDiscoveryUid_ and only re-publishes when the UID actually changes. Legacy entity cleanup (openprinttag_ prefixed) runs once per boot instead of every discovery call. Estimated MQTT traffic reduction: ~80% on repeated scans. --- src/HomeAssistantManager.cpp | 41 +++++++++++++++++++++++------------- src/HomeAssistantManager.h | 4 ++++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/HomeAssistantManager.cpp b/src/HomeAssistantManager.cpp index dbd74ad..7d44e90 100644 --- a/src/HomeAssistantManager.cpp +++ b/src/HomeAssistantManager.cpp @@ -398,8 +398,17 @@ void HomeAssistantManager::taskLoop() { if (strcmp(req.topic, tagStateTopic) == 0) { // Keep spool attributes aligned with the state payload source. mqttClient.publish(tagAttrsTopic, req.payload, req.retained); - // Keep command topics aligned with currently-present UID. - publishDiscovery(); + // Only re-publish discovery when UID changes (command topics include UID). + CurrentSpoolState state; + char currentUid[17] = {0}; + if (NFCManager::getInstance().getCurrentSpoolState(state) && + state.present && state.spool_id[0] != '\0') { + strncpy(currentUid, state.spool_id, sizeof(currentUid) - 1); + } + if (strcmp(currentUid, lastDiscoveryUid_) != 0) { + strncpy(lastDiscoveryUid_, currentUid, sizeof(lastDiscoveryUid_) - 1); + publishDiscovery(); + } } } } @@ -629,19 +638,21 @@ void HomeAssistantManager::publishDiscovery() { } } - // Remove stale retained discovery configs from previous read entities. - removeLegacyEntity("binary_sensor", "tag_present"); - removeLegacyEntity("sensor", "spool_uid"); - removeLegacyEntity("sensor", "remaining_weight"); - removeLegacyEntity("sensor", "material_type"); - removeLegacyEntity("sensor", "color"); - removeLegacyEntity("sensor", "printer_state"); - // Remove old openprinttag_-prefixed control entities (renamed to spoolsense_). - removeLegacyEntity("number", "set_remaining_weight"); - removeLegacyEntity("number", "set_initial_weight"); - removeLegacyEntity("number", "set_spoolman_id"); - removeLegacyEntity("select", "set_material_type"); - removeLegacyEntity("text", "set_manufacturer"); + // Remove stale retained discovery configs — once per boot, not every scan. + if (!legacyCleanupDone_) { + removeLegacyEntity("binary_sensor", "tag_present"); + removeLegacyEntity("sensor", "spool_uid"); + removeLegacyEntity("sensor", "remaining_weight"); + removeLegacyEntity("sensor", "material_type"); + removeLegacyEntity("sensor", "color"); + removeLegacyEntity("sensor", "printer_state"); + removeLegacyEntity("number", "set_remaining_weight"); + removeLegacyEntity("number", "set_initial_weight"); + removeLegacyEntity("number", "set_spoolman_id"); + removeLegacyEntity("select", "set_material_type"); + removeLegacyEntity("text", "set_manufacturer"); + legacyCleanupDone_ = true; + } // UID is carried in command topic; payload contains only values. char updateRemainingCmdTpl[128]; diff --git a/src/HomeAssistantManager.h b/src/HomeAssistantManager.h index 12f396b..751ecb2 100644 --- a/src/HomeAssistantManager.h +++ b/src/HomeAssistantManager.h @@ -75,6 +75,10 @@ class HomeAssistantManager { // Device ID cache char deviceId_[7] = {0}; // 6 hex chars + null CurrentSpoolState spoolScratch_; + + // Discovery dedup — only re-publish when UID changes + char lastDiscoveryUid_[17] = {0}; + bool legacyCleanupDone_ = false; }; #endif // HOME_ASSISTANT_MANAGER_H From 368de8b02510f4f5815e5b905fe99b1daf6f2c79 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 06:58:12 -0500 Subject: [PATCH 02/16] fix: stale openprinttag comment in MQTT command handler --- src/HomeAssistantManager.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HomeAssistantManager.cpp b/src/HomeAssistantManager.cpp index 7d44e90..ef8e4e0 100644 --- a/src/HomeAssistantManager.cpp +++ b/src/HomeAssistantManager.cpp @@ -819,7 +819,7 @@ void HomeAssistantManager::handleCommand(const char* topic, const char* payload) Serial.printf("HomeAssistantManager: Command received: %s payload=%s\n", topic, payload); // Parse topic to extract command name (and optional uid suffix) - // Format: openprinttag/{id}/cmd/{command}[/uid] + // Format: spoolsense/{id}/cmd/{command}[/uid] char cmdPrefix[64]; snprintf(cmdPrefix, sizeof(cmdPrefix), "spoolsense/%s/cmd/", deviceId_); From 5832e32b652cbc9b190c4a8214f3b16bbed14157 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 07:02:20 -0500 Subject: [PATCH 03/16] perf: reuse HTTP connection for Spoolman API calls (#30) Replace per-call WiFiClient + HTTPClient stack objects with persistent file-scope statics. setReuse(true) keeps the TCP connection alive across requests. All calls serialized by httpMutex_ so no concurrency issues. --- src/SpoolmanManager.cpp | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/SpoolmanManager.cpp b/src/SpoolmanManager.cpp index 43f5deb..4ecbe3f 100644 --- a/src/SpoolmanManager.cpp +++ b/src/SpoolmanManager.cpp @@ -219,54 +219,49 @@ static bool parseSpoolUuid(const char* jsonText, char* outUuid, size_t outUuidSi } // --- File-local HTTP helpers --- +// Persistent client + http objects — reuse TCP connection across requests. +// All Spoolman calls are serialized by httpMutex_ so no concurrent access. +static WiFiClient spoolmanClient; +static HTTPClient spoolmanHttp; static int httpGet(const char* path, String& response) { const char* baseUrl = ConfigurationManager::getInstance().getSpoolmanURL(); - WiFiClient client; - HTTPClient http; - char url[256]; snprintf(url, sizeof(url), "%s%s", baseUrl, path); - http.begin(client, url); - int code = http.GET(); + spoolmanHttp.begin(spoolmanClient, url); + spoolmanHttp.setReuse(true); + int code = spoolmanHttp.GET(); if (code > 0) { - response = http.getString(); + response = spoolmanHttp.getString(); } - http.end(); return code; } static int httpPost(const char* path, const char* body, String& response) { const char* baseUrl = ConfigurationManager::getInstance().getSpoolmanURL(); - WiFiClient client; - HTTPClient http; - char url[256]; snprintf(url, sizeof(url), "%s%s", baseUrl, path); - http.begin(client, url); - http.addHeader("Content-Type", "application/json"); - int code = http.POST(body); + spoolmanHttp.begin(spoolmanClient, url); + spoolmanHttp.setReuse(true); + spoolmanHttp.addHeader("Content-Type", "application/json"); + int code = spoolmanHttp.POST(body); if (code > 0) { - response = http.getString(); + response = spoolmanHttp.getString(); } - http.end(); return code; } static int httpPatch(const char* path, const char* body, String& response) { const char* baseUrl = ConfigurationManager::getInstance().getSpoolmanURL(); - WiFiClient client; - HTTPClient http; - char url[256]; snprintf(url, sizeof(url), "%s%s", baseUrl, path); - http.begin(client, url); - http.addHeader("Content-Type", "application/json"); - int code = http.PATCH(body); + spoolmanHttp.begin(spoolmanClient, url); + spoolmanHttp.setReuse(true); + spoolmanHttp.addHeader("Content-Type", "application/json"); + int code = spoolmanHttp.PATCH(body); if (code > 0) { - response = http.getString(); + response = spoolmanHttp.getString(); } - http.end(); return code; } From b3f819baffa1cdffb1a13be3db005c7d7144a66c Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 07:06:59 -0500 Subject: [PATCH 04/16] feat: show extruder/bed temps on reader page for NFC+ tags (#56) Add settings_extruder_temp and settings_bed_temp to SpoolDetails, SpoolmanSyncedPayload, and GenericTagSpoolInfo. Parse from Spoolman filament response in getSpoolDetails(). Display on reader page when present. --- src/ApplicationManager.cpp | 2 ++ src/ApplicationManager.h | 2 ++ src/NFCManager.h | 2 ++ src/ReaderHTML.h | 2 ++ src/SpoolmanManager.cpp | 12 ++++++++++++ src/SpoolmanManager.h | 2 ++ src/WebServerManager.cpp | 2 ++ 7 files changed, 24 insertions(+) diff --git a/src/ApplicationManager.cpp b/src/ApplicationManager.cpp index 4c846b2..1a0618c 100644 --- a/src/ApplicationManager.cpp +++ b/src/ApplicationManager.cpp @@ -676,6 +676,8 @@ void ApplicationManager::handleSpoolmanSynced(const AppMessage& msg) { strncpy(info.color_hex, msg.payload.spoolmanSynced.color_hex, sizeof(info.color_hex) - 1); info.remaining_weight_g = kgRemaining * 1000.0f; info.spoolman_id = msg.payload.spoolmanSynced.spoolman_id; + info.extruder_temp = msg.payload.spoolmanSynced.extruder_temp; + info.bed_temp = msg.payload.spoolmanSynced.bed_temp; info.valid = true; NFCManager::getInstance().setGenericTagSpoolInfo(info); } diff --git a/src/ApplicationManager.h b/src/ApplicationManager.h index 9f1ddf5..ac3139a 100644 --- a/src/ApplicationManager.h +++ b/src/ApplicationManager.h @@ -87,6 +87,8 @@ struct SpoolmanSyncedPayload { char material_name[32]; // Carried from sync request — avoids re-reading NFC state char manufacturer[64]; // Vendor name (populated for UID lookups) char color_hex[8]; // "#RRGGBB" (populated for UID lookups) + int16_t extruder_temp; // Spoolman settings_extruder_temp (0 = not set) + int16_t bed_temp; // Spoolman settings_bed_temp (0 = not set) bool is_uid_lookup; // True = result of a UID-only lookup (generic tag) }; diff --git a/src/NFCManager.h b/src/NFCManager.h index 8eb0ddb..e4289ee 100644 --- a/src/NFCManager.h +++ b/src/NFCManager.h @@ -54,6 +54,8 @@ struct GenericTagSpoolInfo { char color_hex[8]; float remaining_weight_g; int32_t spoolman_id; + int16_t extruder_temp; + int16_t bed_temp; bool valid; }; diff --git a/src/ReaderHTML.h b/src/ReaderHTML.h index c2b186a..7be0771 100644 --- a/src/ReaderHTML.h +++ b/src/ReaderHTML.h @@ -143,6 +143,8 @@ const char READER_HTML[] PROGMEM = R"rawliteral( if (s.color) html += row('Color', ' ' + s.color); if (s.remaining_g !== undefined) html += row('Remaining', s.remaining_g.toFixed(1) + ' g'); if (s.spoolman_id > 0) html += row('Spoolman ID', s.spoolman_id); + if (s.extruder_temp > 0) html += row('Extruder Temp', s.extruder_temp + ' °C'); + if (s.bed_temp > 0) html += row('Bed Temp', s.bed_temp + ' °C'); if (!s.material_name && !s.tag_data_valid) html += row('Data', 'No parseable data — scan in progress'); return html; } diff --git a/src/SpoolmanManager.cpp b/src/SpoolmanManager.cpp index 4ecbe3f..e620f0a 100644 --- a/src/SpoolmanManager.cpp +++ b/src/SpoolmanManager.cpp @@ -845,6 +845,16 @@ bool SpoolmanManager::getSpoolDetails(int32_t spoolmanId, SpoolDetails& outDetai snprintf(outDetails.color_hex, sizeof(outDetails.color_hex), "#%s", colorBuf); } } + } else if (strcmp(currentField, "settings_extruder_temp") == 0) { + int temp = 0; + if (readIntValue(reader, temp)) { + outDetails.extruder_temp = static_cast(temp); + } + } else if (strcmp(currentField, "settings_bed_temp") == 0) { + int temp = 0; + if (readIntValue(reader, temp)) { + outDetails.bed_temp = static_cast(temp); + } } else if (strcmp(currentField, "weight") == 0) { // Fallback capacity if initial_weight is not set if (outDetails.initial_weight_g == 0.0f && reader.value_type() == json_value_type::real) { @@ -1136,6 +1146,8 @@ void SpoolmanManager::taskLoop() { found ? details.color_hex : "", sizeof(msg.payload.spoolmanSynced.color_hex) - 1); msg.payload.spoolmanSynced.color_hex[sizeof(msg.payload.spoolmanSynced.color_hex) - 1] = '\0'; + msg.payload.spoolmanSynced.extruder_temp = found ? details.extruder_temp : 0; + msg.payload.spoolmanSynced.bed_temp = found ? details.bed_temp : 0; } else { Serial.printf("SpoolmanManager: Syncing spool %s\n", req.spool_id); int resolvedSpoolmanId = -1; diff --git a/src/SpoolmanManager.h b/src/SpoolmanManager.h index 5902af9..228138e 100644 --- a/src/SpoolmanManager.h +++ b/src/SpoolmanManager.h @@ -37,6 +37,8 @@ struct SpoolDetails { char color_hex[8]; // "#RRGGBB\0" char manufacturer[64]; // vendor name char material_type[32]; // e.g., "PLA", "PETG" + int16_t extruder_temp; // Spoolman settings_extruder_temp (0 = not set) + int16_t bed_temp; // Spoolman settings_bed_temp (0 = not set) bool valid; // indicates successful retrieval }; diff --git a/src/WebServerManager.cpp b/src/WebServerManager.cpp index 57db953..990fef5 100644 --- a/src/WebServerManager.cpp +++ b/src/WebServerManager.cpp @@ -889,6 +889,8 @@ void WebServerManager::handleApiStatus() { doc["color"] = spoolInfo.color_hex; doc["remaining_g"] = spoolInfo.remaining_weight_g; doc["spoolman_id"] = spoolInfo.spoolman_id; + if (spoolInfo.extruder_temp > 0) doc["extruder_temp"] = spoolInfo.extruder_temp; + if (spoolInfo.bed_temp > 0) doc["bed_temp"] = spoolInfo.bed_temp; } } else if (state.tag_data_valid) { // OpenPrintTag — include OPT fields From d41ccbd32928e2a1ced2b47baa2e9e7ec194cb9a Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 08:53:28 -0500 Subject: [PATCH 05/16] fix: HA stack overflow, legacy cleanup removal, NFC+ reader improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump HA task stack 7168 → 8192 (discovery payloads overflowed) - Remove legacy openprinttag_ entity cleanup (no users on old naming) - NFC+ reader: show temps from Spoolman, keep polling until data arrives - Dynamic JSON buffer for UID lookup (sized to response, was fixed 16KB) - Reader page shows "Looking up in Spoolman... keep tag on reader" --- src/HomeAssistantManager.cpp | 25 +------------------------ src/HomeAssistantManager.h | 3 +-- src/ReaderHTML.h | 17 ++++++++++++----- src/SpoolmanManager.cpp | 8 ++++++-- 4 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/HomeAssistantManager.cpp b/src/HomeAssistantManager.cpp index ef8e4e0..0bb1914 100644 --- a/src/HomeAssistantManager.cpp +++ b/src/HomeAssistantManager.cpp @@ -519,15 +519,6 @@ void HomeAssistantManager::publishDiscovery() { Serial.printf("HomeAssistantManager: Discovery %s -> %s (%u bytes)\n", discoveryTopic, ok ? "OK" : "FAIL", (unsigned)len); }; - auto removeLegacyEntity = [&](const char* component, const char* objectId) { - char discoveryTopic[128]; - snprintf(discoveryTopic, sizeof(discoveryTopic), - "homeassistant/%s/openprinttag_%s/%s/config", - component, deviceId_, objectId); - bool ok = mqttClient.publish(discoveryTopic, "", true); - Serial.printf("HomeAssistantManager: Remove legacy discovery %s -> %s\n", - discoveryTopic, ok ? "OK" : "FAIL"); - }; auto publishNumberEntity = [&](const char* objectId, const char* name, const char* valTpl, const char* cmdTopic, const char* cmdTpl, float minV, float maxV, float stepV, const char* unitOfMeas, const char* icon) { @@ -638,21 +629,7 @@ void HomeAssistantManager::publishDiscovery() { } } - // Remove stale retained discovery configs — once per boot, not every scan. - if (!legacyCleanupDone_) { - removeLegacyEntity("binary_sensor", "tag_present"); - removeLegacyEntity("sensor", "spool_uid"); - removeLegacyEntity("sensor", "remaining_weight"); - removeLegacyEntity("sensor", "material_type"); - removeLegacyEntity("sensor", "color"); - removeLegacyEntity("sensor", "printer_state"); - removeLegacyEntity("number", "set_remaining_weight"); - removeLegacyEntity("number", "set_initial_weight"); - removeLegacyEntity("number", "set_spoolman_id"); - removeLegacyEntity("select", "set_material_type"); - removeLegacyEntity("text", "set_manufacturer"); - legacyCleanupDone_ = true; - } + // Legacy openprinttag_ entity cleanup removed — no users on the old naming. // UID is carried in command topic; payload contains only values. char updateRemainingCmdTpl[128]; diff --git a/src/HomeAssistantManager.h b/src/HomeAssistantManager.h index 751ecb2..65292f2 100644 --- a/src/HomeAssistantManager.h +++ b/src/HomeAssistantManager.h @@ -69,7 +69,7 @@ class HomeAssistantManager { static constexpr uint32_t MAX_RECONNECT_DELAY = 30000; static constexpr size_t QUEUE_SIZE = 6; - static constexpr size_t TASK_STACK_SIZE = 7168; + static constexpr size_t TASK_STACK_SIZE = 8192; static constexpr UBaseType_t TASK_PRIORITY = 2; // Device ID cache @@ -78,7 +78,6 @@ class HomeAssistantManager { // Discovery dedup — only re-publish when UID changes char lastDiscoveryUid_[17] = {0}; - bool legacyCleanupDone_ = false; }; #endif // HOME_ASSISTANT_MANAGER_H diff --git a/src/ReaderHTML.h b/src/ReaderHTML.h index 7be0771..024ba4e 100644 --- a/src/ReaderHTML.h +++ b/src/ReaderHTML.h @@ -145,7 +145,7 @@ const char READER_HTML[] PROGMEM = R"rawliteral( if (s.spoolman_id > 0) html += row('Spoolman ID', s.spoolman_id); if (s.extruder_temp > 0) html += row('Extruder Temp', s.extruder_temp + ' °C'); if (s.bed_temp > 0) html += row('Bed Temp', s.bed_temp + ' °C'); - if (!s.material_name && !s.tag_data_valid) html += row('Data', 'No parseable data — scan in progress'); + if (!s.material_name && !s.tag_data_valid) html += row('Data', 'Looking up in Spoolman… keep tag on reader'); return html; } @@ -192,11 +192,18 @@ const char READER_HTML[] PROGMEM = R"rawliteral( function poll() { api('/api/status').then(function(s){ - if (s.present && !tagFound) { - tagFound = true; - stopPolling(); + if (s.present) { render(s); - scanBtn.classList.remove('hidden'); + // For generic UID tags, keep polling until Spoolman data arrives + var isGenericPending = (s.tag_kind === 'GenericUidTag') && !s.material_name; + if (!tagFound && !isGenericPending) { + tagFound = true; + stopPolling(); + scanBtn.classList.remove('hidden'); + } else if (!tagFound) { + // Still waiting for Spoolman lookup — keep polling + render(s); + } } else if (!tagFound) { render(s); } diff --git a/src/SpoolmanManager.cpp b/src/SpoolmanManager.cpp index e620f0a..81c25ed 100644 --- a/src/SpoolmanManager.cpp +++ b/src/SpoolmanManager.cpp @@ -166,10 +166,14 @@ static bool parseSpoolIdByUuid(const char* jsonText, const char* uuid, int& outI // Use ArduinoJson — the streaming parser (htcw_json) can't reliably // handle Spoolman's nested filament/vendor objects with their own 'id' fields. // The response is small enough for heap parsing (~1-2KB per spool). - DynamicJsonDocument doc(16384); + size_t jsonLen = strlen(jsonText); + size_t bufSize = jsonLen + 4096; // response size + overhead for ArduinoJson metadata + if (bufSize < 16384) bufSize = 16384; + DynamicJsonDocument doc(bufSize); DeserializationError err = deserializeJson(doc, jsonText); if (err) { - Serial.printf("SpoolmanManager: parseSpoolIdByUuid JSON parse failed: %s\n", err.c_str()); + Serial.printf("SpoolmanManager: parseSpoolIdByUuid JSON parse failed: %s (input=%u buf=%u)\n", + err.c_str(), (unsigned)jsonLen, (unsigned)bufSize); return false; } From 6a37eb2fdf30659c885d6eccb7197f8dc9130cbf Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 09:29:25 -0500 Subject: [PATCH 06/16] feat: link/re-assign NFC+ tags to Spoolman spools from reader page (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add spool picker to reader page for generic UID tags. Search and link unlinked tags or re-assign already linked tags to different spools. Two new proxy endpoints avoid CORS: - /api/spoolman/spools — streams spool list (512B buffer, no heap) - /api/spoolman/link — PATCHes nfc_id on new spool, clears old Also: dynamic JSON buffer for UID lookup (sized to response), reader keeps polling until Spoolman data arrives for NFC+ tags. --- src/ReaderHTML.h | 112 +++++++++++++++++++++++++++++++++++++++ src/WebServerManager.cpp | 106 ++++++++++++++++++++++++++++++++++++ src/WebServerManager.h | 2 + 3 files changed, 220 insertions(+) diff --git a/src/ReaderHTML.h b/src/ReaderHTML.h index 024ba4e..9ed6300 100644 --- a/src/ReaderHTML.h +++ b/src/ReaderHTML.h @@ -43,6 +43,15 @@ const char READER_HTML[] PROGMEM = R"rawliteral( + + + @@ -146,9 +155,112 @@ const char READER_HTML[] PROGMEM = R"rawliteral( if (s.extruder_temp > 0) html += row('Extruder Temp', s.extruder_temp + ' °C'); if (s.bed_temp > 0) html += row('Bed Temp', s.bed_temp + ' °C'); if (!s.material_name && !s.tag_data_valid) html += row('Data', 'Looking up in Spoolman… keep tag on reader'); + // Link/Re-assign button — show once Spoolman lookup is done (success or fail) + if (s.uid && (s.material_name || s.tag_data_valid === false)) { + var btnLabel = s.spoolman_id > 0 ? 'Re-assign Spool' : 'Link to Spool'; + html += '
'; + } return html; } + // --- Spool picker for NFC+ link/re-assign --- + var spoolmanUrl = ''; + var allSpools = []; + var currentTagUid = ''; + var currentSpoolmanId = -1; + + // Fetch Spoolman URL from scanner config + api('/api/config').then(function(cfg) { + if (cfg.spoolman_url) spoolmanUrl = cfg.spoolman_url.replace(/\/+$/, ''); + }).catch(function(){}); + + function fetchSpools() { + return fetch('/api/spoolman/spools') + .then(function(r) { return r.ok ? r.json() : []; }) + .catch(function() { return []; }); + } + + function renderSpoolRow(spool) { + var fil = spool.filament || {}; + var vendor = fil.vendor ? fil.vendor.name : ''; + var color = fil.color_hex || ''; + var colorSwatch = color ? '' : ''; + var remaining = spool.remaining_weight ? Math.round(spool.remaining_weight) + 'g' : '?'; + var label = colorSwatch + (vendor ? vendor + ' ' : '') + (fil.material || fil.name || '?') + ' — ' + remaining; + return '
' + + '' + label + '' + + '' + + '
'; + } + + function showSpoolPicker(uid, spoolmanId) { + currentTagUid = uid; + currentSpoolmanId = spoolmanId; + var picker = document.getElementById('spoolPicker'); + var results = document.getElementById('spoolResults'); + var linkResult = document.getElementById('linkResult'); + linkResult.classList.add('hidden'); + picker.classList.remove('hidden'); + + fetchSpools().then(function(spools) { + allSpools = spools; + filterSpools(''); + }); + } + + function filterSpools(query) { + var results = document.getElementById('spoolResults'); + var q = query.toLowerCase(); + var filtered = allSpools.filter(function(s) { + var fil = s.filament || {}; + var vendor = fil.vendor ? fil.vendor.name : ''; + var text = (vendor + ' ' + (fil.material || '') + ' ' + (fil.name || '')).toLowerCase(); + return !q || text.indexOf(q) >= 0; + }); + if (filtered.length === 0) { + results.innerHTML = '
No spools found
'; + } else { + results.innerHTML = filtered.slice(0, 20).map(renderSpoolRow).join(''); + } + } + + document.getElementById('spoolSearch').addEventListener('input', function() { + filterSpools(this.value); + }); + + window.linkSpool = function(newSpoolId) { + if (!spoolmanUrl || !currentTagUid) return; + var linkResult = document.getElementById('linkResult'); + linkResult.textContent = 'Linking...'; + linkResult.className = ''; + linkResult.classList.remove('hidden'); + + fetch('/api/spoolman/link', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + spool_id: newSpoolId, + nfc_id: currentTagUid, + old_spool_id: currentSpoolmanId + }) + }).then(function(r) { + return r.json().then(function(data) { + if (data.success) { + linkResult.textContent = 'Linked! Remove and re-scan tag to verify.'; + linkResult.style.color = '#22c55e'; + document.getElementById('spoolResults').innerHTML = ''; + document.getElementById('spoolSearch').value = ''; + } else { + throw new Error(data.error || 'Link failed'); + } + }); + }).catch(function(err) { + linkResult.textContent = 'Failed: ' + err.message; + linkResult.style.color = '#ef4444'; + }); + }; + function render(s) { if (!s.present) { noTag.classList.remove('hidden'); diff --git a/src/WebServerManager.cpp b/src/WebServerManager.cpp index 990fef5..7ea0c8b 100644 --- a/src/WebServerManager.cpp +++ b/src/WebServerManager.cpp @@ -103,6 +103,8 @@ bool WebServerManager::begin(bool apMode, uint16_t port) { _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(); }); + _server.on("/api/spoolman/spools", HTTP_GET, [this]() { handleApiSpoolmanSpools(); }); + _server.on("/api/spoolman/link", HTTP_POST, [this]() { handleApiSpoolmanLink(); }); // Captive portal detection endpoints (AP mode) if (apMode) { @@ -412,6 +414,110 @@ void WebServerManager::handleApiRegisterUid() { // API: Diagnostics // --------------------------------------------------------------------------- +void WebServerManager::handleApiSpoolmanSpools() { + _server.sendHeader("Access-Control-Allow-Origin", "*"); + + 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]; + snprintf(url, sizeof(url), "%s/api/v1/spool?archived=false", baseUrl); + http.begin(client, url); + int code = http.GET(); + + if (code == 200) { + // Stream the response directly — avoid buffering 25KB+ in heap + WiFiClient* stream = http.getStreamPtr(); + int len = http.getSize(); + _server.setContentLength(len > 0 ? len : CONTENT_LENGTH_UNKNOWN); + _server.send(200, "application/json", ""); + uint8_t buf[512]; + while (stream->available() || stream->connected()) { + int avail = stream->available(); + if (avail > 0) { + int toRead = avail > (int)sizeof(buf) ? (int)sizeof(buf) : avail; + int bytesRead = stream->readBytes(buf, toRead); + if (bytesRead > 0) { + _server.client().write(buf, bytesRead); + } + } else { + delay(1); + } + if (len > 0 && !stream->available()) break; + } + } else { + char errMsg[64]; + snprintf(errMsg, sizeof(errMsg), "Spoolman returned HTTP %d", code); + sendError(502, errMsg); + } + http.end(); +} + +void WebServerManager::handleApiSpoolmanLink() { + _server.sendHeader("Access-Control-Allow-Origin", "*"); + + StaticJsonDocument<256> doc; + DeserializationError err = deserializeJson(doc, _server.arg("plain")); + if (err) { + sendError(400, "Invalid JSON"); + return; + } + + int newSpoolId = doc["spool_id"] | -1; + const char* nfcId = doc["nfc_id"] | ""; + int oldSpoolId = doc["old_spool_id"] | -1; + + if (newSpoolId < 0 || strlen(nfcId) == 0) { + sendError(400, "spool_id and nfc_id are 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; + + // Clear old spool's nfc_id if re-assigning + if (oldSpoolId > 0 && oldSpoolId != newSpoolId) { + snprintf(url, sizeof(url), "%s/api/v1/spool/%d", baseUrl, oldSpoolId); + http.begin(client, url); + http.addHeader("Content-Type", "application/json"); + http.PATCH("{\"extra\":{\"nfc_id\":\"\\\"\\\"\"}}"); + http.end(); + Serial.printf("WebServerManager: Cleared nfc_id from spool %d\n", oldSpoolId); + } + + // Set nfc_id on new spool — Spoolman extra values must be valid JSON strings + char body[128]; + snprintf(body, sizeof(body), "{\"extra\":{\"nfc_id\":\"\\\"%s\\\"\"}}", nfcId); + snprintf(url, sizeof(url), "%s/api/v1/spool/%d", baseUrl, newSpoolId); + http.begin(client, url); + http.addHeader("Content-Type", "application/json"); + int code = http.PATCH(body); + response = http.getString(); + http.end(); + + if (code == 200) { + Serial.printf("WebServerManager: Linked nfc_id=%s to spool %d\n", nfcId, newSpoolId); + _server.send(200, "application/json", "{\"success\":true}"); + } else { + char errMsg[64]; + snprintf(errMsg, sizeof(errMsg), "Spoolman PATCH failed (HTTP %d)", code); + sendError(502, errMsg); + } +} + void WebServerManager::handleApiDiagnostics() { _server.sendHeader("Access-Control-Allow-Origin", "*"); diff --git a/src/WebServerManager.h b/src/WebServerManager.h index a3ce303..0e87c85 100644 --- a/src/WebServerManager.h +++ b/src/WebServerManager.h @@ -57,6 +57,8 @@ class WebServerManager { void handleTroubleshootingPage(); void handleUIDRegistrationPage(); void handleApiRegisterUid(); + void handleApiSpoolmanSpools(); + void handleApiSpoolmanLink(); // OTA download state static void otaDownloadTask(void* param); From 86547b5dc9b633d81030052a6031ef3111d3f4e8 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 10:52:19 -0500 Subject: [PATCH 07/16] fix: remove duplicate filament name line on TFT spool display Brand + material was shown twice (line 1 and name line were identical). Drop the name line, keep single brand + material line above weight bar. --- src/TFTManager.cpp | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/TFTManager.cpp b/src/TFTManager.cpp index 181addb..b96459c 100644 --- a/src/TFTManager.cpp +++ b/src/TFTManager.cpp @@ -397,24 +397,19 @@ void TFTManager::renderSpoolScanned(const TFTSpoolData& spool) { drawSpool(cx, cy, 68, 26, fillColor); // ---- Text area ---- - int textY = 185; + int textY = 190; _sprite.setTextDatum(MC_DATUM); - // Brand + material on one line + // Brand + material char brandMat[48]; snprintf(brandMat, sizeof(brandMat), "%s %s", spool.brand, spool.material); - _sprite.setTextColor(COLOR_SUBTEXT); - _sprite.setTextSize(1); - _sprite.drawString(brandMat, cx, textY); - - // Filament name _sprite.setTextColor(COLOR_TEXT); _sprite.setTextSize(1); - _sprite.drawString(spool.name, cx, textY + 14); + _sprite.drawString(brandMat, cx, textY); // Weight bar if (spool.totalWeight > 0) { - drawWeightBar(20, textY + 28, W - 40, 8, + drawWeightBar(20, textY + 14, W - 40, 8, spool.remainingWeight, spool.totalWeight); // Weight text @@ -424,7 +419,7 @@ void TFTManager::renderSpoolScanned(const TFTSpoolData& spool) { _sprite.setTextColor(COLOR_SUBTEXT); _sprite.setTextSize(1); _sprite.setTextDatum(MC_DATUM); - _sprite.drawString(weightStr, cx, textY + 44); + _sprite.drawString(weightStr, cx, textY + 30); } _sprite.pushSprite(0, 0); From 4eb9464e6270db7d28f8cfe79e8817d16bad5622 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 11:09:14 -0500 Subject: [PATCH 08/16] spec: tag writer auto-populate from scanned tag data (#57) --- ...6-03-30-tag-writer-auto-populate-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md diff --git a/docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md b/docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md new file mode 100644 index 0000000..2633baf --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md @@ -0,0 +1,111 @@ +# Tag Writer Auto-Populate from Scanned Tag Data + +**Issue:** #57 +**Date:** 2026-03-30 +**Status:** Approved + +## Problem + +When a tag with existing data is on the reader and the user navigates to a writer page, all form fields are blank. The user must re-enter everything from scratch even if they only want to change one field (e.g., update the color on an existing tag). + +## Solution + +Add a shared `prefillFromTag()` function to SharedJS that fetches `/api/status` on page load. If a tag with data is present, pre-fill the writer form fields. User can edit any field before writing. Works cross-format (scan a TigerTag, write as OpenPrintTag). + +## Design Decisions + +- **Cross-format:** All tag formats normalize to common fields. Material name string is the cross-format key. +- **Page load only:** Check once when the page opens. No polling. Existing material DB auto-fill still works if no tag is present. +- **Editable:** Pre-filled fields are a starting point, not locked. User edits override via `autoFilled` tracking. +- **Client-side only:** No firmware changes. Uses existing `/api/status` endpoint. +- **Shared code in SharedJS.h:** One `prefillFromTag()` function, three callers with field maps. + +## Data Flow + +``` +Page Load → prefillFromTag(fieldMap) → fetch('/api/status') + | + Tag present + has data? + |— No: return silently, user fills manually or uses material DB + |— Yes: normalize tag data → fill form fields → mark autoFilled +``` + +## Cross-Format Material Mapping + +`/api/status` returns different material representations per tag type: + +| Source | Material field | +|--------|---------------| +| OpenPrintTag | `material_type` (enum) + `material_name` (string) | +| TigerTag | `tigertag.material_name` (string) + `tigertag.material_id` (int) | +| OpenTag3D | `opentag3d.base_material` (string) | +| NFC+ | `material_name` (string from Spoolman) | + +`prefillFromTag()` normalizes all sources to a material name string (e.g., "PLA"). Each writer then maps to its own format: + +- **OpenPrintTag writer:** name → material_type enum via JS lookup table +- **TigerTag writer:** name → search SpoolmanDB material list (already loaded), set material_id +- **OpenTag3D writer:** name → set base_material text field directly + +## Field Mapping Per Writer + +| Field | OpenPrintTag | TigerTag | OpenTag3D | +|-------|-------------|----------|-----------| +| Material | material_type dropdown | material_search input | base_material input | +| Color | colorHex + colorPicker | colorHex + colorPicker | colorHex + colorPicker | +| Manufacturer | manufacturer | brand_search | manufacturer | +| Weight (initial) | initial_weight_g | weight_g | target_weight_g | +| Weight (remaining) | remaining_g | — | — | +| Density | density | — | density | +| Diameter | diameter_mm | diameter_id dropdown | diameter_mm | +| Nozzle temp min | min_print_temp | nozzle_min | min_print_temp_c | +| Nozzle temp max | max_print_temp | nozzle_max | max_print_temp_c | +| Bed temp min | min_bed_temp | bed_min | min_bed_temp_c | +| Bed temp max | max_bed_temp | bed_max | max_bed_temp_c | +| Dry temp | — | dry_temp | max_dry_temp_c | +| Dry time | — | dry_time | dry_time_hours | + +Fields not present on a writer page are skipped. + +## Implementation Structure + +### SharedJS.h + +Add `prefillFromTag(fieldMap)`: +- Fetch `/api/status` +- If tag not present or no data, return null +- Extract normalized fields from response (handle all 4 tag types + NFC+) +- For each key in fieldMap, set form field value if data exists +- Mark filled fields as `autoFilled='true'` +- Sync color picker if color was set +- Return the normalized tag data so caller can do writer-specific work + +### Each Writer Page (~10 lines each) + +Call `prefillFromTag()` on page load with the writer's field map. Handle writer-specific follow-up: +- OpenPrintTag: sync material_type dropdown from material name +- TigerTag: sync material_search + brand_search inputs, set hidden material_id/brand_id +- OpenTag3D: set base_material text, sync modifiers if available + +### No Firmware Changes + +Uses existing `/api/status` endpoint which already returns all tag data for all formats. + +## Files Modified + +| File | Change | +|------|--------| +| src/SharedJS.h | Add `prefillFromTag()` function | +| src/OpenPrintTagWriterHTML.h | Call `prefillFromTag()` on load with OPT field map | +| src/TigerTagWriterHTML.h | Call `prefillFromTag()` on load with TT field map | +| src/OpenTag3DWriterHTML.h | Call `prefillFromTag()` on load with OT3D field map | + +## Testing + +1. Scan OpenPrintTag → open OpenPrintTag writer → fields pre-filled +2. Scan TigerTag → open TigerTag writer → fields pre-filled +3. Scan TigerTag → open OpenPrintTag writer → cross-format fields pre-filled, material mapped +4. Scan OpenTag3D → open TigerTag writer → cross-format fields pre-filled +5. No tag on reader → open any writer → fields blank, existing behavior unchanged +6. Pre-fill a field → manually edit it → field keeps user's value (autoFilled tracking) +7. Scan NFC+ tag (Spoolman data) → open any writer → material/color/weight from Spoolman From 52e2d7f961f6878565a2adf01b3723b0e15f1852 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 11:31:07 -0500 Subject: [PATCH 09/16] =?UTF-8?q?Add=20docs/superpowers/=20to=20gitignore?= =?UTF-8?q?=20=E2=80=94=20specs=20stay=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ...6-03-30-tag-writer-auto-populate-design.md | 111 ------------------ 2 files changed, 1 insertion(+), 111 deletions(-) delete mode 100644 docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md diff --git a/.gitignore b/.gitignore index f405f5e..2e52797 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ docs/tigertag-architecture.md docs/deep-thoughts.md CODE-CLEANUP.md docs/writer-ui-plan.md +docs/superpowers/ .gstack/ diff --git a/docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md b/docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md deleted file mode 100644 index 2633baf..0000000 --- a/docs/superpowers/specs/2026-03-30-tag-writer-auto-populate-design.md +++ /dev/null @@ -1,111 +0,0 @@ -# Tag Writer Auto-Populate from Scanned Tag Data - -**Issue:** #57 -**Date:** 2026-03-30 -**Status:** Approved - -## Problem - -When a tag with existing data is on the reader and the user navigates to a writer page, all form fields are blank. The user must re-enter everything from scratch even if they only want to change one field (e.g., update the color on an existing tag). - -## Solution - -Add a shared `prefillFromTag()` function to SharedJS that fetches `/api/status` on page load. If a tag with data is present, pre-fill the writer form fields. User can edit any field before writing. Works cross-format (scan a TigerTag, write as OpenPrintTag). - -## Design Decisions - -- **Cross-format:** All tag formats normalize to common fields. Material name string is the cross-format key. -- **Page load only:** Check once when the page opens. No polling. Existing material DB auto-fill still works if no tag is present. -- **Editable:** Pre-filled fields are a starting point, not locked. User edits override via `autoFilled` tracking. -- **Client-side only:** No firmware changes. Uses existing `/api/status` endpoint. -- **Shared code in SharedJS.h:** One `prefillFromTag()` function, three callers with field maps. - -## Data Flow - -``` -Page Load → prefillFromTag(fieldMap) → fetch('/api/status') - | - Tag present + has data? - |— No: return silently, user fills manually or uses material DB - |— Yes: normalize tag data → fill form fields → mark autoFilled -``` - -## Cross-Format Material Mapping - -`/api/status` returns different material representations per tag type: - -| Source | Material field | -|--------|---------------| -| OpenPrintTag | `material_type` (enum) + `material_name` (string) | -| TigerTag | `tigertag.material_name` (string) + `tigertag.material_id` (int) | -| OpenTag3D | `opentag3d.base_material` (string) | -| NFC+ | `material_name` (string from Spoolman) | - -`prefillFromTag()` normalizes all sources to a material name string (e.g., "PLA"). Each writer then maps to its own format: - -- **OpenPrintTag writer:** name → material_type enum via JS lookup table -- **TigerTag writer:** name → search SpoolmanDB material list (already loaded), set material_id -- **OpenTag3D writer:** name → set base_material text field directly - -## Field Mapping Per Writer - -| Field | OpenPrintTag | TigerTag | OpenTag3D | -|-------|-------------|----------|-----------| -| Material | material_type dropdown | material_search input | base_material input | -| Color | colorHex + colorPicker | colorHex + colorPicker | colorHex + colorPicker | -| Manufacturer | manufacturer | brand_search | manufacturer | -| Weight (initial) | initial_weight_g | weight_g | target_weight_g | -| Weight (remaining) | remaining_g | — | — | -| Density | density | — | density | -| Diameter | diameter_mm | diameter_id dropdown | diameter_mm | -| Nozzle temp min | min_print_temp | nozzle_min | min_print_temp_c | -| Nozzle temp max | max_print_temp | nozzle_max | max_print_temp_c | -| Bed temp min | min_bed_temp | bed_min | min_bed_temp_c | -| Bed temp max | max_bed_temp | bed_max | max_bed_temp_c | -| Dry temp | — | dry_temp | max_dry_temp_c | -| Dry time | — | dry_time | dry_time_hours | - -Fields not present on a writer page are skipped. - -## Implementation Structure - -### SharedJS.h - -Add `prefillFromTag(fieldMap)`: -- Fetch `/api/status` -- If tag not present or no data, return null -- Extract normalized fields from response (handle all 4 tag types + NFC+) -- For each key in fieldMap, set form field value if data exists -- Mark filled fields as `autoFilled='true'` -- Sync color picker if color was set -- Return the normalized tag data so caller can do writer-specific work - -### Each Writer Page (~10 lines each) - -Call `prefillFromTag()` on page load with the writer's field map. Handle writer-specific follow-up: -- OpenPrintTag: sync material_type dropdown from material name -- TigerTag: sync material_search + brand_search inputs, set hidden material_id/brand_id -- OpenTag3D: set base_material text, sync modifiers if available - -### No Firmware Changes - -Uses existing `/api/status` endpoint which already returns all tag data for all formats. - -## Files Modified - -| File | Change | -|------|--------| -| src/SharedJS.h | Add `prefillFromTag()` function | -| src/OpenPrintTagWriterHTML.h | Call `prefillFromTag()` on load with OPT field map | -| src/TigerTagWriterHTML.h | Call `prefillFromTag()` on load with TT field map | -| src/OpenTag3DWriterHTML.h | Call `prefillFromTag()` on load with OT3D field map | - -## Testing - -1. Scan OpenPrintTag → open OpenPrintTag writer → fields pre-filled -2. Scan TigerTag → open TigerTag writer → fields pre-filled -3. Scan TigerTag → open OpenPrintTag writer → cross-format fields pre-filled, material mapped -4. Scan OpenTag3D → open TigerTag writer → cross-format fields pre-filled -5. No tag on reader → open any writer → fields blank, existing behavior unchanged -6. Pre-fill a field → manually edit it → field keeps user's value (autoFilled tracking) -7. Scan NFC+ tag (Spoolman data) → open any writer → material/color/weight from Spoolman From 40770bb4989e923cf74078f71c779ed4e2ae24dd Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 11:37:27 -0500 Subject: [PATCH 10/16] feat: add prefillFromTag() to SharedJS for writer auto-populate (#57) --- src/SharedJS.h | 116 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/SharedJS.h b/src/SharedJS.h index 1037faa..0d85c25 100644 --- a/src/SharedJS.h +++ b/src/SharedJS.h @@ -252,4 +252,120 @@ var TAG_KIND_LABELS = { function tagKindLabel(kind) { return TAG_KIND_LABELS[kind] || kind || 'Unknown'; } + +/* ---- Tag data pre-fill for writer pages ---- */ + +function normalizeTagData(s) { + if (!s || !s.present) return null; + var d = {}; + var kind = s.tag_kind || ''; + + if (kind === 'TigerTag' && s.tigertag) { + var t = s.tigertag; + d.material = t.material_name || ''; + d.color = t.color_hex || ''; + d.manufacturer = t.brand_name || ''; + d.weight = t.weight_g || 0; + d.diameter = t.diameter_mm || 0; + d.nozzle_min = t.nozzle_temp_min || 0; + d.nozzle_max = t.nozzle_temp_max || 0; + d.bed_min = t.bed_temp_min || 0; + d.bed_max = t.bed_temp_max || 0; + d.dry_temp = t.dry_temp || 0; + d.dry_time = t.dry_time_hours || 0; + d.tigertag_material_id = t.material_id; + d.tigertag_brand_id = t.brand_id; + } else if (kind === 'OpenTag3D' && s.opentag3d) { + var o = s.opentag3d; + d.material = o.base_material || ''; + d.modifiers = o.modifiers || ''; + d.color = o.color_hex || ''; + d.manufacturer = o.manufacturer || ''; + d.weight = o.target_weight_g || 0; + d.diameter = o.diameter_mm || 0; + d.density = o.density || 0; + d.nozzle_min = o.min_print_temp || o.print_temp || 0; + d.nozzle_max = o.max_print_temp || o.print_temp || 0; + d.bed_min = o.min_bed_temp || o.bed_temp || 0; + d.bed_max = o.max_bed_temp || o.bed_temp || 0; + d.dry_temp = o.dry_temp || 0; + d.dry_time = o.dry_time_hours || 0; + } else if (s.tag_data_valid) { + // OpenPrintTag + d.material = s.material_name || ''; + d.material_type = s.material_type; + d.color = s.color || ''; + d.manufacturer = s.manufacturer || ''; + d.weight = s.initial_weight_g || 0; + d.remaining = s.remaining_g || 0; + d.density = s.density || 0; + d.diameter = s.diameter_mm || 0; + d.nozzle_min = s.min_print_temp || 0; + d.nozzle_max = s.max_print_temp || 0; + d.bed_min = s.min_bed_temp || 0; + d.bed_max = s.max_bed_temp || 0; + d.preheat = s.preheat_temp || 0; + d.spoolman_id = s.spoolman_id || 0; + } else if (s.material_name) { + // NFC+ with Spoolman data + d.material = s.material_name || ''; + d.color = s.color || ''; + d.manufacturer = s.manufacturer || ''; + d.remaining = s.remaining_g || 0; + d.nozzle_min = s.extruder_temp || 0; + d.nozzle_max = s.extruder_temp || 0; + d.bed_min = s.bed_temp || 0; + d.bed_max = s.bed_temp || 0; + } else { + return null; + } + + return d; +} + +function prefillFromTag(fieldMap) { + return api('/api/status').then(function(s) { + var d = normalizeTagData(s); + if (!d) return null; + + function fill(key, value) { + if (!fieldMap[key] || value === undefined || value === null || value === 0 || value === '') return; + var el = document.getElementById(fieldMap[key]); + if (!el) return; + if (el.dataset.autoFilled === 'false') return; // user already edited + el.value = value; + el.dataset.autoFilled = 'true'; + } + + fill('material', d.material); + fill('manufacturer', d.manufacturer); + fill('weight', d.weight); + fill('remaining', d.remaining); + fill('density', d.density); + fill('diameter', d.diameter); + fill('nozzle_min', d.nozzle_min); + fill('nozzle_max', d.nozzle_max); + fill('bed_min', d.bed_min); + fill('bed_max', d.bed_max); + fill('dry_temp', d.dry_temp); + fill('dry_time', d.dry_time); + fill('preheat', d.preheat); + + // Color — sync both hex input and color picker + if (d.color && fieldMap.color) { + var hexEl = document.getElementById(fieldMap.color); + if (hexEl && hexEl.dataset.autoFilled !== 'false') { + var c = d.color.charAt(0) === '#' ? d.color : '#' + d.color; + hexEl.value = c.toUpperCase(); + hexEl.dataset.autoFilled = 'true'; + if (fieldMap.colorPicker) { + var picker = document.getElementById(fieldMap.colorPicker); + if (picker) picker.value = c; + } + } + } + + return d; + }).catch(function() { return null; }); +} )rawliteral"; From d3fb49169749f6a7ad2141d1b3017a7e1a0e3e6f Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 11:39:28 -0500 Subject: [PATCH 11/16] feat: OpenPrintTag writer pre-fills from scanned tag (#57) --- src/OpenPrintTagWriterHTML.h | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/OpenPrintTagWriterHTML.h b/src/OpenPrintTagWriterHTML.h index 81a0368..83e8364 100644 --- a/src/OpenPrintTagWriterHTML.h +++ b/src/OpenPrintTagWriterHTML.h @@ -280,6 +280,37 @@ const char OPENPRINTTAG_WRITER_HTML[] PROGMEM = R"rawliteral( var STEP_IDS = ['step-wait', 'step-detect', 'step-format', 'step-write', 'step-verify']; syncColorPicker('colorPicker', 'colorHex'); + + // Pre-fill from scanned tag if present + prefillFromTag({ + material: 'material_search', + color: 'colorHex', + colorPicker: 'colorPicker', + manufacturer: 'manufacturer', + weight: 'initial_weight_g', + remaining: 'remaining_g', + density: 'density', + diameter: 'diameter_mm', + nozzle_min: 'min_print_temp', + nozzle_max: 'max_print_temp', + preheat: 'preheat_temp', + bed_min: 'min_bed_temp', + bed_max: 'max_bed_temp' + }).then(function(d) { + if (!d) return; + // Sync material_type hidden field from material name + var opts = document.getElementById('material-list').querySelectorAll('option'); + var search = document.getElementById('material_search'); + var hidden = document.getElementById('material_type'); + for (var i = 0; i < opts.length; i++) { + if (opts[i].value.toUpperCase() === (d.material || '').toUpperCase()) { + search.value = opts[i].value; + hidden.value = opts[i].dataset.id || '0'; + break; + } + } + }); + setupAdvancedToggle('advancedToggle', 'advancedBox'); // Sync material search → hidden material_type ID From cea5ac5cfaa1ac9e2816e81e279346c4724633cb Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 11:39:47 -0500 Subject: [PATCH 12/16] feat: TigerTag writer pre-fills from scanned tag (#57) --- src/TigerTagWriterHTML.h | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/TigerTagWriterHTML.h b/src/TigerTagWriterHTML.h index 830d1b7..8b086b0 100644 --- a/src/TigerTagWriterHTML.h +++ b/src/TigerTagWriterHTML.h @@ -406,6 +406,33 @@ const char TIGERTAG_WRITER_HTML[] PROGMEM = R"rawliteral( materialSearchEl.addEventListener('input', autoFillFromMaterial); + // Pre-fill from scanned tag if present + prefillFromTag({ + material: 'material_search', + color: 'colorHex', + colorPicker: 'colorPicker', + manufacturer: 'brand_search', + weight: 'weight_g', + nozzle_min: 'nozzle_min', + nozzle_max: 'nozzle_max', + bed_min: 'bed_min', + bed_max: 'bed_max', + dry_temp: 'dry_temp', + dry_time: 'dry_time' + }).then(function(d) { + if (!d) return; + // Sync hidden material_id from material name + if (d.tigertag_material_id !== undefined) { + document.getElementById('material_id').value = d.tigertag_material_id; + } else { + syncMaterialId(); + } + // Sync hidden brand_id from brand name + if (d.tigertag_brand_id !== undefined) { + document.getElementById('brand_id').value = d.tigertag_brand_id; + } + }); + (async function loadTigerTagAPI() { try { var resp = await fetch('https://raw.githubusercontent.com/TigerTag-Project/TigerTag-RFID-Guide/main/database/id_material.json'); From febe836b28cf01f103cb1c421b3224e8535674f4 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 11:40:07 -0500 Subject: [PATCH 13/16] feat: OpenTag3D writer pre-fills from scanned tag (#57) --- src/OpenTag3DWriterHTML.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/OpenTag3DWriterHTML.h b/src/OpenTag3DWriterHTML.h index 5635e1d..a5890b4 100644 --- a/src/OpenTag3DWriterHTML.h +++ b/src/OpenTag3DWriterHTML.h @@ -334,6 +334,31 @@ const char OPENTAG3D_WRITER_HTML[] PROGMEM = R"rawliteral( } } baseMaterialEl.addEventListener('input', ot3dAutoFill); + + // Pre-fill from scanned tag if present + prefillFromTag({ + material: 'base_material', + color: 'colorHex', + colorPicker: 'colorPicker', + manufacturer: 'manufacturer', + weight: 'target_weight_g', + density: 'density', + diameter: 'diameter_mm', + nozzle_min: 'min_print_temp_c', + nozzle_max: 'max_print_temp_c', + bed_min: 'min_bed_temp_c', + bed_max: 'max_bed_temp_c', + dry_temp: 'max_dry_temp_c', + dry_time: 'dry_time_hours' + }).then(function(d) { + if (!d) return; + // Sync modifiers dropdown if available + if (d.modifiers) { + var modEl = document.getElementById('material_modifiers'); + if (modEl) modEl.value = d.modifiers; + } + }); + if (modifiersEl) modifiersEl.addEventListener('change', ot3dAutoFill); loadMaterialDb().then(function(db) { var dl = document.getElementById('material-list'); From 9defd76bf1ad66369e6c0d49c13c312c86f96899 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Mon, 30 Mar 2026 12:11:14 -0500 Subject: [PATCH 14/16] fix: code review fixes for #54 spool picker - Add 5s HTTP timeout on proxy requests to Spoolman - Add 10s safety timeout on streaming loop to prevent web server hang - Validate nfc_id is hex-only, max 16 chars before PATCH - Fail on old spool clear error instead of silently continuing - Remove redundant spoolmanUrl JS guard (server validates) - Hide spool picker on Scan Again - Show spool ID in picker rows for disambiguation --- src/ReaderHTML.h | 5 +++-- src/WebServerManager.cpp | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/ReaderHTML.h b/src/ReaderHTML.h index 9ed6300..411287d 100644 --- a/src/ReaderHTML.h +++ b/src/ReaderHTML.h @@ -186,7 +186,7 @@ const char READER_HTML[] PROGMEM = R"rawliteral( var color = fil.color_hex || ''; var colorSwatch = color ? '' : ''; var remaining = spool.remaining_weight ? Math.round(spool.remaining_weight) + 'g' : '?'; - var label = colorSwatch + (vendor ? vendor + ' ' : '') + (fil.material || fil.name || '?') + ' — ' + remaining; + var label = colorSwatch + '#' + spool.id + ' ' + (vendor ? vendor + ' ' : '') + (fil.material || fil.name || '?') + ' — ' + remaining; return '
' + '' + label + '' + '