Files
InventreeFilamentGauger/src/main.cpp
2026-04-13 17:20:26 +03:00

2429 lines
78 KiB
C++

#include <Arduino.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include <esp32_smartdisplay.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "hx711_sensor.h"
#ifndef HX711_SCK_PIN
#define HX711_SCK_PIN 17
#endif
#ifndef HX711_DOUT_PIN
#define HX711_DOUT_PIN 18
#endif
#ifndef HX711_REFERENCE_WEIGHT_GRAMS
#define HX711_REFERENCE_WEIGHT_GRAMS 100.0f
#endif
namespace
{
constexpr const char *kPrefsNamespace = "filgauge";
constexpr const char *kDefaultInventreeBaseUrl = "http://192.168.0.3:1337";
constexpr const char *kDefaultInventreeUsername = "scale";
constexpr const char *kDefaultInventreePassword = "scale";
constexpr uint32_t kUiRefreshMs = 200;
constexpr uint32_t kWifiPollMs = 500;
constexpr uint32_t kWifiConnectTimeoutMs = 20000;
constexpr uint32_t kSensorTimeoutMs = 1500;
constexpr uint8_t kAverageSamples = 16;
constexpr uint8_t kRawFilterWindow = 8;
constexpr float kRawFilterAlpha = 0.20f;
constexpr float kWeightStepHysteresisGrams = 0.75f;
enum class PageId : uint8_t
{
Weight = 0,
Calibration = 1,
Wifi = 2,
Inventree = 3,
Count = 4
};
struct CalibrationData
{
int32_t offset = 0;
float scale = 0.0f;
bool has_offset = false;
};
struct WifiData
{
String ssid;
String password;
bool connecting = false;
wl_status_t last_status = WL_IDLE_STATUS;
uint32_t connect_started_ms = 0;
uint32_t next_poll_ms = 0;
};
struct InventreeMatch
{
int id = 0;
float quantity = 0.0f;
String location;
String part_name;
String batch;
};
constexpr size_t kInventreeMaxMatches = 8;
struct InventreeData
{
String base_url;
String token;
String batch;
String status_text;
String result_text;
int total_matches = 0;
int stored_matches = 0;
int selected_match = -1;
InventreeMatch matches[kInventreeMaxMatches];
};
struct UiRefs
{
lv_obj_t *screen = nullptr;
lv_obj_t *nav_panel = nullptr;
lv_obj_t *content_panel = nullptr;
lv_obj_t *nav_buttons[static_cast<size_t>(PageId::Count)] = {};
lv_obj_t *nav_labels[static_cast<size_t>(PageId::Count)] = {};
lv_obj_t *pages[static_cast<size_t>(PageId::Count)] = {};
lv_obj_t *status_chip = nullptr;
lv_obj_t *status_label = nullptr;
lv_obj_t *weight_label = nullptr;
lv_obj_t *weight_unit_label = nullptr;
lv_obj_t *mode_label = nullptr;
lv_obj_t *weight_note_label = nullptr;
lv_obj_t *weight_raw_value = nullptr;
lv_obj_t *workflow_batch_ta = nullptr;
lv_obj_t *workflow_stock_name_label = nullptr;
lv_obj_t *workflow_stock_meta_label = nullptr;
lv_obj_t *cal_offset_value = nullptr;
lv_obj_t *cal_scale_value = nullptr;
lv_obj_t *cal_raw_value = nullptr;
lv_obj_t *cal_note_label = nullptr;
lv_obj_t *wifi_status_value = nullptr;
lv_obj_t *wifi_ip_value = nullptr;
lv_obj_t *wifi_ssid_ta = nullptr;
lv_obj_t *wifi_password_ta = nullptr;
lv_obj_t *wifi_note_label = nullptr;
lv_obj_t *wifi_keyboard = nullptr;
lv_obj_t *text_editor_overlay = nullptr;
lv_obj_t *text_editor_panel = nullptr;
lv_obj_t *text_editor_title = nullptr;
lv_obj_t *text_editor_ta = nullptr;
lv_obj_t *text_editor_target = nullptr;
lv_obj_t *inventree_url_ta = nullptr;
lv_obj_t *inventree_token_ta = nullptr;
lv_obj_t *inventree_batch_ta = nullptr;
lv_obj_t *inventree_status_value = nullptr;
lv_obj_t *inventree_result_value = nullptr;
lv_obj_t *inventree_note_label = nullptr;
};
Hx711Sensor hx711;
Preferences prefs;
CalibrationData calibration;
WifiData wifi_data;
InventreeData inventree_data;
UiRefs ui;
PageId active_page = PageId::Weight;
bool have_sample = false;
bool sensor_online = false;
float filtered_raw = 0.0f;
int32_t last_raw = 0;
int32_t raw_samples[kRawFilterWindow] = {};
uint8_t raw_sample_count = 0;
uint8_t raw_sample_index = 0;
int32_t displayed_weight_grams = 0;
bool displayed_weight_valid = false;
uint32_t last_sample_ms = 0;
uint32_t next_ui_refresh_ms = 0;
uint32_t last_lv_tick_ms = 0;
String serial_line;
constexpr size_t pageIndex(PageId page)
{
return static_cast<size_t>(page);
}
void clearInventreeMatches();
void findInventreeStockByBatch();
void updateInventreeLabels();
void setLabelTextIfChanged(lv_obj_t *label, const char *text)
{
if (label == nullptr || text == nullptr)
{
return;
}
const char *current = lv_label_get_text(label);
if (current != nullptr && strcmp(current, text) == 0)
{
return;
}
lv_label_set_text(label, text);
}
void setWeightStatus(const char *text, lv_color_t color)
{
if (ui.status_chip == nullptr || ui.status_label == nullptr)
{
return;
}
lv_obj_set_style_bg_color(ui.status_chip, color, 0);
setLabelTextIfChanged(ui.status_label, text);
}
void setWeightNote(const char *text)
{
setLabelTextIfChanged(ui.weight_note_label, text);
}
void setCalibrationNote(const char *text)
{
setLabelTextIfChanged(ui.cal_note_label, text);
}
void setWifiNote(const char *text)
{
setLabelTextIfChanged(ui.wifi_note_label, text);
}
void setInventreeNote(const char *text)
{
setLabelTextIfChanged(ui.inventree_note_label, text);
setLabelTextIfChanged(ui.weight_note_label, text);
}
void hideKeyboard()
{
if (ui.wifi_keyboard != nullptr)
{
lv_keyboard_set_textarea(ui.wifi_keyboard, nullptr);
}
if (ui.text_editor_ta != nullptr)
{
lv_textarea_set_text(ui.text_editor_ta, "");
lv_textarea_set_password_mode(ui.text_editor_ta, false);
}
if (ui.text_editor_overlay != nullptr)
{
lv_obj_add_flag(ui.text_editor_overlay, LV_OBJ_FLAG_HIDDEN);
}
ui.text_editor_target = nullptr;
}
void applyTextEditorChanges()
{
if (ui.text_editor_target == nullptr || ui.text_editor_ta == nullptr)
{
return;
}
lv_textarea_set_text(ui.text_editor_target, lv_textarea_get_text(ui.text_editor_ta));
if (ui.text_editor_target == ui.workflow_batch_ta || ui.text_editor_target == ui.inventree_batch_ta)
{
const char *batch_text = lv_textarea_get_text(ui.text_editor_target);
if (ui.workflow_batch_ta != nullptr && ui.text_editor_target != ui.workflow_batch_ta)
{
lv_textarea_set_text(ui.workflow_batch_ta, batch_text);
}
if (ui.inventree_batch_ta != nullptr && ui.text_editor_target != ui.inventree_batch_ta)
{
lv_textarea_set_text(ui.inventree_batch_ta, batch_text);
}
inventree_data.batch = batch_text;
inventree_data.batch.trim();
clearInventreeMatches();
if (inventree_data.batch.isEmpty())
{
inventree_data.status_text = "Batch empty";
inventree_data.result_text = "Enter Batch Code to find the stock item.";
setInventreeNote("Enter Batch Code, then confirm input to load the stock item.");
updateInventreeLabels();
}
else
{
findInventreeStockByBatch();
}
}
}
void openTextEditor(lv_obj_t *target, const char *title)
{
if (target == nullptr ||
ui.text_editor_overlay == nullptr ||
ui.text_editor_title == nullptr ||
ui.text_editor_ta == nullptr ||
ui.wifi_keyboard == nullptr)
{
return;
}
ui.text_editor_target = target;
setLabelTextIfChanged(ui.text_editor_title, title != nullptr ? title : "Edit");
lv_textarea_set_password_mode(ui.text_editor_ta, lv_textarea_get_password_mode(target));
lv_textarea_set_text(ui.text_editor_ta, lv_textarea_get_text(target));
lv_keyboard_set_textarea(ui.wifi_keyboard, ui.text_editor_ta);
lv_keyboard_set_mode(ui.wifi_keyboard, LV_KEYBOARD_MODE_TEXT_LOWER);
lv_obj_clear_flag(ui.text_editor_overlay, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(ui.text_editor_overlay);
}
void saveCalibration()
{
prefs.putLong("offset", calibration.offset);
prefs.putFloat("scale", calibration.scale);
prefs.putBool("has_offset", calibration.has_offset);
}
void loadCalibration()
{
calibration.offset = prefs.getLong("offset", 0);
calibration.scale = prefs.getFloat("scale", 0.0f);
calibration.has_offset = prefs.getBool("has_offset", false);
}
void saveWifiConfig()
{
prefs.putString("wifi_ssid", wifi_data.ssid);
prefs.putString("wifi_pass", wifi_data.password);
}
void loadWifiConfig()
{
wifi_data.ssid = prefs.getString("wifi_ssid", "");
wifi_data.password = prefs.getString("wifi_pass", "");
}
void saveInventreeConfig()
{
prefs.putString("inv_url", inventree_data.base_url);
prefs.putString("inv_tok", inventree_data.token);
prefs.putString("inv_batch", inventree_data.batch);
}
void loadInventreeConfig()
{
inventree_data.base_url = prefs.getString("inv_url", kDefaultInventreeBaseUrl);
inventree_data.base_url.trim();
while (inventree_data.base_url.endsWith("/"))
{
inventree_data.base_url.remove(inventree_data.base_url.length() - 1);
}
if (inventree_data.base_url.isEmpty())
{
inventree_data.base_url = kDefaultInventreeBaseUrl;
}
inventree_data.token = prefs.getString("inv_tok", "");
inventree_data.batch = prefs.getString("inv_batch", prefs.getString("inv_ipn", ""));
}
void setLabelLongText(lv_obj_t *label, const char *format, long value)
{
if (label == nullptr)
{
return;
}
char buffer[32];
snprintf(buffer, sizeof(buffer), format, value);
setLabelTextIfChanged(label, buffer);
}
void setLabelFloatText(lv_obj_t *label, const char *format, float value)
{
if (label == nullptr)
{
return;
}
char buffer[64];
snprintf(buffer, sizeof(buffer), format, static_cast<double>(value));
setLabelTextIfChanged(label, buffer);
}
void resetFilter(int32_t seed_value)
{
for (uint8_t i = 0; i < kRawFilterWindow; ++i)
{
raw_samples[i] = seed_value;
}
raw_sample_count = kRawFilterWindow;
raw_sample_index = 0;
filtered_raw = static_cast<float>(seed_value);
have_sample = true;
}
void pushRawSample(int32_t raw_value)
{
raw_samples[raw_sample_index] = raw_value;
raw_sample_index = (raw_sample_index + 1) % kRawFilterWindow;
if (raw_sample_count < kRawFilterWindow)
{
++raw_sample_count;
}
}
float getAveragedRawSample()
{
if (raw_sample_count == 0)
{
return static_cast<float>(last_raw);
}
int64_t total = 0;
for (uint8_t i = 0; i < raw_sample_count; ++i)
{
total += raw_samples[i];
}
return static_cast<float>(total) / static_cast<float>(raw_sample_count);
}
int32_t applyWeightHysteresis(float grams)
{
if (!displayed_weight_valid)
{
displayed_weight_grams = static_cast<int32_t>(lroundf(grams));
displayed_weight_valid = true;
return displayed_weight_grams;
}
while (grams >= static_cast<float>(displayed_weight_grams) + kWeightStepHysteresisGrams)
{
++displayed_weight_grams;
}
while (grams <= static_cast<float>(displayed_weight_grams) - kWeightStepHysteresisGrams)
{
--displayed_weight_grams;
}
return displayed_weight_grams;
}
bool captureAverage(uint8_t samples, int32_t &value)
{
if (!hx711.waitReady(kSensorTimeoutMs))
{
return false;
}
value = hx711.readAverage(samples);
last_raw = value;
resetFilter(value);
sensor_online = true;
displayed_weight_valid = false;
last_sample_ms = millis();
return true;
}
void applyCalibrationState()
{
setLabelLongText(ui.cal_offset_value, "%ld", static_cast<long>(calibration.offset));
setLabelLongText(ui.cal_raw_value, "%ld", static_cast<long>(lroundf(filtered_raw)));
if (calibration.scale > 0.0001f)
{
setLabelFloatText(ui.cal_scale_value, "%.2f cnt/g", calibration.scale);
}
else
{
setLabelTextIfChanged(ui.cal_scale_value, "raw mode");
}
}
void refreshUi()
{
if (ui.weight_label == nullptr)
{
return;
}
if (!sensor_online)
{
displayed_weight_valid = false;
setLabelTextIfChanged(ui.weight_label, "--");
setLabelTextIfChanged(ui.weight_unit_label, "g");
setLabelTextIfChanged(ui.mode_label, "HX711 offline");
setLabelTextIfChanged(ui.weight_raw_value, "waiting...");
setLabelTextIfChanged(ui.cal_raw_value, "waiting...");
setWeightStatus("WAIT", lv_color_hex(0x8A6D1A));
return;
}
applyCalibrationState();
setLabelLongText(ui.weight_raw_value, "%ld", static_cast<long>(lroundf(filtered_raw)));
setLabelLongText(ui.cal_raw_value, "%ld", static_cast<long>(lroundf(filtered_raw)));
if (calibration.scale > 0.0001f && calibration.has_offset)
{
const float grams = (filtered_raw - static_cast<float>(calibration.offset)) / calibration.scale;
const int32_t stable_grams = applyWeightHysteresis(grams);
setLabelLongText(ui.weight_label, "%ld", static_cast<long>(stable_grams));
setLabelTextIfChanged(ui.weight_unit_label, "g");
setLabelTextIfChanged(ui.mode_label, "Filtered weight, ready for Batch workflow");
setWeightStatus("LIVE", lv_color_hex(0x1E7A55));
}
else
{
displayed_weight_valid = false;
setLabelLongText(ui.weight_label, "%ld", static_cast<long>(lroundf(filtered_raw)));
setLabelTextIfChanged(ui.weight_unit_label, "cnt");
setLabelTextIfChanged(ui.mode_label, "Raw mode. Open Calibration to tare and scale.");
setWeightStatus("RAW", lv_color_hex(0x1F5E7A));
}
}
void performTare()
{
setWeightStatus("BUSY", lv_color_hex(0x7A5A13));
setCalibrationNote("Tare in progress...");
int32_t average = 0;
if (!captureAverage(kAverageSamples, average))
{
setWeightStatus("ERR", lv_color_hex(0x8B2E2E));
setCalibrationNote("HX711 did not answer during tare.");
return;
}
calibration.offset = average;
calibration.has_offset = true;
saveCalibration();
displayed_weight_valid = false;
refreshUi();
setWeightStatus("TARE", lv_color_hex(0x2B6E3F));
setCalibrationNote("Tare saved. Put the reference weight and tap Cal.");
log_i("Tare saved, offset=%ld", static_cast<long>(calibration.offset));
}
void performCalibration(float reference_grams)
{
setWeightStatus("BUSY", lv_color_hex(0x7A5A13));
setCalibrationNote("Calibration in progress...");
if (reference_grams <= 0.0f)
{
setWeightStatus("ERR", lv_color_hex(0x8B2E2E));
setCalibrationNote("Reference weight must be greater than zero.");
return;
}
if (!calibration.has_offset)
{
performTare();
if (!calibration.has_offset)
{
return;
}
}
int32_t average = 0;
if (!captureAverage(kAverageSamples, average))
{
setWeightStatus("ERR", lv_color_hex(0x8B2E2E));
setCalibrationNote("HX711 did not answer during calibration.");
return;
}
const float delta = static_cast<float>(average - calibration.offset);
if (fabsf(delta) < 100.0f)
{
setWeightStatus("ERR", lv_color_hex(0x8B2E2E));
setCalibrationNote("Signal delta is too small. Check HX711 wiring and load.");
return;
}
calibration.scale = delta / reference_grams;
saveCalibration();
displayed_weight_valid = false;
refreshUi();
setWeightStatus("CAL", lv_color_hex(0x1E7A55));
setCalibrationNote("Calibration saved. Main page now shows filtered grams.");
log_i("Calibration saved, scale=%f cnt/g", calibration.scale);
}
void clearCalibration()
{
calibration.scale = 0.0f;
saveCalibration();
displayed_weight_valid = false;
refreshUi();
setWeightStatus("RAW", lv_color_hex(0x1F5E7A));
setCalibrationNote("Scale factor cleared. Offset remains saved.");
log_i("Calibration cleared");
}
void setNavButtonState(PageId page, bool selected)
{
lv_obj_t *button = ui.nav_buttons[pageIndex(page)];
lv_obj_t *label = ui.nav_labels[pageIndex(page)];
if (button == nullptr || label == nullptr)
{
return;
}
lv_obj_set_style_bg_color(button, selected ? lv_color_hex(0x1E7A55) : lv_color_hex(0x101010), 0);
lv_obj_set_style_border_color(button, selected ? lv_color_hex(0x2E9C70) : lv_color_hex(0x1C1C1C), 0);
lv_obj_set_style_text_color(label, selected ? lv_color_white() : lv_color_hex(0x8FA3AC), 0);
}
void updateNavState()
{
for (size_t i = 0; i < pageIndex(PageId::Count); ++i)
{
setNavButtonState(static_cast<PageId>(i), i == pageIndex(active_page));
}
}
void setActivePage(PageId page)
{
active_page = page;
for (size_t i = 0; i < pageIndex(PageId::Count); ++i)
{
if (ui.pages[i] == nullptr)
{
continue;
}
if (i == pageIndex(page))
{
lv_obj_clear_flag(ui.pages[i], LV_OBJ_FLAG_HIDDEN);
}
else
{
lv_obj_add_flag(ui.pages[i], LV_OBJ_FLAG_HIDDEN);
}
}
if (page != PageId::Wifi && page != PageId::Inventree)
{
hideKeyboard();
}
updateNavState();
}
void updateWifiUi(bool force = false)
{
const wl_status_t status = WiFi.status();
if (!force && status == wifi_data.last_status)
{
return;
}
String status_text;
if (status == WL_CONNECTED)
{
status_text = "Connected";
if (!WiFi.SSID().isEmpty())
{
status_text += ": ";
status_text += WiFi.SSID();
}
}
else if (wifi_data.connecting)
{
status_text = "Connecting";
if (!wifi_data.ssid.isEmpty())
{
status_text += ": ";
status_text += wifi_data.ssid;
}
}
else if (status == WL_NO_SSID_AVAIL)
{
status_text = "SSID not found";
}
else if (status == WL_CONNECT_FAILED)
{
status_text = "Password error";
}
else if (status == WL_CONNECTION_LOST)
{
status_text = "Connection lost";
}
else if (!wifi_data.ssid.isEmpty())
{
status_text = "Saved: ";
status_text += wifi_data.ssid;
}
else
{
status_text = "Not configured";
}
String ip_text = "--";
if (status == WL_CONNECTED)
{
ip_text = WiFi.localIP().toString();
}
setLabelTextIfChanged(ui.wifi_status_value, status_text.c_str());
setLabelTextIfChanged(ui.wifi_ip_value, ip_text.c_str());
wifi_data.last_status = status;
}
void connectWifi(const String &ssid, const String &password, bool save_credentials)
{
String trimmed_ssid = ssid;
trimmed_ssid.trim();
if (trimmed_ssid.isEmpty())
{
setWifiNote("Enter Wi-Fi SSID first.");
return;
}
wifi_data.ssid = trimmed_ssid;
wifi_data.password = password;
if (save_credentials)
{
saveWifiConfig();
}
hideKeyboard();
WiFi.persistent(false);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(wifi_data.ssid.c_str(), wifi_data.password.c_str());
wifi_data.connecting = true;
wifi_data.connect_started_ms = millis();
wifi_data.last_status = WL_IDLE_STATUS;
wifi_data.next_poll_ms = 0;
setWifiNote("Connecting to Wi-Fi...");
updateWifiUi(true);
}
void connectWifiFromForm(bool save_credentials)
{
String ssid = ui.wifi_ssid_ta != nullptr ? String(lv_textarea_get_text(ui.wifi_ssid_ta)) : String();
String password = ui.wifi_password_ta != nullptr ? String(lv_textarea_get_text(ui.wifi_password_ta)) : String();
connectWifi(ssid, password, save_credentials);
}
void disconnectWifi()
{
hideKeyboard();
wifi_data.connecting = false;
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
setWifiNote("Wi-Fi disconnected.");
updateWifiUi(true);
}
void handleWifi()
{
const uint32_t now = millis();
if (now < wifi_data.next_poll_ms)
{
return;
}
wifi_data.next_poll_ms = now + kWifiPollMs;
const wl_status_t status = WiFi.status();
if (status == WL_CONNECTED)
{
if (wifi_data.connecting)
{
wifi_data.connecting = false;
setWifiNote("Wi-Fi connected.");
}
}
else if (wifi_data.connecting && now - wifi_data.connect_started_ms > kWifiConnectTimeoutMs)
{
wifi_data.connecting = false;
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
setWifiNote("Connection timeout. Check SSID and password.");
}
else if (status != wifi_data.last_status)
{
if (status == WL_CONNECT_FAILED)
{
wifi_data.connecting = false;
setWifiNote("Connection failed. Check password.");
}
else if (status == WL_NO_SSID_AVAIL)
{
wifi_data.connecting = false;
setWifiNote("SSID not found.");
}
else if (status == WL_CONNECTION_LOST)
{
wifi_data.connecting = false;
setWifiNote("Wi-Fi connection lost.");
}
else if (status == WL_DISCONNECTED && !wifi_data.connecting && !wifi_data.ssid.isEmpty())
{
setWifiNote("Wi-Fi disconnected.");
}
}
updateWifiUi(true);
}
String normalizeInventreeBaseUrl(const String &value)
{
String base_url = value;
base_url.trim();
while (base_url.endsWith("/"))
{
base_url.remove(base_url.length() - 1);
}
return base_url;
}
String urlEncode(const String &value)
{
String encoded;
encoded.reserve(value.length() * 3U);
for (size_t i = 0; i < value.length(); ++i)
{
const uint8_t ch = static_cast<uint8_t>(value[i]);
if ((ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') ||
ch == '-' || ch == '_' || ch == '.' || ch == '~')
{
encoded += static_cast<char>(ch);
}
else
{
char buffer[4];
snprintf(buffer, sizeof(buffer), "%%%02X", ch);
encoded += buffer;
}
}
return encoded;
}
void syncInventreeInputs(bool persist)
{
inventree_data.base_url = ui.inventree_url_ta != nullptr
? normalizeInventreeBaseUrl(String(lv_textarea_get_text(ui.inventree_url_ta)))
: inventree_data.base_url;
inventree_data.token = ui.inventree_token_ta != nullptr
? String(lv_textarea_get_text(ui.inventree_token_ta))
: inventree_data.token;
inventree_data.token.trim();
lv_obj_t *batch_input = nullptr;
if (active_page == PageId::Weight && ui.workflow_batch_ta != nullptr)
{
batch_input = ui.workflow_batch_ta;
}
else if (ui.inventree_batch_ta != nullptr)
{
batch_input = ui.inventree_batch_ta;
}
else
{
batch_input = ui.workflow_batch_ta;
}
inventree_data.batch = batch_input != nullptr
? String(lv_textarea_get_text(batch_input))
: inventree_data.batch;
inventree_data.batch.trim();
if (ui.workflow_batch_ta != nullptr && batch_input != ui.workflow_batch_ta)
{
lv_textarea_set_text(ui.workflow_batch_ta, inventree_data.batch.c_str());
}
if (ui.inventree_batch_ta != nullptr && batch_input != ui.inventree_batch_ta)
{
lv_textarea_set_text(ui.inventree_batch_ta, inventree_data.batch.c_str());
}
if (persist)
{
saveInventreeConfig();
}
}
bool inventreeHasBasicCredentials()
{
return kDefaultInventreeUsername[0] != '\0' && kDefaultInventreePassword[0] != '\0';
}
bool inventreeConfigured()
{
return !inventree_data.base_url.isEmpty() && (!inventree_data.token.isEmpty() || inventreeHasBasicCredentials());
}
void clearInventreeMatches()
{
inventree_data.total_matches = 0;
inventree_data.stored_matches = 0;
inventree_data.selected_match = -1;
}
void updateInventreeLabels()
{
setLabelTextIfChanged(ui.inventree_status_value,
inventree_data.status_text.isEmpty() ? "Not configured" : inventree_data.status_text.c_str());
setLabelTextIfChanged(ui.inventree_result_value,
inventree_data.result_text.isEmpty() ? "No stock item selected." : inventree_data.result_text.c_str());
if (ui.workflow_stock_name_label != nullptr && ui.workflow_stock_meta_label != nullptr)
{
if (inventree_data.selected_match >= 0 && inventree_data.selected_match < inventree_data.stored_matches)
{
const InventreeMatch &match = inventree_data.matches[inventree_data.selected_match];
String title = match.part_name.isEmpty()
? "Stock item ID " + String(match.id)
: match.part_name;
String meta;
meta.reserve(192);
meta += "Batch ";
meta += match.batch.isEmpty() ? "--" : match.batch;
meta += " | Qty ";
meta += String(match.quantity, 1);
if (!match.location.isEmpty())
{
meta += " | ";
meta += match.location;
}
setLabelTextIfChanged(ui.workflow_stock_name_label, title.c_str());
setLabelTextIfChanged(ui.workflow_stock_meta_label, meta.c_str());
}
else if (!inventree_data.batch.isEmpty())
{
setLabelTextIfChanged(ui.workflow_stock_name_label, "Stock item not selected");
setLabelTextIfChanged(ui.workflow_stock_meta_label,
inventree_data.result_text.isEmpty() ? "No matches for the entered Batch Code." : inventree_data.result_text.c_str());
}
else
{
setLabelTextIfChanged(ui.workflow_stock_name_label, "Enter Batch Code");
setLabelTextIfChanged(ui.workflow_stock_meta_label,
"The device will load the stock item and prepare the current weight for writeback.");
}
}
}
void updateInventreeSelectionSummary()
{
if (inventree_data.selected_match < 0 || inventree_data.selected_match >= inventree_data.stored_matches)
{
inventree_data.result_text = "No stock item selected.";
updateInventreeLabels();
return;
}
const InventreeMatch &match = inventree_data.matches[inventree_data.selected_match];
String text = "Match ";
text += String(inventree_data.selected_match + 1);
text += "/";
text += String(inventree_data.total_matches);
text += " ID ";
text += String(match.id);
text += " Qty ";
text += String(match.quantity, 1);
if (!match.location.isEmpty())
{
text += "\nLoc: ";
text += match.location;
}
if (!match.batch.isEmpty() || !match.part_name.isEmpty())
{
text += "\n";
if (!match.batch.isEmpty())
{
text += "Batch ";
text += match.batch;
}
if (!match.part_name.isEmpty())
{
if (!match.batch.isEmpty())
{
text += " | ";
}
text += match.part_name;
}
}
inventree_data.result_text = text;
updateInventreeLabels();
}
String buildInventreeUrl(const String &path)
{
if (path.startsWith("http://") || path.startsWith("https://"))
{
return path;
}
if (path.startsWith("/"))
{
return inventree_data.base_url + path;
}
return inventree_data.base_url + "/" + path;
}
template <typename TClient>
bool performInventreeHttpRequest(TClient &client,
const String &url,
const char *method,
const String &request_body,
bool prefer_token_auth,
int &http_code,
String &response_body,
String &error_text)
{
HTTPClient http;
http.setTimeout(12000);
if (!http.begin(client, url))
{
error_text = "HTTP begin failed.";
return false;
}
if (prefer_token_auth && !inventree_data.token.isEmpty())
{
http.addHeader("Authorization", "Token " + inventree_data.token);
}
else if (inventreeHasBasicCredentials())
{
http.setAuthorization(kDefaultInventreeUsername, kDefaultInventreePassword);
}
else
{
http.end();
error_text = "Enter InvenTree API token.";
return false;
}
http.addHeader("Accept", "application/json");
if (!request_body.isEmpty())
{
http.addHeader("Content-Type", "application/json");
}
if (strcmp(method, "GET") == 0)
{
http_code = http.GET();
}
else if (strcmp(method, "POST") == 0)
{
http_code = http.POST(request_body);
}
else
{
http_code = http.sendRequest(method, request_body);
}
if (http_code > 0)
{
response_body = http.getString();
}
else
{
error_text = HTTPClient::errorToString(http_code);
}
http.end();
return http_code > 0;
}
bool requestInventreeToken(String &error_text)
{
error_text = "";
if (WiFi.status() != WL_CONNECTED)
{
error_text = "Wi-Fi is not connected.";
return false;
}
if (inventree_data.base_url.isEmpty())
{
error_text = "Enter InvenTree URL.";
return false;
}
if (!inventreeHasBasicCredentials())
{
error_text = "No InvenTree login credentials configured.";
return false;
}
int http_code = 0;
String response_body;
const String url = buildInventreeUrl("/api/user/token/?name=" + urlEncode("FilamentGauger"));
const bool is_https = url.startsWith("https://");
bool request_ok = false;
if (is_https)
{
WiFiClientSecure client;
client.setInsecure();
request_ok = performInventreeHttpRequest(client, url, "GET", "", false, http_code, response_body, error_text);
}
else
{
WiFiClient client;
request_ok = performInventreeHttpRequest(client, url, "GET", "", false, http_code, response_body, error_text);
}
if (!request_ok)
{
return false;
}
if (http_code != 200)
{
error_text = response_body.length() > 0 ? response_body : "Unexpected token response.";
return false;
}
JsonDocument token_doc;
const DeserializationError json_error = deserializeJson(token_doc, response_body);
if (json_error)
{
error_text = json_error.c_str();
return false;
}
inventree_data.token = String(token_doc["token"] | "");
inventree_data.token.trim();
if (inventree_data.token.isEmpty())
{
error_text = "InvenTree returned an empty token.";
return false;
}
if (ui.inventree_token_ta != nullptr)
{
lv_textarea_set_text(ui.inventree_token_ta, inventree_data.token.c_str());
}
saveInventreeConfig();
return true;
}
bool performInventreeRequest(const char *method,
const String &path,
const String &request_body,
int &http_code,
String &response_body,
String &error_text)
{
response_body = "";
error_text = "";
http_code = 0;
if (WiFi.status() != WL_CONNECTED)
{
error_text = "Wi-Fi is not connected.";
return false;
}
if (!inventreeConfigured())
{
error_text = "Enter InvenTree URL.";
return false;
}
const String url = buildInventreeUrl(path);
if (inventree_data.token.isEmpty() && inventreeHasBasicCredentials())
{
String token_error;
if (!requestInventreeToken(token_error))
{
Serial.printf("InvenTree token fetch failed, using basic auth: %s\n", token_error.c_str());
}
}
const bool prefer_token_auth = !inventree_data.token.isEmpty();
const bool is_https = url.startsWith("https://");
if (is_https)
{
WiFiClientSecure client;
client.setInsecure();
const bool request_ok = performInventreeHttpRequest(client, url, method, request_body, prefer_token_auth, http_code, response_body, error_text);
if (request_ok && http_code == 401 && prefer_token_auth && inventreeHasBasicCredentials())
{
inventree_data.token = "";
if (ui.inventree_token_ta != nullptr)
{
lv_textarea_set_text(ui.inventree_token_ta, "");
}
saveInventreeConfig();
return performInventreeHttpRequest(client, url, method, request_body, false, http_code, response_body, error_text);
}
return request_ok;
}
WiFiClient client;
const bool request_ok = performInventreeHttpRequest(client, url, method, request_body, prefer_token_auth, http_code, response_body, error_text);
if (request_ok && http_code == 401 && prefer_token_auth && inventreeHasBasicCredentials())
{
inventree_data.token = "";
if (ui.inventree_token_ta != nullptr)
{
lv_textarea_set_text(ui.inventree_token_ta, "");
}
saveInventreeConfig();
return performInventreeHttpRequest(client, url, method, request_body, false, http_code, response_body, error_text);
}
return request_ok;
}
bool getMeasuredWeightForInventree(String &quantity_text)
{
quantity_text = "";
if (!sensor_online || !calibration.has_offset || calibration.scale <= 0.0001f)
{
return false;
}
const float grams = (filtered_raw - static_cast<float>(calibration.offset)) / calibration.scale;
const int32_t stable_grams = displayed_weight_valid ? displayed_weight_grams : static_cast<int32_t>(lroundf(grams));
quantity_text = String(stable_grams < 0 ? 0 : stable_grams);
return true;
}
void saveInventreeSettingsFromForm()
{
syncInventreeInputs(true);
clearInventreeMatches();
if (!inventreeConfigured())
{
inventree_data.status_text = "Missing URL";
inventree_data.result_text = "Fill Base URL, then save.";
setInventreeNote("InvenTree settings were not saved because Base URL is empty.");
}
else
{
inventree_data.status_text = "Settings saved";
inventree_data.result_text = inventree_data.token.isEmpty()
? "Saved URL and Batch Code. Token will be requested automatically from login."
: "Saved URL, token and Batch Code to device storage.";
setInventreeNote("Saved InvenTree settings. API token is optional on this device.");
}
updateInventreeLabels();
}
void testInventreeApi()
{
syncInventreeInputs(false);
clearInventreeMatches();
int http_code = 0;
String response;
String error_text;
if (!performInventreeRequest("GET", "/api/stock/?limit=1", "", http_code, response, error_text))
{
inventree_data.status_text = "API error";
inventree_data.result_text = error_text;
setInventreeNote("Could not reach InvenTree API.");
updateInventreeLabels();
return;
}
if (http_code != 200)
{
inventree_data.status_text = "HTTP " + String(http_code);
inventree_data.result_text = response.length() > 0 ? response : "Unexpected API response.";
setInventreeNote("API test failed.");
updateInventreeLabels();
return;
}
inventree_data.status_text = "API OK";
inventree_data.result_text = "Connected to " + inventree_data.base_url;
setInventreeNote("InvenTree API authentication succeeded.");
updateInventreeLabels();
}
void findInventreeStockByBatch()
{
syncInventreeInputs(false);
clearInventreeMatches();
if (inventree_data.batch.isEmpty())
{
inventree_data.status_text = "Missing Batch";
inventree_data.result_text = "Enter the Batch Code to search.";
setInventreeNote("Batch Code is required for search.");
updateInventreeLabels();
return;
}
const String path = "/api/stock/?batch=" + urlEncode(inventree_data.batch) + "&limit=" + String(kInventreeMaxMatches) +
"&part_detail=true&location_detail=true";
int http_code = 0;
String response;
String error_text;
if (!performInventreeRequest("GET", path, "", http_code, response, error_text))
{
inventree_data.status_text = "Search error";
inventree_data.result_text = error_text;
setInventreeNote("Could not search InvenTree stock.");
updateInventreeLabels();
return;
}
if (http_code != 200)
{
inventree_data.status_text = "HTTP " + String(http_code);
inventree_data.result_text = response.length() > 0 ? response : "Unexpected API response.";
setInventreeNote("Search request failed.");
updateInventreeLabels();
return;
}
JsonDocument filter;
filter["count"] = true;
JsonArray filter_results = filter["results"].to<JsonArray>();
JsonObject filter_item = filter_results.add<JsonObject>();
filter_item["pk"] = true;
filter_item["batch"] = true;
filter_item["quantity"] = true;
filter_item["location"] = true;
filter_item["location_detail"]["pathstring"] = true;
filter_item["location_detail"]["name"] = true;
filter_item["part_detail"]["name"] = true;
JsonDocument doc;
const DeserializationError json_error = deserializeJson(doc, response, DeserializationOption::Filter(filter));
if (json_error)
{
inventree_data.status_text = "JSON error";
inventree_data.result_text = json_error.c_str();
setInventreeNote("Could not parse InvenTree response.");
updateInventreeLabels();
return;
}
const JsonArray results = doc["results"].as<JsonArray>();
inventree_data.total_matches = doc["count"] | static_cast<int>(results.size());
inventree_data.stored_matches = 0;
for (JsonObject item : results)
{
if (inventree_data.stored_matches >= static_cast<int>(kInventreeMaxMatches))
{
break;
}
InventreeMatch &match = inventree_data.matches[inventree_data.stored_matches];
match.id = item["pk"] | 0;
match.quantity = item["quantity"] | 0.0f;
match.batch = String(static_cast<const char *>(item["batch"] | ""));
match.part_name = String(static_cast<const char *>(item["part_detail"]["name"] | ""));
const char *pathstring = item["location_detail"]["pathstring"] | nullptr;
const char *name = item["location_detail"]["name"] | nullptr;
if (pathstring != nullptr && strlen(pathstring) > 0U)
{
match.location = pathstring;
}
else if (name != nullptr && strlen(name) > 0U)
{
match.location = name;
}
else
{
match.location = "Location #" + String(item["location"] | 0);
}
++inventree_data.stored_matches;
}
if (inventree_data.stored_matches == 0)
{
inventree_data.status_text = "No matches";
inventree_data.result_text = "No stock items found for Batch " + inventree_data.batch;
setInventreeNote("Search completed but no stock item matched that Batch Code.");
updateInventreeLabels();
return;
}
inventree_data.selected_match = 0;
inventree_data.status_text = "Found " + String(inventree_data.total_matches) + " item(s)";
setInventreeNote(inventree_data.total_matches > inventree_data.stored_matches
? "More items exist than shown here. Use the first loaded results."
: "Use Prev / Next if multiple stock items were found.");
updateInventreeSelectionSummary();
}
void selectInventreeMatch(int delta)
{
if (inventree_data.stored_matches <= 0)
{
setInventreeNote("Search stock items first.");
return;
}
inventree_data.selected_match += delta;
if (inventree_data.selected_match < 0)
{
inventree_data.selected_match = inventree_data.stored_matches - 1;
}
else if (inventree_data.selected_match >= inventree_data.stored_matches)
{
inventree_data.selected_match = 0;
}
updateInventreeSelectionSummary();
}
void pushMeasuredWeightToInventree()
{
syncInventreeInputs(false);
if (inventree_data.selected_match < 0 || inventree_data.selected_match >= inventree_data.stored_matches)
{
setInventreeNote("No stock item selected. Run Find first.");
return;
}
String quantity_text;
if (!getMeasuredWeightForInventree(quantity_text))
{
setInventreeNote("Weight is not ready. Calibrate HX711 in grams first.");
return;
}
JsonDocument body_doc;
JsonArray items = body_doc["items"].to<JsonArray>();
JsonObject item = items.add<JsonObject>();
item["pk"] = inventree_data.matches[inventree_data.selected_match].id;
item["quantity"] = quantity_text;
body_doc["notes"] = "Updated from FilamentGauger sensor";
String request_body;
serializeJson(body_doc, request_body);
int http_code = 0;
String response;
String error_text;
if (!performInventreeRequest("POST", "/api/stock/count/", request_body, http_code, response, error_text))
{
inventree_data.status_text = "Update error";
inventree_data.result_text = error_text;
setInventreeNote("Could not submit measured weight to InvenTree.");
updateInventreeLabels();
return;
}
if (http_code != 200 && http_code != 201)
{
inventree_data.status_text = "HTTP " + String(http_code);
inventree_data.result_text = response.length() > 0 ? response : "Unexpected API response.";
setInventreeNote("Stock count update failed.");
updateInventreeLabels();
return;
}
inventree_data.status_text = "Stock updated";
inventree_data.result_text = "Sent " + quantity_text + " g to stock item ID " +
String(inventree_data.matches[inventree_data.selected_match].id);
setInventreeNote("Measured weight was written to InvenTree stock quantity.");
updateInventreeLabels();
findInventreeStockByBatch();
setInventreeNote("Measured weight was written to InvenTree stock quantity.");
}
lv_obj_t *createPageContainer(lv_obj_t *parent)
{
lv_obj_t *page = lv_obj_create(parent);
lv_obj_set_size(page, 608, 456);
lv_obj_set_pos(page, 0, 0);
lv_obj_remove_flag(page, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(page, lv_color_black(), 0);
lv_obj_set_style_bg_grad_color(page, lv_color_black(), 0);
lv_obj_set_style_border_width(page, 0, 0);
lv_obj_set_style_radius(page, 24, 0);
lv_obj_set_style_pad_all(page, 0, 0);
return page;
}
lv_obj_t *createMetricCard(lv_obj_t *parent,
const char *title,
lv_coord_t x,
lv_coord_t y,
lv_coord_t width,
lv_coord_t height,
const lv_font_t *value_font,
lv_obj_t **value_label)
{
lv_obj_t *card = lv_obj_create(parent);
lv_obj_set_size(card, width, height);
lv_obj_set_pos(card, x, y);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(card, 18, 0);
lv_obj_set_style_bg_color(card, lv_color_black(), 0);
lv_obj_set_style_bg_grad_color(card, lv_color_black(), 0);
lv_obj_set_style_border_color(card, lv_color_hex(0x1A1A1A), 0);
lv_obj_set_style_border_width(card, 1, 0);
lv_obj_set_style_pad_all(card, 16, 0);
lv_obj_t *title_label = lv_label_create(card);
lv_obj_set_style_text_color(title_label, lv_color_hex(0x6F8893), 0);
lv_obj_set_style_text_font(title_label, &lv_font_montserrat_14, 0);
lv_label_set_text(title_label, title);
lv_obj_align(title_label, LV_ALIGN_TOP_LEFT, 0, 0);
*value_label = lv_label_create(card);
lv_obj_set_width(*value_label, width - 32);
lv_obj_set_style_text_color(*value_label, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(*value_label, value_font, 0);
lv_label_set_long_mode(*value_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(*value_label, "--");
lv_obj_align(*value_label, LV_ALIGN_BOTTOM_LEFT, 0, 0);
return card;
}
lv_obj_t *createActionButton(lv_obj_t *parent,
const char *label_text,
lv_coord_t x,
lv_coord_t y,
lv_coord_t width,
lv_coord_t height,
lv_color_t color,
lv_event_cb_t cb)
{
lv_obj_t *button = lv_button_create(parent);
lv_obj_set_size(button, width, height);
lv_obj_set_pos(button, x, y);
lv_obj_remove_flag(button, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(button, 18, 0);
lv_obj_set_style_bg_color(button, color, 0);
lv_obj_set_style_border_width(button, 0, 0);
lv_obj_set_style_shadow_width(button, 0, 0);
lv_obj_set_style_shadow_opa(button, LV_OPA_TRANSP, 0);
lv_obj_add_event_cb(button, cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *label = lv_label_create(button);
lv_obj_set_style_text_font(label, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(label, lv_color_white(), 0);
lv_label_set_text(label, label_text);
lv_obj_center(label);
return button;
}
lv_obj_t *createNavButton(lv_obj_t *parent,
const char *label_text,
lv_coord_t y,
lv_event_cb_t cb,
PageId page)
{
constexpr lv_coord_t kButtonWidth = 116;
lv_obj_t *button = lv_button_create(parent);
lv_obj_set_size(button, kButtonWidth, 58);
lv_obj_set_pos(button, (156 - kButtonWidth) / 2, y);
lv_obj_remove_flag(button, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(button, 18, 0);
lv_obj_set_style_bg_color(button, lv_color_hex(0x101010), 0);
lv_obj_set_style_border_color(button, lv_color_hex(0x1C1C1C), 0);
lv_obj_set_style_border_width(button, 1, 0);
lv_obj_set_style_shadow_width(button, 0, 0);
lv_obj_add_event_cb(button, cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *label = lv_label_create(button);
lv_obj_set_style_text_font(label, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(label, lv_color_hex(0x8FA3AC), 0);
lv_label_set_text(label, label_text);
lv_obj_center(label);
ui.nav_buttons[pageIndex(page)] = button;
ui.nav_labels[pageIndex(page)] = label;
return button;
}
void tareButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
performTare();
}
}
void calibrateButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
performCalibration(HX711_REFERENCE_WEIGHT_GRAMS);
}
}
void resetButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
clearCalibration();
}
}
void navWeightEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
setActivePage(PageId::Weight);
}
}
void navCalibrationEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
setActivePage(PageId::Calibration);
}
}
void navWifiEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
setActivePage(PageId::Wifi);
}
}
void navInventreeEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
setActivePage(PageId::Inventree);
}
}
void wifiConnectButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
connectWifiFromForm(false);
}
}
void wifiSaveConnectButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
connectWifiFromForm(true);
}
}
void wifiDisconnectButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
disconnectWifi();
}
}
void inventreeSaveButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
saveInventreeSettingsFromForm();
}
}
void inventreeTestButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
testInventreeApi();
}
}
void inventreeFindButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
findInventreeStockByBatch();
}
}
void inventreeNextButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
selectInventreeMatch(1);
}
}
void inventreePushButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
pushMeasuredWeightToInventree();
}
}
void textEditorApplyButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
applyTextEditorChanges();
hideKeyboard();
}
}
void textEditorCancelButtonEvent(lv_event_t *event)
{
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
hideKeyboard();
}
}
void textInputEvent(lv_event_t *event)
{
if (lv_event_get_code(event) != LV_EVENT_CLICKED)
{
return;
}
lv_obj_t *target = static_cast<lv_obj_t *>(lv_event_get_target(event));
const char *title = static_cast<const char *>(lv_event_get_user_data(event));
openTextEditor(target, title);
}
void textEditorKeyboardEvent(lv_event_t *event)
{
const lv_event_code_t code = lv_event_get_code(event);
if (code == LV_EVENT_READY)
{
applyTextEditorChanges();
hideKeyboard();
}
else if (code == LV_EVENT_CANCEL)
{
hideKeyboard();
}
}
lv_obj_t *createTextInput(lv_obj_t *parent,
lv_coord_t x,
lv_coord_t y,
lv_coord_t width,
const char *editor_title,
const char *placeholder,
bool password_mode)
{
lv_obj_t *textarea = lv_textarea_create(parent);
lv_obj_set_size(textarea, width, 46);
lv_obj_set_pos(textarea, x, y);
lv_obj_set_style_radius(textarea, 14, 0);
lv_obj_set_style_bg_color(textarea, lv_color_black(), 0);
lv_obj_set_style_border_color(textarea, lv_color_hex(0x1A1A1A), 0);
lv_obj_set_style_border_width(textarea, 1, 0);
lv_obj_set_style_text_color(textarea, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_pad_hor(textarea, 14, 0);
lv_obj_set_style_pad_ver(textarea, 10, 0);
lv_textarea_set_one_line(textarea, true);
lv_textarea_set_password_mode(textarea, password_mode);
lv_textarea_set_placeholder_text(textarea, placeholder);
lv_obj_add_event_cb(textarea, textInputEvent, LV_EVENT_CLICKED, const_cast<char *>(editor_title));
return textarea;
}
void buildWeightPage()
{
lv_obj_t *page = createPageContainer(ui.content_panel);
ui.pages[pageIndex(PageId::Weight)] = page;
lv_obj_t *title = lv_label_create(page);
lv_obj_set_pos(title, 24, 18);
lv_obj_set_style_text_color(title, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_label_set_text(title, "Main");
lv_obj_t *subtitle = lv_label_create(page);
lv_obj_set_pos(subtitle, 24, 48);
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x6F8893), 0);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);
lv_label_set_text(subtitle, "Weigh filament spools by Batch Code and write grams to InvenTree");
ui.status_chip = lv_obj_create(page);
lv_obj_set_size(ui.status_chip, 110, 40);
lv_obj_set_pos(ui.status_chip, 474, 20);
lv_obj_remove_flag(ui.status_chip, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(ui.status_chip, 20, 0);
lv_obj_set_style_border_width(ui.status_chip, 0, 0);
lv_obj_set_style_pad_all(ui.status_chip, 0, 0);
lv_obj_set_style_bg_color(ui.status_chip, lv_color_hex(0x8A6D1A), 0);
ui.status_label = lv_label_create(ui.status_chip);
lv_obj_set_style_text_font(ui.status_label, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(ui.status_label, lv_color_white(), 0);
lv_label_set_text(ui.status_label, "WAIT");
lv_obj_center(ui.status_label);
ui.weight_label = lv_label_create(page);
lv_obj_set_pos(ui.weight_label, 18, 92);
lv_obj_set_width(ui.weight_label, 470);
lv_obj_set_style_text_color(ui.weight_label, lv_color_hex(0xF8FEFF), 0);
lv_obj_set_style_text_font(ui.weight_label, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_align(ui.weight_label, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(ui.weight_label, "--");
ui.weight_unit_label = lv_label_create(page);
lv_obj_set_pos(ui.weight_unit_label, 500, 130);
lv_obj_set_style_text_color(ui.weight_unit_label, lv_color_hex(0x8FA3AC), 0);
lv_obj_set_style_text_font(ui.weight_unit_label, &lv_font_montserrat_24, 0);
lv_label_set_text(ui.weight_unit_label, "g");
ui.mode_label = lv_label_create(page);
lv_obj_set_pos(ui.mode_label, 24, 166);
lv_obj_set_width(ui.mode_label, 550);
lv_obj_set_style_text_color(ui.mode_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(ui.mode_label, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_align(ui.mode_label, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(ui.mode_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.mode_label, "Waiting for HX711...");
ui.weight_raw_value = nullptr;
lv_obj_t *batch_label = lv_label_create(page);
lv_obj_set_pos(batch_label, 24, 212);
lv_obj_set_style_text_color(batch_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(batch_label, &lv_font_montserrat_14, 0);
lv_label_set_text(batch_label, "Batch Code");
ui.workflow_batch_ta = createTextInput(page, 24, 236, 560, "Main Batch Code", "Scan or type Batch Code", false);
if (!inventree_data.batch.isEmpty())
{
lv_textarea_set_text(ui.workflow_batch_ta, inventree_data.batch.c_str());
}
lv_obj_t *stock_title = lv_label_create(page);
lv_obj_set_pos(stock_title, 24, 296);
lv_obj_set_style_text_color(stock_title, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(stock_title, &lv_font_montserrat_14, 0);
lv_label_set_text(stock_title, "Stock Item");
ui.workflow_stock_name_label = lv_label_create(page);
lv_obj_set_pos(ui.workflow_stock_name_label, 24, 320);
lv_obj_set_width(ui.workflow_stock_name_label, 560);
lv_obj_set_style_text_color(ui.workflow_stock_name_label, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(ui.workflow_stock_name_label, &lv_font_montserrat_20, 0);
lv_label_set_long_mode(ui.workflow_stock_name_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.workflow_stock_name_label, "Enter Batch Code");
ui.workflow_stock_meta_label = lv_label_create(page);
lv_obj_set_pos(ui.workflow_stock_meta_label, 24, 352);
lv_obj_set_width(ui.workflow_stock_meta_label, 560);
lv_obj_set_style_text_color(ui.workflow_stock_meta_label, lv_color_hex(0x8FA3AC), 0);
lv_obj_set_style_text_font(ui.workflow_stock_meta_label, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(ui.workflow_stock_meta_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.workflow_stock_meta_label, "The device will load the stock item and prepare the current weight for writeback.");
createActionButton(page, "Write Weight", 24, 392, 560, 44, lv_color_hex(0x7A3A2A), inventreePushButtonEvent);
ui.weight_note_label = lv_label_create(page);
lv_obj_set_pos(ui.weight_note_label, 24, 438);
lv_obj_set_width(ui.weight_note_label, 560);
lv_obj_set_style_text_color(ui.weight_note_label, lv_color_hex(0x8FA3AC), 0);
lv_obj_set_style_text_font(ui.weight_note_label, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_align(ui.weight_note_label, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(ui.weight_note_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.weight_note_label, "Enter Batch Code, confirm input, then tap Write Weight.");
}
void buildCalibrationPage()
{
lv_obj_t *page = createPageContainer(ui.content_panel);
ui.pages[pageIndex(PageId::Calibration)] = page;
lv_obj_t *title = lv_label_create(page);
lv_obj_set_pos(title, 24, 18);
lv_obj_set_style_text_color(title, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_label_set_text(title, "Calibration");
lv_obj_t *subtitle = lv_label_create(page);
lv_obj_set_pos(subtitle, 24, 48);
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x6F8893), 0);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);
lv_label_set_text(subtitle, "Tare the empty platform, then calibrate with the reference weight");
createMetricCard(page, "RAW", 24, 98, 170, 104, &lv_font_montserrat_24, &ui.cal_raw_value);
createMetricCard(page, "OFFSET", 214, 98, 170, 104, &lv_font_montserrat_24, &ui.cal_offset_value);
createMetricCard(page, "SCALE", 404, 98, 170, 104, &lv_font_montserrat_24, &ui.cal_scale_value);
createActionButton(page, "Tare", 24, 238, 170, 62, lv_color_hex(0x245C7A), tareButtonEvent);
char calibrate_text[32];
snprintf(calibrate_text, sizeof(calibrate_text), "Cal %.0f g", static_cast<double>(HX711_REFERENCE_WEIGHT_GRAMS));
createActionButton(page, calibrate_text, 214, 238, 170, 62, lv_color_hex(0x28714A), calibrateButtonEvent);
createActionButton(page, "Reset Cal", 404, 238, 170, 62, lv_color_hex(0x7A3A2A), resetButtonEvent);
ui.cal_note_label = lv_label_create(page);
lv_obj_set_pos(ui.cal_note_label, 24, 332);
lv_obj_set_width(ui.cal_note_label, 550);
lv_obj_set_style_text_color(ui.cal_note_label, lv_color_hex(0x8FA3AC), 0);
lv_obj_set_style_text_font(ui.cal_note_label, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(ui.cal_note_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.cal_note_label, "Empty the platform, tap Tare, place the 100 g reference weight, then tap Cal.");
}
void buildWifiPage()
{
lv_obj_t *page = createPageContainer(ui.content_panel);
ui.pages[pageIndex(PageId::Wifi)] = page;
lv_obj_t *title = lv_label_create(page);
lv_obj_set_pos(title, 24, 18);
lv_obj_set_style_text_color(title, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_label_set_text(title, "Wi-Fi");
lv_obj_t *subtitle = lv_label_create(page);
lv_obj_set_pos(subtitle, 24, 48);
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x6F8893), 0);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);
lv_label_set_text(subtitle, "Save credentials and connect without leaving the device");
lv_obj_t *status_label = lv_label_create(page);
lv_obj_set_pos(status_label, 24, 98);
lv_obj_set_style_text_color(status_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(status_label, &lv_font_montserrat_14, 0);
lv_label_set_text(status_label, "Status");
ui.wifi_status_value = lv_label_create(page);
lv_obj_set_pos(ui.wifi_status_value, 118, 94);
lv_obj_set_width(ui.wifi_status_value, 440);
lv_obj_set_style_text_color(ui.wifi_status_value, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(ui.wifi_status_value, &lv_font_montserrat_18, 0);
lv_label_set_long_mode(ui.wifi_status_value, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.wifi_status_value, "Not configured");
lv_obj_t *ip_label = lv_label_create(page);
lv_obj_set_pos(ip_label, 24, 132);
lv_obj_set_style_text_color(ip_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(ip_label, &lv_font_montserrat_14, 0);
lv_label_set_text(ip_label, "IP");
ui.wifi_ip_value = lv_label_create(page);
lv_obj_set_pos(ui.wifi_ip_value, 118, 128);
lv_obj_set_width(ui.wifi_ip_value, 440);
lv_obj_set_style_text_color(ui.wifi_ip_value, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(ui.wifi_ip_value, &lv_font_montserrat_18, 0);
lv_label_set_text(ui.wifi_ip_value, "--");
lv_obj_t *ssid_label = lv_label_create(page);
lv_obj_set_pos(ssid_label, 24, 182);
lv_obj_set_style_text_color(ssid_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(ssid_label, &lv_font_montserrat_14, 0);
lv_label_set_text(ssid_label, "SSID");
ui.wifi_ssid_ta = createTextInput(page, 24, 208, 536, "Wi-Fi SSID", "Network name", false);
if (!wifi_data.ssid.isEmpty())
{
lv_textarea_set_text(ui.wifi_ssid_ta, wifi_data.ssid.c_str());
}
lv_obj_t *password_label = lv_label_create(page);
lv_obj_set_pos(password_label, 24, 268);
lv_obj_set_style_text_color(password_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(password_label, &lv_font_montserrat_14, 0);
lv_label_set_text(password_label, "Password");
ui.wifi_password_ta = createTextInput(page, 24, 294, 536, "Wi-Fi Password", "Password", true);
if (!wifi_data.password.isEmpty())
{
lv_textarea_set_text(ui.wifi_password_ta, wifi_data.password.c_str());
}
createActionButton(page, "Connect", 24, 356, 154, 58, lv_color_hex(0x245C7A), wifiConnectButtonEvent);
createActionButton(page, "Save + Connect", 198, 356, 186, 58, lv_color_hex(0x28714A), wifiSaveConnectButtonEvent);
createActionButton(page, "Disconnect", 404, 356, 156, 58, lv_color_hex(0x7A3A2A), wifiDisconnectButtonEvent);
ui.wifi_note_label = lv_label_create(page);
lv_obj_set_pos(ui.wifi_note_label, 24, 420);
lv_obj_set_width(ui.wifi_note_label, 550);
lv_obj_set_style_text_color(ui.wifi_note_label, lv_color_hex(0x8FA3AC), 0);
lv_obj_set_style_text_font(ui.wifi_note_label, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(ui.wifi_note_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.wifi_note_label, "Enter Wi-Fi credentials, then tap Connect or Save + Connect.");
}
void buildInventreePage()
{
lv_obj_t *page = createPageContainer(ui.content_panel);
ui.pages[pageIndex(PageId::Inventree)] = page;
lv_obj_t *title = lv_label_create(page);
lv_obj_set_pos(title, 24, 18);
lv_obj_set_style_text_color(title, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_label_set_text(title, "InvenTree");
lv_obj_t *subtitle = lv_label_create(page);
lv_obj_set_pos(subtitle, 24, 48);
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x6F8893), 0);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);
lv_label_set_text(subtitle, "Find stock by Batch Code and write the measured weight into stock quantity");
lv_obj_t *url_label = lv_label_create(page);
lv_obj_set_pos(url_label, 24, 88);
lv_obj_set_style_text_color(url_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(url_label, &lv_font_montserrat_14, 0);
lv_label_set_text(url_label, "Base URL");
ui.inventree_url_ta = createTextInput(page, 24, 112, 250, "InvenTree Base URL", kDefaultInventreeBaseUrl, false);
if (!inventree_data.base_url.isEmpty())
{
lv_textarea_set_text(ui.inventree_url_ta, inventree_data.base_url.c_str());
}
lv_obj_t *token_label = lv_label_create(page);
lv_obj_set_pos(token_label, 24, 170);
lv_obj_set_style_text_color(token_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(token_label, &lv_font_montserrat_14, 0);
lv_label_set_text(token_label, "API Token (opt)");
ui.inventree_token_ta = createTextInput(page, 24, 194, 250, "InvenTree API Token", "Token", true);
if (!inventree_data.token.isEmpty())
{
lv_textarea_set_text(ui.inventree_token_ta, inventree_data.token.c_str());
}
lv_obj_t *batch_label = lv_label_create(page);
lv_obj_set_pos(batch_label, 24, 252);
lv_obj_set_style_text_color(batch_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(batch_label, &lv_font_montserrat_14, 0);
lv_label_set_text(batch_label, "Batch Code");
ui.inventree_batch_ta = createTextInput(page, 24, 276, 250, "InvenTree Batch Code", "Batch Code", false);
if (!inventree_data.batch.isEmpty())
{
lv_textarea_set_text(ui.inventree_batch_ta, inventree_data.batch.c_str());
}
lv_obj_t *status_label = lv_label_create(page);
lv_obj_set_pos(status_label, 304, 88);
lv_obj_set_style_text_color(status_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(status_label, &lv_font_montserrat_14, 0);
lv_label_set_text(status_label, "Status");
ui.inventree_status_value = lv_label_create(page);
lv_obj_set_pos(ui.inventree_status_value, 304, 112);
lv_obj_set_width(ui.inventree_status_value, 280);
lv_obj_set_style_text_color(ui.inventree_status_value, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(ui.inventree_status_value, &lv_font_montserrat_18, 0);
lv_label_set_long_mode(ui.inventree_status_value, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.inventree_status_value, "Not configured");
lv_obj_t *result_label = lv_label_create(page);
lv_obj_set_pos(result_label, 304, 170);
lv_obj_set_style_text_color(result_label, lv_color_hex(0x7FA1B2), 0);
lv_obj_set_style_text_font(result_label, &lv_font_montserrat_14, 0);
lv_label_set_text(result_label, "Selected Stock Item");
ui.inventree_result_value = lv_label_create(page);
lv_obj_set_pos(ui.inventree_result_value, 304, 194);
lv_obj_set_width(ui.inventree_result_value, 280);
lv_obj_set_style_text_color(ui.inventree_result_value, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(ui.inventree_result_value, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(ui.inventree_result_value, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.inventree_result_value, "No stock item selected.");
createActionButton(page, "Save", 24, 340, 118, 46, lv_color_hex(0x245C7A), inventreeSaveButtonEvent);
createActionButton(page, "Test API", 156, 340, 118, 46, lv_color_hex(0x28714A), inventreeTestButtonEvent);
createActionButton(page, "Find", 24, 394, 118, 46, lv_color_hex(0x245C7A), inventreeFindButtonEvent);
createActionButton(page, "Next", 156, 394, 118, 46, lv_color_hex(0x3D4E5F), inventreeNextButtonEvent);
createActionButton(page, "Push Weight", 304, 394, 280, 46, lv_color_hex(0x7A3A2A), inventreePushButtonEvent);
ui.inventree_note_label = lv_label_create(page);
lv_obj_set_pos(ui.inventree_note_label, 304, 332);
lv_obj_set_width(ui.inventree_note_label, 280);
lv_obj_set_style_text_color(ui.inventree_note_label, lv_color_hex(0x8FA3AC), 0);
lv_obj_set_style_text_font(ui.inventree_note_label, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(ui.inventree_note_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(ui.inventree_note_label, "Save URL, test API, find by Batch Code, then push the measured weight.");
}
void buildUi()
{
ui.screen = lv_obj_create(nullptr);
lv_obj_remove_style_all(ui.screen);
lv_obj_set_style_bg_color(ui.screen, lv_color_black(), 0);
lv_obj_set_style_bg_grad_color(ui.screen, lv_color_black(), 0);
ui.nav_panel = lv_obj_create(ui.screen);
lv_obj_set_size(ui.nav_panel, 156, 456);
lv_obj_set_pos(ui.nav_panel, 12, 12);
lv_obj_remove_flag(ui.nav_panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(ui.nav_panel, 24, 0);
lv_obj_set_style_bg_color(ui.nav_panel, lv_color_black(), 0);
lv_obj_set_style_bg_grad_color(ui.nav_panel, lv_color_black(), 0);
lv_obj_set_style_border_color(ui.nav_panel, lv_color_hex(0x161616), 0);
lv_obj_set_style_border_width(ui.nav_panel, 1, 0);
lv_obj_set_style_shadow_width(ui.nav_panel, 0, 0);
lv_obj_t *nav_title = lv_label_create(ui.nav_panel);
lv_obj_set_pos(nav_title, 18, 24);
lv_obj_set_width(nav_title, 120);
lv_obj_set_style_text_color(nav_title, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(nav_title, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_align(nav_title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(nav_title, "Menu");
createNavButton(ui.nav_panel, "Main", 96, navWeightEvent, PageId::Weight);
createNavButton(ui.nav_panel, "Cal", 166, navCalibrationEvent, PageId::Calibration);
createNavButton(ui.nav_panel, "Wi-Fi", 236, navWifiEvent, PageId::Wifi);
createNavButton(ui.nav_panel, "Inv", 306, navInventreeEvent, PageId::Inventree);
ui.content_panel = lv_obj_create(ui.screen);
lv_obj_set_size(ui.content_panel, 608, 456);
lv_obj_set_pos(ui.content_panel, 180, 12);
lv_obj_remove_flag(ui.content_panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(ui.content_panel, 24, 0);
lv_obj_set_style_bg_color(ui.content_panel, lv_color_black(), 0);
lv_obj_set_style_bg_grad_color(ui.content_panel, lv_color_black(), 0);
lv_obj_set_style_border_color(ui.content_panel, lv_color_hex(0x161616), 0);
lv_obj_set_style_border_width(ui.content_panel, 1, 0);
lv_obj_set_style_shadow_width(ui.content_panel, 0, 0);
lv_obj_set_style_pad_all(ui.content_panel, 0, 0);
buildWeightPage();
buildCalibrationPage();
buildWifiPage();
buildInventreePage();
ui.text_editor_overlay = lv_obj_create(ui.screen);
lv_obj_remove_flag(ui.text_editor_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(ui.text_editor_overlay, 800, 480);
lv_obj_set_pos(ui.text_editor_overlay, 0, 0);
lv_obj_set_style_radius(ui.text_editor_overlay, 0, 0);
lv_obj_set_style_bg_color(ui.text_editor_overlay, lv_color_black(), 0);
lv_obj_set_style_bg_opa(ui.text_editor_overlay, LV_OPA_80, 0);
lv_obj_set_style_border_width(ui.text_editor_overlay, 0, 0);
lv_obj_set_style_pad_all(ui.text_editor_overlay, 0, 0);
lv_obj_add_flag(ui.text_editor_overlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(ui.text_editor_overlay, LV_OBJ_FLAG_HIDDEN);
ui.text_editor_panel = lv_obj_create(ui.text_editor_overlay);
lv_obj_remove_flag(ui.text_editor_panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(ui.text_editor_panel, 776, 112);
lv_obj_set_pos(ui.text_editor_panel, 12, 12);
lv_obj_set_style_radius(ui.text_editor_panel, 24, 0);
lv_obj_set_style_bg_color(ui.text_editor_panel, lv_color_black(), 0);
lv_obj_set_style_bg_grad_color(ui.text_editor_panel, lv_color_black(), 0);
lv_obj_set_style_border_color(ui.text_editor_panel, lv_color_hex(0x1A1A1A), 0);
lv_obj_set_style_border_width(ui.text_editor_panel, 1, 0);
lv_obj_set_style_shadow_width(ui.text_editor_panel, 0, 0);
lv_obj_set_style_pad_all(ui.text_editor_panel, 0, 0);
ui.text_editor_title = lv_label_create(ui.text_editor_panel);
lv_obj_set_pos(ui.text_editor_title, 24, 14);
lv_obj_set_width(ui.text_editor_title, 320);
lv_obj_set_style_text_color(ui.text_editor_title, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_text_font(ui.text_editor_title, &lv_font_montserrat_20, 0);
lv_label_set_text(ui.text_editor_title, "Edit");
createActionButton(ui.text_editor_panel, "Cancel", 528, 8, 108, 38, lv_color_hex(0x323232), textEditorCancelButtonEvent);
createActionButton(ui.text_editor_panel, "OK", 648, 8, 104, 38, lv_color_hex(0x245C7A), textEditorApplyButtonEvent);
ui.text_editor_ta = lv_textarea_create(ui.text_editor_panel);
lv_obj_set_size(ui.text_editor_ta, 728, 44);
lv_obj_set_pos(ui.text_editor_ta, 24, 58);
lv_obj_set_style_radius(ui.text_editor_ta, 14, 0);
lv_obj_set_style_bg_color(ui.text_editor_ta, lv_color_black(), 0);
lv_obj_set_style_border_color(ui.text_editor_ta, lv_color_hex(0x2A2A2A), 0);
lv_obj_set_style_border_width(ui.text_editor_ta, 1, 0);
lv_obj_set_style_text_color(ui.text_editor_ta, lv_color_hex(0xF5FAFC), 0);
lv_obj_set_style_pad_hor(ui.text_editor_ta, 16, 0);
lv_obj_set_style_pad_ver(ui.text_editor_ta, 8, 0);
lv_textarea_set_one_line(ui.text_editor_ta, true);
ui.wifi_keyboard = lv_keyboard_create(ui.text_editor_overlay);
lv_obj_set_size(ui.wifi_keyboard, 776, 336);
lv_obj_set_pos(ui.wifi_keyboard, 12, 132);
lv_obj_set_style_radius(ui.wifi_keyboard, 24, LV_PART_MAIN);
lv_obj_set_style_bg_color(ui.wifi_keyboard, lv_color_black(), LV_PART_MAIN);
lv_obj_set_style_bg_grad_color(ui.wifi_keyboard, lv_color_black(), LV_PART_MAIN);
lv_obj_set_style_border_color(ui.wifi_keyboard, lv_color_hex(0x1A1A1A), LV_PART_MAIN);
lv_obj_set_style_border_width(ui.wifi_keyboard, 1, LV_PART_MAIN);
lv_obj_set_style_shadow_width(ui.wifi_keyboard, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(ui.wifi_keyboard, 4, LV_PART_MAIN);
lv_obj_set_style_pad_row(ui.wifi_keyboard, 4, LV_PART_MAIN);
lv_obj_set_style_pad_column(ui.wifi_keyboard, 4, LV_PART_MAIN);
lv_obj_set_style_radius(ui.wifi_keyboard, 12, LV_PART_ITEMS);
lv_obj_set_style_border_width(ui.wifi_keyboard, 0, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(ui.wifi_keyboard, 0, LV_PART_ITEMS);
lv_keyboard_set_mode(ui.wifi_keyboard, LV_KEYBOARD_MODE_TEXT_LOWER);
lv_obj_add_event_cb(ui.wifi_keyboard, textEditorKeyboardEvent, LV_EVENT_ALL, nullptr);
lv_screen_load(ui.screen);
setWeightNote("Enter Batch Code, confirm input, then tap Write Weight.");
setCalibrationNote("Empty the platform, tap Tare, place the 100 g reference weight, then tap Cal.");
if (wifi_data.ssid.isEmpty())
{
setWifiNote("Tap a field to edit fullscreen, then Connect or Save + Connect.");
}
else
{
setWifiNote("Saved credentials loaded. Tap a field to edit fullscreen, then Connect.");
}
if (inventreeConfigured())
{
inventree_data.status_text = "Config loaded";
inventree_data.result_text = inventree_data.token.isEmpty()
? "Saved URL and Batch Code loaded. Token will be requested automatically."
: "Saved URL, token and Batch Code loaded from device storage.";
setInventreeNote("Use Test API, then Find by Batch Code and Push Weight. API token is optional.");
}
else
{
inventree_data.status_text = "Not configured";
inventree_data.result_text = "Base URL is prefilled. Enter Batch Code. Token is optional.";
setInventreeNote("Tap a field to edit fullscreen, then Save, Test API, Find and Push Weight.");
}
setActivePage(PageId::Weight);
refreshUi();
updateWifiUi(true);
updateInventreeLabels();
}
void handleSerialCommand(const String &command)
{
if (command.equalsIgnoreCase("tare"))
{
performTare();
return;
}
if (command.equalsIgnoreCase("reset"))
{
clearCalibration();
return;
}
if (command.equalsIgnoreCase("status"))
{
Serial.printf("online=%d raw=%ld offset=%ld scale=%f has_offset=%d wifi_status=%d\n",
sensor_online ? 1 : 0,
static_cast<long>(last_raw),
static_cast<long>(calibration.offset),
calibration.scale,
calibration.has_offset ? 1 : 0,
static_cast<int>(WiFi.status()));
return;
}
if (command.startsWith("cal "))
{
const float grams = command.substring(4).toFloat();
performCalibration(grams);
return;
}
if (command.startsWith("scale "))
{
const float scale = command.substring(6).toFloat();
if (scale > 0.0f)
{
calibration.scale = scale;
saveCalibration();
displayed_weight_valid = false;
refreshUi();
setCalibrationNote("Manual scale factor saved.");
}
else
{
setCalibrationNote("Invalid scale factor.");
}
return;
}
setCalibrationNote("Unknown serial command. Use: tare | cal <grams> | scale <cnt_per_g> | reset | status");
}
void handleSerial()
{
while (Serial.available() > 0)
{
const char ch = static_cast<char>(Serial.read());
if (ch == '\r')
{
continue;
}
if (ch == '\n')
{
serial_line.trim();
if (!serial_line.isEmpty())
{
handleSerialCommand(serial_line);
}
serial_line = "";
continue;
}
serial_line += ch;
}
}
void sampleSensor()
{
if (!hx711.isReady())
{
if (millis() - last_sample_ms > kSensorTimeoutMs)
{
sensor_online = false;
displayed_weight_valid = false;
}
return;
}
last_raw = hx711.readRaw();
pushRawSample(last_raw);
const float averaged_raw = getAveragedRawSample();
if (!have_sample)
{
filtered_raw = averaged_raw;
have_sample = true;
}
else
{
filtered_raw += kRawFilterAlpha * (averaged_raw - filtered_raw);
}
sensor_online = true;
last_sample_ms = millis();
}
} // namespace
void setup()
{
#ifdef ARDUINO_USB_CDC_ON_BOOT
delay(5000);
#endif
Serial.begin(115200);
Serial.setDebugOutput(true);
log_i("Board: %s", BOARD_NAME);
log_i("CPU: %s rev%d, %d MHz, %d core(s)",
ESP.getChipModel(),
ESP.getChipRevision(),
getCpuFrequencyMhz(),
ESP.getChipCores());
log_i("Free heap: %d bytes", ESP.getFreeHeap());
log_i("Free PSRAM: %d bytes", ESP.getPsramSize());
log_i("SDK version: %s", ESP.getSdkVersion());
prefs.begin(kPrefsNamespace, false);
loadCalibration();
loadWifiConfig();
loadInventreeConfig();
hx711.begin(HX711_DOUT_PIN, HX711_SCK_PIN);
smartdisplay_init();
auto *display = lv_disp_get_default();
lv_display_set_rotation(display, LV_DISPLAY_ROTATION_180);
buildUi();
WiFi.mode(WIFI_OFF);
if (!wifi_data.ssid.isEmpty())
{
connectWifi(wifi_data.ssid, wifi_data.password, false);
setWifiNote("Auto-connecting to saved Wi-Fi...");
}
last_lv_tick_ms = millis();
next_ui_refresh_ms = millis();
log_i("HX711 pins: SCK=%d DOUT=%d", HX711_SCK_PIN, HX711_DOUT_PIN);
}
void loop()
{
const uint32_t now = millis();
sampleSensor();
handleSerial();
handleWifi();
if (now >= next_ui_refresh_ms)
{
next_ui_refresh_ms = now + kUiRefreshMs;
refreshUi();
}
lv_tick_inc(now - last_lv_tick_ms);
last_lv_tick_ms = now;
lv_timer_handler();
delay(5);
}