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);