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/CHANGELOG.md b/CHANGELOG.md index 75a7dde..03f738a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.6.1] - 2026-03-30 + +### Added + +- **Link/re-assign NFC+ tags to Spoolman spools** — spool picker on reader page with search. Link unlinked tags or re-assign existing ones. Proxy endpoints avoid CORS. (#54) +- **Tag writer auto-populate from scanned tag** — place a tag on the reader, open any writer page, form fields pre-fill from the tag's data. Works cross-format (scan TigerTag, write as OpenPrintTag). (#57) +- **NFC+ reader shows temps** — extruder and bed temps from Spoolman now displayed on the reader page for NFC+ tags. (#56) + +### Fixed + +- **HA discovery MQTT traffic reduced ~80%** — only re-publishes when UID changes, not every scan. Legacy openprinttag_ entity cleanup removed. (#55) +- **Spoolman HTTP connection reuse** — persistent TCP connection across API calls, eliminates per-request connection overhead. (#30) +- **NFC+ reader polling** — keeps polling until Spoolman data arrives instead of stopping on first tag detection. +- **Spoolman JSON buffer** — dynamically sized to response (was fixed 16KB, failed on 25KB+ spool lists). +- **Spool picker security** — XSS escaping, hex validation on nfc_id, HTTP timeouts, safe re-assign order (set new before clearing old). +- **TFT display** — removed duplicate filament name line. +- **HA task stack** — bumped 7168 → 8192 to prevent overflow during discovery. + +--- + ## [1.6.0] - 2026-03-30 ### Added diff --git a/platformio.ini b/platformio.ini index 7c662aa..7cbd548 100644 --- a/platformio.ini +++ b/platformio.ini @@ -16,7 +16,7 @@ board_build.partitions = partitions.csv monitor_speed = 115200 monitor_filters = direct, printable build_flags = - -DFIRMWARE_VERSION=\"1.6.0\" + -DFIRMWARE_VERSION=\"1.6.1\" lib_deps = marcoschwartz/LiquidCrystal_I2C@^1.1.4 bblanchon/ArduinoJson@^7.0.0 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/HomeAssistantManager.cpp b/src/HomeAssistantManager.cpp index dbd74ad..88d6753 100644 --- a/src/HomeAssistantManager.cpp +++ b/src/HomeAssistantManager.cpp @@ -369,6 +369,7 @@ void HomeAssistantManager::taskLoop() { lastMqttState_ = 0; reconnectDelay_ = 1000; // Reset backoff Serial.println("HomeAssistantManager: MQTT connected, publishing discovery/state"); + lastDiscoveryUid_[0] = '\0'; // Reset dedupe so reconnect publishes fresh publishDiscovery(); subscribeCommands(); publishAvailability("online"); @@ -398,8 +399,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(); + } } } } @@ -510,15 +520,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) { @@ -629,19 +630,7 @@ 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"); + // Legacy openprinttag_ entity cleanup removed — no users on the old naming. // UID is carried in command topic; payload contains only values. char updateRemainingCmdTpl[128]; @@ -808,7 +797,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_); diff --git a/src/HomeAssistantManager.h b/src/HomeAssistantManager.h index 12f396b..65292f2 100644 --- a/src/HomeAssistantManager.h +++ b/src/HomeAssistantManager.h @@ -69,12 +69,15 @@ 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 char deviceId_[7] = {0}; // 6 hex chars + null CurrentSpoolState spoolScratch_; + + // Discovery dedup — only re-publish when UID changes + char lastDiscoveryUid_[17] = {0}; }; #endif // HOME_ASSISTANT_MANAGER_H 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/OpenPrintTagWriterHTML.h b/src/OpenPrintTagWriterHTML.h index 81a0368..c69a55b 100644 --- a/src/OpenPrintTagWriterHTML.h +++ b/src/OpenPrintTagWriterHTML.h @@ -280,6 +280,40 @@ 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; + } + } + // Trigger material DB auto-fill for any fields the tag didn't have + var materialSearchEl = document.getElementById('material_search'); + if (materialSearchEl) materialSearchEl.dispatchEvent(new Event('input')); + }); + setupAdvancedToggle('advancedToggle', 'advancedBox'); // Sync material search → hidden material_type ID diff --git a/src/OpenTag3DWriterHTML.h b/src/OpenTag3DWriterHTML.h index 5635e1d..e982fc3 100644 --- a/src/OpenTag3DWriterHTML.h +++ b/src/OpenTag3DWriterHTML.h @@ -334,6 +334,51 @@ 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; + } + // Sync basic print/bed temp from nozzle_max/bed_max + var pt = document.getElementById('print_temp_c'); + if (pt && d.nozzle_max && pt.dataset.autoFilled !== 'false') { + pt.value = d.nozzle_max; pt.dataset.autoFilled = 'true'; + } + var bt = document.getElementById('bed_temp_c'); + if (bt && d.bed_max && bt.dataset.autoFilled !== 'false') { + bt.value = d.bed_max; bt.dataset.autoFilled = 'true'; + } + // Sync diameter dropdown (values in micrometers) + if (d.diameter) { + var dEl = document.getElementById('diameter_um'); + if (dEl && dEl.dataset.autoFilled !== 'false') { + var um = Math.round(d.diameter * 1000); + dEl.value = um; dEl.dataset.autoFilled = 'true'; + } + } + // Trigger material auto-fill for any missing fields + var matEl = document.getElementById('base_material'); + if (matEl) matEl.dispatchEvent(new Event('input')); + }); + if (modifiersEl) modifiersEl.addEventListener('change', ot3dAutoFill); loadMaterialDb().then(function(db) { var dl = document.getElementById('material-list'); diff --git a/src/ReaderHTML.h b/src/ReaderHTML.h index c2b186a..41b45a2 100644 --- a/src/ReaderHTML.h +++ b/src/ReaderHTML.h @@ -43,6 +43,15 @@ const char READER_HTML[] PROGMEM = R"rawliteral( + + + @@ -143,10 +152,119 @@ 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.material_name && !s.tag_data_valid) html += row('Data', 'No parseable data — scan in progress'); + 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 esc(s) { + var d = document.createElement('div'); d.textContent = s; return d.innerHTML; + } + + function renderSpoolRow(spool) { + var fil = spool.filament || {}; + var vendor = fil.vendor ? esc(fil.vendor.name) : ''; + var color = (fil.color_hex || '').replace(/[^0-9a-fA-F]/g, ''); + var colorSwatch = color ? '' : ''; + var remaining = spool.remaining_weight ? Math.round(spool.remaining_weight) + 'g' : '?'; + var label = colorSwatch + '#' + spool.id + ' ' + (vendor ? vendor + ' ' : '') + esc(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 (!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'); @@ -184,17 +302,25 @@ const char READER_HTML[] PROGMEM = R"rawliteral( noTag.classList.remove('hidden'); tagView.classList.add('hidden'); scanBtn.classList.add('hidden'); + document.getElementById('spoolPicker').classList.add('hidden'); pollTimer = setInterval(poll, 1000); poll(); } 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/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"; diff --git a/src/SpoolmanManager.cpp b/src/SpoolmanManager.cpp index 43f5deb..f19e87c 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; } @@ -219,54 +223,50 @@ 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. +// begin() internally calls end() + resets headers. setReuse(true) keeps TCP alive. +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; } @@ -850,6 +850,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) { @@ -1141,6 +1151,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/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); 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'); diff --git a/src/WebServerManager.cpp b/src/WebServerManager.cpp index 57db953..e3fd8a9 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,131 @@ 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); + http.setTimeout(5000); + 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]; + unsigned long lastData = millis(); + while (stream->available() || stream->connected()) { + if (millis() - lastData > 10000) break; // 10s timeout on stalled stream + int avail = stream->available(); + if (avail > 0) { + lastData = millis(); + 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); + } + } + } 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; + } + + // Validate nfc_id — only hex characters, max 16 chars + size_t nfcLen = strlen(nfcId); + if (nfcLen > 16) { + sendError(400, "nfc_id too long (max 16 chars)"); + return; + } + for (size_t i = 0; i < nfcLen; i++) { + char c = nfcId[i]; + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { + sendError(400, "nfc_id must be hex characters only"); + 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; + + // Set nfc_id on new spool FIRST — confirm it works before clearing old + 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.setTimeout(5000); + http.addHeader("Content-Type", "application/json"); + int code = http.PATCH(body); + response = http.getString(); + http.end(); + + if (code != 200) { + char errMsg[64]; + snprintf(errMsg, sizeof(errMsg), "Spoolman PATCH failed (HTTP %d)", code); + sendError(502, errMsg); + return; + } + + Serial.printf("WebServerManager: Linked nfc_id=%s to spool %d\n", nfcId, newSpoolId); + + // Clear old spool's nfc_id AFTER new spool confirmed + if (oldSpoolId > 0 && oldSpoolId != newSpoolId) { + snprintf(url, sizeof(url), "%s/api/v1/spool/%d", baseUrl, oldSpoolId); + http.begin(client, url); + http.setTimeout(5000); + http.addHeader("Content-Type", "application/json"); + int clearCode = http.PATCH("{\"extra\":{\"nfc_id\":\"\\\"\\\"\"}}"); + http.end(); + Serial.printf("WebServerManager: Cleared nfc_id from spool %d (HTTP %d)\n", oldSpoolId, clearCode); + } + + _server.send(200, "application/json", "{\"success\":true}"); +} + void WebServerManager::handleApiDiagnostics() { _server.sendHeader("Access-Control-Allow-Origin", "*"); @@ -889,6 +1016,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 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);