Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ void loop() {
#endif

if (!ConfigManager::getSpoolmanUrl().empty() && networkReady &&
(!currentSpoolData.spool_id.empty() || !currentSpoolData.lot_nr.empty())) {
(!currentSpoolData.spool_id.empty() || !currentSpoolData.lot_nr.empty() ||
!currentSpoolData.hardware_uid.empty())) {
DisplayUI::showFetchingOverlay();
lv_timer_handler(); // Force overlay render before blocking
NetworkManager::fetchSpoolmanData(currentSpoolData);
Expand Down
216 changes: 155 additions & 61 deletions src/network/NetworkManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -116,84 +116,123 @@ bool NetworkManager::sendWebhookPayload(const OpenSpoolData &data,
}

if (!u1_host.empty()) {
// Snapmaker U1 Direct API Integration (OpenSpool U1 Extended Format)
// Snapmaker U1 Direct API Integration
JsonDocument doc;
doc["channel"] = toolhead_id;
JsonObject info = doc["info"].to<JsonObject>();

info["VENDOR"] = data.brand;

// Ensure MAIN_TYPE is uppercase as recommended by spec
std::string mainType = data.type;
for (char &c : mainType)
c = toupper(c);
info["MAIN_TYPE"] = mainType;

info["SUB_TYPE"] = data.subtype;

// Convert HEX to decimal int for RGB_1
int rgb = 0;
std::string hex = data.color_hex;
if (!hex.empty()) {
if (hex[0] == '#')
hex = hex.substr(1);
try {
rgb = std::stoi(hex, nullptr, 16);
} catch (...) {
rgb = 0;
// --- Attempt CARD_UID path first ---
// Normalize the hardware_uid: strip 0x/0X, colons, hyphens, spaces, quotes,
// whitespace; uppercase. Then convert each byte pair to a decimal int.
std::string rawUid = data.hardware_uid;
Serial.printf("[U1] Raw UID from tag: '%s'\n", rawUid.c_str());

std::string normUid;
normUid.reserve(rawUid.size());
for (char c : rawUid) {
if (c == ' ' || c == ':' || c == '-' || c == '\t' || c == '\n' || c == '\r' || c == '"')
continue;
normUid += (char)toupper((unsigned char)c);
}
if (normUid.size() >= 2 && normUid[0] == '0' && normUid[1] == 'X')
normUid = normUid.substr(2);

Serial.printf("[U1] Normalized UID: '%s'\n", normUid.c_str());

// Validate: non-empty, even length, only hex chars
bool uidValid = !normUid.empty() && (normUid.size() % 2 == 0);
if (uidValid) {
for (char c : normUid) {
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) {
uidValid = false;
break;
}
}
}
info["RGB_1"] = rgb;
info["ALPHA"] = data.alpha.empty() ? 255 : std::stoi(data.alpha, nullptr, 16);

// Temperature parsing
auto s2i = [](const std::string &s) {
try {
return std::stoi(s);
} catch (...) {
return 0;

if (uidValid) {
// Build CARD_UID as array of decimal byte values
JsonArray cardUid = info["CARD_UID"].to<JsonArray>();
std::string byteLog = "[";
for (size_t i = 0; i < normUid.size(); i += 2) {
int byteVal = std::stoi(normUid.substr(i, 2), nullptr, 16);
cardUid.add(byteVal);
byteLog += std::to_string(byteVal);
if (i + 2 < normUid.size()) byteLog += ",";
}
byteLog += "]";
Serial.printf("[U1] Sending CARD_UID: %s\n", byteLog.c_str());
} else {
// Fallback: no valid UID — send full vendor/material/color data
if (!rawUid.empty())
Serial.println("[U1] UID invalid, falling back to vendor/material payload");

info["VENDOR"] = data.brand;

std::string mainType = data.type;
for (char &c : mainType) c = toupper(c);
info["MAIN_TYPE"] = mainType;

info["SUB_TYPE"] = data.subtype;

int rgb = 0;
std::string hex = data.color_hex;
if (!hex.empty()) {
if (hex[0] == '#') hex = hex.substr(1);
try { rgb = std::stoi(hex, nullptr, 16); } catch (...) { rgb = 0; }
}
};
info["HOTEND_MIN_TEMP"] = s2i(data.min_temp);
info["HOTEND_MAX_TEMP"] = s2i(data.max_temp);
info["BED_TEMP"] = s2i(data.bed_min_temp);

int s_id = 0;
try { s_id = std::stoi(data.spool_id); } catch(...) { s_id = 0; }
info["SPOOL_ID"] = s_id;
info["RGB_1"] = rgb;
info["ALPHA"] = data.alpha.empty() ? 255 : std::stoi(data.alpha, nullptr, 16);

auto s2i = [](const std::string &s) {
try { return std::stoi(s); } catch (...) { return 0; }
};
info["HOTEND_MIN_TEMP"] = s2i(data.min_temp);
info["HOTEND_MAX_TEMP"] = s2i(data.max_temp);
info["BED_TEMP"] = s2i(data.bed_min_temp);

int s_id = 0;
try { s_id = std::stoi(data.spool_id); } catch (...) { s_id = 0; }
info["SPOOL_ID"] = s_id;
}

std::string payload;
serializeJson(doc, payload);
Serial.printf("[U1] Payload: %s\n", payload.c_str());

std::string u1_url = "http://" + u1_host;
if (u1_host.find(':') == std::string::npos) {
if (u1_host.find(':') == std::string::npos)
u1_url += ":7125";
}
u1_url += "/printer/filament_detect/set";

#ifndef USE_SDL2
HTTPClient http;
http.begin(u1_url.c_str());
http.addHeader("Content-Type", "application/json");
int code = http.POST(payload.c_str());
// Consume response to avoid 'flush() fail on fd' errors
http.getString();
std::string response = http.getString().c_str();
http.end();
Serial.printf("[U1] Response %d: %s\n", code, response.c_str());
return (code >= 200 && code < 300);
#else
CURL *curl = curl_easy_init();
if (!curl)
return false;
std::string response;
curl_easy_setopt(curl, CURLOPT_URL, u1_url.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
CURLcode res = curl_easy_perform(curl);
long code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
Serial.printf("[U1] Response %ld: %s\n", code, response.c_str());
return (res == CURLE_OK && code >= 200 && code < 300);
#endif
}
Expand Down Expand Up @@ -269,8 +308,8 @@ bool NetworkManager::fetchSpoolmanData(OpenSpoolData &data) {
}

if (data.spool_id.empty()) {
if (data.hardware_uid.empty()) return false;
// Fallback: search by External ID (Tag UID)
if (data.hardware_uid.empty() && data.lot_nr.empty()) return false;
// Fallback: search by card_uid or lot_nr
if (fetchSpoolmanByExternalId(data)) {
// Continue to fetch full details if we found an ID
} else {
Expand Down Expand Up @@ -319,9 +358,23 @@ bool NetworkManager::fetchSpoolmanData(OpenSpoolData &data) {
return false;
}

// Normalize a UID string: uppercase, strip spaces/colons/hyphens/tabs/newlines/quotes, remove 0x prefix.
static std::string normalizeUid(const std::string &uid) {
std::string out;
out.reserve(uid.size());
for (char c : uid) {
if (c == ' ' || c == ':' || c == '-' || c == '\t' || c == '\n' || c == '\r' || c == '"')
continue;
out += (char)toupper((unsigned char)c);
}
if (out.size() >= 2 && out[0] == '0' && out[1] == 'X')
out = out.substr(2);
return out;
}

bool NetworkManager::fetchSpoolmanByExternalId(OpenSpoolData &data) {
std::string baseUrl = ConfigManager::getSpoolmanUrl();
if (baseUrl.empty() || data.hardware_uid.empty()) {
if (baseUrl.empty() || (data.hardware_uid.empty() && data.lot_nr.empty())) {
return false;
}

Expand All @@ -331,10 +384,9 @@ bool NetworkManager::fetchSpoolmanByExternalId(OpenSpoolData &data) {

if (baseUrl.back() == '/')
baseUrl.pop_back();

// Spoolman API: GET /api/v1/spool?lot_nr="<LOT>"&allow_archived=true
// We use quotes to ensure exact match for the lot_nr
std::string api_url = baseUrl + "/api/v1/spool?lot_nr=%22" + data.lot_nr + "%22&allow_archived=true";

// Fetch all spools (including archived) so both card_uids and lot_nr can be checked in one pass.
std::string api_url = baseUrl + "/api/v1/spool?allow_archived=true";

std::string payload;
long response_code = 0;
Expand All @@ -359,22 +411,64 @@ bool NetworkManager::fetchSpoolmanByExternalId(OpenSpoolData &data) {
}
#endif

if (response_code == 200) {
// Parse response (JSON list of spools)
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error && doc.is<JsonArray>() && doc.as<JsonArray>().size() > 0) {
// Take the first matching spool
JsonObject first = doc[0];
if (first["id"].is<std::string>()) {
data.spool_id = first["id"].as<std::string>();
return true;
} else if (first["id"].is<int>()) {
data.spool_id = std::to_string(first["id"].as<int>());
if (response_code != 200) return false;

JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (error || !doc.is<JsonArray>()) return false;

JsonArray spools = doc.as<JsonArray>();
std::string normHwUid = normalizeUid(data.hardware_uid);
int lotNrMatchIdx = -1;

for (int i = 0; i < (int)spools.size(); i++) {
JsonObject spool = spools[i];

// 1. card_uids check (priority)
if (!normHwUid.empty()) {
JsonVariant cardUidsVar = spool["extra"]["card_uids"];
if (!cardUidsVar.isNull()) {
std::string cardUidsStr = cardUidsVar.as<std::string>();
// Spoolman encodes extra values as JSON strings — strip surrounding quotes.
if (cardUidsStr.size() >= 2 && cardUidsStr.front() == '"' && cardUidsStr.back() == '"')
cardUidsStr = cardUidsStr.substr(1, cardUidsStr.size() - 2);

// Split comma-separated list and compare each entry.
size_t start = 0;
while (start <= cardUidsStr.size()) {
size_t end = cardUidsStr.find(',', start);
if (end == std::string::npos) end = cardUidsStr.size();
if (normalizeUid(cardUidsStr.substr(start, end - start)) == normHwUid) {
if (spool["id"].is<int>())
data.spool_id = std::to_string(spool["id"].as<int>());
else
data.spool_id = spool["id"].as<std::string>();
Serial.println("[Spoolman] Matched by card_uid");
return true;
}
start = end + 1;
}
}
}

// 2. Remember first lot_nr match as fallback.
if (lotNrMatchIdx < 0 && !data.lot_nr.empty()) {
JsonVariant lotVar = spool["lot_nr"];
if (!lotVar.isNull() && lotVar.as<std::string>() == data.lot_nr)
lotNrMatchIdx = i;
}
}

// Fallback: lot_nr
if (lotNrMatchIdx >= 0) {
JsonObject spool = spools[lotNrMatchIdx];
if (spool["id"].is<int>())
data.spool_id = std::to_string(spool["id"].as<int>());
else
data.spool_id = spool["id"].as<std::string>();
return true;
}

return false;
}

Expand Down
23 changes: 20 additions & 3 deletions src/serial/SerialTerminal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ void SerialTerminal::processCommand(const String &cmdLine) {

if (cmd.equalsIgnoreCase("help") || cmd == "?") {
Serial.println("Available commands:");
Serial.println(" set wifi <ssid> <password>");
Serial.println(" set wifi <ssid> <password> (SSID must not contain spaces)");
Serial.println(" set wifi_ssid <ssid> (SSID may contain spaces)");
Serial.println(" set wifi_pass <password> (password may contain spaces)");
Serial.println(" set webhook <http://...>");
Serial.println(" set spoolman <http://...>");
Serial.println(" set wifi_timeout <seconds> (10-300)");
Expand All @@ -73,9 +75,16 @@ void SerialTerminal::processCommand(const String &cmdLine) {

if (cmd.equalsIgnoreCase("get config")) {
Serial.println("[Current Config]");
std::string ssid = ConfigManager::getWifiSSID();
std::string pass = ConfigManager::getWifiPass();
std::string redactedPass = pass.empty() ? "" : "********";
Serial.printf("set wifi %s %s\n", ConfigManager::getWifiSSID().c_str(), redactedPass.c_str());
// Use separate commands when SSID contains spaces so the output is directly re-enterable
if (ssid.find(' ') != std::string::npos) {
Serial.printf("set wifi_ssid %s\n", ssid.c_str());
Serial.printf("set wifi_pass %s\n", redactedPass.c_str());
} else {
Serial.printf("set wifi %s %s\n", ssid.c_str(), redactedPass.c_str());
}
Serial.printf("set webhook %s\n", ConfigManager::getWebhook().c_str());
Serial.printf("set spoolman %s\n", ConfigManager::getSpoolmanUrl().c_str());
Serial.printf("set wifi_timeout %d\n", ConfigManager::getWifiTimeout());
Expand Down Expand Up @@ -125,7 +134,7 @@ void SerialTerminal::processCommand(const String &cmdLine) {
if (key.equalsIgnoreCase("wifi")) {
int thirdSpace = cmd.indexOf(' ', secondSpace + 1);
if (thirdSpace == -1) {
// Assume the rest of the string is just the SSID (no password network)
// Only SSID provided — open network (no password)
ConfigManager::setWifiSSID(value.c_str());
ConfigManager::setWifiPass("");
Serial.println("Saved open Wi-Fi network (no password).");
Expand All @@ -138,6 +147,14 @@ void SerialTerminal::processCommand(const String &cmdLine) {
ConfigManager::setWifiPass(pass.c_str());
Serial.println("Wi-Fi credentials saved.");
}
} else if (key.equalsIgnoreCase("wifi_ssid")) {
// Accepts the full rest of the line as SSID — spaces allowed
ConfigManager::setWifiSSID(value.c_str());
Serial.printf("Wi-Fi SSID saved: %s\n", value.c_str());
} else if (key.equalsIgnoreCase("wifi_pass")) {
// Accepts the full rest of the line as password — spaces allowed
ConfigManager::setWifiPass(value.c_str());
Serial.println("Wi-Fi password saved.");
} else if (key.equalsIgnoreCase("webhook")) {
ConfigManager::setWebhook(value.c_str());
Serial.println("Webhook saved.");
Expand Down
3 changes: 3 additions & 0 deletions src/ui/DisplayUI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ lv_obj_t *DisplayUI::labelSpoolId = nullptr;
lv_obj_t *DisplayUI::labelSubtype = nullptr;
lv_obj_t *DisplayUI::labelLotNr = nullptr;
lv_obj_t *DisplayUI::keyLotNr = nullptr;
lv_obj_t *DisplayUI::labelHardwareUid = nullptr;
lv_obj_t *DisplayUI::labelTemp = nullptr;
lv_obj_t *DisplayUI::labelBedTemp = nullptr;
lv_obj_t *DisplayUI::labelDiameter = nullptr;
Expand Down Expand Up @@ -764,6 +765,7 @@ void DisplayUI::buildExtendedInfoScreen() {
create_ext_row("Diameter", &labelDiameter);
create_ext_row("Subtype", &labelSubtype);
create_ext_row("Lot Nr", &labelLotNr);
create_ext_row("Card UID", &labelHardwareUid);
create_ext_row("Density", &labelDensity);
create_ext_row("Actual W.", &labelActualWeight);
create_ext_row("Empty W.", &labelEmptyWeight);
Expand Down Expand Up @@ -2060,6 +2062,7 @@ void DisplayUI::showExtendedInfoScreen() {
set_field(labelDiameter, spool.diameter, "mm");
set_field(labelSubtype, spool.subtype);
set_field(labelLotNr, spool.lot_nr);
set_field(labelHardwareUid, spool.hardware_uid);
set_field(labelDensity, spool.density, "g/cm3");
set_field(labelActualWeight, spool.actual_weight, "g");
set_field(labelEmptyWeight, spool.empty_weight, "g");
Expand Down
1 change: 1 addition & 0 deletions src/ui/DisplayUI.h
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class DisplayUI {
static lv_obj_t *labelSubtype;
static lv_obj_t *labelLotNr;
static lv_obj_t *keyLotNr;
static lv_obj_t *labelHardwareUid;
static lv_obj_t *labelDiameter;
static lv_obj_t *keyDiameter;
static lv_obj_t *labelTemp;
Expand Down