commit 93e55a7418f654068b76544f05b0d313a046fd98 Author: tcomlab Date: Mon Apr 13 17:02:28 2026 +0300 Initial FilamentGauger firmware diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f7cf23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.pio/ +.vscode/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb4bf82 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# FilamentGauger + +PlatformIO firmware for the Guition `JC8048W550(C)` display with an `HX711` load-cell amplifier. + +Pins: +- `HX711 SCK -> IO17` +- `HX711 DOUT -> IO18` + +Important: +- On this display family `IO18` is normally used by the capacitive touch interrupt. +- In this firmware the touch controller stays enabled, but its interrupt pin is moved to `GPIO_NUM_NC`, so touch works in polling mode and `IO18` becomes available for `HX711`. + +Default calibration flow: +1. Leave the platform empty. +2. Tap `Tare`. +3. Put a `100 g` reference weight on the platform. +4. Tap `Cal 100 g`. + +Serial commands: +- `tare` +- `cal 100` +- `scale 432.1` +- `reset` +- `status` + +Build: +```bash +platformio run +``` diff --git a/boards/JC8048W550_HX711.json b/boards/JC8048W550_HX711.json new file mode 100644 index 0000000..5945dfe --- /dev/null +++ b/boards/JC8048W550_HX711.json @@ -0,0 +1,142 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "'-D ARDUINO_ESP32S3_DEV'", + "'-D BOARD_HAS_PSRAM'", + "'-D ARDUINO_USB_MODE=1'", + "'-D ARDUINO_RUNNING_CORE=1'", + "'-D ARDUINO_EVENT_RUNNING_CORE=1'", + "'-D ARDUINO_USB_CDC_ON_BOOT=0'", + "'-D JC8048W550_HX711'", + "'-D DISPLAY_WIDTH=800'", + "'-D DISPLAY_HEIGHT=480'", + "'-D DISPLAY_SOFTWARE_ROTATION'", + "'-D LVGL_BUFFER_PIXELS=(DISPLAY_WIDTH*DISPLAY_HEIGHT)'", + "'-D LVGL_BUFFER_MALLOC_FLAGS=(MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)'", + "'-D DISPLAY_BCKL=2'", + "'-D DISPLAY_ST7262_PAR'", + "'-D SMARTDISPLAY_DMA_BUFFER_SIZE=131072'", + "'-D SMARTDISPLAY_DMA_QUEUE_SIZE=6'", + "'-D SMARTDISPLAY_DMA_CHUNK_THRESHOLD=1024'", + "'-D SMARTDISPLAY_DMA_TIMEOUT_MS=500'", + "'-D ST7262_PANEL_CONFIG_CLK_SRC=LCD_CLK_SRC_PLL160M'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_PCLK_HZ=(12.5*1000000)'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_H_RES=DISPLAY_WIDTH'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_V_RES=DISPLAY_HEIGHT'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_HSYNC_PULSE_WIDTH=4'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_HSYNC_BACK_PORCH=8'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_HSYNC_FRONT_PORCH=8'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_VSYNC_PULSE_WIDTH=4'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_VSYNC_BACK_PORCH=8'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_VSYNC_FRONT_PORCH=8'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_FLAGS_HSYNC_IDLE_LOW=false'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_FLAGS_VSYNC_IDLE_LOW=false'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_FLAGS_DE_IDLE_HIGH=false'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_FLAGS_PCLK_ACTIVE_NEG=true'", + "'-D ST7262_PANEL_CONFIG_TIMINGS_FLAGS_PCLK_IDLE_HIGH=false'", + "'-D ST7262_PANEL_CONFIG_DATA_WIDTH=16'", + "'-D ST7262_PANEL_CONFIG_SRAM_TRANS_ALIGN=4'", + "'-D ST7262_PANEL_CONFIG_PSRAM_TRANS_ALIGN=64'", + "'-D ST7262_PANEL_CONFIG_HSYNC=39'", + "'-D ST7262_PANEL_CONFIG_VSYNC=41'", + "'-D ST7262_PANEL_CONFIG_DE=40'", + "'-D ST7262_PANEL_CONFIG_PCLK=42'", + "'-D ST7262_PANEL_CONFIG_DATA_R0=8'", + "'-D ST7262_PANEL_CONFIG_DATA_R1=3'", + "'-D ST7262_PANEL_CONFIG_DATA_R2=46'", + "'-D ST7262_PANEL_CONFIG_DATA_R3=9'", + "'-D ST7262_PANEL_CONFIG_DATA_R4=1'", + "'-D ST7262_PANEL_CONFIG_DATA_G0=5'", + "'-D ST7262_PANEL_CONFIG_DATA_G1=6'", + "'-D ST7262_PANEL_CONFIG_DATA_G2=7'", + "'-D ST7262_PANEL_CONFIG_DATA_G3=15'", + "'-D ST7262_PANEL_CONFIG_DATA_G4=16'", + "'-D ST7262_PANEL_CONFIG_DATA_G5=4'", + "'-D ST7262_PANEL_CONFIG_DATA_B0=45'", + "'-D ST7262_PANEL_CONFIG_DATA_B1=48'", + "'-D ST7262_PANEL_CONFIG_DATA_B2=47'", + "'-D ST7262_PANEL_CONFIG_DATA_B3=21'", + "'-D ST7262_PANEL_CONFIG_DATA_B4=14'", + "'-D ST7262_PANEL_CONFIG_DISP=GPIO_NUM_NC'", + "'-D ST7262_PANEL_CONFIG_FLAGS_DISP_ACTIVE_LOW=false'", + "'-D ST7262_PANEL_CONFIG_FLAGS_RELAX_ON_IDLE=false'", + "'-D ST7262_PANEL_CONFIG_FLAGS_FB_IN_PSRAM=true'", + "'-D DISPLAY_SWAP_XY=false'", + "'-D DISPLAY_MIRROR_X=false'", + "'-D DISPLAY_MIRROR_Y=false'", + "'-D BOARD_HAS_TOUCH'", + "'-D CONFIG_ESP_LCD_TOUCH_MAX_POINTS=1'", + "'-D CONFIG_ESP_LCD_TOUCH_MAX_BUTTONS=0'", + "'-D TOUCH_GT911_I2C'", + "'-D GT911_I2C_HOST=I2C_NUM_0'", + "'-D GT911_I2C_CONFIG_SDA=19'", + "'-D GT911_I2C_CONFIG_SCL=20'", + "'-D GT911_I2C_CONFIG_SDA_PULLUP_EN=GPIO_PULLUP_DISABLE'", + "'-D GT911_I2C_CONFIG_SCL_PULLUP_EN=GPIO_PULLUP_DISABLE'", + "'-D GT911_I2C_CONFIG_MASTER_CLK_SPEED=400000'", + "'-D GT911_I2C_CONFIG_CLK_FLAGS=I2C_SCLK_SRC_FLAG_FOR_NOMAL'", + "'-D GT911_IO_I2C_CONFIG_DEV_ADDR=GT911_IO_I2C_CONFIG_DEV_ADDRESS_5D'", + "'-D GT911_IO_I2C_CONFIG_CONTROL_PHASE_BYTES=1'", + "'-D GT911_IO_I2C_CONFIG_DC_BIT_OFFSET=0'", + "'-D GT911_IO_I2C_CONFIG_LCD_CMD_BITS=16'", + "'-D GT911_IO_I2C_CONFIG_LCD_PARAM_BITS=0'", + "'-D GT911_IO_I2C_CONFIG_FLAGS_DC_LOW_ON_DATA=false'", + "'-D GT911_IO_I2C_CONFIG_FLAGS_DISABLE_CONTROL_PHASE=true'", + "'-D GT911_TOUCH_CONFIG_X_MAX=DISPLAY_WIDTH'", + "'-D GT911_TOUCH_CONFIG_Y_MAX=DISPLAY_HEIGHT'", + "'-D GT911_TOUCH_CONFIG_RST=38'", + "'-D GT911_TOUCH_CONFIG_INT=GPIO_NUM_NC'", + "'-D GT911_TOUCH_CONFIG_LEVELS_RESET=0'", + "'-D GT911_TOUCH_CONFIG_LEVELS_INTERRUPT=0'", + "'-D TOUCH_SWAP_XY=false'", + "'-D TOUCH_MIRROR_X=false'", + "'-D TOUCH_MIRROR_Y=false'", + "'-D BOARD_HAS_TF'", + "'-D TF_CS=10'", + "'-D TF_SPI_MOSI=11'", + "'-D TF_SPI_SCLK=12'", + "'-D TF_SPI_MISO=13'", + "'-D HX711_SCK_PIN=17'", + "'-D HX711_DOUT_PIN=18'" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [ + [ + "0x303A", + "0x1001" + ] + ], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": [ + "wifi" + ], + "debug": { + "openocd_target": "esp32s3.cfg" + }, + "frameworks": [ + "arduino", + "espidf" + ], + "name": "JC8048W550_HX711", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 460800 + }, + "url": "https://github.com/rzeldent/esp32-smartdisplay/discussions/185", + "vendor": "Guition" +} diff --git a/include/lv_conf.h b/include/lv_conf.h new file mode 100644 index 0000000..079d9c7 --- /dev/null +++ b/include/lv_conf.h @@ -0,0 +1,31 @@ +#ifndef LV_CONF_H +#define LV_CONF_H + +#define LV_COLOR_DEPTH 16 +#define LV_MEM_SIZE (128U * 1024U) +#define LV_DEF_REFR_PERIOD 16 + +#define LV_USE_LOG 0 + +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_18 1 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_24 1 +#define LV_FONT_MONTSERRAT_32 1 +#define LV_FONT_MONTSERRAT_48 1 +#define LV_FONT_DEFAULT &lv_font_montserrat_20 + +#define LV_USE_LABEL 1 +#define LV_LABEL_LONG_TXT_HINT 1 +#define LV_USE_BUTTON 1 +#define LV_USE_BUTTONMATRIX 1 +#define LV_USE_FLEX 1 +#define LV_USE_KEYBOARD 1 +#define LV_USE_OBSERVER 1 +#define LV_USE_TEXTAREA 1 + +#define LV_USE_THEME_DEFAULT 1 +#define LV_THEME_DEFAULT_DARK 1 +#define LV_THEME_DEFAULT_GROW 0 + +#endif diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..ff14f1f --- /dev/null +++ b/platformio.ini @@ -0,0 +1,34 @@ +; FilamentGauger firmware for Guition JC8048W550(C) +; HX711 wiring: +; SCK -> IO17 +; DOUT -> IO18 + +[platformio] +default_envs = jc8048w550_hx711 + +[env] +platform = espressif32 @ 6.9.0 +framework = arduino + +monitor_speed = 115200 +monitor_rts = 0 +monitor_dtr = 0 +monitor_filters = esp32_exception_decoder + +build_flags = + -Ofast + -Wall + '-D BOARD_NAME="${this.board}"' + '-D CORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_INFO' + '-D LV_CONF_PATH=${platformio.include_dir}/lv_conf.h' + '-D HX711_REFERENCE_WEIGHT_GRAMS=100.0f' + +lib_deps = + https://github.com/rzeldent/esp32-smartdisplay + lvgl/lvgl @ 9.2.2 + bblanchon/ArduinoJson @ 7.4.3 + +lib_ldf_mode = chain+ + +[env:jc8048w550_hx711] +board = JC8048W550_HX711 diff --git a/src/hx711_sensor.cpp b/src/hx711_sensor.cpp new file mode 100644 index 0000000..cf1b650 --- /dev/null +++ b/src/hx711_sensor.cpp @@ -0,0 +1,110 @@ +#include "hx711_sensor.h" + +void Hx711Sensor::begin(uint8_t dout_pin, uint8_t sck_pin, uint8_t gain) +{ + dout_pin_ = dout_pin; + sck_pin_ = sck_pin; + + pinMode(dout_pin_, INPUT); + pinMode(sck_pin_, OUTPUT); + digitalWrite(sck_pin_, LOW); + + setGain(gain); +} + +bool Hx711Sensor::isReady() const +{ + return digitalRead(dout_pin_) == LOW; +} + +bool Hx711Sensor::waitReady(uint32_t timeout_ms) const +{ + const uint32_t started = millis(); + + while (!isReady()) + { + if (millis() - started >= timeout_ms) + { + return false; + } + delay(1); + } + + return true; +} + +int32_t Hx711Sensor::readRaw() +{ + uint32_t value = 0; + + noInterrupts(); + for (uint8_t bit = 0; bit < 24; ++bit) + { + digitalWrite(sck_pin_, HIGH); + delayMicroseconds(1); + value = (value << 1) | (digitalRead(dout_pin_) ? 1UL : 0UL); + digitalWrite(sck_pin_, LOW); + delayMicroseconds(1); + } + + for (uint8_t pulse = 0; pulse < gain_pulses_; ++pulse) + { + digitalWrite(sck_pin_, HIGH); + delayMicroseconds(1); + digitalWrite(sck_pin_, LOW); + delayMicroseconds(1); + } + interrupts(); + + if (value & 0x800000UL) + { + value |= 0xFF000000UL; + } + + return static_cast(value); +} + +int32_t Hx711Sensor::readAverage(uint8_t samples, uint32_t timeout_ms_per_sample) +{ + int64_t total = 0; + uint8_t collected = 0; + + while (collected < samples) + { + if (!waitReady(timeout_ms_per_sample)) + { + break; + } + + total += readRaw(); + ++collected; + delay(1); + } + + if (collected == 0) + { + return 0; + } + + return static_cast(total / collected); +} + +void Hx711Sensor::setGain(uint8_t gain) +{ + switch (gain) + { + case 128: + gain_pulses_ = 1; + break; + case 32: + gain_pulses_ = 2; + break; + case 64: + gain_pulses_ = 3; + break; + default: + gain_pulses_ = 1; + break; + } +} + diff --git a/src/hx711_sensor.h b/src/hx711_sensor.h new file mode 100644 index 0000000..07cd0d2 --- /dev/null +++ b/src/hx711_sensor.h @@ -0,0 +1,24 @@ +#ifndef HX711_SENSOR_H +#define HX711_SENSOR_H + +#include + +class Hx711Sensor +{ +public: + void begin(uint8_t dout_pin, uint8_t sck_pin, uint8_t gain = 128); + bool isReady() const; + bool waitReady(uint32_t timeout_ms) const; + int32_t readRaw(); + int32_t readAverage(uint8_t samples, uint32_t timeout_ms_per_sample = 1500); + +private: + void setGain(uint8_t gain); + + uint8_t dout_pin_ = 0; + uint8_t sck_pin_ = 0; + uint8_t gain_pulses_ = 1; +}; + +#endif + diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..de18ff7 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,2289 @@ +#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 + +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 *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 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); +} + +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)); +} + +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, "Main scale view, filtered 1 g"); + 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(); + + inventree_data.batch = ui.inventree_batch_ta != nullptr + ? String(lv_textarea_get_text(ui.inventree_batch_ta)) + : inventree_data.batch; + inventree_data.batch.trim(); + + 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()); +} + +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, "Scale"); + + 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, "Main page with filtered HX711 weight"); + + 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, 132); + 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, 170); + 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, 246); + 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; + + ui.weight_note_label = lv_label_create(page); + lv_obj_set_pos(ui.weight_note_label, 24, 338); + lv_obj_set_width(ui.weight_note_label, 550); + 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, "Open Calibration to tare and set the 100 g reference. Open Wi-Fi to configure the network."); +} + +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, "Scale", 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("Open Calibration to tare and set the 100 g reference. Open Wi-Fi to configure the network."); + setCalibrationNote("Empty the platform, tap Tare, place the 100 g reference weight, then tap Cal."); + + if (wifi_data.ssid.isEmpty()) + { + setWifiNote("Tap a field to edit fullscreen, then Connect or Save + Connect."); + } + else + { + setWifiNote("Saved credentials loaded. Tap a field to edit fullscreen, then Connect."); + } + + if (inventreeConfigured()) + { + inventree_data.status_text = "Config loaded"; + inventree_data.result_text = inventree_data.token.isEmpty() + ? "Saved URL and Batch Code loaded. Token will be requested automatically." + : "Saved URL, token and Batch Code loaded from device storage."; + setInventreeNote("Use Test API, then Find by Batch Code and Push Weight. API token is optional."); + } + else + { + inventree_data.status_text = "Not configured"; + inventree_data.result_text = "Base URL is prefilled. Enter Batch Code. Token is optional."; + setInventreeNote("Tap a field to edit fullscreen, then Save, Test API, Find and Push Weight."); + } + + setActivePage(PageId::Weight); + refreshUi(); + updateWifiUi(true); + updateInventreeLabels(); +} + +void handleSerialCommand(const String &command) +{ + if (command.equalsIgnoreCase("tare")) + { + performTare(); + return; + } + + if (command.equalsIgnoreCase("reset")) + { + clearCalibration(); + return; + } + + if (command.equalsIgnoreCase("status")) + { + Serial.printf("online=%d raw=%ld offset=%ld scale=%f has_offset=%d wifi_status=%d\n", + sensor_online ? 1 : 0, + static_cast(last_raw), + static_cast(calibration.offset), + calibration.scale, + calibration.has_offset ? 1 : 0, + static_cast(WiFi.status())); + return; + } + + if (command.startsWith("cal ")) + { + const float grams = command.substring(4).toFloat(); + performCalibration(grams); + return; + } + + if (command.startsWith("scale ")) + { + const float scale = command.substring(6).toFloat(); + if (scale > 0.0f) + { + calibration.scale = scale; + saveCalibration(); + displayed_weight_valid = false; + refreshUi(); + setCalibrationNote("Manual scale factor saved."); + } + else + { + setCalibrationNote("Invalid scale factor."); + } + return; + } + + setCalibrationNote("Unknown serial command. Use: tare | cal | scale | reset | status"); +} + +void handleSerial() +{ + while (Serial.available() > 0) + { + const char ch = static_cast(Serial.read()); + if (ch == '\r') + { + continue; + } + + if (ch == '\n') + { + serial_line.trim(); + if (!serial_line.isEmpty()) + { + handleSerialCommand(serial_line); + } + serial_line = ""; + continue; + } + + serial_line += ch; + } +} + +void sampleSensor() +{ + if (!hx711.isReady()) + { + if (millis() - last_sample_ms > kSensorTimeoutMs) + { + sensor_online = false; + displayed_weight_valid = false; + } + return; + } + + last_raw = hx711.readRaw(); + pushRawSample(last_raw); + const float averaged_raw = getAveragedRawSample(); + + if (!have_sample) + { + filtered_raw = averaged_raw; + have_sample = true; + } + else + { + filtered_raw += kRawFilterAlpha * (averaged_raw - filtered_raw); + } + + sensor_online = true; + last_sample_ms = millis(); +} + +} // namespace + +void setup() +{ +#ifdef ARDUINO_USB_CDC_ON_BOOT + delay(5000); +#endif + + Serial.begin(115200); + Serial.setDebugOutput(true); + + log_i("Board: %s", BOARD_NAME); + log_i("CPU: %s rev%d, %d MHz, %d core(s)", + ESP.getChipModel(), + ESP.getChipRevision(), + getCpuFrequencyMhz(), + ESP.getChipCores()); + log_i("Free heap: %d bytes", ESP.getFreeHeap()); + log_i("Free PSRAM: %d bytes", ESP.getPsramSize()); + log_i("SDK version: %s", ESP.getSdkVersion()); + + prefs.begin(kPrefsNamespace, false); + loadCalibration(); + loadWifiConfig(); + loadInventreeConfig(); + + hx711.begin(HX711_DOUT_PIN, HX711_SCK_PIN); + + smartdisplay_init(); + auto *display = lv_disp_get_default(); + lv_display_set_rotation(display, LV_DISPLAY_ROTATION_180); + + buildUi(); + + WiFi.mode(WIFI_OFF); + if (!wifi_data.ssid.isEmpty()) + { + connectWifi(wifi_data.ssid, wifi_data.password, false); + setWifiNote("Auto-connecting to saved Wi-Fi..."); + } + + last_lv_tick_ms = millis(); + next_ui_refresh_ms = millis(); + + log_i("HX711 pins: SCK=%d DOUT=%d", HX711_SCK_PIN, HX711_DOUT_PIN); +} + +void loop() +{ + const uint32_t now = millis(); + + sampleSensor(); + handleSerial(); + handleWifi(); + + if (now >= next_ui_refresh_ms) + { + next_ui_refresh_ms = now + kUiRefreshMs; + refreshUi(); + } + + lv_tick_inc(now - last_lv_tick_ms); + last_lv_tick_ms = now; + lv_timer_handler(); + + delay(5); +}