From 33b93e976bf40aa918bde8662d473d69ac64fde7 Mon Sep 17 00:00:00 2001 From: Taras Syvash Date: Mon, 13 Apr 2026 20:05:30 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=BE=D0=B7=D1=80=D1=96=D0=B7=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D0=BA=D1=96=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D1=96=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.h | 206 ++++ src/app_context.cpp | 22 + src/connectivity.cpp | 801 +++++++++++++++ src/main.cpp | 2277 +----------------------------------------- src/sensor_logic.cpp | 227 +++++ src/ui.cpp | 1183 ++++++++++++++++++++++ 6 files changed, 2440 insertions(+), 2276 deletions(-) create mode 100644 src/app.h create mode 100644 src/app_context.cpp create mode 100644 src/connectivity.cpp create mode 100644 src/sensor_logic.cpp create mode 100644 src/ui.cpp diff --git a/src/app.h b/src/app.h new file mode 100644 index 0000000..97c1816 --- /dev/null +++ b/src/app.h @@ -0,0 +1,206 @@ +#ifndef APP_H +#define APP_H + +#include +#include +#include +#include + +#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 + +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; +constexpr lv_coord_t kScreenMargin = 12; +constexpr lv_coord_t kPanelRadius = 24; +constexpr lv_coord_t kContentPadding = 24; +constexpr lv_coord_t kContentWidth = 608; +constexpr lv_coord_t kContentHeight = 456; +constexpr lv_coord_t kContentInnerWidth = kContentWidth - (kContentPadding * 2); +constexpr lv_coord_t kTextEditorPanelHeight = 112; +constexpr lv_coord_t kTextEditorGap = 12; +constexpr lv_coord_t kTextEditorMinKeyboardHeight = 240; + +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(PageId::Count)] = {}; + lv_obj_t *nav_labels[static_cast(PageId::Count)] = {}; + lv_obj_t *pages[static_cast(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_cancel_button = nullptr; + lv_obj_t *text_editor_apply_button = 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; +}; + +extern Hx711Sensor hx711; +extern Preferences prefs; +extern CalibrationData calibration; +extern WifiData wifi_data; +extern InventreeData inventree_data; +extern UiRefs ui; + +extern PageId active_page; +extern bool have_sample; +extern bool sensor_online; +extern float filtered_raw; +extern int32_t last_raw; +extern int32_t raw_samples[kRawFilterWindow]; +extern uint8_t raw_sample_count; +extern uint8_t raw_sample_index; +extern int32_t displayed_weight_grams; +extern bool displayed_weight_valid; +extern uint32_t last_sample_ms; +extern uint32_t next_ui_refresh_ms; +extern uint32_t last_lv_tick_ms; + +constexpr size_t pageIndex(PageId page) +{ + return static_cast(page); +} + +void saveCalibration(); +void loadCalibration(); +void saveWifiConfig(); +void loadWifiConfig(); +void saveInventreeConfig(); +void loadInventreeConfig(); + +float getCurrentWeightGrams(); +void performTare(); +void performCalibration(float reference_grams); +void clearCalibration(); +void sampleSensor(); + +void connectWifi(const String &ssid, const String &password, bool save_credentials); +void connectWifiFromForm(bool save_credentials); +void disconnectWifi(); +void handleWifi(); +bool inventreeConfigured(); +void clearInventreeMatches(); +void saveInventreeSettingsFromForm(); +void testInventreeApi(); +void findInventreeStockByBatch(); +void selectInventreeMatch(int delta); +void pushMeasuredWeightToInventree(); + +void setLabelTextIfChanged(lv_obj_t *label, const char *text); +void setWeightStatus(const char *text, lv_color_t color); +void setWeightNote(const char *text); +void setCalibrationNote(const char *text); +void setWifiNote(const char *text); +void setInventreeNote(const char *text); +void hideKeyboard(); +void refreshUi(); +void updateWifiUi(bool force = false); +void updateInventreeLabels(); +void setActivePage(PageId page); +void buildUi(); + +#endif diff --git a/src/app_context.cpp b/src/app_context.cpp new file mode 100644 index 0000000..f48be32 --- /dev/null +++ b/src/app_context.cpp @@ -0,0 +1,22 @@ +#include "app.h" + +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; diff --git a/src/connectivity.cpp b/src/connectivity.cpp new file mode 100644 index 0000000..1b51d94 --- /dev/null +++ b/src/connectivity.cpp @@ -0,0 +1,801 @@ +#include "app.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +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(value[i]); + if ((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '-' || ch == '_' || ch == '.' || ch == '~') + { + encoded += static_cast(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'; +} + +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 +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 int32_t stable_grams = displayed_weight_valid ? displayed_weight_grams : static_cast(lroundf(getCurrentWeightGrams())); + quantity_text = String(stable_grams < 0 ? 0 : stable_grams); + return true; +} + +} // namespace + +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", "")); +} + +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 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(); + bool force_refresh = false; + if (status == WL_CONNECTED) + { + if (wifi_data.connecting) + { + wifi_data.connecting = false; + setWifiNote("Wi-Fi connected."); + force_refresh = true; + } + } + 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."); + force_refresh = true; + } + else if (status != wifi_data.last_status) + { + if (status == WL_CONNECT_FAILED) + { + wifi_data.connecting = false; + setWifiNote("Connection failed. Check password."); + force_refresh = true; + } + else if (status == WL_NO_SSID_AVAIL) + { + wifi_data.connecting = false; + setWifiNote("SSID not found."); + force_refresh = true; + } + else if (status == WL_CONNECTION_LOST) + { + wifi_data.connecting = false; + setWifiNote("Wi-Fi connection lost."); + force_refresh = true; + } + else if (status == WL_DISCONNECTED && !wifi_data.connecting && !wifi_data.ssid.isEmpty()) + { + setWifiNote("Wi-Fi disconnected."); + force_refresh = true; + } + } + + updateWifiUi(force_refresh || status != wifi_data.last_status); +} + +void saveInventreeSettingsFromForm() +{ + syncInventreeInputs(true); + clearInventreeMatches(); + + if (!inventreeConfigured()) + { + inventree_data.status_text = "Settings saved"; + inventree_data.result_text = "Saved current values. Add Base URL to enable API requests."; + setInventreeNote("Settings are stored on the device, but Base URL is still required for API access."); + } + 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(); + JsonObject filter_item = filter_results.add(); + 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(); + inventree_data.total_matches = doc["count"] | static_cast(results.size()); + inventree_data.stored_matches = 0; + + for (JsonObject item : results) + { + if (inventree_data.stored_matches >= static_cast(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(item["batch"] | "")); + match.part_name = String(static_cast(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(); + JsonObject item = items.add(); + 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."); +} diff --git a/src/main.cpp b/src/main.cpp index 37339bb..0a3d3f8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,2255 +1,10 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#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 +#include "app.h" 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(PageId::Count)] = {}; - lv_obj_t *nav_labels[static_cast(PageId::Count)] = {}; - lv_obj_t *pages[static_cast(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(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(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(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(last_raw); - } - - int64_t total = 0; - for (uint8_t i = 0; i < raw_sample_count; ++i) - { - total += raw_samples[i]; - } - - return static_cast(total) / static_cast(raw_sample_count); -} - -int32_t applyWeightHysteresis(float grams) -{ - if (!displayed_weight_valid) - { - displayed_weight_grams = static_cast(lroundf(grams)); - displayed_weight_valid = true; - return displayed_weight_grams; - } - - while (grams >= static_cast(displayed_weight_grams) + kWeightStepHysteresisGrams) - { - ++displayed_weight_grams; - } - - while (grams <= static_cast(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(calibration.offset)); - setLabelLongText(ui.cal_raw_value, "%ld", static_cast(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(lroundf(filtered_raw))); - setLabelLongText(ui.cal_raw_value, "%ld", static_cast(lroundf(filtered_raw))); - - if (calibration.scale > 0.0001f && calibration.has_offset) - { - const float grams = (filtered_raw - static_cast(calibration.offset)) / calibration.scale; - const int32_t stable_grams = applyWeightHysteresis(grams); - setLabelLongText(ui.weight_label, "%ld", static_cast(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(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(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(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(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(value[i]); - if ((ch >= 'a' && ch <= 'z') || - (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || - ch == '-' || ch == '_' || ch == '.' || ch == '~') - { - encoded += static_cast(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 -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(calibration.offset)) / calibration.scale; - const int32_t stable_grams = displayed_weight_valid ? displayed_weight_grams : static_cast(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(); - JsonObject filter_item = filter_results.add(); - 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(); - inventree_data.total_matches = doc["count"] | static_cast(results.size()); - inventree_data.stored_matches = 0; - - for (JsonObject item : results) - { - if (inventree_data.stored_matches >= static_cast(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(item["batch"] | "")); - match.part_name = String(static_cast(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(); - JsonObject item = items.add(); - 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_event_get_target(event)); - const char *title = static_cast(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(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(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")) @@ -2329,36 +84,6 @@ void handleSerial() } } -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() diff --git a/src/sensor_logic.cpp b/src/sensor_logic.cpp new file mode 100644 index 0000000..4e54f9a --- /dev/null +++ b/src/sensor_logic.cpp @@ -0,0 +1,227 @@ +#include "app.h" + +#include + +namespace +{ + +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(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(last_raw); + } + + int64_t total = 0; + for (uint8_t i = 0; i < raw_sample_count; ++i) + { + total += raw_samples[i]; + } + + return static_cast(total) / static_cast(raw_sample_count); +} + +int32_t applyWeightHysteresis(float grams) +{ + if (!displayed_weight_valid) + { + displayed_weight_grams = static_cast(lroundf(grams)); + displayed_weight_valid = true; + return displayed_weight_grams; + } + + while (grams >= static_cast(displayed_weight_grams) + kWeightStepHysteresisGrams) + { + ++displayed_weight_grams; + } + + while (grams <= static_cast(displayed_weight_grams) - kWeightStepHysteresisGrams) + { + --displayed_weight_grams; + } + + return displayed_weight_grams; +} + +void updateDisplayedWeight() +{ + if (!sensor_online || !calibration.has_offset || calibration.scale <= 0.0001f) + { + displayed_weight_valid = false; + return; + } + + applyWeightHysteresis(getCurrentWeightGrams()); +} + +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; +} + +} // namespace + +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); +} + +float getCurrentWeightGrams() +{ + return (filtered_raw - static_cast(calibration.offset)) / calibration.scale; +} + +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(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(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 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(); + updateDisplayedWeight(); +} diff --git a/src/ui.cpp b/src/ui.cpp new file mode 100644 index 0000000..1af1c45 --- /dev/null +++ b/src/ui.cpp @@ -0,0 +1,1183 @@ +#include "app.h" + +#include +#include +#include + +namespace +{ + +lv_coord_t clampCoord(lv_coord_t value, lv_coord_t min_value, lv_coord_t max_value) +{ + if (value < min_value) + { + return min_value; + } + + if (value > max_value) + { + return max_value; + } + + return value; +} + +lv_coord_t getDisplayWidth() +{ + lv_display_t *display = lv_display_get_default(); + return display != nullptr ? lv_display_get_horizontal_resolution(display) : 800; +} + +lv_coord_t getDisplayHeight() +{ + lv_display_t *display = lv_display_get_default(); + return display != nullptr ? lv_display_get_vertical_resolution(display) : 480; +} + +void alignTopCenter(lv_obj_t *obj, lv_coord_t y_offset) +{ + if (obj == nullptr) + { + return; + } + + lv_obj_align(obj, LV_ALIGN_TOP_MID, 0, y_offset); +} + +void styleCenteredText(lv_obj_t *label, lv_coord_t width, const lv_font_t *font, lv_color_t color) +{ + if (label == nullptr) + { + return; + } + + lv_obj_set_width(label, width); + lv_obj_set_style_text_color(label, color, 0); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); +} + +void createPageHeader(lv_obj_t *page, const char *title_text, const char *subtitle_text) +{ + if (page == nullptr) + { + return; + } + + lv_obj_t *title = lv_label_create(page); + styleCenteredText(title, kContentInnerWidth, &lv_font_montserrat_20, lv_color_hex(0xF5FAFC)); + lv_label_set_text(title, title_text); + alignTopCenter(title, 18); + + lv_obj_t *subtitle = lv_label_create(page); + styleCenteredText(subtitle, kContentInnerWidth, &lv_font_montserrat_14, lv_color_hex(0x6F8893)); + lv_label_set_text(subtitle, subtitle_text); + lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP); + alignTopCenter(subtitle, 48); +} + +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 layoutTextEditor() +{ + if (ui.text_editor_overlay == nullptr || + ui.text_editor_panel == nullptr || + ui.text_editor_title == nullptr || + ui.text_editor_ta == nullptr || + ui.text_editor_cancel_button == nullptr || + ui.text_editor_apply_button == nullptr || + ui.wifi_keyboard == nullptr) + { + return; + } + + const lv_coord_t screen_width = getDisplayWidth(); + const lv_coord_t screen_height = getDisplayHeight(); + const lv_coord_t panel_width = clampCoord(screen_width - (kScreenMargin * 2), 320, 776); + const lv_coord_t keyboard_height = clampCoord(screen_height - ((kScreenMargin * 2) + kTextEditorPanelHeight + kTextEditorGap), + kTextEditorMinKeyboardHeight, + screen_height - (kScreenMargin * 2)); + const lv_coord_t button_gap = 12; + const lv_coord_t cancel_width = 108; + const lv_coord_t ok_width = 104; + const lv_coord_t title_width = panel_width - (48 + cancel_width + ok_width + button_gap + 48); + + lv_obj_set_size(ui.text_editor_overlay, screen_width, screen_height); + lv_obj_set_size(ui.text_editor_panel, panel_width, kTextEditorPanelHeight); + alignTopCenter(ui.text_editor_panel, kScreenMargin); + + lv_obj_set_width(ui.text_editor_title, title_width); + lv_obj_set_pos(ui.text_editor_title, 24, 14); + + lv_obj_set_size(ui.text_editor_cancel_button, cancel_width, 38); + lv_obj_set_pos(ui.text_editor_cancel_button, panel_width - (24 + ok_width + button_gap + cancel_width), 8); + + lv_obj_set_size(ui.text_editor_apply_button, ok_width, 38); + lv_obj_set_pos(ui.text_editor_apply_button, panel_width - (24 + ok_width), 8); + + lv_obj_set_size(ui.text_editor_ta, panel_width - 48, 44); + lv_obj_set_pos(ui.text_editor_ta, 24, 58); + + lv_obj_set_size(ui.wifi_keyboard, panel_width, keyboard_height); + alignTopCenter(ui.wifi_keyboard, kScreenMargin + kTextEditorPanelHeight + kTextEditorGap); +} + +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); + layoutTextEditor(); + lv_obj_clear_flag(ui.text_editor_overlay, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(ui.text_editor_overlay); +} + +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(value)); + setLabelTextIfChanged(label, buffer); +} + +void applyCalibrationState() +{ + setLabelLongText(ui.cal_offset_value, "%ld", static_cast(calibration.offset)); + setLabelLongText(ui.cal_raw_value, "%ld", static_cast(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 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(i), i == pageIndex(active_page)); + } +} + +lv_obj_t *createPageContainer(lv_obj_t *parent) +{ + lv_obj_t *page = lv_obj_create(parent); + lv_obj_set_size(page, kContentWidth, kContentHeight); + 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, kPanelRadius, 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_event_get_target(event)); + const char *title = static_cast(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(editor_title)); + return textarea; +} + +void buildWeightPage() +{ + lv_obj_t *page = createPageContainer(ui.content_panel); + ui.pages[pageIndex(PageId::Weight)] = page; + createPageHeader(page, "Main", "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_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); + lv_obj_align(ui.status_chip, LV_ALIGN_TOP_RIGHT, -24, 18); + + 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); + styleCenteredText(ui.weight_label, kContentInnerWidth, &lv_font_montserrat_48, lv_color_hex(0xF8FEFF)); + lv_label_set_text(ui.weight_label, "--"); + alignTopCenter(ui.weight_label, 82); + + ui.weight_unit_label = lv_label_create(page); + styleCenteredText(ui.weight_unit_label, kContentInnerWidth, &lv_font_montserrat_24, lv_color_hex(0x8FA3AC)); + lv_label_set_text(ui.weight_unit_label, "g"); + alignTopCenter(ui.weight_unit_label, 134); + + ui.mode_label = lv_label_create(page); + styleCenteredText(ui.mode_label, kContentInnerWidth, &lv_font_montserrat_18, lv_color_hex(0x7FA1B2)); + lv_label_set_long_mode(ui.mode_label, LV_LABEL_LONG_WRAP); + lv_label_set_text(ui.mode_label, "Waiting for HX711..."); + alignTopCenter(ui.mode_label, 170); + ui.weight_raw_value = nullptr; + + lv_obj_t *batch_label = lv_label_create(page); + lv_obj_set_pos(batch_label, 24, 210); + 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, 234, kContentInnerWidth, "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, 290); + 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); + styleCenteredText(ui.workflow_stock_name_label, kContentInnerWidth, &lv_font_montserrat_18, lv_color_hex(0xF5FAFC)); + 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"); + alignTopCenter(ui.workflow_stock_name_label, 314); + + ui.workflow_stock_meta_label = lv_label_create(page); + styleCenteredText(ui.workflow_stock_meta_label, kContentInnerWidth, &lv_font_montserrat_14, lv_color_hex(0x8FA3AC)); + 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."); + alignTopCenter(ui.workflow_stock_meta_label, 348); + + createActionButton(page, "Write Weight", 24, 378, kContentInnerWidth, 40, lv_color_hex(0x7A3A2A), inventreePushButtonEvent); + + ui.weight_note_label = lv_label_create(page); + styleCenteredText(ui.weight_note_label, kContentInnerWidth, &lv_font_montserrat_14, lv_color_hex(0x8FA3AC)); + 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."); + alignTopCenter(ui.weight_note_label, 422); +} + +void buildCalibrationPage() +{ + lv_obj_t *page = createPageContainer(ui.content_panel); + ui.pages[pageIndex(PageId::Calibration)] = page; + createPageHeader(page, "Calibration", "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(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); + styleCenteredText(ui.cal_note_label, kContentInnerWidth, &lv_font_montserrat_14, lv_color_hex(0x8FA3AC)); + 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."); + alignTopCenter(ui.cal_note_label, 332); +} + +void buildWifiPage() +{ + lv_obj_t *page = createPageContainer(ui.content_panel); + ui.pages[pageIndex(PageId::Wifi)] = page; + createPageHeader(page, "Wi-Fi", "Save credentials and connect without leaving the device"); + + createMetricCard(page, "STATUS", 24, 92, 268, 88, &lv_font_montserrat_18, &ui.wifi_status_value); + createMetricCard(page, "IP", 316, 92, 268, 88, &lv_font_montserrat_18, &ui.wifi_ip_value); + + lv_obj_t *ssid_label = lv_label_create(page); + lv_obj_set_pos(ssid_label, 24, 198); + 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, 222, kContentInnerWidth, "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, 280); + 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, 304, kContentInnerWidth, "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, 368, 180, 48, lv_color_hex(0x245C7A), wifiConnectButtonEvent); + createActionButton(page, "Save + Connect", 214, 368, 180, 48, lv_color_hex(0x28714A), wifiSaveConnectButtonEvent); + createActionButton(page, "Disconnect", 404, 368, 180, 48, lv_color_hex(0x7A3A2A), wifiDisconnectButtonEvent); + + ui.wifi_note_label = lv_label_create(page); + styleCenteredText(ui.wifi_note_label, kContentInnerWidth, &lv_font_montserrat_14, lv_color_hex(0x8FA3AC)); + 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."); + alignTopCenter(ui.wifi_note_label, 420); +} + +void buildInventreePage() +{ + lv_obj_t *page = createPageContainer(ui.content_panel); + ui.pages[pageIndex(PageId::Inventree)] = page; + createPageHeader(page, "InvenTree", "Find stock by Batch Code and write the measured weight into stock quantity"); + + constexpr lv_coord_t kLeftColumnX = 24; + constexpr lv_coord_t kColumnWidth = 268; + constexpr lv_coord_t kRightColumnX = kLeftColumnX + kColumnWidth + 24; + + lv_obj_t *url_label = lv_label_create(page); + lv_obj_set_pos(url_label, kLeftColumnX, 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, kLeftColumnX, 112, kColumnWidth, "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, kLeftColumnX, 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, kLeftColumnX, 194, kColumnWidth, "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, kLeftColumnX, 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, kLeftColumnX, 276, kColumnWidth, "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, kRightColumnX, 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, kRightColumnX, 112); + lv_obj_set_width(ui.inventree_status_value, kColumnWidth); + 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, kRightColumnX, 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, kRightColumnX, 194); + lv_obj_set_width(ui.inventree_result_value, kColumnWidth); + 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", kLeftColumnX, 342, 128, 44, lv_color_hex(0x245C7A), inventreeSaveButtonEvent); + createActionButton(page, "Test API", kLeftColumnX + 140, 342, 128, 44, lv_color_hex(0x28714A), inventreeTestButtonEvent); + createActionButton(page, "Find", kLeftColumnX, 394, 128, 44, lv_color_hex(0x245C7A), inventreeFindButtonEvent); + createActionButton(page, "Next", kLeftColumnX + 140, 394, 128, 44, lv_color_hex(0x3D4E5F), inventreeNextButtonEvent); + createActionButton(page, "Push Weight", kRightColumnX, 394, kColumnWidth, 44, lv_color_hex(0x7A3A2A), inventreePushButtonEvent); + + ui.inventree_note_label = lv_label_create(page); + lv_obj_set_pos(ui.inventree_note_label, kRightColumnX, 322); + lv_obj_set_width(ui.inventree_note_label, kColumnWidth); + 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."); +} + +} // namespace + +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 refreshUi() +{ + if (ui.weight_label == nullptr) + { + return; + } + + if (!sensor_online) + { + displayed_weight_valid = false; + if (active_page == PageId::Calibration) + { + applyCalibrationState(); + } + 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; + } + + if (active_page == PageId::Weight) + { + setLabelLongText(ui.weight_raw_value, "%ld", static_cast(lroundf(filtered_raw))); + } + + if (active_page == PageId::Calibration) + { + applyCalibrationState(); + } + + if (calibration.scale > 0.0001f && calibration.has_offset) + { + const int32_t stable_grams = displayed_weight_valid + ? displayed_weight_grams + : static_cast(lroundf(getCurrentWeightGrams())); + setLabelLongText(ui.weight_label, "%ld", static_cast(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(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 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(); + refreshUi(); + updateWifiUi(true); + updateInventreeLabels(); +} + +void updateWifiUi(bool force) +{ + 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 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 buildUi() +{ + ui.screen = lv_obj_create(nullptr); + lv_obj_remove_style_all(ui.screen); + lv_obj_set_size(ui.screen, getDisplayWidth(), getDisplayHeight()); + 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, kContentHeight); + lv_obj_set_pos(ui.nav_panel, kScreenMargin, kScreenMargin); + lv_obj_remove_flag(ui.nav_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(ui.nav_panel, kPanelRadius, 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, kContentWidth, kContentHeight); + lv_obj_set_pos(ui.content_panel, 180, kScreenMargin); + lv_obj_remove_flag(ui.content_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(ui.content_panel, kPanelRadius, 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_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_style_radius(ui.text_editor_panel, kPanelRadius, 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_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"); + + ui.text_editor_cancel_button = + createActionButton(ui.text_editor_panel, "Cancel", 528, 8, 108, 38, lv_color_hex(0x323232), textEditorCancelButtonEvent); + ui.text_editor_apply_button = + 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_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_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_obj_remove_flag(ui.wifi_keyboard, LV_OBJ_FLAG_SCROLLABLE); + 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); + layoutTextEditor(); + + 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(); +}