Skip to content

05 – Software

This is the single place for the knowledge needed to build and change the firmware: toolchain, code layout, conventions, how the hardware is driven, how the numbers are computed and how the screen is built.

What lives elsewhere, and is only linked from here:

Where What
01 – Overview Purpose, architecture, project-wide conventions, way of working, status
02 – Component list Components and their key data
Electrical drawing Wiring, pins, levels, supply (hardware data)
03 – Check list All open items Ö1, Ö2 … (they are never kept in this file)

Status: the firmware is built with the ESP32 toolchain, uploaded and tested on the board, and works (display, touch and HX711; confirmed by the user 2026-10-16, closes Ö3). Before that it was checked on a PC: the C++ was compiled against the LVGL 9.3.0 sources, and the HX711 logic was run against a simulated HX711 (decoding, tare, calibration, peaks, offline detection). What is still not verified on the board is tracked as open items in 03 – Check list (section 9 lists the software-related ones).


1. Toolchain and build

Item Value
IDE / build PlatformIO (VS Code extension), configuration in platformio.ini
Platform pioarduino fork of espressif32 (platform-espressif32.zip, stable). The official PlatformIO platform has no ESP32-P4 support yet
Framework Arduino, as bundled with the pioarduino platform (arduino-esp32 3.x; 3.3.12 was resolved when this was written)
Board esp32-p4-evboard (ES pre rev.300). Fallback if the DSI display does not start: esp32-p4_r3
Memory 32 MB flash and 32 MB PSRAM: default_32MB.csv, -DBOARD_HAS_PSRAM, QIO flash at 80 MHz
Build flags -DBOARD_HAS_PSRAM -DLV_CONF_INCLUDE_SIMPLE -Iinclude
Libraries Arduino_GFX (from GitHub) and lvgl/lvgl@^9.3.0. The HX711 needs no library
Serial 115200 baud; upload_port and monitor_port are set to COM4 and must be changed to the actual port

Notes:

  • main.cpp stops at compile time if BOARD_HAS_PSRAM is missing.
  • The decisions pioarduino + Arduino come from the working project Boxter83/Flip-HMI. This repository has been built and uploaded with them (Ö3, closed 2026-10-16).
  • Fonts are generated, not hand-made: tools/generate_fonts.sh needs node/npm (see section 7.5).

2. Repository layout

File Role
src/main.cpp Starts the subsystems and runs the loop. Display, touch and LVGL start-up taken from Boxter83/Flip-HMI
include/load_cell_config.h Fixed constants: HX711 pins and rate, load cell data, sign, factory scale estimate, filter settings
include/hx711_manager.h, src/hx711_manager.cpp Reads the HX711 (bit-banged, non-blocking), signal plausibility checks, moving average, tare, calibration, rate and sensor range (stored in NVS), peak values
include/ui_screen.h, src/ui_screen.cpp The screen, its calibration, settings, force alarm and overload popups
include/ui_fonts.h, src/fonts/font_force_96.c 96 px digit font for the force readout
include/backlight.h, src/backlight.cpp Screen brightness (10–100 %): PWM on GPIO26 and registers 0x86 and 0x96 of the I²C bridge 0x45, stored in NVS
include/displays_config.h Panel timing and the JD9365 initialisation table for the 10.1" display, backlight and I²C bridge start-up
include/i2c.h, src/i2c.cpp I²C master set-up (shared by the display bridge and the touch controller)
include/gt911.h, src/gt911.cpp, include/touch.h, src/touch.cpp Touch driver
include/serial_log.h Serial output helper
include/lv_conf.h LVGL configuration
tools/generate_fonts.sh Regenerates src/fonts/font_force_96.c
tools/render_hmi_screens.py Draws the HMI screens as SVG pictures for 06 – User manual (docs/images/hmi/). Mirrors the layout in ui_screen.cpp; update it when the screen changes

The display, I²C and touch files are copied from Boxter83/Flip-HMI (identical to Boxter83/Flip_HMI_Test) and are known to bring up this display. Only the 10.1" panel was kept from the Waveshare example, which also had 7" and 8" variants. Change these files as little as possible.


3. Conventions

  • Modular firmware. Each subsystem has its own .h/.cpp pair following a manager pattern (a namespace with begin() or build() and a non-blocking poll() or update()), and main.cpp is changed as little as possible.
  • Language. All code, comments, log text and all text on the screen is in English (see 01 – Overview).
  • Constants that describe the hardware or the load cell go in include/load_cell_config.h, not in the managers.
  • No blocking in loop(). hx711_manager::poll() returns at once if no conversion is ready; the screen limits its own update rate.
  • Branch. Ask which GitHub branch to work from before starting on new code (rule in 01 – Overview, Way of working).
  • Adding a subsystem. Create include/<name>_manager.h and src/<name>_manager.cpp, call begin() from setup() and poll() from loop(), then record new facts here and new items in 03 – Check list.
  • When wiring or I/O changes, update the files listed in section 9 of Electrical drawing, including the pin definitions in include/load_cell_config.h.

4. Program structure

Load → bridge (mV) → HX711 (24-bit raw) ─ hx711_manager ─ (tare, scale) → kg ─ ui_screen → display
                                                  ▲                                  │
                                                  └──── TARE / CALIBRATE / RESET ◄───┘ touch (GT911)

4.1 setup() order and why

  1. hx711_manager::begin() first. GPIO20 (PD_SCK) and GPIO22 (RATE) have no pull resistor at reset and float until driven. begin() sets RATE, drives PD_SCK low and gives a reset pulse (high > 60 µs, then low). The HX711 then needs its settling time, which the display start-up provides.
  2. display_cfg_prepare(): I²C bridge sequence on device 0x45 (must run before gfx->begin()), then display_cfg_backlight(true) and a 300 ms delay.
  3. gfx->begin(), clear the screen.
  4. lv_init(), log callback, tick callback (millis), draw buffer, display.
  5. Touch: DEV_I2C_Init() reuses the bus from step 2, then touch_gt911_init().
  6. ui_screen::build().

4.2 loop()

hx711_manager::poll() → ui_screen::update() → lv_timer_handler() → delay(2). At 80 SPS a conversion is ready every 12.5 ms; a conversion that is not read in time is simply replaced by the next one, which the averaging tolerates.

4.3 Memory

Item Where Size
LVGL draw buffer (partial render, 100 lines × 800 px, RGB565) PSRAM, 64-byte aligned 160 kB
LVGL objects (built-in TLSF allocator, LV_MEM_SIZE) Internal RAM (static) 128 kB
Trend history (600 floats) Internal RAM 2.4 kB

The LVGL draw buffer is allocated as width × lines × sizeof(uint16_t) and the display colour format is set explicitly to RGB565, because lv_color_t is 3 bytes in LVGL 9 and must not be used for sizing an RGB565 buffer.


5. HX711

5.1 Pins

Defined in include/load_cell_config.h (Ö8, closed 2026-10-16: works on the board; the hardware reasoning is in Electrical drawing, section 5.2).

Signal ESP32-P4 GPIO P6 pin Direction Constant
CLK / PD_SCK GPIO20 14 Output HX711_PIN_CLK
DO / DOUT GPIO21 12 Input HX711_PIN_DOUT
RTE / RATE GPIO22 11 Output HX711_PIN_RATE

Output rate: HX711_RATE_80SPS (default false = 10 SPS) is only the value used until a rate has been stored; the rate is changed on the SETTINGS screen (section 8.4) and then kept in NVS. Gain: channel A, gain 128 (HX711_GAIN_PULSES = 1, i.e. 25 clock pulses per conversion).

5.2 Serial interface

Moved here from the electrical drawing (was section 4.5).

Item Value
Data ready DOUT goes low
Clock 25–27 pulses on PD_SCK per conversion: 25 = ch A gain 128, 26 = ch B gain 32, 27 = ch A gain 64
PD_SCK high time 0.2–50 µs (keep pulses short; disable interrupts while clocking)
PD_SCK low time ≥ 0.2 µs
Power down PD_SCK high > 60 µs; returning low resets the chip (default ch A, gain 128)
Data format 24-bit two's complement, MSB first; saturates at 800000h / 7FFFFFh
Output rate RATE = 0: 10 SPS, RATE = 1: 80 SPS (internal oscillator)
Settling time 400 ms at 10 SPS, 50 ms at 80 SPS (after power-up, reset, channel/gain change)

Start-up behaviour (moved from section 5.2 of the electrical drawing): GPIO20 and GPIO22 float until firmware drives them. If PD_SCK floated high long enough the HX711 enters power-down; it resets when PD_SCK goes low, so this is harmless, but the first readings after start-up must be discarded.

5.3 How hx711_manager reads

  • poll() checks DOUT. Only if it is low, one conversion is clocked out: 24 data bits (DOUT sampled after each rising edge, about 1 µs high and 1 µs low) plus one gain pulse. The 25 pulses run inside a critical section so that no pulse can be stretched beyond 60 µs.
  • decode24() sign-extends the 24-bit value. 0x7FFFFF and 0x800000 are reported as saturated (input outside the ADC range).
  • The first kWarmupSamples (4) conversions after reset are discarded: about 400 ms at 10 SPS and 50 ms at 80 SPS, matching the settling time.
  • A moving average over kAverageSamples (8) conversions is used for the displayed value, about 0.8 s at 10 SPS.
  • Is a real HX711 answering? A stuck-low DOUT (nothing connected, a floating or shorted line) reads as an endless stream of zeros at several hundred conversions per second and used to be shown as SENSOR OK. The sensor now counts as online (fault() == Fault::None) only if all of these hold:
  • After the 25th clock pulse DOUT is high again (the datasheet says the 25th pulse pulls it high). Three reads in a row that leave it low = StuckLine; the clocking is abandoned and the chip is reset.
  • The conversion rate, measured over one second, lies between 0.2 and 1.6 times the nominal rate (10 or 80 SPS) = otherwise BadRate (kRateLowFactor, kRateHighFactor).
  • The value is not the same non-saturated number 16 times in a row (kStuckValueSamples) = otherwise StuckValue. A real 24-bit conversion always has some noise in the low bits.
  • A conversion arrived within kOfflineAfterMs (1500 ms) = otherwise NoData. Settling is reported for the first second after start or restart. The reason is shown on the SETTINGS screen (SIGNAL card, faultText()); the header shows NO SIGNAL for every fault.
  • A DOUT that floats and happens to toggle cannot be told from a real reading with certainty, but fails at least one of the checks above in practice.

6. Measurement and calibration

6.1 Formula

force [kg] = sign × (filtered_raw − tare_offset) / counts_per_kg
force [N]  = force [kg] × N_per_kg      (default 9.80665, setting "Newton per kg")

The sensor measures kilograms. The display unit (g, kg, N, kN) and the N-per-kg factor only affect what is shown (section 8.4); all internal values, the limits, the calibration weight and the settings values stay in kg.

  • Sign. The screen shows compression positive and tension negative (LOAD_CELL_COMPRESSION_POSITIVE, true; requirement of 2026-10-14). The raw counts are assumed to rise with tension, which LOAD_CELL_INVERT_SIGN (default false) corrects if the wire polarity turns out to be the other way round (Ö1). The two flags are independent: the wire correction is applied first, the display convention second. A swapped pair only inverts the reading and damages nothing. Record in the check list which fix was used (swap the wires or set the flag).
  • Sensor range and limits. The measuring range of the connected load cell is a setting (SETTINGS, "Sensor range", 0.1–2000 kg; default kRatedKg = 200 kg for the Gefran TU-K2C). Gauge, bar, alarm limit and the maximum permitted force may be set up to kPermittedFactor (1.3) times the range, the permitted static load of the Gefran TU. The maximum permitted force (default = the range) triggers the red OVERLOAD popup (section 8.4).

6.2 Factory estimate of the scale

Until a calibration is stored the scale is the ideal value 2²³ × 0.512 / range ≈ 21 475 counts/kg for the default 200 kg (defaultCountsPerKg(), kDefaultCountsPerKg). It follows from the ratiometric HX711 input: full scale at gain 128 is ±0.5 × AVDD / 128, and the bridge gives 2 mV/V × AVDD at 200 kg, so the supply voltage cancels (AVDD ≈ 4.30 V on the click: 8.6 mV of 16.8 mV, about 51 % of the range; see section 4.4 of the electrical drawing). Real cells and the click's excitation network differ from this by a few percent, so the screen shows UNCALIBRATED until a calibration has been run (Ö12).

6.3 Tare

TARE stores the present filtered raw value as tare_offset and clears the peaks. It is refused while the sensor is offline or the moving average is not yet full (about a second after start-up).

6.4 Calibration

  1. Remove the load and press TARE.
  2. Load the cell with a known weight (either direction) and open CALIBRATE.
  3. Enter the weight (0.1 kg steps, 1.0 kg up to the sensor range) and press APPLY.

counts_per_kg = |filtered_raw − tare_offset| / known_kg. It is refused if the sensor is offline, the weight is below 1 kg, or the signal is smaller than kMinCalDeltaCounts (1000 counts, i.e. no load). Only the magnitude is stored; the direction comes from the two sign flags above. FACTORY in the popup restores the factory estimate and clears the tare.

6.5 Stored values (NVS)

Namespace loadcell, written when TARE or CALIBRATE succeeds and when the calibration is cleared:

Key Type Content
tare int32 Raw value at zero load
scale float Counts per kg (values ≤ 100 or non-finite are ignored at start-up)
cal bool A real calibration has been stored (the stored scale is used only if this is true)
rate80 bool HX711 rate: true = 80 SPS, false = 10 SPS (SETTINGS)
rated float Measuring range of the connected load cell in kg (SETTINGS)
stab int Stabilisation level 0 off, 1 medium, 2 high (SETTINGS)

Changing the sensor range means another load cell, so it clears the tare and the calibration (back to the factory estimate for the new range). Changing the rate restarts the HX711 and the filter. The screen settings (gauge, limits) are stored separately, see section 8.4.

6.6 Stabilisation of the displayed force

The last digits of a 24-bit reading always move with the ADC noise (the HX711 has roughly ±100–250 counts of noise after the 8-sample average; 0.01 kg is about 215 counts on a 200 kg cell), so with 2–3 decimals the last digit flickers. Two stages calm it:

  1. Filter in hx711_manager (forceStableKg()): after the moving average, a low-pass filter (time constant 1.0 s on MED, 2.5 s on HIGH) is applied as long as the value stays within a band around the filtered value (400 counts on MED, 800 on HIGH, kStabBandCounts, kStabTauMs in load_cell_config.h). A change larger than the band is followed at once, so a real load change is not delayed. The band is in counts, not kg, because the noise is about the same number of counts for any load cell range. OFF = no filter (forceStableKg() equals forceKg()). The peak values are tracked on the stabilised value, so noise does not push them upward.
  2. Hold in the display (holdForDisplay() in ui_screen.cpp): the shown number only changes when the stabilised value has moved by at least 0.8 (MED) or 1.0 (HIGH) of the last digit; with OFF it is plain rounding (0.5).

All numbers, the gauge, the bar, the trend and the arrows show the stabilised value. The limits (FORCE ALARM, OVERLOAD) are judged on the fast value forceKg(), so they react without the filter delay. Host test on a simulated noisy HX711 (±250 counts, 2 decimals, 300 samples): the displayed value changed 37 times with the filter off, 7 times on MED and once on HIGH. Residual noise on HIGH is about ±0.003 kg, so the third decimal still moves a little at this noise level.


7. Display, touch and LVGL

7.1 Panel

Item Value
Panel JD9365, 800 × 1280 (portrait), RGB565, MIPI-DSI 2 lanes, 1500 Mbit/s per lane
Timing HSYNC pulse/back/front porch 20/20/40, VSYNC 4/12/30, prefer_speed 80 MHz
Initialisation Command table kJd9365Lcd10Commands in include/displays_config.h (from the Waveshare example)
Driver Arduino_GFX: Arduino_ESP32DSIPanel and Arduino_DSI_Display
LCD reset GPIO27 (lcd_rst)
Output to LVGL gfx->draw16bitRGBBitmap() from the flush callback

7.2 Backlight and the I²C bridge (Ö11)

Two mechanisms are executed at start-up, exactly as in Flip-HMI, because it is not known which one is needed on this display:

  • display_cfg_prepare() writes to device 0x45 on the I²C bus: register 0x95 ← 0x11, 0x95 ← 0x17, 0x96 ← 0x00, 100 ms pause, 0x96 ← 0xFF, then waits 1 s. The sequence was written for the 7" panel and kept because it is part of the start-up that works in Flip-HMI.
  • display_cfg_backlight(true) sets up PWM on GPIO26 (5 kHz, 10 bit) at full duty.

Brightness control (works on the board, confirmed 2026-10-14). The brightness is set from the SETTINGS screen (slider and − / + buttons, 10–100 %) by backlight::apply() in src/backlight.cpp. To make it work, the firmware must:

  1. Have the I²C bus up (DEV_I2C_Init(), GPIO7 / GPIO8) and the bridge start-up sequence above executed in setup() before gfx->begin().
  2. For every change, add device 0x45 on the shared bus (7-bit address, 100 kHz), write the value 255 × percent / 100 to register 0x86 and the same value to register 0x96 (two writes of two bytes: register, value), and remove the device again. Register 0x86 is the one given by the Waveshare wiki for the 10.1-DSI-TOUCH-A (0x00–0xFF); 0x96 is the register the start-up sequence uses for full brightness (0xFF). Both are written because it was not tested which one the display needs; with both written the brightness follows the slider.
  3. Never go below 10 % (backlight::kMinPercent), so the screen cannot be turned off by mistake.
  4. Also set the GPIO26 PWM duty (ledcWrite), as in the inherited start-up. It is harmless but probably has no effect, because GPIO26 is a USB pin on this board.
  5. Keep the live preview while the slider moves; SAVE stores the value in NVS (namespace display, key bright), CANCEL restores the stored one. backlight::begin() applies the stored value at start-up, right after display_cfg_backlight(true).

Every change prints a line on the serial monitor, for example Backlight 60 %: PWM set, I2C 0x45 reg 0x86 and 0x96 written; FAILED means that the I²C write was not acknowledged.

Still open (Ö11): which of 0x86, 0x96 and the GPIO26 PWM the display really needs. To find out, write only one of them at a time (and later remove display_cfg_backlight(true)) and see whether the brightness still follows the slider. Note also that GPIO24–27 are listed as USB pins on the ESP32-P4-WIFI6-POE-ETH (electrical drawing, section 5.2), while the inherited firmware uses GPIO26 for the backlight PWM and GPIO27 for the LCD reset; those numbers come from the Waveshare LCD-X example code.

7.3 Touch (Ö10)

  • Driver: gt911.cpp, I²C bus on GPIO7 (SDA) and GPIO8 (SCL), I2C_NUM_1, 400 kHz (i2c.h). The bus is initialised once by DEV_I2C_Init() and reused.
  • Address: probes 0x5D first, then 0x14. The product ID is read from register 0x8140 and printed as TouchPad_ID:0x..,0x..,0x..; the bytes are ASCII "911" for a GT911 and "927" for a GT9271. The driver never checks the value.
  • The wiki and the component list say GT9271; the code is the GT911 driver from Flip-HMI, which works there. No touch reset or interrupt GPIO is used (GPIO_NUM_NC).
  • touchpad_read_cb() in main.cpp returns the first touch point.

7.4 LVGL configuration (include/lv_conf.h)

Setting Value Why
LV_COLOR_DEPTH 16 RGB565 to match the panel
LV_USE_STDLIB_MALLOC LV_STDLIB_BUILTIN, LV_MEM_SIZE 128 kB One screen plus a popup fits with a wide margin
LV_USE_LOG 1 (level WARN), printed to the serial monitor Must stay on. Without it LVGL is silent when lv_malloc() fails: the memory assert just loops and the screen stays black
LV_USE_PERF_MONITOR 0 The FPS box would cover the UI
LV_USE_PPA not enabled The ESP32-P4 PPA driver does not build in a PlatformIO project (Flip-HMI found it needs a Kconfig symbol that only exists in an ESP-IDF component build)
LV_USE_FLOAT 0 LVGL's own printf has no %f; numbers are formatted with snprintf
Fonts Montserrat 14, 16, 18, 20, 22, 24, 26, 28, 32, 36, 40, 48 enabled The screen uses 14–48, plus the custom 96 px digit font

If a network connection (Ethernet, MQTT) is ever added, read the comments in Flip-HMI's lv_conf.h and lvgl_pool.cpp first. LVGL allocating small objects from the normal heap (LV_STDLIB_CLIB) used up the internal, DMA-capable memory that lwIP needs for every new socket: Ethernet had link and IP, but MQTT never connected. Flip-HMI therefore uses a fixed-size LVGL pool from lvgl_pool_alloc() and takes internal RAM for the pool and the draw buffer only if enough is left for the network stack, otherwise PSRAM.

7.5 Fonts

font_force_96 is Montserrat Medium at 96 px, 4 bpp, limited to space, +, -, . and 0–9, so it has no letters and no symbols. Use lv_font_montserrat_XX for text and for LV_SYMBOL_* icons. To change the size or the character set, edit tools/generate_fonts.sh and run it (requires node/npm; the script fetches lv_font_conv through npx). The generated file is committed, so building does not need node.


8. The screen

Portrait 800 × 1280, dark blue palette from Flip-HMI. Implemented in src/ui_screen.cpp; geometry and colours are constants at the top of the file.

Layout rules (binding for all screens)

The 10.1" panel has about 5.9 px/mm; the code rounds this to 6 px/mm (kPxPerMm).

Rule Value Constant
Header bar Always present at the top of the screen, fixed (does not scroll). Height about 15 mm (90 px). Full width with no margin to the screen edges and no corner radius kHeaderH
Edge margin Cards and buttons keep about 1 mm (6 px) to the left, right and bottom screen edges kMargin
Popups Every popup / modal is drawn on lv_layer_top() over a dimmed backdrop (black, LV_OPA_70) that covers the whole screen, including the header, and blocks touches to everything behind it –

Colour rules (binding for all screens)

Every colour that carries a meaning comes from this palette and is used only for that meaning. Background, card and border colours are neutral and not part of it.

Colour Hex Meaning
Turquoise #00D4E8 Active measured values, focus and selected functions
Green #20D979 Machine started and confirmed operation
Yellow/orange #FFB547 Warnings and values close to a limit
Red #FF4057 Stop, faults and critical alarms
Magenta #C65BFF Setpoints and reference lines in graphs
Grey-blue #8291A5 Inactive functions and secondary information

8.1 Layout

Area y [px] Content
Header 0–90 Full-width bar. Inside, with 6 px to the edges and one common height (78 px), radius (14 px) and border: title LOAD CELL, FORCE ALARM switch button (fills the free width), two stacked status chips of equal size (sensor: SENSOR OK / NO SIGNAL / INPUT OVER RANGE / OVERLOAD / LIMIT EXCEEDED; calibration: CALIBRATED / UNCALIBRATED), square gear at the far right (opens SETTINGS)
Force card 96–681 Vertical bar on the right (compression only, 0 … gauge maximum, red mark = max permitted force, magenta mark = alarm limit), 96 px readout in the chosen unit, second line with the value in the other unit family (N when g or kg is shown, kg when N or kN is shown), 270° gauge arc (560 px) with an adjustable range, scale labels, alarm limit text and markers, compression / tension / no-load chip
Sensor card 687–887 Load cell drawing with arrows, peak compression, peak tension
Trend card 893–1150 Force over the last 60 s (takes the height left above the buttons). Dark card like the others; turquoise curve and grid (grid at 30 % opacity), white text
Buttons 1156–1274 TARE, RESET PEAKS (CALIBRATE is on the SETTINGS page)
Message pill floats at y 1090 Short feedback after a button press (3 s), drawn on lv_layer_top()

Margins and header follow the layout rules above; the cards are 6 px apart (kGap). The popups (calibration, settings, force alarm, overload) follow the popup rule; the overload popup is created last and therefore lies on top of the others, also on top of the settings page. The raw value and the conversion rate are not on the home screen; they are on the SETTINGS page (SIGNAL card), as are CALIBRATE and the brightness.

8.2 Meaning of the graphics

  • Colours. Only the palette in "Colour rules" is used: turquoise for the active measured force (value, gauge, bar, curve, load cell outline and arrows, the COMPRESSION / TENSION chip), red above the maximum permitted force, grey-blue when there is no signal or no load. The direction is shown by the chip text and the arrow directions, not by colour. Green: SENSOR OK, CALIBRATED and confirmations. Yellow/orange: UNCALIBRATED, LIMIT EXCEEDED and the force alarm popup. Red: NO SIGNAL, OVERLOAD, INPUT OVER RANGE and the overload popup. Magenta: the alarm limit markers on the gauge and the bar. Grey-blue: FORCE ALARM while off, secondary text. Selected functions (the FORCE ALARM button when on, the selected setting, the selected rate, the main buttons TARE / APPLY / SAVE) have a turquoise border on the neutral button face.
  • Gauge. 270° arc over the range set in SETTINGS (default −range … +range, i.e. −200 … +200 kg). The indicator is drawn from the zero point to the value, so positive values (compression) fill clockwise and negative values (tension) counter-clockwise, also when the range is not symmetric. The scale labels are the minimum, half of it, 0, half of the maximum and the maximum.
  • Load cell drawing. Tension (negative) pulls the studs apart (arrows point away from the body), compression (positive) pushes them together (arrows point towards the body). The arrow length grows with the force; the outline and glow of the body follow the force colour and size. Arrows are hidden below 0.6 kg.
  • Unit. The unit selected in SETTINGS (g, kg, N or kN) is used for the large readout and its unit label, the second line, the gauge and bar scale labels (compact: 0–2 decimals depending on size, grams of 10 000 and more as 100k), the unit text under the bar, the peak values, the trend read-out bubble, the alarm and overload popups and the alarm limit text. Not converted (always kg): the settings values and their step buttons, the calibration popup, the trend chart axis (labelled scale +-N kg).
  • Sign display. Values that round to zero show as 0, 0.0 or 0.00 (depending on the decimals setting), without a sign and never as -0.0. formatForce() is used for every force value on the screen.
  • Trend. 600 points, one every 100 ms (the HX711 delivers 10 SPS at the default rate). The vertical range steps through ±1, 2, 5, 10, 20, 50, 100, 200, 500, 1000 and 2000 kg so that the curve fits without constantly rescaling; it is chosen once a second, or at once if the value would leave the chart. The middle grid line is zero and positive (compression) is up. The curve is stored in 0.01 kg units.
  • Touch read-out on the trend. While a finger is held on the chart, a white vertical line follows it, a turquoise dot marks the point on the curve and a bubble at the top of the chart shows the value (with the decimals setting) and how long ago it was measured, for example -54.01 kg -3.8 s; no signal where the sensor was offline. The values come from the ring buffer trend_hist (full float precision), not from the chart. Line, dot and bubble are drawn on lv_layer_top() and disappear when the finger is lifted.
  • Gauge position. The round gauge is centred on the screen width (kArcCx = kContentW / 2); the vertical bar sits at the right edge.
  • Vertical bar. Shows the same value as the gauge, but only the positive (compression) part: it starts at 0 at the bottom and its top is the gauge maximum, so it follows every change of the gauge maximum. It turns red above the maximum permitted force.

8.3 Update behaviour

  • ui_screen::update() runs at most every 50 ms (kUiPeriodMs).
  • Widgets are only touched when a value changes (text compared first, colours and arrow geometry cached), which keeps LVGL's redraw area small.
  • Peaks are tracked in hx711_manager on the filtered force and are cleared by RESET PEAKS, TARE and CALIBRATE. peakPositiveKg() is the largest value (compression), peakNegativeKg() the most negative (tension).
  • The moving average (kAverageSamples, 8) is unchanged, so at 10 SPS the shown value lags by about 0.4 s. 80 SPS makes it react faster; it has been tested on the board and works (Ö8), and is selected on the SETTINGS screen. The default stays 10 SPS (calmer readings).

8.4 Settings, force alarm and overload

The gear in the header opens the SETTINGS page. Touch a value to select it, then change it with the step buttons (−100, −10, −1, −0.1, +0.1, +1, +10, +100 kg):

Setting Range Meaning
Unit g / kg / N / kN Unit of the displayed force, see section 8.2 (Unit). Default kg. Stored with SAVE (uiset/unit). The sensor range, gauge, limits and calibration weight are still entered in kg
Newton per kg 1.00000 … 20.00000 Factor for N and kN and for the second line (default 9.80665 = standard gravity). Buttons −0.1, −0.01, −0.001, +0.001, +0.01, +0.1 and STD (back to 9.80665). Stored with SAVE as an integer in units of 1e-5 (uiset/npk)
Decimals 0 / 1 / 2 / 3 Number of decimals of the large force value in the gauge, of the peak compression / tension values, and of the values in the alarm and overload popups and in the trend read-out, in the chosen unit (0 decimals in g is 1 g, in kN it is 1 kN). Default 1. Selected with four buttons, stored with SAVE. The settings values themselves are not affected. With 3 decimals (0.001 kg is about 21 counts on a 200 kg cell) the last digit moves with the ADC noise unless Stabilise is on (section 6.6)
Stabilise OFF / MED / HIGH Calms the last digits of the displayed force (section 6.6). Default MED; stored in NVS by hx711_manager
HX711 RATE 10 SPS / 80 SPS Conversions per second (RATE pin). Saved with SAVE; restarts the HX711 and the filter
Sensor range 0.1 … 2000 kg Measuring range of the connected load cell. A new range resets gauge and max force to ±range / range and clears tare and calibration (press TARE with no load afterwards)
Gauge minimum −1.3 × range … 0 Tension end of the round gauge
Gauge maximum 1 kg … 1.3 × range Compression end of the round gauge; also the top of the vertical bar
Max permitted force 1 kg … 1.3 × range Largest force allowed for the connected sensor (default = range)
Alarm limit 0 … 1.3 × range Limit of the FORCE ALARM; 0 = not set
Screen brightness 10 … 100 % Slider (20 px high with a 26 px larger touch area), applied live; SAVE stores it, CANCEL restores the stored value. Also has − / + buttons (10 % steps). Written to the GPIO26 PWM and to registers 0x86 (Waveshare wiki) and 0x96 (start-up sequence from Flip-HMI) of device 0x45; Ö11: not yet known which one lights the backlight. Every change prints a Backlight … line on the serial monitor, with FAILED if the I²C write was not acknowledged
CALIBRATE – Button on the page; opens the calibration popup (section 6.4) above the settings page

The CALIBRATE button, the SIGNAL card and the rest of the page are on one screen. The SIGNAL card shows the RAW value, the measured conversions per second, the sensor status text (faultText()) and the counts per kg, live while the page is open. SAVE refuses a gauge that spans less than 1.0 kg; DEFAULTS restores range 200 kg, gauge ±200, max force 200 kg, alarm limit not set, unit kg, 9.80665 N/kg and the default rate; CANCEL discards the changes.

Overload popup (always on). When |force| exceeds the maximum permitted force a modal popup (OVERLOAD, dimmed background) flashes red (bright and dark red, 350 ms each) with the live force and the limit. It is latched and stays until ACKNOWLEDGE is pressed; it reopens only after the force has dropped below the limit (hysteresis: the larger of 0.2 kg and 5 % of the limit) and exceeds it again. The header chip reads OVERLOAD while the force is above the limit. Nothing is raised while the sensor is offline.

Force alarm (optional). The FORCE ALARM button in the header switches an additional alarm on and off (amber when on). When it is on and the alarm limit is set, reaching |force| ≥ limit opens an amber modal popup with the same latching and hysteresis rules; the header chip reads LIMIT EXCEEDED and the alarm text on the force card turns red. Amber markers on the arc and the bar show the limit. Changing a limit or switching the alarm re-arms it.

Stored values (NVS). Namespace uiset, written by SAVE and by the FORCE ALARM button; forces in units of 0.1 kg:

Key Content
gmin Gauge minimum (≤ 0)
gmax Gauge maximum (≥ 1 kg)
pmax Maximum permitted force
alim Alarm limit (0 = not set)
aon FORCE ALARM button state (if missing, on when alim > 0, for older firmware)
dec Decimals of the displayed force (0 … 3; default 1)
unit Display unit: 0 g, 1 kg (default), 2 N, 3 kN
npk Newton per kg × 1e5 (default 980665; allowed 100000 … 2000000)

The brightness is stored in namespace display, key bright (percent), by backlight::save().

The sensor range and the HX711 rate are stored by hx711_manager (rated, rate80, section 6.5). Values outside the allowed ranges are ignored at start-up and the defaults for the stored range are used.


The items themselves are kept in 03 – Check list.

# Concerns
Ö1 Sign of the reading (LOAD_CELL_INVERT_SIGN)
Ö4 DOUT level (3.3 V logic) towards GPIO21
Ö10 Touch IC (GT9271 or GT911) and driver
Ö11 Backlight path (I²C 0x45 or GPIO26 PWM) and the use of GPIO26/27
Ö12 Real counts per kg versus the factory estimate

First start-up checklist for the serial monitor (115200 baud):

  1. HX711: 10 SPS, tare …, … counts/kg (factory estimate) — set-up values.
  2. GT911 touch initialised and the TouchPad_ID line — touch (Ö10).
  3. UI ready — LVGL and the screen built. Any [LVGL] … line means a problem.
  4. Open SETTINGS (gear): RAW and SPS show that the HX711 answers (about 10 SPS) and the status reads Sensor OK. With nothing connected the header shows NO SIGNAL and the status gives the reason (section 5.3).