Parallel 20×4 LCD menu system with dual rotary encoders for APA Devices water treatment automation
·
·
- Parallel 4-bit LiquidCrystal 20×4 LCD — no I2C module required
- Two PEC11R quadrature rotary encoders, native ISR decoding — no external library
- 12-state non-blocking machine: HOME, NAV, EDIT, FLASH_SAVE, FLASH_BACK, FLASH_ACTION, BRIGHTNESS, CONFIRM, RTC_NAV, RTC_EDIT, TIMER, TIMER_EDIT
- Up to 4 submenu screens per side (left / right), configurable via
APA_LCD_MAX_SCREENS - Multiple home screen pages — register up to 4 via
addHomeScreen(), scroll with KB2 rotation - Inline timer schedule screen — up to 3 on/off time slots in 30-minute steps, auto-saved to EEPROM
- 1, 2, or 3 fields per screen: INT, FLOAT, CHOICE, BOOL, ACTION, or READONLY
- Passive alerts: corner indicator (
[i]/[*]/[!]flashing) — navigation not blocked - Active alerts: home screen takeover, queue of 3 — each requires operator acknowledgment
- Status indicator:
setStatusIndicator('M')shows[M]in the corner when no alert is pending — alert always takes priority - Automatic alert routing from APADOSE via user-supplied callback (libraries stay independent)
- PWM brightness: ACTIVE → DIM → OFF with configurable timeout
- Brightness persisted in EEPROM, adjusted via gesture (hold KB1 for 800 ms, then rotate knob1)
- Off-timeout suspended while active alerts are pending
- Factory functions for all field types — beginners never fill a struct by hand
- BOOL toggle field and ACTION confirmation dialog added in v1.1
- Optional DS3231 RTC modal compiled out if DS3231.h not included
- Long-press and both-buttons-pressed gesture callbacks
- Beginner defaults: all pin numbers match APA Devices HMI board v1.0 —
begin()takes no arguments
- No heap allocation —
LiquidCrystalis a direct class member, initialised in the constructor - ISR singleton: one static instance pointer, two static ISR stubs — fully portable
F()macro on all string literals — zero SRAM cost for labels on AVR- Zero
delay()calls —update()always returns within one loop iteration
Arduino IDE: Sketch → Include Library → Add .ZIP Library → select the APALCDGUI folder.
PlatformIO:
lib_deps =
arduino-libraries/LiquidCrystal @ ^1.0.7
https://github.com/apadevices/APALCDGUIAPALCDGUI drives a 20-column × 4-row parallel LCD and two rotary encoders as a complete menu system for pool automation hardware. The operator turns the right knob (knob1) to switch between parameter screens, turns the left knob (knob2) to move the cursor between fields, and presses the left knob to enter edit mode or confirm actions. Alarms appear either as a quiet corner indicator (passive) or as a full-screen takeover requiring acknowledgment (active). The backlight dims and extinguishes after inactivity, remembers the operator's preferred brightness, and wakes immediately on any knob movement.
HOME ──── knob1 (right) rotates ─────────────────► NAV (submenu screen)
──── knob2 (left) rotates ──────────────────► next / previous home page
◄─── menu timeout (60 s) ─────────────────────
◄─── BACK confirmed ──────────────────────────
NAV ──── knob2 (left) moves cursor 0 → 1 → BACK → SAVE → 0
──── knob2 press on field ──────────────────► EDIT
EDIT ──── knob2 press ───────────────────────────► NAV (cursor jumps to SAVE)
NAV ──── cursor on SAVE, press ─────────────────► FLASH_SAVE → onSave() → NAV
NAV ──── cursor on BACK, press ─────────────────► FLASH_BACK → HOME
NAV ──── cursor on ACTION field, press ─────────► FLASH_ACTION (► 300 ms) → action() → NAV
Both buttons pressed ────────────────────────────► RTC_NAV (if setRTC() called)
Hold KB1 for 800 ms, then rotate knob1 ──────────► BRIGHTNESS adjust
0 1 2
01234567890123456789
Row0: ► pH setpoint 7.24pH
Row1: ► ORP setpoint 680mV
Row2: Filter ON [!] ← cols 17–19: passive alert indicator
Row3: ►BACK →2/4 ►SAVE
- Col 0: cursor marker (space or
►) - Cols 1–12: field label (max 12 chars)
- Cols 14–17: value (4 chars)
- Cols 18–19: unit (2 chars)
- Row 2 cols 0–16:
setMenuRow2Callbackoutput or optional screen title - Row 2 cols 17–19:
[i]info /[*]warning /[!]critical alert indicator, or[M]status indicator when no alert is pending
The home screen is fully drawn by your callback(s). The library overlays two elements on top:
0 1 2
01234567890123456789
Row0: pH 7.24 ORP 680mV ← your callback writes all four rows
Row1: Cl 1.2 Temp 26°C
Row2: Filter ON [!] ← cols 17–19: passive alert indicator (library)
Row3: System OK 2/3 ← cols 17–19: page indicator (library, only when > 1 page)
- Rows 0–3 cols 0–16 are entirely yours — write whatever you need.
- Row 2 cols 17–19: alert indicator (
[!]/[*]/[i]) or status indicator ([M]etc.) when no alert — always drawn by the library. - Row 3 cols 17–19: page indicator (
1/3,2/3, …) drawn automatically when more than one home page is registered. Only 1 page → no indicator, those 3 cols are yours. - If your callback writes to cols 17–19 of rows 2 or 3, the library overwrites it — avoid those positions.
#include <APALCDGUI.h>
APALCDGUI gui;
float phSetpoint = 7.20f;
int16_t orpSetpoint = 680;
void drawHome(LiquidCrystal& lcd) {
lcd.setCursor(0, 0); lcd.print(F("pH 7.24 ORP 680mV "));
lcd.setCursor(0, 1); lcd.print(F("Cl 1.2 Temp 26C "));
lcd.setCursor(0, 2); lcd.print(F("Filter ON 12:34 "));
lcd.setCursor(0, 3); lcd.print(F("System OK "));
}
void onSave() { /* write to EEPROM or send to APADOSE here */ }
void setup() {
gui.begin(); // all pin defaults match HMI board v1.0
gui.setHomeCallback(drawHome);
gui.addScreen(SCREEN_RIGHT,
APALCDGUI::fieldFloat(F("pH setpoint"), F("pH"), &phSetpoint, 6.8f, 7.8f, 0.01f, 2),
APALCDGUI::fieldInt( F("ORP setpoint"), F("mV"), &orpSetpoint, 400, 850, 10),
onSave
);
}
void loop() {
gui.update(); // non-blocking — call every loop()
}Register up to 4 home pages with addHomeScreen(). When more than one page is registered, the operator scrolls between them by rotating knob2 (left knob) while on the home screen. The library automatically draws a page indicator (1/3, 2/3, 3/3) at row 3 cols 17–19 so the operator always knows how many pages there are and which one is showing.
setHomeCallback() still works as before — it is an alias for addHomeScreen(), so single-page sketches need no changes.
#include <APALCDGUI.h>
APALCDGUI gui;
// Page 1 — main sensor readings
void drawPage1(LiquidCrystal& lcd) {
lcd.setCursor(0, 0); lcd.print(F("pH 7.24 ORP 680mV "));
lcd.setCursor(0, 1); lcd.print(F("Cl 1.2 Temp 26 "));
lcd.write(CC_DEGREE); // ° at current cursor position
lcd.setCursor(0, 2); lcd.print(F("Filter ON 12:34 "));
lcd.setCursor(0, 3); lcd.print(F("System OK "));
// cols 17–19 of row 3 are the page indicator — do not write there
}
// Page 2 — weekly totals
void drawPage2(LiquidCrystal& lcd) {
lcd.setCursor(0, 0); lcd.print(F("Acid doses this week"));
lcd.setCursor(0, 1); lcd.print(F("pH-: 14 CL+: 22 "));
lcd.setCursor(0, 2); lcd.print(F("Last dose: 12:10 "));
lcd.setCursor(0, 3); lcd.print(F("Weekly total: 136 ml"));
}
// Page 3 — system status
void drawPage3(LiquidCrystal& lcd) {
lcd.setCursor(0, 0); lcd.print(F("Uptime: 3d 14h 22m "));
lcd.setCursor(0, 1); lcd.print(F("EEPROM writes: 1024"));
lcd.setCursor(0, 2); lcd.print(F("Backlight: 200/255"));
lcd.setCursor(0, 3); lcd.print(F("Firmware: v1.1.4 "));
}
void setup() {
gui.begin();
gui.addHomeScreen(drawPage1); // page 1 — shown first
gui.addHomeScreen(drawPage2); // page 2
gui.addHomeScreen(drawPage3); // page 3
// gui.addScreen(...) for submenus as usual
}
void loop() { gui.update(); }What the operator sees on page 2 of 3:
0 1 2
01234567890123456789
Row0: Acid doses this week
Row1: pH-: 14 CL+: 22
Row2: Last dose: 12:10 [!]
Row3: Weekly total: 136 ml2/3
The library writes at fixed positions on the home screen regardless of what your callback draws there:
| Position | What the library writes | Condition |
|---|---|---|
| Row 2 cols 17–19 | [i], [*], or [!] — passive alert indicator |
always |
| Row 3 cols 17–19 | 1/3, 2/3, 3/3 — page indicator |
only when > 1 page registered |
If your callback writes anything to those positions, the library overwrites it on every update(). Leave them blank in your callbacks.
When only one page is registered (or setHomeCallback() used for a single screen), no page indicator is drawn and all 20 columns of row 3 belong to your callback.
The timer screen lets the operator set up to 3 on/off time slots directly on the LCD — no extra screens or menus needed. Each slot has a start and end time in 30-minute steps (00:00–23:30). A slot is disabled when both times are 00:00. Times are automatically saved to EEPROM when the operator presses SAVE and automatically loaded from EEPROM on begin().
0 1 2
01234567890123456789
Row0: T1: 08:00-09:30
Row1: ►T2: 13:00-15:00 ← cursor is on this row
Row2: T3: 00:00-00:00 ← 00:00-00:00 means disabled
Row3: Total: 4h30m SAVE
When the operator presses KB2 on a timer row, the start time is selected for inline editing:
Row1: ►T2:[08:00]09:00 ← [ ] marks the active field; KB2 rotate changes the time
Row1: ►T2: 08:00[09:00] ← after confirming start, end is selected
| Input | Action |
|---|---|
| KB2 rotate | Move cursor between T1 / T2 / T3 / SAVE |
| KB2 press on timer row | Enter inline edit — start time first, then end |
| KB2 press while editing | Confirm field and advance to the next (start → end → back to timer list) |
| KB2 press on SAVE row | Write to EEPROM, fire optional callback, return HOME |
| KB1 press | Return HOME, discard uncommitted edits |
Call addTimerScreen() after all addScreen() calls on the same side. The timer screen always sits at the end of that side's rotation sequence.
#include <APALCDGUI.h>
APALCDGUI gui;
void drawHome(LiquidCrystal& lcd) { /* ... */ }
void onTimerSave() {
// Re-evaluate relay state immediately after the operator presses SAVE
}
void setup() {
gui.begin();
gui.addHomeScreen(drawHome);
// Regular submenu screens first
gui.addScreen(SCREEN_RIGHT, /* ... */);
// Timer screen last — always after addScreen() calls on the same side
gui.addTimerScreen(SCREEN_RIGHT, onTimerSave); // onSave is optional
}
void loop() { gui.update(); }void checkSchedule() {
uint16_t nowMin = hour * 60 + minute; // minutes since midnight
bool shouldRun = false;
for (uint8_t i = 0; i < APA_LCD_MAX_TIMERS; i++) {
if (gui.isTimerEnabled(i) &&
nowMin >= gui.getTimerStart(i) &&
nowMin < gui.getTimerEnd(i)) {
shouldRun = true;
}
}
// drive relay from shouldRun
}getTimerStart(i) and getTimerEnd(i) return minutes since midnight (0–1410). isTimerEnabled(i) returns false when both times are 00:00 (slot disabled). getTimerTotalMinutes() returns the sum of all enabled slot durations — useful as a daily target for external pump controllers:
// Bridge to APAPUMP daily target
pump.begin(scheduleActive, nullptr, []() { return gui.getTimerTotalMinutes(); });See examples/06_timers/ for a complete pump control example.
Define APA_LCD_MAX_TIMERS before including the library to increase slot count. The EEPROM address and layout expand automatically.
#define APA_LCD_MAX_TIMERS 6
#include <APALCDGUI.h>The LCD shows 3 timer slots at a time (rows 0–2); SAVE is always on row 3. When more than 3 slots are configured, the list scrolls: ↑ and ↓ indicators appear at the right edge of rows 0–1.
APA_LCD_MAX_TIMERSsupports up to 6.
Every field on a screen is created by a factory function. Here is a full breakdown of fieldFloat:
┌── the function ────────────────────────────────────────────────────────────────┐
APALCDGUI::fieldFloat( F("pH setpoint"), F("pH"), &phSetpoint, 6.8f, 7.8f, 0.01f, 2 )
└── param 1 ───┘ └─ p2 ┘ └── p3 ─────┘ └─p4┘ └─p5┘ └─ p6┘ └p7
| # | Example | What it does |
|---|---|---|
| Function | APALCDGUI::fieldFloat(...) |
Creates a float editing field. Never called alone — always passed as an argument to addScreen(). Use fieldInt for whole numbers, fieldChoice for named options, etc. |
| 1 | F("pH setpoint") |
Label shown on the left side of the row, up to 12 characters. F(...) keeps the text in flash memory — always use it for string literals. |
| 2 | F("pH") |
Unit suffix shown after the value, up to 2 characters. Pass nullptr if no unit is needed. |
| 3 | &phSetpoint |
Address of your float variable. The & gives the library direct access — it reads the current value and writes the new one when the operator presses SAVE. |
| 4 | 6.8f |
Minimum value. Rotating below this has no effect. The f suffix marks it as a float constant. |
| 5 | 7.8f |
Maximum value. Rotating above this has no effect. |
| 6 | 0.01f |
Change per encoder click. 0.01f = fine, 0.1f = medium, 1.0f = coarse. |
| 7 | 2 |
Decimal places shown on the LCD. 2 → 7.24, 1 → 7.2, 0 → 7. |
What the operator sees during editing:
►pH setpoint 7.24pH
// Integer value — operator scrolls between min and max by step
fieldInt(label, unit, int16_t* val, min, max, step = 1)
// Decimal value — same as INT but displays with a fixed number of decimal places
fieldFloat(label, unit, float* val, min, max, step, decimals)
// Named options — operator cycles through the list; each string MUST be exactly 4 chars
// Pad shorter strings with trailing spaces: {"AUTO", "MANU", "OFF ", nullptr}
fieldChoice(label, uint8_t* index, const char* choices[])
// On/off toggle — shows " ON " or "OFF "; rotate to preview, press to commit
fieldBool(label, bool* val)
// Button — shows "STRT" on SAVE; pressing flashes ► for 300 ms then fires fn()
// confirm=true adds a "Confirm action?" prompt (KB1=NO, KB2=YES) before firing
fieldAction(label, void (*fn)(), confirm = false)
// Display only — cursor skips this field; no editing possible
fieldReadonly(label, unit, float* val, decimals)| Method | Description |
|---|---|
APALCDGUI(rs, en, d4..d7) |
Constructor — sets LCD pin assignments. All defaults match HMI board v1.0. LCD pins are fixed here; not in begin(). |
begin(blPin, enc1Clk, enc1Dt, enc1Btn, enc2Clk, enc2Dt, enc2Btn, det1, det2) |
Initialise LCD, attach encoder ISRs, load custom characters, restore brightness from EEPROM. All defaults match HMI board v1.0. |
update() |
Process encoders, buttons, timeouts, redraw LCD. Call every loop() — never blocks. |
| Method | Description |
|---|---|
addHomeScreen(fn) |
Register a home page. Call multiple times for a scrollable dashboard (KB2 scrolls pages). Returns false if APA_LCD_MAX_HOME_SCREENS is reached. |
setHomeCallback(fn) |
Alias for addHomeScreen() — single-page sketches need no changes. |
setMenuRow2Callback(fn) |
Draw live sensor data on row 2 of every 1- and 2-field screen (cols 0–16 only). |
addScreen(side, field1, onSave, title) |
Register a 1-field screen. |
addScreen(side, field1, field2, onSave, title) |
Register a 2-field screen (most common). |
addScreen(side, field1, field2, field3, onSave, title) |
Register a 3-field screen. |
addTimerScreen(side, onSave) |
Register the timer schedule screen on side. Call after all addScreen() on that side. onSave is optional — times are saved to EEPROM regardless. |
setRTC(DS3231*) |
Wire 800 ms both-buttons-hold gesture to built-in time/date modal. Requires build flag -DAPA_LCD_USE_DS3231. |
| Factory | Description |
|---|---|
fieldInt(label, unit, int16_t*, min, max, step=1) |
Integer field, scrolls by step. |
fieldFloat(label, unit, float*, min, max, step, decimals) |
Float field, displayed with N decimal places. |
fieldChoice(label, uint8_t*, const char*[]) |
Cycles through null-terminated string array. Each string must be exactly 4 chars. |
fieldBool(label, bool*) |
Toggle — shows " ON " / "OFF ". |
fieldAction(label, fn, confirm=false) |
Button — shows "STRT". Press flashes ► for 300 ms then fires fn(). confirm=true adds a confirmation prompt first. |
fieldReadonly(label, unit, float*, decimals) |
Display only — cursor skips this field. |
| Method | Description |
|---|---|
postAlert(l1, l2, level) |
Show passive corner indicator. Levels: ALERT_INFO, ALERT_WARNING, ALERT_CRITICAL. |
clearAlert() |
Dismiss passive alert. |
hasAlert() |
True if passive alert is active. |
postActiveAlert(l1, l2, level, ackCb) |
Add to active alert queue (max 3). Replaces home screen until acknowledged. |
cancelActiveAlert() |
Remove current active alert silently (for auto-cleared alarms). |
hasActiveAlert() / activeAlertCount() |
Query active alert state. |
setStatusIndicator(char c) |
Show [c] in cols 17–19 row 2 when no alert is pending. Example: 'M' for pump manual mode. Alert always takes priority. |
clearStatusIndicator() |
Remove the status indicator; corner shows blank when no alert is pending. |
| Method | Description |
|---|---|
setBacklight(bool) |
Force on or off immediately. |
setBacklightTimeout(seconds) |
Off timeout; dim fires at 40% of this value. 0 = always on. Default 300 s. |
setMenuTimeout(seconds) |
Return to HOME after idle. 0 = disabled. Default 60 s. |
| Method | Description |
|---|---|
setLongPressCallback(enc, fn) |
800 ms hold: 0 = right knob (KB1), 1 = left knob (KB2). |
setBothPressedCallback(fn) |
Both buttons within 200 ms — always wins over RTC modal. |
showMessage(l1, l2, ms) |
Timed message covering all 4 rows (line1→row 0, line2→row 1, rows 2–3 blanked). Default 1500 ms. |
clearMessage() |
Dismiss overlay early. |
markDirty() |
Schedule a full LCD redraw on the next update(). |
isMenuActive() |
True when not at HOME. |
isEditActive() |
True during EDIT, RTC_EDIT, or TIMER_EDIT state. |
currentScreen() |
Screen position: 0 = HOME, +N = right screen N, -N = left screen N. |
currentHomePage() |
Index of the currently displayed home page (0-based). |
homePageCount() |
Number of home pages registered via addHomeScreen(). |
getBrightness() |
Current backlight level (0–255, EEPROM-persisted). |
getTimerStart(i) |
Start time for slot i in minutes since midnight (0–1410). Returns 0 if index out of range. |
getTimerEnd(i) |
End time for slot i in minutes since midnight (0–1410). Returns 0 if index out of range. |
isTimerEnabled(i) |
true when slot i has a non-zero start or end time (i.e., is not disabled). |
getTimerTotalMinutes() |
Sum of all enabled timer slot durations in minutes. Use as a daily target for external pump controllers. Returns 0 if no timers registered or all slots disabled. |
Libraries are independent — user code is the bridge:
dose1.setAlarmCallback([](AlarmType alarm) {
if (alarm == ALARM_NONE) {
gui.cancelActiveAlert();
return;
}
if (alarm == ALARM_INEFFECTIVE || alarm == ALARM_WRONG_DIRECTION
|| alarm == ALARM_TANK_EMPTY || alarm == ALARM_OFA) {
gui.postActiveAlert(F("ALARM: Ineffective"), F("pH-minus pump #1"),
ALERT_CRITICAL, []() { dose1.acknowledgeAlarm(); });
} else {
gui.postAlert(F("Warning: Safety band"), F("pH-minus"), ALERT_WARNING);
}
});The library includes a built-in time/date edit modal for the DS3231 module. Enable it by defining APA_LCD_USE_DS3231 — the library then pulls in Wire.h and DS3231.h automatically.
Wiring (Mega 2560): SDA → pin 20, SCL → pin 21, VCC → 3.3 V, GND → GND.
platformio.ini:
lib_deps =
arduino-libraries/LiquidCrystal @ ^1.0.7
NorthernWidget/DS3231
build_flags = -DAPA_LCD_USE_DS3231Arduino IDE — add before #include:
#define APA_LCD_USE_DS3231
#include <APALCDGUI.h>Sketch:
#include <APALCDGUI.h> // Wire.h and DS3231.h are included automatically
APALCDGUI gui;
DS3231 rtc;
void setup() {
Wire.begin(); // required — initialise I2C bus
gui.begin();
gui.setRTC(&rtc); // wires both-buttons gesture to the modal
// addScreen() calls ...
}Operation: hold both buttons for 800 ms → the TIME screen opens. Knob2 moves the cursor between the three time fields (HH / MM / SS); press to enter edit, rotate to change, press to confirm the new value. Press SAVE to advance to the DATE screen (DD / MM / YYYY). Press SAVE again to write all values to the DS3231 and return to HOME. BACK on the DATE screen returns to TIME; BACK on the TIME screen discards all changes.
setBothPressedCallback()takes priority over the RTC modal — do not set it if you want the modal to work.
See examples/04_rtc/04_rtc.ino for a full working example with live clock display on the home screen.
| Sketch | Description |
|---|---|
examples/01_minimal/ |
Simplest working sketch — one screen, two fields, home callback |
examples/02_8screens/ |
All 6 field types across 8 screens — best starting point for new projects |
examples/03_alerts/ |
Passive and active alert system demonstration |
examples/04_rtc/ |
DS3231 real-time clock — live time display and time/date set modal |
examples/05_multi_home/ |
Three scrollable home pages with automatic page indicator |
examples/06_timers/ |
Timer schedule screen — pump control with 3 on/off slots and EEPROM persistence |
Define these before #include <APALCDGUI.h>:
#define APA_LCD_MAX_SCREENS 4 // total submenu screens left+right (default 4)
#define APA_LCD_MAX_HOME_SCREENS 4 // home screen pages scrolled by KB2 (default 4)
#define APA_LCD_ACTIVE_ALERT_QUEUE 3 // simultaneous active alerts (default 3)
#define APA_LCD_EEPROM_ADDR 500 // EEPROM base address (default 500, uses 2 bytes)
#define APA_LCD_MAX_TIMERS 3 // timer slots on the schedule screen (default 3, max 6)
#define APA_LCD_TIMER_EEPROM_ADDR 502 // EEPROM start address for timer data (default 502, uses 7 bytes)Compiled and size-checked with the 02_8screens example using the default 4-screen limit on all supported platforms. Zero errors, zero library warnings.
| Platform | Board | Clock | RAM used | RAM total | Flash used | Flash total |
|---|---|---|---|---|---|---|
| Arduino Mega 2560 | ATmega2560 | 16 MHz | 1 361 B | 8 192 B (17%) | 21 326 B | 253 952 B (8%) |
| Arduino Uno | ATmega328P | 16 MHz | 1 349 B | 2 048 B (66%) | 19 420 B | 32 256 B (60%) |
| ESP32 DevKit | ESP32 | 240 MHz | 23 380 B | 327 680 B (7%) | 299 581 B | 1 310 720 B (23%) |
| ESP8266 D1 Mini | ESP8266 | 80 MHz | 29 984 B | 81 920 B (37%) | 284 415 B | 1 044 464 B (27%) |
| STM32 Bluepill | STM32F103C8 | 72 MHz | 3 448 B | 20 480 B (17%) | 38 484 B | 65 536 B (59%) |
The Uno row shows 66% RAM with the 4-screen example — that includes the full
02_8screenssketch overhead (6 field types, 8 registrations capped at 4, home + alert callbacks). The library core alone is smaller. For production Uno use, a 2–3 screen sketch will sit comfortably below 50%.ESP32 and ESP8266 totals include the full Arduino framework (WiFi stack etc.) regardless of whether it is used.
Dual license — see LICENSE file for full terms.
| Use | License |
|---|---|
| Personal, private, educational, hobby | Free — no charge, no paperwork |
| Commercial (products, services, OEM) | Separate written license required |
Commercial use includes selling or distributing hardware with this software, providing paid water treatment or automation services, or integrating it into any revenue-generating product or system.
To obtain a commercial license: kecup@vazac.eu
APALCDGUI — APA Devices · kecup@vazac.eu
